diff --git a/.env.example b/.env.example new file mode 100644 index 0000000..f22ca05 --- /dev/null +++ b/.env.example @@ -0,0 +1,20 @@ +# SMTP 邮件服务配置(模板文件) +# 复制此文件为 .env 并填入真实凭证:cp .env.example .env + +# 官方线路1 +SMTP_ROUTE1_NAME=官方线路1 +SMTP_ROUTE1_ICON=🚀 +SMTP_ROUTE1_HOST= +SMTP_ROUTE1_PORT=465 +SMTP_ROUTE1_SSL=true +SMTP_ROUTE1_USERNAME= +SMTP_ROUTE1_PASSWORD= + +# 官方线路2 +SMTP_ROUTE2_NAME=官方线路2 +SMTP_ROUTE2_ICON=✉️ +SMTP_ROUTE2_HOST= +SMTP_ROUTE2_PORT=465 +SMTP_ROUTE2_SSL=true +SMTP_ROUTE2_USERNAME= +SMTP_ROUTE2_PASSWORD= diff --git a/.gitignore b/.gitignore index 72abdd3..79f6a72 100644 --- a/.gitignore +++ b/.gitignore @@ -43,3 +43,8 @@ app.*.map.json /android/app/debug /android/app/profile /android/app/release + +# Environment variables (contains secrets) +.env +.env.* +!.env.example diff --git a/.metadata b/.metadata index cf3510a..585339a 100644 --- a/.metadata +++ b/.metadata @@ -18,24 +18,6 @@ migration: - platform: android create_revision: 3370e71cd34c0a0e3aae5bf2251863e518fe4cca base_revision: 3370e71cd34c0a0e3aae5bf2251863e518fe4cca - - platform: ios - create_revision: 3370e71cd34c0a0e3aae5bf2251863e518fe4cca - base_revision: 3370e71cd34c0a0e3aae5bf2251863e518fe4cca - - platform: linux - create_revision: 3370e71cd34c0a0e3aae5bf2251863e518fe4cca - base_revision: 3370e71cd34c0a0e3aae5bf2251863e518fe4cca - - platform: macos - create_revision: 3370e71cd34c0a0e3aae5bf2251863e518fe4cca - base_revision: 3370e71cd34c0a0e3aae5bf2251863e518fe4cca - - platform: web - create_revision: 3370e71cd34c0a0e3aae5bf2251863e518fe4cca - base_revision: 3370e71cd34c0a0e3aae5bf2251863e518fe4cca - - platform: windows - create_revision: 3370e71cd34c0a0e3aae5bf2251863e518fe4cca - base_revision: 3370e71cd34c0a0e3aae5bf2251863e518fe4cca - - platform: ohos - create_revision: 3370e71cd34c0a0e3aae5bf2251863e518fe4cca - base_revision: 3370e71cd34c0a0e3aae5bf2251863e518fe4cca # User provided section diff --git a/Android -ErrorAction SilentlyContinue  Select-Object Name b/Android -ErrorAction SilentlyContinue  Select-Object Name deleted file mode 100644 index e95a1ad..0000000 --- a/Android -ErrorAction SilentlyContinue  Select-Object Name +++ /dev/null @@ -1,100 +0,0 @@ - - SSUUMMMMAARRYY OOFF LLEESSSS CCOOMMMMAANNDDSS - - Commands marked with * may be preceded by a number, _N. - Notes in parentheses indicate the behavior if _N is given. - A key preceded by a caret indicates the Ctrl key; thus ^K is ctrl-K. - - h H Display this help. - q :q Q :Q ZZ Exit. - --------------------------------------------------------------------------- - - MMOOVVIINNGG - - e ^E j ^N CR * Forward one line (or _N lines). - y ^Y k ^K ^P * Backward one line (or _N lines). - ESC-j * Forward one file line (or _N file lines). - ESC-k * Backward one file line (or _N file lines). - f ^F ^V SPACE * Forward one window (or _N lines). - b ^B ESC-v * Backward one window (or _N lines). - z * Forward one window (and set window to _N). - w * Backward one window (and set window to _N). - ESC-SPACE * Forward one window, but don't stop at end-of-file. - ESC-b * Backward one window, but don't stop at beginning-of-file. - d ^D * Forward one half-window (and set half-window to _N). - u ^U * Backward one half-window (and set half-window to _N). - ESC-) RightArrow * Right one half screen width (or _N positions). - ESC-( LeftArrow * Left one half screen width (or _N positions). - ESC-} ^RightArrow Right to last column displayed. - ESC-{ ^LeftArrow Left to first column. - F Forward forever; like "tail -f". - ESC-F Like F but stop when search pattern is found. - ESC-f Like F but ring the bell when search pattern is found. - r ^R ^L Repaint screen. - R Repaint screen, discarding buffered input. - --------------------------------------------------- - Default "window" is the screen height. - Default "half-window" is half of the screen height. - --------------------------------------------------------------------------- - - SSEEAARRCCHHIINNGG - - /_p_a_t_t_e_r_n * Search forward for (_N-th) matching line. - ?_p_a_t_t_e_r_n * Search backward for (_N-th) matching line. - n * Repeat previous search (for _N-th occurrence). - N * Repeat previous search in reverse direction. - ESC-n * Repeat previous search, spanning files. - ESC-N * Repeat previous search, reverse dir. & spanning files. - ^O^N ^On * Search forward for (_N-th) OSC8 hyperlink. - ^O^P ^Op * Search backward for (_N-th) OSC8 hyperlink. - ^O^L ^Ol Jump to the currently selected OSC8 hyperlink. - ESC-u Undo (toggle) search highlighting. - ESC-U Clear search highlighting. - &_p_a_t_t_e_r_n * Display only matching lines. - --------------------------------------------------- - Search is case-sensitive unless changed with -i or -I. - A search pattern may begin with one or more of: - ^N or ! Search for NON-matching lines. - ^E or * Search multiple files (pass thru END OF FILE). - ^F or @ Start search at FIRST file (for /) or last file (for ?). - ^K Highlight matches, but don't move (KEEP position). - ^R Don't use REGULAR EXPRESSIONS. - ^S _n Search for match in _n-th parenthesized subpattern. - ^W WRAP search if no match found. - ^L Enter next character literally into pattern. - --------------------------------------------------------------------------- - - JJUUMMPPIINNGG - - g < ESC-< * Go to first line in file (or line _N). - G > ESC-> * Go to last line in file (or line _N). - p % * Go to beginning of file (or _N percent into file). - t * Go to the (_N-th) next tag. - T * Go to the (_N-th) previous tag. - { ( [ * Find close bracket } ) ]. - } ) ] * Find open bracket { ( [. - ESC-^F _<_c_1_> _<_c_2_> * Find close bracket _<_c_2_>. - ESC-^B _<_c_1_> _<_c_2_> * Find open bracket _<_c_1_>. - --------------------------------------------------- - Each "find close bracket" command goes forward to the close bracket - matching the (_N-th) open bracket in the top line. - Each "find open bracket" command goes backward to the open bracket - matching the (_N-th) close bracket in the bottom line. - - m_<_l_e_t_t_e_r_> Mark the current top line with . - M_<_l_e_t_t_e_r_> Mark the current bottom line with . - '_<_l_e_t_t_e_r_> Go to a previously marked position. - '' Go to the previous position. - ^X^X Same as '. - ESC-m_<_l_e_t_t_e_r_> Clear a mark. - --------------------------------------------------- - A mark is any upper-case or lower-case letter. - Certain marks are predefined: - ^ means beginning of the file - $ means end of the file - --------------------------------------------------------------------------- - - CCHHAANNGGIINNGG FFIILLEESS - - :e [_f_i_l_e] Examine a new file. - ^X^V Same as :e. diff --git a/CHANGELOG.md b/CHANGELOG.md index 38229e1..cc81e20 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -3,262 +3,405 @@ All notable changes to this project will be documented in this file. -## [0.96.1] - 2026-04-15 +## [0.97.36] - 2026-04-17 -### 🐛 修复 — Flutter调试安装APK卡住 / Release构建找不到APK +### 🔧 修复 — Web端 Platform._operatingSystem 崩溃 -#### Bug修复 -- 🐛 **修复 `flutter run` 调试时 APK 安装一直卡在 "Installing build\app\outputs\flutter-apk\app-debug.apk..." 的问题** - - 原因:`local.properties` 中残留 `flutter.buildMode=release`/`flutter.versionName`/`flutter.versionCode` 等自动生成属性,干扰 Flutter 工具的构建模式判断和 APK 路径查找 - - 修复:清理 `local.properties` 中的自动生成属性(这些属性由 Flutter 构建时自动写入,不应持久化) - - 修复:卸载设备上旧版本应用 + `flutter clean` 清理构建缓存 -- 🐛 **修复 `flutter build apk --release` 构建后报 "Gradle build failed to produce an .apk file" 的问题** - - 原因:`android/build.gradle.kts` 中的构建目录重定向(`newBuildDir` → `../../build`)是 Flutter 新版模板的正确设计,误删后导致 APK 路径不匹配 - - 修复:恢复 `build.gradle.kts` 中的 `newBuildDir` 重定向逻辑,确保 APK 输出到 `<项目根>/build/app/outputs/flutter-apk/` +#### 问题描述 +- 🐛 **Web端崩溃**:`Unsupported operation: Platform._operatingSystem` +- 🐛 **根因**:`crash_guard_service.dart` 直接使用 `dart:io` 的 `Platform` 类 +- 🐛 **影响范围**:错误报告生成(L137) + 错误对话框显示(L426) -## [0.96.0] - 2026-04-15 +#### 修复方案 +- 🔄 **移除直接导入**:删除 `import 'dart:io'`,改用项目已有的 `PlatformUtils` +- 🛡️ **使用兼容层**:`Platform.operatingSystem` → `PlatformUtils().operatingSystemName` +- ✅ **Web端安全**:`PlatformUtils` 使用条件导入,Web端返回 `'Web'` -### 📧 菜谱邮件分享功能 — 发送菜谱详情到邮箱 +#### 修改文件 +- `lib/src/services/crash_guard_service.dart` — 移除dart:io导入,使用PlatformUtils + + +## [0.97.35] - 2026-04-17 + +### 🔧 修复 — Web版API超时错误 (v2) + +#### 问题描述 +- 🐛 **超时错误**:Web端显示"加载失败 - Exception: 加载超时,请检查网络连接" +- 🐛 **根因1**:`connectTimeout: 2秒` 太短,Web端经过CORS代理链路长 +- 🐛 **根因2**:请求策略错误(先走代理再直连),代理不稳定导致延迟更高 + +#### 修复方案 +- ⏱️ **增加超时时间**:Web端 `connectTimeout` 从 2s → **10s** +- 🔄 **反转请求策略**:**优先直连** → 失败后用CORS代理备用 +- 📡 **增强错误检测**:新增 `_shouldTryProxy()` 方法,检测 connectionTimeout/connectionError/statusCode=0/5xx 等情况触发代理回退 +- 🛡️ **全方法覆盖**:GET/POST/PUT/DELETE 四种请求方法均支持新策略 + +#### 请求流程变更 +``` +旧流程 (v1): 浏览器 → CORS代理(2s超时) → API服务器 ❌ 超时 +新流程 (v2): 浏览器 → 直连API(10s超时) ✅ → 失败时 → CORS代理备用 +``` + +#### 修改文件 +- `lib/src/services/api/api_service.dart` — Web端10s超时、优先直连、_buildProxyUrl备用、_shouldTryProxy检测 + + +## [0.97.34] - 2026-04-17 + +### 🔧 修复 — Web版API连接错误 + +#### 问题描述 +- 🐛 **CORS代理失败**:Web端使用 corsproxy.io 代理服务不稳定,导致 API 请求失败 +- 错误信息:`ApiException(unknown). The connection errored. The XMLHttpRequest onerror callback was called.` + +#### 修复方案 +- 🔄 **直接请求回退机制**:CORS代理失败时自动尝试直连原始URL +- 📡 **智能错误检测**:新增 `_isCorsProxyError()` 方法识别 CORS 代理相关错误 +- 🛡️ **全方法覆盖**:GET/POST/PUT/DELETE 四种请求方法均支持回退逻辑 +- 📝 **详细日志**:添加调试日志便于追踪请求路径(代理/直连) + +#### 技术实现 +```dart +// 核心流程: +// 1. 尝试通过 CORS 代理请求 (corsproxy.io/?url=...) +// 2. 失败时检测是否为代理错误 (_isCorsProxyError) +// 3. 自动切换为直接请求原始 URL +// 4. 直连失败则抛出原始错误 +``` + +#### 修改文件 +- `lib/src/services/api/api_service.dart` — 新增 _buildDirectUrl、_isCorsProxyError 方法;修改 get/post/put/delete 和 _executeWithOfflineCheck 支持回退 + + +## [0.97.33] - 2026-04-17 + +### ✨ 新增 — 隐私政策与用户协议页面 + 首次引导页 #### 新增功能 -- 🆕 **邮件分享按钮** — 菜谱详情页底部新增"📧 发送菜谱到邮箱"按钮 - - 点击弹出底部对话框,支持3种发送线路选择 - - 🚀 官方线路1 (gg@0gg.cc via free.mboxhosting.com) - - ✉️ 官方线路2 (2821981550@qq.com via smtp.qq.com) - - 🔧 自定义SMTP(用户填写服务器/账号/密码/端口) - - 支持纯文本+HTML双格式邮件,包含菜谱标题、简介、食材、步骤、营养信息 - - HTML邮件采用iOS风格设计(渐变头部、圆角卡片、营养网格布局) - - 邮件标题格式:🍳 {菜谱名称} — 小妈厨房菜谱分享 - - 输入校验:邮箱格式、必填项检查 - - 发送状态反馈:发送中显示loading、成功/失败Toast提示 -- 🆕 **EmailService** — 基于 `mailer` 库的SMTP邮件发送服务 - - 多线路支持:官方线路1/线路2/自定义SMTP - - 支持异常捕获:MailerException/SocketException/通用异常 - - Logger日志记录发送结果 - - 发送成功/失败自动记录到 EmailHistoryController -- 🆕 **发件记录页面** — 足迹页统计栏新增📧发件记录入口 - - 记录列表展示:菜谱标题、收件人、线路、状态、时间 - - 左滑删除单条记录 - - 清空全部记录 - - 点击查看详情弹窗(收件人/发件人/服务器/线路/主题/状态/错误信息) - - 点击"查看菜谱"跳转菜谱详情 - - 统计头部:总记录数 + 成功/失败数量标签 -- 🆕 **EmailRecordModel** — 邮件发送记录模型(SharedPreferences JSON持久化) -- 🆕 **EmailHistoryController** — 邮件记录控制器(最多200条记录) +- 🔒 **隐私政策页面**:新增隐私政策与用户协议页面,支持分段切换和左右滑动浏览 +- 📋 **公开组件**:`PrivacyPolicyContent` 和 `UserAgreementContent` 为公开类,可供其他页面调用 +- 🔗 **关于页面跳转**:关于页面"软件协议"入口点击后跳转至新页面 +- 🎬 **首次引导页**:新增引导页,首次启动展示欢迎信息和协议,用户同意后方可使用 +- ✅ **协议拦截**:启动时检查协议同意状态,未同意则跳转引导页,不同意则退出应用 -#### Dependencies -- 新增: mailer ^7.1.0 - -#### 影响文件 -- `pubspec.yaml` — 添加 mailer 依赖 -- `lib/src/services/data/email_service.dart` — 新建邮件发送服务(多线路+历史记录) -- `lib/src/widgets/recipe_detail/interaction/recipe_email_button.dart` — 新建邮件按钮组件 -- `lib/src/models/data/email_record_model.dart` — 新建邮件记录模型 -- `lib/src/controllers/data/email_history_controller.dart` — 新建邮件记录控制器 -- `lib/src/pages/profile/social/email_history_page.dart` — 新建发件记录页面 -- `lib/src/pages/profile/social/footprints_page.dart` — 统计栏增加发件记录入口 -- `lib/src/pages/home/recipe_detail_page.dart` — 底部添加邮件按钮 -- `lib/src/config/app_routes.dart` — 注册 /email-history 路由 +#### 修改文件 +- `lib/src/pages/profile/privacy_policy_page.dart` — 新增隐私政策与用户协议页面 +- `lib/src/pages/profile/guide_page.dart` — 新增首次引导页(欢迎+协议同意) +- `lib/src/pages/profile/about_page.dart` — 软件协议入口跳转新页面 +- `lib/src/config/app_routes.dart` — 注册 `/privacy-policy`、`/guide` 路由 +- `lib/main.dart` — 启动时检查协议同意状态 -## [0.95.0] - 2026-04-14 +## [0.97.32] - 2026-04-17 -### 🔄 MiniCardPage 滑动引擎重构 — 引入 flutter_card_swiper - -#### 重构 -- 🔄 **自定义手势系统移除** — 移除 GestureDetector + 3个AnimationController(exit/enter/bounce)+ 手动拖拽追踪(_dragX/_dragY) 的自实现滑动系统 -- 🔄 **引入 flutter_card_swiper ^7.2.0** — 使用专业 Tinder 风格卡片滑动库替代自实现 - - CardSwiper 内置:滑动动画、回弹效果、堆叠缩放、旋转角度 - - CardSwiperController 支持程序化控制:swipe(direction)、undo()、moveTo(index) - - onSwipe/onUndo 回调统一处理 like/dislike/撤销逻辑 - - 配置:threshold=80, maxAngle=20, scale=0.92, duration=300ms, numberOfCardsDisplayed=2 - -#### 删除代码(-158行) -- ~~_exitAnimController / _enterAnimController / _bounceAnimController~~ — 3个AnimationController -- ~~/_dragX / _dragY / _isDragging / _isAnimatingExit / _exitDirection / _exitStartX / _exitStartY~~ — 手势状态变量 -- ~~/_swipeRight() / _swipeLeft() / _startExitAnimation() / _onExitComplete() / _goPrev() / _bounceBack() / _buildSwipeLabel()~~ — 7个手势方法 - -#### 新增代码(+30行) -- `CardSwiperController _swiperController` — 程序化控制器 -- `_handleSwipe(previousIndex, currentIndex, direction)` — 统一滑动回调 -- `_handleUndo(previousIndex, currentIndex, direction)` — 撤销回调 -- `CardSwiper` widget 替代原 `GestureDetector + Transform` 嵌套 - -#### 保留功能(100%不变) -- ✅ 分类筛选 / 搜索 / 网格视图切换 -- ✅ 收藏 / 分享 / 详情跳转 / 全屏查看器 -- ✅ 图片预加载 / 本地缓存 / SharedPreferences持久化 -- ✅ MiniCardImageView 组件(液态玻璃效果) -- ✅ MiniCardViewer 全屏查看器 - -#### Dependencies -- 新增: flutter_card_swiper ^7.2.0 - -#### 影响文件 -- `pubspec.yaml` — 添加 flutter_card_swiper 依赖 -- `lib/src/pages/discover/mini_card/mini_card_page.dart` — 核心重构(1262行→1104行,减少12.5%) -- `lib/src/pages/discover/mini_card/mini_card_image_view.dart` — 分类描述提升为独立显眼行(fontSm + 85%透明度 + 2行显示) - -#### 补充修复(v0.95.0-hotfix) -- 🐛 **卡片右侧空白** — CardSwiper padding 从 `horizontal:16` 改为 `EdgeInsets.zero`,内部 width 从 `maxW-32` 改为 `constraints.maxWidth` -- 🎨 **底部层叠效果** — 布局从 Column 重构为 Stack,底部操作栏使用 `Positioned(bottom:0)` + `BackdropFilter(sigma:20)` + 渐变遮罩(0→0.95),卡片视觉延伸至按钮下方 -- 🏷️ **分类栏** — `_buildCategoryChips` 已正确遍历 `_meta.categories.entries`,显示全部11个分类(✨全部 🥬素菜 🥩荤菜 🦞水产 🌅早餐 🍚主食 🍲汤与粥 🧃饮品 🍰甜品 📦半成品加工 🪙酱料) -- 📝 **分类描述** — MiniCardImageView 已通过 `_meta.descriptions[recipe.category]` 获取对应分类描述,独立行显示 - - -## [0.94.0] - 2026-04-14 - -### 🎬 迷你卡片动画系统重构 — 滑出飞走+入场弹性+回弹弹簧+图片预加载+缓存 - -#### 优化 -- 🎬 **滑出飞走动画** — 拖拽超过阈值后,卡片带旋转+位移飞出屏幕(easeIn加速),不再瞬间跳变 -- 🎬 **入场弹性动画** — 新卡片从 scale 0.88→1.0 + opacity 0→1 弹性进入(easeOutBack回弹曲线) -- 🎬 **回弹弹簧动画** — 拖拽未达阈值时,卡片平滑弹回原位(easeOut减速),不再硬切 -- 🎬 **下一张卡片联动** — 滑出过程中,下一张卡片从 scale 0.92→1.0 + opacity 0.6→1.0 逐渐浮现 -- 🖼️ **图片预加载** — 切换卡片时 precacheImage 预加载后续3张图片,消除转圈等待 -- 🖼️ **CachedNetworkImage 替换** — MiniCardImageView/MiniCardViewer/MiniCardPage 全部替换 Image.network → CachedNetworkImage - - 磁盘缓存 maxWidthDiskCache/maxHeightDiskCache: 800/1200 - - 内存缓存 memCacheWidth/memCacheHeight: 800 - - fadeIn 300ms easeOut 渐入效果 -- 🎬 **滑出动画期间禁止拖拽** — 防止动画中重复触发滑动手势 - -#### Bug修复 -- 🐛 **动画控制器未绑定** — 原 _likeAnimController/_nopeAnimController 创建后从未驱动任何动画,卡片切换全靠 setState 硬切 -- 🐛 **图片无缓存** — Image.network 每次切换都重新请求网络,导致转圈加载 - -#### 影响文件 -- `lib/src/pages/discover/mini_card/mini_card_page.dart` — 动画系统重构+CachedNetworkImage+预加载 -- `lib/src/pages/discover/mini_card/mini_card_image_view.dart` — CachedNetworkImage替换 -- `lib/src/pages/discover/mini_card/mini_card_viewer.dart` — CachedNetworkImage替换 - -## [0.93.0] - 2026-04-14 - -### 🎨 视觉拉满 + 滚动卡顿修复 + 布局Bug修复 - -#### 优化 -- 🎨 **液态玻璃效果拉满** — 迷你卡片3层BackdropFilter+光感折射+荤素标签玻璃效果 -- 🎨 **底部栏恢复毛玻璃** — 瀑布流底部状态栏恢复BackdropFilter(sigmaX/Y:20)+渐变+边框 -- 🖼️ **CachedNetworkImage** — 迷你卡片图片改用CachedNetworkImage,避免滚动时重复网络请求 -- 🏗️ **ValueNotifier隔离** — 副栏显隐状态改用ValueNotifier,不再触发全局setState重建瀑布流 -- 🏗️ **merge保留缓存** — DiscoverData.merge()保留已有flattenedItems缓存并追加新项,避免重新shuffle导致滚动跳动 -- 🏗️ **RepaintBoundary** — 迷你卡片添加RepaintBoundary隔离渲染,不影响其他卡片绘制 - -#### Bug修复 -- 🐛 **滚动卡顿** — 移除_applyPendingDiscoverIfAny中的clearCache()调用,避免滚动中列表重新shuffle -- 🐛 **Column溢出** — 错误页和空数据页改用SingleChildScrollView包裹,防止溢出 -- 🐛 **SliverMasonryGrid null check** — 空chunk不再创建SliverMasonryGrid,防止null check错误 -- 🐛 **tag_discover_card溢出** — 标签类型和数量文字添加Flexible包裹,防止Row溢出 -- 🐛 **image_viewer_page** — _extractImageUrl改为imageUrlExtractor回调参数,修复未定义方法错误 -- 🐛 **discover_waterfall Colors** — 添加material导入,修复Colors未定义错误 -- 🐛 **主页缓存恢复** — _loadDiscover已有数据时使用merge而非替换,保持列表顺序稳定 - -#### 影响文件 -- `lib/src/pages/home/home_page.dart` — ValueNotifier隔离+merge策略+布局修复 -- `lib/src/widgets/discover/mini_card_discover_card.dart` — 液态玻璃拉满+CachedNetworkImage+RepaintBoundary -- `lib/src/widgets/discover/discover_waterfall.dart` — 底部栏毛玻璃+空chunk保护+material导入 -- `lib/src/widgets/discover/tag_discover_card.dart` — Row溢出修复 -- `lib/src/models/discover_model.dart` — merge保留flattenedItems缓存 -- `lib/src/widgets/image_viewer/image_viewer_page.dart` — imageUrlExtractor回调 - -## [0.92.9] - 2026-04-14 - -### 🃏 迷你卡片优化 — 液态玻璃效果+详情跳转+Bug修复 - -#### 优化 -- 🎨 **液态玻璃效果重设计** — 底部信息区改为"装水玻璃杯"效果 - - 使用 ClipRect + BackdropFilter(sigmaX/Y: 30) 实现高斯模糊 - - 顶部白色半透明边框(0.25 alpha)模拟光线折射 - - 渐变背景(0.05→0.12 alpha)模拟水杯透明度 - - 文字添加 Shadow(black54/black38) 增强可读性 - - 按钮使用渐变玻璃效果(0.25→0.1 alpha)替代纯色 -- 🆕 **categoryName 显示** — 迷你卡片底部信息区显示分类名称标签(液态玻璃胶囊样式) -- 🆕 **详情按钮API搜索** — 点击详情按钮通过 RecipeRepository.search() 搜索菜名获取API真实ID,再跳转菜谱详情页 -- 🆕 **全屏查看器详情按钮** — 全屏查看器中详情按钮也通过API搜索跳转 - -#### Bug修复 -- 🐛 **setState after dispose** — _goNext()/_goPrev() 添加 mounted 检查,防止动画回调在页面销毁后调用 setState -- 🐛 **MediaQuery initState 报错** — 图片预加载从 initState 移至 didChangeDependencies -- 🐛 **分享功能改为图片分享** — 使用 RepaintBoundary 截取卡片组件为PNG图片,通过 ShareXFiles 分享 -- 🐛 **PaginatedData 缺少 totalPages** — 补充必需参数 - -#### 影响文件 -- `lib/src/pages/discover/mini_card_page.dart` — 液态玻璃重设计+详情跳转+Bug修复 - -## [0.92.8] - 2026-04-14 - -### 🃏 迷你卡片页面 — Tinder风格滑动浏览菜品 + 首页瀑布流插入 +### ✨ 新增 — 点餐助手分享功能 + UI布局优化 + 权限页面 #### 新增功能 -- 🆕 **迷你卡片页面** — 交友软件风格左右滑动浏览菜品 - - 数据源:`/assets/recipes.json`(341道菜,11个分类) - - 图片URL:`https://eat.wktyl.com/api/assets/mpic/{id}.jpeg` - - 左右拖拽滑动切换菜品卡片,拖拽距离>80触发滑动 - - 滑动时显示"❤️ 喜欢"或"👎 下一道"标签 - - 底部4个操作按钮:下一道/收藏/喜欢/上一道 - - 进度条显示浏览进度 -- 🆕 **分类筛选** — 顶部横向滚动分类标签(全部/素菜/荤菜/水产等11类) -- 🆕 **搜索功能** — 搜索菜品名称,点击结果跳转到对应卡片 -- 🆕 **网格视图** — 卡片/网格双视图切换,响应式布局(2/3/4列) -- 🆕 **收藏集成** — 卡片右上角收藏按钮,与全局收藏系统联动 -- 🆕 **本地缓存** — SharedPreferences存储5-10条记录(不含图片),离线fallback -- 🆕 **喜欢/不喜欢记录** — 持久化到SharedPreferences,重启保留 -- 🆕 **缓存管理** — 缓存管理页面新增迷你卡片缓存清理入口 -- 🆕 **图片独立组件** — _MiniCardImageView 独立组件,文本在图片内底部展示 -- 🆕 **液态玻璃效果** — 卡片顶部操作栏和底部信息区使用 GlassContainer 毛玻璃 -- 🆕 **全屏图片查看器** — 点击卡片打开全屏 PageView,左右滑动切换,异步预加载5张相邻图片 -- 🆕 **分享按钮** — 使用 share_plus 分享菜品信息+图片URL -- 🆕 **缓存优先加载** — 查看过的卡片存入缓存,下次进入先显示缓存再内部加载 -- 🆕 **首页瀑布流插入** — 迷你卡片以全宽横幅插入首页瀑布流(1:20比例) - - 每20个瀑布流item后插入1个迷你卡片横幅 - - 点击横幅跳转迷你卡片页面(支持指定卡片ID) - - MiniCardDiscoverCard 组件:液态玻璃+全宽图片+分类标签 -- 🆕 **MiniCardService** — 独立数据服务,支持缓存优先,供多页面复用 -- 🆕 **路由参数支持** — 迷你卡片页面支持 initialRecipeId 参数,从首页跳转到指定卡片 +- 📝 **生成文本分享**:列表区域新增"生成文本"按钮,调用系统分享接口分享格式化订单文本 +- 🖼️ **生成图片分享**:列表区域新增"生成图片"按钮,弹出预览卡片(完整渲染所有菜品),确认后截图生成PNG图片并调用系统分享 +- 📤 **分享区域**:独立的"分享订单"卡片组件,与底部栏"关闭订单/生成账单"按钮分离 +- 🗑️ **关闭订单**:底部栏左侧新增"关闭订单"按钮,确认后标记取消并从服务器删除 +- 👥 **用餐人数**:桌号下方新增人数选择器,支持1/2/3/4/5/6/8/10常量快速选择和自定义输入 +- 🪑 **添加菜品按钮移至单号下方**:布局调整,添加菜品按钮紧跟订单头部 +- 🔒 **软件权限页面**:新增权限管理页面,展示应用所需权限说明和沙盒运行说明 -#### 入口 -- 个人中心 → 迷你卡片(原null路由已连接) -- 首页瀑布流 → 迷你卡片横幅(每20个item后出现) +#### 修改文件 +- `lib/src/pages/tools/cooking/order_assistant_page.dart` — 重构底部栏(仅保留关闭+账单),新增分享卡片、人数选择器、分享方法 +- `lib/src/models/tools/order_model.dart` — peopleCount字段(此前已添加) +- `lib/src/controllers/tools/order_assistant_controller.dart` — setPeopleCount/clearAllData方法(此前已添加) +- `lib/src/pages/profile/permission_page.dart` — 新增权限页面 +- `lib/src/pages/profile/about_page.dart` — 新增软件权限入口 -#### 影响文件 -- `lib/src/models/mini_card_model.dart` — 新建数据模型 -- `lib/src/pages/discover/mini_card_page.dart` — 重构:图片独立组件+全屏查看器+分享+缓存优先 -- `lib/src/config/app_routes.dart` — 路由支持 initialRecipeId 参数 -- `lib/src/pages/profile/profile_home.dart` — 连接入口 -- `lib/src/pages/profile/data/cache_manage_page.dart` — 新增迷你卡片缓存管理 -- `lib/src/widgets/discover/mini_card_discover_card.dart` — 新建瀑布流迷你卡片横幅组件 -- `lib/src/widgets/discover/discover_waterfall.dart` — 支持迷你卡片插入(SliverMainAxisGroup) -- `lib/src/models/discover_model.dart` — 新增 miniCard 类型和 MiniCardRecipeRef -- `lib/src/services/data/mini_card_service.dart` — 新建迷你卡片数据服务 -- `lib/src/pages/home/home_page.dart` — 加载迷你卡片数据,传递给瀑布流 -## [0.92.7] - 2026-04-14 +## [0.97.31] - 2026-04-17 -### 🧬 营养成分交互增强 + 食材详情页修复 - -#### 新增功能 -- 🆕 **营养成分展开更多** — 菜品详情页详细营养成分标题右侧新增"更多"按钮 - - 点击弹出底部对话框(CupertinoModalPopup),从下到上弹出 - - 每个营养成分显示emoji图标+名称+数值+跳转提示 - - 点击营养成分跳转到含该成分的菜品列表页 - - 营养成分emoji映射表(31种营养成分对应emoji) -- 🆕 **营养成分菜品列表页** — 新增 NutritionRecipeListPage - - 使用 `api_filter.php?act=filter_recipes&nutrition_name=` 接口查询 - - 支持分页加载,显示菜品卡片+图片 - - 路由: `/nutrition-recipe-list` +### 🔧 修复 — 二维码扫码显示网页而非JSON #### 修复内容 -- 🐛 **食材详情菜谱数量显示0** — 修复 `recipeCount` 始终为0的问题 - - 根因:API `ingredient_detail` 不返回 `statistics` 字段,但返回 `related_recipes` 数组 - - 修复:IngredientModel 新增 `relatedRecipes` 字段和 `effectiveRecipeCount` getter - - 当 `statistics.recipeCount` 为0时,fallback 到 `related_recipes.length` - - 同时支持 `recipe_count` 和 `view_count` 顶层字段 -- 🔧 **食材详情卡片位置调整** — 将"食材详情"卡片移到"储存方法"下面 +- 🐛 **QR URL修正**:二维码/条形码URL从 `kitchen.php?act=get&id=xxx`(返回JSON)改为 `?id=xxx`(加载index.html网页) +- 扫码后现在正确显示点单网页,包含菜品列表、金额、备注、桌号等信息 +- 网页端通过 `?id=xxx` 参数自动调用API获取订单数据并渲染 -#### 影响文件 -- `lib/src/widgets/recipe_detail/info/recipe_nutrition_section.dart` — 新增展开更多+弹窗+跳转 -- `lib/src/pages/discover/nutrition_recipe_list_page.dart` — 新建 -- `lib/src/config/app_routes.dart` — 新增路由 -- `lib/src/models/recipe/ingredient_model.dart` — 新增relatedRecipes+effectiveRecipeCount -- `lib/src/pages/tools/ingredient_detail_page.dart` — 修复count+调整卡片顺序 +#### 修改文件 +- `lib/src/models/tools/order_model.dart` — qrUrl 改为 `https://eat.wktyl.com/api/kitchen/?id=$id` +- `lib/src/services/tools/order_api_service.dart` — getQrUrls 同步修正 -> 📌 已移除较早版本记录(0.92.6及之前),功能已归档至软件特性清单。 + +## [0.97.30] - 2026-04-17 + +### 🔧 修复 — API路径修正 + 接口测试脚本完善 + +#### 修复内容 +- 🐛 **API路径修正**:`/kitchen.php` → `/kitchen/kitchen.php`,所有端统一指向正确服务器路径 +- 🐛 **测试脚本runInShell修复**:移除 `runInShell: true`,避免shell将URL中 `&` 解释为后台运行符导致参数丢失 +- 🐛 **SSE测试修复**:改用 `Process.run` + 临时文件方式读取SSE流,替代 `Process.start` + stdout.fold +- 🐛 **UTF-8编码修复**:curl添加 `--compressed` 参数,正确处理gzip压缩响应 + +#### 接口验证结果(全部通过) +| # | 测试项 | 结果 | +|---|--------|------| +| 1 | 接口首页 (index) | ✅ | +| 2 | CORS预检 (OPTIONS) | ✅ | +| 3 | 创建点单 (POST create) | ✅ | +| 4 | 获取点单 (GET get) | ✅ | +| 5 | 更新点单 (POST update) | ✅ | +| 6 | 点单列表 (GET list) | ✅ | +| 7 | 统计信息 (GET stats) | ✅ | +| 8 | SSE实时推送 | ✅ | +| 9 | 清理过期 (GET cleanup) | ✅ | +| 10 | 删除点单 (GET delete) | ✅ | +| 11 | 确认删除 (404) | ✅ | + +#### 修改文件 +- `lib/src/services/tools/order_api_service.dart` — _basePath 修正为 /kitchen/kitchen.php,QR/barcode URL同步修正 +- `lib/src/models/tools/order_model.dart` — qrUrl 修正为 /kitchen/kitchen.php +- `web_order/index.html` — API_BASE 和 SSE_URL 修正为 /kitchen/ 子路径 +- `scripts/test_kitchen_api.dart` — 修复 runInShell、SSE测试、UTF-8编码问题 + + +## [0.97.29] - 2026-04-17 + +### ✨ 新增 — 点餐助手PHP后端 + SSE实时推送 + 数据清理 + +#### 功能描述 +- 🖥️ **PHP后端API**:kitchen.php 完整CRUD接口,JSON文件存储,文件锁保证并发安全 +- 📡 **SSE实时推送**:kitchen_sse.php Server-Sent Events端点,App更新后网页端实时刷新 +- 🗑️ **数据清理**:App端支持7天/30天过期清理、本地+服务器联合清理、清空全部历史 +- ☁️ **远程同步**:OrderApiService 对接真实API,创建/更新/删除操作同步到服务器 +- 🌐 **网页端SSE**:web_order/index.html 接入SSE,实时显示连接状态,自动重连+轮询降级 + +#### API接口 +| 操作 | 方法 | URL | +|------|------|-----| +| 创建点单 | POST | kitchen.php?act=create | +| 获取点单 | GET | kitchen.php?act=get&id=xxx | +| 更新点单 | POST | kitchen.php?act=update | +| 点单列表 | GET | kitchen.php?act=list&page=1&limit=20 | +| 删除点单 | GET | kitchen.php?act=delete&id=xxx | +| 清理过期 | GET | kitchen.php?act=cleanup&days=30 | +| 统计信息 | GET | kitchen.php?act=stats | +| SSE推送 | GET | kitchen_sse.php?order_id=xxx | + +#### 新增文件 +- `docs/api/kitchen.php` — 点餐助手PHP后端API(CRUD + JSON存储 + 过期清理) +- `docs/api/kitchen_sse.php` — SSE实时推送端点(监听订单变化,推送更新) + +#### 修改文件 +- `lib/src/services/tools/order_api_service.dart` — Mock→真实API,新增 deleteOrder/cleanupExpired/getStats +- `lib/src/controllers/tools/order_assistant_controller.dart` — 新增 cleanupExpiredLocal/cleanupExpiredRemote/cleanupAllExpired +- `lib/src/pages/tools/cooking/order_assistant_page.dart` — 新增🗑️数据清理按钮和清理弹窗 +- `lib/src/models/tools/order_model.dart` — qrUrl 更新为 kitchen.php?act=get&id=xxx +- `web_order/index.html` — 接入SSE实时推送,新增连接状态指示器,轮询降级 + + +## [0.97.28] - 2026-04-17 + +### ✨ 新增 — 点餐助手工具 + +#### 功能描述 +- 🍽️ **用户点餐**:支持从浏览记录、搜索、手动填写、商家推荐四种方式添加菜品 +- 🏪 **商家推单**:一键切换商家推单模式,支持商家推荐菜品 +- 📋 **账单生成**:自动计算菜品数量和金额,生成唯一单号和时间戳 +- 📱 **二维码/条形码**:生成点单二维码和条形码,URL指向 eat.wktyl.com/api/kitchen +- 💾 **本地持久化**:SharedPreferences 存储历史记录和记录条数统计 +- 🌐 **网页端**:web_order/index.html 支持扫码查看点单信息,自动15秒刷新 +- 🎨 **iOS风格UI**:毛玻璃效果、圆角卡片、动态主题适配 + +#### 新增文件 +- `lib/src/models/tools/order_model.dart` — 点单数据模型(Order、OrderItem、OrderType、OrderStatus、OrderItemSource) +- `lib/src/services/tools/order_api_service.dart` — 点单API服务(Mock实现,后端就绪后切换) +- `lib/src/controllers/tools/order_assistant_controller.dart` — 点餐助手控制器,管理状态和持久化 +- `lib/src/pages/tools/cooking/order_assistant_page.dart` — 点餐助手主页面 +- `lib/src/pages/tools/cooking/widgets/order_item_card.dart` — 菜品卡片组件 +- `lib/src/pages/tools/cooking/widgets/add_item_sheet.dart` — 添加菜品弹窗入口 +- `lib/src/pages/tools/cooking/widgets/browse_history_picker.dart` — 浏览记录选择器 +- `lib/src/pages/tools/cooking/widgets/manual_input_sheet.dart` — 手动填写菜品弹窗 +- `lib/src/pages/tools/cooking/widgets/qr_barcode_dialog.dart` — 二维码/条形码弹窗 +- `web_order/index.html` — 网页端点单展示页 + +#### 修改文件 +- `lib/src/models/tool_item_model.dart` — 新增 order_assistant 工具注册 +- `lib/src/config/app_routes.dart` — 新增 toolsOrderAssistant 路由 + + +## [0.97.27] - 2026-04-17 + +### ✨ 新增 — 瀑布流工具卡片插槽系统 + +#### 功能描述 +- 🧩 **统一插槽系统**:WaterfallSlotRegistry 统一管理瀑布流中插入的各类卡片(miniCard、toolCard) +- 🔀 **交替插入策略**:miniCard 和 toolCard 每20个卡片交替插入(位20插miniCard,位40插toolCard) +- 🃏 **工具卡片**:毛玻璃中等卡片样式,展示工具 icon、名称、描述、分类标签 +- ℹ️ **详情入口**:卡片右上角 info 图标,点击进入独立工具详情页 +- 🚀 **一键打开**:卡片整体点击直接跳转对应工具页面 +- 📋 **强制声明**:ToolItem 新增 waterfallSlot 必填字段,不声明编译报错 +- 🔮 **未来扩展**:新增工具只需在 defaultTools 中声明 waterfallSlot: WaterfallSlotConfig(show: true) 即可自动出现在首页瀑布流 + +#### 新增文件 +- `lib/src/models/waterfall_slot.dart` — 瀑布流插槽模型(WaterfallSlotType、WaterfallSlot、WaterfallSlotConfig、WaterfallSlotRegistry) +- `lib/src/widgets/discover/tool_card_discover_card.dart` — 瀑布流工具卡片组件(毛玻璃风格) +- `lib/src/pages/tools/tool_detail_page.dart` — 工具详情页(独立页面,展示工具信息和打开按钮) + +#### 修改文件 +- `lib/src/models/tool_item_model.dart` — ToolItem 新增 waterfallSlot 必填字段;ToolRegistry 新增 homeCardTools getter;所有 defaultTools 均声明 waterfallSlot +- `lib/src/models/discover_model.dart` — DiscoverItemType 新增 toolCard 枚举值;DiscoverItem 新增 toolItemRef 字段和 toolCard 工厂构造;新增 ToolItemRef 类 +- `lib/src/widgets/discover/discover_waterfall.dart` — 接入 WaterfallSlotRegistry 统一插槽系统;新增 toolCards 参数;_buildItem 新增 toolCard 分支 +- `lib/src/pages/home/home_page.dart` — 传递 toolCards: ToolRegistry.homeCardTools 参数 +- `lib/src/config/app_routes.dart` — 新增 toolDetail 路由常量和 GetPage 注册 + + +## [0.97.26] - 2026-04-16 + +### ✨ 新增 — 用料管理工具 + +#### 功能描述 +- 🧴 **瓶子管理**:网格布局展示厨房用料瓶子,类似小瓶子视觉效果 +- 📊 **分类筛选**:支持比例、调味料、食材三种类型筛选 +- ➕ **增减容量**:点击瓶子可快速增加或减少容量(每次10%) +- ✏️ **自定义瓶子**:支持自定义瓶子名称、容量、类型 +- 💾 **本地持久化**:数据通过 SharedPreferences 本地存储 +- 🎬 **入场动画**:网格交错入场动画,流畅的视觉体验 +- 🎨 **iOS风格UI**:毛玻璃效果、圆角卡片、渐变色设计 + +#### 新增文件 +- `lib/src/models/bottle_model.dart` — 用料瓶子数据模型,包含类型、容量、填充量等 +- `lib/src/controllers/ingredient_manage_controller.dart` — 用料管理控制器,管理瓶子增删改查 +- `lib/src/pages/tools/ingredient_manage_page.dart` — 用料管理主页面,网格布局展示瓶子 + +#### 修改文件 +- `lib/src/models/tool_item_model.dart` — 注册「用料管理」工具项(id: ingredient_manage, route: /tools/ingredient-manage) +- `lib/src/config/app_routes.dart` — 注册路由 /tools/ingredient-manage + + +## [0.97.25] - 2026-04-16 + +### ✨ 新增 — 菜品排名(Tier List)工具 + +#### 功能描述 +- 🏆 **五级排行体系**:夯(红) → 顶级(橙金) → 人上人(黄) → NPC(米白) → 拉完了(灰白) +- 📖 **浏览记录导入**:从浏览历史中选择菜品加入排名 +- ❤️ **收藏导入**:从收藏列表中选择菜品加入排名 +- ✏️ **手动输入**:自定义菜品名称 + 选择 emoji 图标 +- 🔍 **搜索过滤**:选择面板内支持实时搜索 +- 🔄 **跨层级移动**:点击菜品可移动到其他层级 +- 🗑️ **删除/清空**:支持单条删除和一键清空 +- 💾 **本地持久化**:数据通过 SharedPreferences 本地存储 +- 🎬 **交错入场动画**:每行依次 slide + fade 进入 + +#### 新增文件 +- `lib/src/models/dish_rank_model.dart` — 菜品排名数据模型 + 层级定义常量 +- `lib/src/pages/tools/ranking/dish_ranking_controller.dart` — 排名控制器(数据管理、持久化) +- `lib/src/pages/tools/ranking/dish_ranking_page.dart` — Tier List 主页面 +- `lib/src/pages/tools/ranking/dish_pick_sheet.dart` — 底部选择面板组件 + +#### 修改文件 +- `lib/src/models/tool_item_model.dart` — 注册「菜品排名」工具项(id: dish_ranking, route: /tools/dish-ranking) +- `lib/src/config/app_routes.dart` — 注册路由 /tools/dish-ranking + + +## [0.97.24] - 2026-04-16 + +### ♻️ 重构 — 发现页列表下拉手势完全劫持 + +#### 问题 +- 子列表(热门排行 ListView、标签列表、分类网格)拥有独立滚动控制器 +- 在子列表顶部下拉时,手势被子列表消费,外层无法劫持为打开工具中心 + +#### 变更 +- 🔽 **禁用子列表独立滚动**:热门排行改为 `Column` + `map`,标签列表改为 `Column` + `map`,分类网格改为 `GridView` + `shrinkWrap` + `NeverScrollableScrollPhysics` +- 🎯 **统一滚动管理**:所有内容由外层 `CustomScrollView` 统一滚动,下拉手势不再被子列表拦截 +- 🧹 **移除 GestureDetector 冲突**:移除外层 `GestureDetector`(会和列表滚动竞争),改用纯 `NotificationListener` + `ClampingScrollPhysics` 捕获 `OverscrollNotification` +- 🔧 **修复 PageRoute 构造异常**:自定义 `_SlideFromTopPageRoute` 改为 `PageRouteBuilder`,修复 `NoSuchMethodError: No constructor '' declared in class 'null'` + +#### 修改文件 +- `lib/src/pages/discover/discover_page.dart` — 移除 GestureDetector,修复 PageRoute,使用 ClampingScrollPhysics +- `lib/src/pages/discover/components/discover_sections_widget.dart` — 所有子列表改为无独立滚动布局 + + +## [0.97.23] - 2026-04-16 + +### ♻️ 重构 — 发现页下拉手势拦截 & 工具中心页面化 + +#### 变更 +- 🔽 **全局下拉拦截**:发现页拦截所有下拉手势,任意位置下拉均可唤出工具中心 +- 📄 **页面化导航**:工具中心从 Overlay 弹窗改为 `PageRoute` 页面导航,天然支持系统返回键 +- 🎬 **从顶部滑入**:自定义 `_SlideFromTopPageRoute`,工具中心从顶部滑入(类 iOS 通知中心/搜索面板) +- ⏸️ **动画可打断**:页面转场动画支持手势打断(系统默认支持) +- 👆 **下拉关闭**:工具中心页面内下拉手势可关闭返回,带拖拽指示条 +- 📳 **震动反馈**:下拉达到阈值触发中等+强烈震动 +- 📱 **系统返回键**:工具中心页面支持 Android 系统返回键、iOS 滑动返回 +- 🎨 **保留原有 UI**:工具中心面板 UI 不变(常用工具、分类工具、浏览记录、底部操作栏) + +#### 移除 +- ❌ `OverlayEntry` 方案(不支持系统返回键) +- ❌ `AnimationController` 面板动画(改用系统 PageRoute 动画) + +#### 修改文件 +- `lib/src/pages/discover/discover_page.dart` — 移除 Overlay 方案,改为 `GestureDetector` + `PageRoute` 导航 +- `lib/src/pages/discover/components/tools_panel_widget.dart` — 重构为独立页面模式,支持下拉关闭手势和系统返回键 + + +## [0.97.22] - 2026-04-16 + +### 🐛 修复 — 发现页工具中心交互优化 + +#### 变更 +- 🔽 **从底部滑入**:工具中心改为从底部滑入(类微信小程序交互),取代原顶部滑入 +- 📐 **覆盖底部tab栏**:使用 Overlay 确保工具面板层级最高,覆盖底部导航栏 +- 🔘 **固定按钮可见**:底部功能按钮(首页/收藏/设置/关于)随面板一起显示 +- 📳 **震动反馈**:下拉触发工具中心时添加触觉反馈(中等+强烈震动) +- 🔍 **响应式搜索**:搜索框从只读改为可输入,实时搜索匹配工具并显示结果 +- ⚡ **触发灵敏度**:下拉阈值从80px降至50px,阻尼系数从0.5提升至0.8 +- 👆 **下滑关闭**:面板内下滑手势关闭面板,提示文案更新为"下滑关闭" + +#### 修改文件 +- `lib/src/pages/discover/discover_page.dart` — 搜索框响应式搜索、Overlay显示面板、震动反馈、灵敏度调整 +- `lib/src/pages/discover/components/tools_panel_widget.dart` — 从底部滑入动画、下滑关闭手势、遮罩层分离 + + +## [0.97.21] - 2026-04-16 + +### ♻️ 重构 — 发现页代码拆分与工具中心完善 + +#### 变更 +- 📁 **代码拆分**:将 discover_page.dart (原1834行) 拆分为多个文件,每个文件不超过800行 +- 📦 **新建文件夹**:`lib/src/pages/discover/components/` 存放拆分后的组件 +- 🔧 **工具面板组件**:`tools_panel_widget.dart` (792行) — 包含下拉工具中心面板所有UI +- 📊 **分区内容组件**:`discover_sections_widget.dart` (685行) — 包含热门/今天吃什么/推荐三个分区 +- 📄 **主文件精简**:`discover_page.dart` (499行) — 保留页面骨架和状态管理 +- 💾 **备份文件**:`discover_page.dart.bak` 保留原始代码 + +#### 工具中心完善 +- ✅ **上滑关闭**:支持手势上滑关闭工具中心面板 +- 🔍 **搜索框**:基础信息区新增工具搜索功能 +- 📝 **工具详情**:长按工具显示详情弹窗,包含使用次数、联网状态等信息 +- 📂 **分类展示**:所有工具按分类分组显示(烹饪助手/健康营养/数据查询/规划管理) +- 🕐 **浏览记录**:显示最近浏览的菜谱,横向滚动卡片列表 +- 🔘 **底部按钮**:固定功能按钮栏(首页/收藏/设置/关于) +- 📏 **层级修复**:工具中心面板覆盖在底部tab栏之上 + +#### 修改文件 +- `lib/src/pages/discover/discover_page.dart` — 主文件精简,引用新组件 +- `lib/src/pages/discover/components/tools_panel_widget.dart` — 工具面板组件 +- `lib/src/pages/discover/components/discover_sections_widget.dart` — 分区内容组件 +- `lib/src/pages/discover/components/tool_detail_sheet.dart` — 工具详情弹窗 +- `lib/src/pages/discover/components/browse_history_section.dart` — 浏览记录组件 +- `lib/src/pages/discover/discover_page.dart.bak` — 备份 + + +## [0.97.20] - 2026-04-16 + +### ♻️ 重构 — 发现页新增动态工具栏 + +#### 变更 +- 🔄 **发现页面**:新增动态工具栏,显示常用工具快捷入口(按使用次数排序) +- 📊 **智能排序**:工具按 `usageCount` 降序排列,常用工具优先显示 +- 🔧 **工具功能**:点击工具记录使用次数并跳转;"更多"按钮打开完整工具中心面板 +- 💎 **视觉风格**:毛玻璃卡片效果,与 iOS 26 Liquid Glass 风格一致 +- 📍 **位置调整**:工具栏位于搜索框与分段控制之间 + +#### 修改文件 +- `lib/src/pages/discover/discover_page.dart` — 新增 _buildToolsBar、_buildToolShortcut、_buildMoreToolsCard、_navigateToTool 方法 + + +> 📌 已移除较早版本记录(0.97.19及之前),功能已归档至软件特性清单。 diff --git a/android/app/src/main/AndroidManifest.xml b/android/app/src/main/AndroidManifest.xml index cd87fd3..f83c35b 100644 --- a/android/app/src/main/AndroidManifest.xml +++ b/android/app/src/main/AndroidManifest.xml @@ -1,8 +1,16 @@ + + + + + + + - + + $value) { + if (!isset($_GET[$key])) { + $_GET[$key] = $value; + } + } +} + $act = strtolower(trim($_GET['act'] ?? 'index')); $result = array(); @@ -56,8 +89,9 @@ switch ($act) { 'code' => 200, 'message' => '动态接口', 'data' => array( - 'version' => '2.0.0', + 'version' => '2.1.0', 'description' => '包含点赞、评分、浏览量等写操作', + 'request_method' => '支持 GET 和 POST 两种请求方式', 'ip_limit' => '每个IP每天可评分30次', 'endpoints' => array( 'like' => '?act=like&type=recipe&id=1&action=like', @@ -66,6 +100,7 @@ switch ($act) { 'ip_status' => '?act=ip_status' ), 'changes' => array( + 'v2.1.0' => '新增POST请求支持(JSON body / form-data),兼容GET参数', 'recommend' => '已废弃,改用rate接口', 'rate' => '评分功能,1-5分,不可取消,每日30次限制' ) diff --git a/docs/api/kitchen.php b/docs/api/kitchen.php new file mode 100644 index 0000000..4ea5627 --- /dev/null +++ b/docs/api/kitchen.php @@ -0,0 +1,610 @@ + $value) { + if (!isset($_GET[$key])) { + $_GET[$key] = $value; + } + } +} + +$act = strtolower(trim($_GET['act'] ?? 'index')); + +// ─── 数据目录配置 ─── +$dataDir = dirname(__FILE__) . '/cache/kitchen/'; +if (!is_dir($dataDir)) { + if (!@mkdir($dataDir, 0755, true)) { + $tmpDir = sys_get_temp_dir() . '/kitchen/'; + if (!is_dir($tmpDir)) { + @mkdir($tmpDir, 0755, true); + } + $dataDir = $tmpDir; + } +} +if (!is_writable($dataDir)) { + $tmpDir = sys_get_temp_dir() . '/kitchen/'; + if (!is_dir($tmpDir)) { + @mkdir($tmpDir, 0755, true); + } + $dataDir = $tmpDir; +} + +$ordersFile = $dataDir . 'orders.json'; +$counterFile = $dataDir . 'counter.json'; +$updateFlagFile = $dataDir . 'last_update.json'; + +// ─── 默认过期天数 ─── +$defaultExpireDays = 30; + +// ─── 路由分发 ─── +$result = array(); + +switch ($act) { + case 'create': + $result = create_order(); + break; + case 'get': + $result = get_order(); + break; + case 'update': + $result = update_order(); + break; + case 'list': + $result = list_orders(); + break; + case 'delete': + $result = delete_order(); + break; + case 'cleanup': + $result = cleanup_expired(); + break; + case 'clear_all': + $result = clear_all_orders(); + break; + case 'stats': + $result = get_stats(); + break; + case 'index': + default: + $result = array( + 'code' => 200, + 'message' => '🍽️ 点餐助手API', + 'data' => array( + 'version' => '1.0.0', + 'description' => '点单CRUD操作,JSON文件存储,SSE实时推送', + 'endpoints' => array( + 'create' => 'POST ?act=create {order JSON}', + 'get' => 'GET ?act=get&id=订单ID', + 'update' => 'POST ?act=update {order JSON}', + 'list' => 'GET ?act=list&page=1&limit=20&status=0', + 'delete' => 'GET ?act=delete&id=订单ID', + 'cleanup' => 'GET ?act=cleanup&days=30', + 'clear_all' => 'POST ?act=clear_all&confirm=yes', + 'stats' => 'GET ?act=stats', + 'sse' => 'GET kitchen_sse.php?order_id=xxx', + ), + 'storage' => 'JSON文件 (' . $ordersFile . ')', + 'expire' => $defaultExpireDays . '天', + ) + ); + break; +} + +$result['_query_time'] = round((microtime(true) - $startTime) * 1000, 2) . 'ms'; + +echo json_encode($result, JSON_UNESCAPED_UNICODE | JSON_UNESCAPED_SLASHES); +exit; + +// ══════════════════════════════════════════════════════════════ +// 数据读写函数 +// ══════════════════════════════════════════════════════════════ + +/** + * 读取所有订单数据 + */ +function read_orders() { + global $ordersFile; + if (!file_exists($ordersFile)) { + return array(); + } + $content = file_get_contents($ordersFile); + $data = json_decode($content, true); + return is_array($data) ? $data : array(); +} + +/** + * 写入所有订单数据(文件锁) + */ +function write_orders($orders) { + global $ordersFile; + $fp = fopen($ordersFile, 'c'); + if (flock($fp, LOCK_EX)) { + ftruncate($fp, 0); + rewind($fp); + fwrite($fp, json_encode($orders, JSON_UNESCAPED_UNICODE | JSON_UNESCAPED_SLASHES | JSON_PRETTY_PRINT)); + flock($fp, LOCK_UN); + } + fclose($fp); + touch_update_flag(); +} + +/** + * 读取计数器 + */ +function read_counter() { + global $counterFile; + if (!file_exists($counterFile)) { + return array('total' => 0, 'today' => 0, 'today_date' => date('Y-m-d')); + } + $content = file_get_contents($counterFile); + $data = json_decode($content, true); + return is_array($data) ? $data : array('total' => 0, 'today' => 0, 'today_date' => date('Y-m-d')); +} + +/** + * 写入计数器 + */ +function write_counter($counter) { + global $counterFile; + file_put_contents($counterFile, json_encode($counter, JSON_UNESCAPED_UNICODE)); +} + +/** + * 更新SSE推送标记 + */ +function touch_update_flag() { + global $updateFlagFile; + $data = array( + 'timestamp' => microtime(true), + 'time' => date('Y-m-d H:i:s'), + ); + file_put_contents($updateFlagFile, json_encode($data)); +} + +/** + * 递增计数器 + */ +function increment_counter() { + $counter = read_counter(); + $today = date('Y-m-d'); + if ($counter['today_date'] !== $today) { + $counter['today'] = 0; + $counter['today_date'] = $today; + } + $counter['total']++; + $counter['today']++; + write_counter($counter); + return $counter; +} + +// ══════════════════════════════════════════════════════════════ +// CRUD函数 +// ══════════════════════════════════════════════════════════════ + +/** + * 创建点单 + * POST ?act=create + * Body: {order JSON} + */ +function create_order() { + $orderJson = $_GET['order'] ?? $_GET['data'] ?? null; + + if ($orderJson === null) { + $rawBody = file_get_contents('php://input'); + $orderData = json_decode($rawBody, true); + } else { + $orderData = json_decode($orderJson, true); + } + + if (!is_array($orderData)) { + return array('code' => 400, 'message' => '无效的订单数据,需要JSON格式'); + } + + // 补全字段 + if (empty($orderData['id'])) { + $orderData['id'] = uniqid('ord_', true); + } + if (empty($orderData['orderNo'])) { + $orderData['orderNo'] = generate_order_no(); + } + if (!isset($orderData['status'])) { + $orderData['status'] = 1; // active + } + if (empty($orderData['createdAt'])) { + $orderData['createdAt'] = date('c'); + } + if (empty($orderData['updatedAt'])) { + $orderData['updatedAt'] = date('c'); + } + if (empty($orderData['createdBy'])) { + $orderData['createdBy'] = get_client_ip(); + } + if (!isset($orderData['recordCount'])) { + $counter = increment_counter(); + $orderData['recordCount'] = $counter['total']; + } + if (!isset($orderData['items'])) { + $orderData['items'] = array(); + } + + $orders = read_orders(); + array_unshift($orders, $orderData); + + // 限制最大存储量 + if (count($orders) > 500) { + $orders = array_slice($orders, 0, 500); + } + + write_orders($orders); + + return array( + 'code' => 200, + 'message' => '创建成功', + 'data' => $orderData, + ); +} + +/** + * 获取点单 + * GET ?act=get&id=xxx + */ +function get_order() { + $id = trim($_GET['id'] ?? ''); + if (empty($id)) { + return array('code' => 400, 'message' => '缺少订单ID参数'); + } + + $orders = read_orders(); + foreach ($orders as $order) { + if (isset($order['id']) && $order['id'] === $id) { + return array( + 'code' => 200, + 'message' => '获取成功', + 'data' => $order, + ); + } + } + + return array('code' => 404, 'message' => '订单不存在'); +} + +/** + * 更新点单 + * POST ?act=update + * Body: {order JSON with id} + */ +function update_order() { + $orderJson = $_GET['order'] ?? $_GET['data'] ?? null; + + if ($orderJson === null) { + $rawBody = file_get_contents('php://input'); + $orderData = json_decode($rawBody, true); + } else { + $orderData = json_decode($orderJson, true); + } + + if (!is_array($orderData) || empty($orderData['id'])) { + return array('code' => 400, 'message' => '无效的订单数据,缺少id字段'); + } + + $orders = read_orders(); + $found = false; + + foreach ($orders as $i => $order) { + if (isset($order['id']) && $order['id'] === $orderData['id']) { + $orderData['updatedAt'] = date('c'); + $orders[$i] = array_merge($order, $orderData); + $found = true; + break; + } + } + + if (!$found) { + // 不存在则创建 + array_unshift($orders, $orderData); + } + + write_orders($orders); + + return array( + 'code' => 200, + 'message' => $found ? '更新成功' : '订单不存在,已创建', + 'data' => $orderData, + ); +} + +/** + * 点单列表 + * GET ?act=list&page=1&limit=20&status=0&type=0 + */ +function list_orders() { + $page = max(1, (int)($_GET['page'] ?? 1)); + $limit = min(100, max(1, (int)($_GET['limit'] ?? 20))); + $status = $_GET['status'] ?? null; + $type = $_GET['type'] ?? null; + + $orders = read_orders(); + + // 筛选 + if ($status !== null) { + $statusVal = (int)$status; + $orders = array_filter($orders, function($o) use ($statusVal) { + return isset($o['status']) && (int)$o['status'] === $statusVal; + }); + $orders = array_values($orders); + } + if ($type !== null) { + $typeVal = (int)$type; + $orders = array_filter($orders, function($o) use ($typeVal) { + return isset($o['type']) && (int)$o['type'] === $typeVal; + }); + $orders = array_values($orders); + } + + $total = count($orders); + $offset = ($page - 1) * $limit; + $list = array_slice($orders, $offset, $limit); + + return array( + 'code' => 200, + 'message' => '获取成功', + 'data' => array( + 'list' => $list, + 'total' => $total, + 'page' => $page, + 'limit' => $limit, + 'pages' => ceil($total / $limit), + ), + ); +} + +/** + * 删除点单 + * GET ?act=delete&id=xxx + */ +function delete_order() { + $id = trim($_GET['id'] ?? ''); + if (empty($id)) { + return array('code' => 400, 'message' => '缺少订单ID参数'); + } + + $orders = read_orders(); + $originalCount = count($orders); + $orders = array_filter($orders, function($o) use ($id) { + return !isset($o['id']) || $o['id'] !== $id; + }); + $orders = array_values($orders); + + if (count($orders) === $originalCount) { + return array('code' => 404, 'message' => '订单不存在'); + } + + write_orders($orders); + + return array( + 'code' => 200, + 'message' => '删除成功', + 'data' => array('deleted_id' => $id), + ); +} + +/** + * 清理过期数据 + * GET ?act=cleanup&days=30 + */ +function cleanup_expired() { + global $defaultExpireDays; + $days = max(1, (int)($_GET['days'] ?? $defaultExpireDays)); + $cutoff = date('c', strtotime("-{$days} days")); + + $orders = read_orders(); + $originalCount = count($orders); + + $orders = array_filter($orders, function($o) use ($cutoff) { + $updatedAt = $o['updatedAt'] ?? $o['createdAt'] ?? ''; + return $updatedAt >= $cutoff; + }); + $orders = array_values($orders); + + $deletedCount = $originalCount - count($orders); + + if ($deletedCount > 0) { + write_orders($orders); + } + + return array( + 'code' => 200, + 'message' => "清理完成,删除{$deletedCount}条过期数据", + 'data' => array( + 'deleted_count' => $deletedCount, + 'remaining_count' => count($orders), + 'cutoff_date' => $cutoff, + 'expire_days' => $days, + ), + ); +} + +/** + * 清空全部点餐助手数据 + * POST ?act=clear_all&confirm=yes + * 仅清理 cache/kitchen/ 目录下的文件,不影响其他数据 + */ +function clear_all_orders() { + global $dataDir, $ordersFile, $counterFile, $updateFlagFile; + + $confirm = strtolower(trim($_GET['confirm'] ?? $_POST['confirm'] ?? '')); + if ($confirm !== 'yes') { + return array( + 'code' => 400, + 'message' => '需要确认参数 confirm=yes 才能清空数据', + ); + } + + $deletedFiles = array(); + $errors = array(); + + $kitchenFiles = array( + 'orders' => $ordersFile, + 'counter' => $counterFile, + 'update_flag' => $updateFlagFile, + ); + + foreach ($kitchenFiles as $name => $file) { + if (file_exists($file)) { + if (@unlink($file)) { + $deletedFiles[] = basename($file); + } else { + $errors[] = '无法删除: ' . basename($file); + } + } + } + + $scanDir = $dataDir; + if (is_dir($scanDir)) { + $extraFiles = @scandir($scanDir); + if ($extraFiles !== false) { + foreach ($extraFiles as $f) { + if ($f === '.' || $f === '..') continue; + $fullPath = $scanDir . $f; + if (is_file($fullPath) && !in_array($f, $deletedFiles)) { + if (@unlink($fullPath)) { + $deletedFiles[] = $f; + } else { + $errors[] = '无法删除: ' . $f; + } + } + } + } + } + + $message = '点餐助手数据已清空'; + if (count($deletedFiles) > 0) { + $message .= ',已删除: ' . implode(', ', $deletedFiles); + } + if (count($errors) > 0) { + $message .= ',错误: ' . implode(', ', $errors); + } + + return array( + 'code' => 200, + 'message' => $message, + 'data' => array( + 'deleted_files' => $deletedFiles, + 'errors' => $errors, + 'data_dir' => basename($dataDir), + 'scope' => 'kitchen_only', + ), + ); +} + +/** + * 统计信息 + * GET ?act=stats + */ +function get_stats() { + $counter = read_counter(); + $orders = read_orders(); + + $statusCount = array(0 => 0, 1 => 0, 2 => 0, 3 => 0); + $typeCount = array(0 => 0, 1 => 0); + + foreach ($orders as $o) { + $s = (int)($o['status'] ?? 0); + $t = (int)($o['type'] ?? 0); + if (isset($statusCount[$s])) $statusCount[$s]++; + if (isset($typeCount[$t])) $typeCount[$t]++; + } + + return array( + 'code' => 200, + 'message' => '获取成功', + 'data' => array( + 'total_orders' => $counter['total'], + 'today_orders' => $counter['today'], + 'stored_orders' => count($orders), + 'by_status' => $statusCount, + 'by_type' => $typeCount, + 'storage_file' => basename($GLOBALS['ordersFile']), + ), + ); +} + +// ══════════════════════════════════════════════════════════════ +// 工具函数 +// ══════════════════════════════════════════════════════════════ + +/** + * 生成订单号 + */ +function generate_order_no() { + $ts = substr(microtime(true) . '', 5); + $rand = rand(100, 999); + return 'OD' . $ts . $rand; +} + +/** + * 获取客户端IP + */ +function get_client_ip() { + if (!empty($_SERVER['HTTP_X_FORWARDED_FOR'])) { + $ips = explode(',', $_SERVER['HTTP_X_FORWARDED_FOR']); + return trim($ips[0]); + } + if (!empty($_SERVER['HTTP_X_REAL_IP'])) { + return $_SERVER['HTTP_X_REAL_IP']; + } + return $_SERVER['REMOTE_ADDR'] ?? '0.0.0.0'; +} diff --git a/docs/api/kitchen_sse.php b/docs/api/kitchen_sse.php new file mode 100644 index 0000000..71a11ae --- /dev/null +++ b/docs/api/kitchen_sse.php @@ -0,0 +1,159 @@ + 0, 'time' => ''); + } + $content = file_get_contents($updateFlagFile); + $data = json_decode($content, true); + return is_array($data) ? $data : array('timestamp' => 0, 'time' => ''); +} + +// ─── 读取订单数据 ─── +function read_order_by_id($id) { + global $ordersFile; + if (!file_exists($ordersFile)) { + return null; + } + $content = file_get_contents($ordersFile); + $orders = json_decode($content, true); + if (!is_array($orders)) return null; + + foreach ($orders as $order) { + if (isset($order['id']) && $order['id'] === $id) { + return $order; + } + } + return null; +} + +// ─── 发送初始连接事件 ─── +send_sse('connected', array( + 'message' => 'SSE连接已建立', + 'order_id' => $order_id, + 'timestamp' => microtime(true), +)); + +// ─── 如果有order_id,立即发送当前订单数据 ─── +if (!empty($order_id)) { + $order = read_order_by_id($order_id); + if ($order !== null) { + send_sse('order_update', $order); + } +} + +// ─── 主循环:监听变化 ─── +$lastKnownTimestamp = read_update_flag()['timestamp']; + +while ($iteration < $maxIterations) { + $iteration++; + + // 检查客户端是否断开 + if (connection_aborted()) { + break; + } + + // 检查更新标记 + $flag = read_update_flag(); + $currentTimestamp = (float)($flag['timestamp'] ?? 0); + + if ($currentTimestamp > $lastKnownTimestamp) { + $lastKnownTimestamp = $currentTimestamp; + + // 有更新,推送数据 + if (!empty($order_id)) { + // 推送特定订单 + $order = read_order_by_id($order_id); + if ($order !== null) { + send_sse('order_update', $order); + } else { + send_sse('order_deleted', array( + 'order_id' => $order_id, + 'timestamp' => $currentTimestamp, + )); + } + } else { + // 推送全局更新通知 + send_sse('global_update', array( + 'timestamp' => $currentTimestamp, + 'time' => $flag['time'] ?? '', + )); + } + } + + // 心跳包(每10秒) + if ($iteration % 10 === 0) { + send_sse('heartbeat', array( + 'timestamp' => microtime(true), + 'iteration' => $iteration, + )); + } + + sleep(1); +} + +// ─── 连接关闭 ─── +send_sse('close', array('message' => '连接超时,请重新连接')); diff --git a/docs/dev/PAGE_STRUCTURE_ANALYSIS.md b/docs/dev/PAGE_STRUCTURE_ANALYSIS.md index 3c14617..6fa3364 100644 --- a/docs/dev/PAGE_STRUCTURE_ANALYSIS.md +++ b/docs/dev/PAGE_STRUCTURE_ANALYSIS.md @@ -38,7 +38,7 @@ ┌─────────────────────────────┐ │ HomeAppBar (双层) │ │ ┌───────────────────────┐ │ -│ │ 🍳 妈妈厨房 🔔 │ │ ← 固定顶栏 +│ │ 🍳 小妈厨房 🔔 │ │ ← 固定顶栏 │ └───────────────────────┘ │ │ ┌───────────────────────┐ │ │ │ 🔍 搜索框 [→搜索页] │ │ ← 可折叠副栏,点击跳转 @@ -569,7 +569,7 @@ ├─────────────────────────────┤ │ │ │ ┌───────────────────────┐ │ -│ │ 🍳 妈妈厨房 │ │ +│ │ 🍳 小妈厨房 │ │ │ │ Version 0.92.4 │ │ │ └───────────────────────┘ │ │ │ diff --git a/docs/dev/UNFINISHED_FEATURES.md b/docs/dev/UNFINISHED_FEATURES.md index 1dfa9d0..5664f30 100644 --- a/docs/dev/UNFINISHED_FEATURES.md +++ b/docs/dev/UNFINISHED_FEATURES.md @@ -1,6 +1,6 @@ # 📋 未完成功能清单 -> 创建: 2026-04-09 | 更新: 2026-04-14 v0.92.6 | 优先级: P1=核心 P2=重要 P3=增强 | 优先级值1-5(5=最高) +> 创建: 2026-04-09 | 更新: 2026-04-16 v0.97.14 | 优先级: P1=核心 P2=重要 P3=增强 | 优先级值1-5(5=最高) --- @@ -28,14 +28,17 @@ | 二十九:菜品详情页功能完善 | 9 | 9 | 100% | ✅ | | 三十:发现页口味/工艺筛选 | 3 | 3 | 100% | ✅ | | 三十一:搜索功能修复与高级搜索 | 3 | 3 | 100% | ✅ | -| 三十二 | 主题色全局生效修复 | 1 | 1 | 100% | ✅ | -| 三十三 | 全局UI统一(圆角/颜色/空状态/加载) | 4 | 4 | 100% | ✅ | -| 三十四 | 食材详情本地缓存+缓存管理 | 2 | 2 | 100% | ✅ | -| 三十五 | 食材详情页闪退修复 | 1 | 1 | 100% | ✅ | -| 三十六 | 21项功能批量实现 | 21 | 21 | 100% | ✅ | -| 三十七 | 目录结构整理+导入路径修复 | 8 | 8 | 100% | ✅ | -| 三十八 | UI布局优化+缓存修复 | 4 | 4 | 100% | ✅ | -| **合计** | **232** | **222** | **96%** | | +| 三十二:主题色全局生效修复 | 1 | 1 | 100% | ✅ | +| 三十三:全局UI统一 | 4 | 4 | 100% | ✅ | +| 三十四:食材详情本地缓存+缓存管理 | 2 | 2 | 100% | ✅ | +| 三十五:食材详情页闪退修复 | 1 | 1 | 100% | ✅ | +| 三十六:21项功能批量实现 | 21 | 21 | 100% | ✅ | +| 三十七:目录结构整理+导入路径修复 | 8 | 8 | 100% | ✅ | +| 三十八:UI布局优化+缓存修复 | 4 | 4 | 100% | ✅ | +| 三十九:迷你卡片功能 | 14 | 14 | 100% | ✅ | +| 四十:收藏页面功能增强 | 7 | 7 | 100% | ✅ | +| 四十一:代码质量审计+修复 | — | — | — | 🔴 | +| **合计** | **234** | **224** | **96%** | | --- @@ -45,7 +48,7 @@ | 序号 | 阶段 | 任务 | 优先级 | 优先级值 | 说明 | |------|------|------|--------|---------|------| -| 1 | 十五 | 👤 用户注册登录 | P1 | 5 | 需后端支持,当前暂不开发 | +| 1 | 十五 | 👤 用户注册登录 | P1 | 5 | 需后端支持,当前暂不开发(ProfileController.login()为模拟实现) | | 2 | 十五 | 💾 收藏云端同步 | P1 | 4 | 需后端支持 | | 3 | 十二 | 🔔 烹饪提醒通知 | P2 | 3 | 定时提醒烹饪步骤,需 flutter_local_notifications | | 4 | 二十三 | ⚠️ 过敏原智能过滤 | P2 | 3 | 搜索/推荐时自动过滤含用户过敏原的菜品 | @@ -53,13 +56,70 @@ | 6 | 十五 | 🔔 消息推送 | P2 | 2 | 需后端支持 | | 7 | 十五 | 📜 浏览历史同步 | P2 | 2 | 需后端支持 | | 8 | 十五 | 📝 菜谱上传 | P2 | 2 | 需后端支持 | -| 9 | 十四 | 📱 二维码海报 | P3 | 2 | 生成菜谱二维码分享图 | -| 10 | 十四 | 🔗 社交分享增强 | P3 | 2 | 分享链接+热度标签+社交平台 | -| 11 | 十二 | 📸 拍照记录 | P3 | 2 | 烹饪笔记支持拍照上传 | +| 9 | 十四 | 🔗 社交分享增强 | P3 | 2 | 分享链接+热度标签+社交平台 | +| 10 | 十二 | 📸 拍照记录 | P3 | 2 | 烹饪笔记支持拍照上传 | --- -## 🟢 已有API可直接开发的功能(优先开发) +## 🚨 代码质量审计(浑水摸鱼清单) + +> 以下功能声称已完成,但实际存在实现缺陷、架构不一致或核心逻辑缺失 +> 更新时间: 2026-04-16 | 审计范围: lib/src 全量扫描 + +### 🔴 严重问题:核心功能未实现/安全漏洞 + +| # | 文件 | 问题 | 严重度 | 修复方案 | +|---|------|------|--------|---------| +| 1 | `email_service.dart` | **🚨安全漏洞**:SMTP密码硬编码在源码中(`520kiss123`),两条线路均暴露,任何人都可从代码获取邮箱凭据 | 🔴🔴极高 | 迁移到后端代理发送或使用环境变量/加密存储 | +| 2 | `profile_controller.dart` | **🚨模拟登录**:`login()` 方法完全伪造,硬编码 `user_id='user_123'`、`name='Test User'`、头像为字节跳动API链接,无任何真实认证逻辑 | 🔴🔴极高 | 等后端API就绪后重写,当前应标注为"演示模式" | +| 3 | `bedtime_reminder_controller.dart` | 就寝提醒通知核心功能未实现,仅保存设置,`TODO`标记实际通知逻辑,且 `flutter_local_notifications` 未在 pubspec.yaml 中声明依赖 | 🔴高 | 添加 flutter_local_notifications 依赖 + 实现通知逻辑 | +| 4 | `recommendation_service.dart` | **死代码**:`RecommendationService` 是 `GetxService` 但从未在 `AppBinding` 中注册,永远不会被初始化;`_viewedRecipeIds` 始终为空列表,`recordView()` 方法从未被调用 | 🔴高 | 在 AppBinding 注册 + 接入 BrowseHistoryController 真实数据 + 在 RecipeDetailController._recordBrowseHistory() 中同步调用 | +| 5 | `chat_page.dart` (FeedbackPage) | 意见反馈页面仅为UI模拟,`_send()` 方法只添加假回复消息"感谢您的反馈",无实际提交逻辑,用户反馈数据完全丢失 | 🔴高 | 接入 EmailService 邮件发送或后端API提交反馈 | + +### 🟡 中等问题:架构不一致 + +| # | 文件 | 问题 | 严重度 | 修复方案 | +|---|------|------|--------|---------| +| 6 | `allergen_checker_page.dart` | 直接使用 `Dio()` 发起HTTP请求获取 gmy.json,绕过 ApiService/Repository 层 | 🟡中 | 改用 RecipeRepository 或 ApiService 统一请求 | +| 7 | `eating_times_page.dart` | 直接使用 `Dio()` 发起HTTP请求获取 eating_times.json,绕过 ApiService/Repository 层 | 🟡中 | 改用 RecipeRepository 或 ApiService 统一请求 | +| 8 | `meal_time_recommend_page.dart` | 直接使用 `Dio()` 发起HTTP请求,绕过 ApiService/Repository 层 | 🟡中 | 改用 RecipeRepository 或 ApiService 统一请求 | +| 9 | `browse_history_controller.dart` | 使用 SharedPreferences 存储浏览历史,与项目统一使用 HiveService 的规范不一致 | 🟡中 | 迁移到 HiveService 统一数据层 | +| 10 | `email_history_controller.dart` | 使用 SharedPreferences 存储邮件记录,与项目统一使用 HiveService 的规范不一致 | 🟡中 | 迁移到 HiveService 统一数据层 | +| 11 | `cooking_note_controller.dart` | 双重存储混乱:同时使用 HiveService + SharedPreferences,逻辑复杂且冗余 | 🟡中 | 统一使用 HiveService,移除 SharedPreferences 备份逻辑 | +| 12 | 31处路由硬编码 | 31处使用 `Get.toNamed('/xxx')` 硬编码路由字符串,未使用 `AppRoutes.xxx` 常量 | 🟡中 | 批量替换为 AppRoutes 常量引用 | +| 13 | 14处非标准导航 | 14处使用 `Get.to(() => XxxPage())` 直接构造页面,未走路由系统 | 🟡中 | 改为 `Get.toNamed(AppRoutes.xxx)` 统一导航 | + +### 🟡 中等问题:代码规范 + +| # | 文件 | 问题 | 严重度 | 修复方案 | +|---|------|------|--------|---------| +| 14 | 57个文件 | 385处 `debugPrint()` 调用,应使用 LoggerService 统一日志 | 🟡中 | 批量替换为 AppLogger | +| 15 | `ingredient_detail_page.dart` | 1853行,严重超标(AGENTS.md规定≤1000行) | 🟡中 | 拆分为多个子组件 | +| 16 | `safe_period_calculator_page.dart` | 1165行,超标 | 🟡中 | 拆分计算逻辑和UI | +| 17 | `favorites_page.dart` | 1155行,超标 | 🟡中 | 已拆分 builders/tools_panel,继续拆分 | +| 18 | `mini_card_page.dart` | 1154行,超标 | 🟡中 | 拆分缓存/搜索/网格视图逻辑 | +| 19 | `date_calculator_page.dart` | 1144行,超标 | 🟡中 | 拆分两种计算模式为独立组件 | +| 20 | `cache_manage_page.dart` | 1095行,超标 | 🟡中 | 按缓存类型拆分子组件 | +| 21 | `app_routes.dart` | 1076行,超标 | 🟡中 | 路由定义与页面注册分离 | +| 22 | `recipe_repository.dart` | 1062行,超标 | 🟡中 | 按API文件拆分为多个 Repository | +| 23 | `search_page.dart` | 1017行,超标 | 🟡中 | 拆分搜索结果/历史/建议子组件 | +| 24 | `recipe_model.dart` | 1010行,超标 | 🟡中 | 拆分统计/评分/营养子模型 | + +--- + +## ✅ 状态更正:已实现但文档标记为"待开发" + +> 以下功能代码已完整实现,但旧版文档错误标记为"待开发" + +| # | 功能 | 文件 | 代码行数 | 实现状态 | 旧文档状态 | +|---|------|------|---------|---------|-----------| +| 1 | 🍳 烹饪模式 | `cooking_mode_page.dart` | 545行 | ✅ 完整实现(全屏步骤浏览+计时器+防息屏+完成动画) | ❌ 标记为"待开发" | +| 2 | 📱 二维码海报 | `recipe_qr_poster.dart` | ~280行 | ✅ 完整实现(QR生成+海报渲染+截图保存+弹窗展示) | ❌ 标记为"待开发" | +| 3 | 📅 每日菜单规划 | `daily_menu_page.dart` | 520行 | ✅ 完整实现(三餐生成+手动调整+营养汇总+本地持久化) | ❌ 标记为"待开发" | + +--- + +## 🟢 已有API可直接开发的功能 > 以下功能后端API已就绪,仅需前端开发 @@ -104,16 +164,16 @@ | # | 功能 | 所需API | 页面位置 | 工作量 | 状态 | |---|------|---------|---------|--------|------| -| 1 | 🧠 智能推荐 | `api_feed.php?act=recommend` + `api_filter.php` + 用户偏好 | 首页 | ⭐⭐⭐⭐ | 待开发 | -| 2 | 📅 每日菜单规划 | `api_what_to_eat.php?act=filter_apply` × 3次 + eating_times.json | 工具中心 | ⭐⭐⭐ | 待开发 | -| 3 | 📱 二维码海报 | `api.php?act=detail` code字段 + qr_flutter库 | 菜品详情页 | ⭐⭐ | 待开发 | +| 1 | 🧠 智能推荐 | `api_feed.php?act=recommend` + `api_filter.php` + 用户偏好 | 首页 | ⭐⭐⭐⭐ | 🟡框架已有,数据层需修复(RecommendationService未注册) | +| 2 | 📅 每日菜单规划 | `api_what_to_eat.php?act=filter_apply` × 3次 + eating_times.json | 工具中心 | ⭐⭐⭐ | ✅ v0.97.x | +| 3 | 📱 二维码海报 | `api.php?act=detail` code字段 + qr库 | 菜品详情页 | ⭐⭐ | ✅ v0.97.x | | 4 | 🔗 社交分享增强 | `api.php?act=detail` code字段 + `api_hot.php` statistics | 菜品详情页 | ⭐⭐ | 待开发 | | 5 | 🥗 食材营养详情 | `api.php?act=ingredient_detail` + nutrition_types.json | 食材详情页 | ⭐⭐ | ✅ v0.92.0 | | 6 | 🔄 食材替代建议 | `api_filter.php?act=filter_recipes` ingredient参数 + gmy.json | 食材详情页 | ⭐⭐⭐ | ✅ v0.92.0 | | 7 | 📈 营养目标追踪 | `api.php?act=full` nutrition × 多菜谱 + nutrition_types.json | 营养中心 | ⭐⭐⭐⭐ | ✅ v0.92.0 | | 8 | 📊 运营数据大屏 | `stats_full.php` stats/online/request/hot | 管理后台 | ⭐⭐⭐ | ✅ v0.92.0 | | 9 | 🔄 相关菜谱推荐 | `api.php?act=list` cate_id + tag_id 组合查询 | 菜品详情页 | ⭐⭐⭐ | ✅ v0.92.0 | -| 10 | 🍳 烹饪模式 | `api.php?act=full` content字段 + 计时器 | 菜品详情页 | ⭐⭐⭐ | 待开发 | +| 10 | 🍳 烹饪模式 | `api.php?act=full` content字段 + 计时器 | 菜品详情页 | ⭐⭐⭐ | ✅ v0.97.x | --- @@ -134,114 +194,167 @@ | 9 | 🏆 用户成就系统 | `api_achievement.php` | P3 | 经验值+等级+徽章 | — | | 10 | 📝 烹饪笔记同步 | `api_note.php` | P2 | CRUD+分页 | 当前本地Hive存储 | | 11 | 🛒 购物清单同步 | `api_shopping.php` | P2 | CRUD | 当前本地存储 | -| 12 | ⏰ 每周菜单存储 | `api_menu.php` | P2 | CRUD+日期范围 | 当前无持久化 | -| 13 | 🔄 相关菜谱推荐 | `api.php?act=related&id=` | P2 | 基于分类+标签+食材相似度 | 可用现有API组合替代 | +| 12 | ⏰ 每周菜单存储 | `api_menu.php` | P2 | CRUD+日期范围 | 当前本地持久化 | +| 13 | 🔄 相关菜谱推荐 | `api.php?act=related&id=` | P2 | 基于分类+标签+食材相似度 | 已用现有API组合替代 | --- -## 🎯 推荐开发路线图 +## 🎯 开发路线图(2026-04-16 更新) -### 第一阶段:快速见效(1-2天/功能) +### 第一阶段:安全+核心功能修复(紧急) + +> 修复安全漏洞和浑水摸鱼问题,确保已声称完成的功能真正可用 + +| 顺序 | 任务 | 问题 | 修复方案 | 优先级值 | +|------|------|------|---------|---------| +| 1 | 🚨 SMTP密码泄露 | `email_service.dart` 硬编码 `520kiss123` | 迁移到后端代理发送或使用环境变量/加密存储 | 5 | +| 2 | � 模拟登录标注 | `profile_controller.dart` login()为假实现 | 标注为"演示模式",隐藏登录入口或添加提示 | 5 | +| 3 | �🔔 就寝提醒通知 | 核心通知逻辑未实现 + 缺少依赖 | 添加 flutter_local_notifications + 实现通知逻辑 | 5 | +| 4 | 🧠 推荐服务修复 | RecommendationService 未注册 + 数据层为空 | 在 AppBinding 注册 + 接入 BrowseHistoryController + 在 _recordBrowseHistory() 中同步调用 | 4 | +| 5 | 📧 意见反馈提交 | 仅UI模拟,无提交逻辑 | 接入 EmailService 邮件发送反馈内容 | 4 | + +### 第二阶段:架构统一(重要) + +> 统一架构规范,消除技术债 + +| 顺序 | 任务 | 范围 | 修复方案 | 优先级值 | +|------|------|------|---------|---------| +| 6 | 🌐 3个页面绕过ApiService | allergen_checker/eating_times/meal_time_recommend 直接用Dio | 改用 ApiService 统一请求 | 3 | +| 7 | 💾 存储层统一 | browse_history/email_history/cooking_note 使用 SharedPreferences | 迁移到 HiveService 统一数据层 | 3 | +| 8 | 🔗 路由硬编码统一 | 31处硬编码路由字符串 | 批量替换为 AppRoutes 常量 | 3 | +| 9 | 🧭 导航方式统一 | 14处 Get.to() 直接构造页面 | 改为 Get.toNamed() | 3 | +| 10 | 📝 日志统一 | 57文件385处 debugPrint | 替换为 AppLogger | 2 | +| 11 | 📁 超大文件拆分 | 10个文件超1000行 | 按功能拆分子组件 | 2 | + +### 第三阶段:功能补全 + +> 补全缺失功能,提升用户体验 | 顺序 | 功能 | 价值 | 工作量 | API状态 | |------|------|------|--------|---------| -| 1 | 🏆 评分排行榜 | 热门页增加评分排序 | 极小 | ✅已可用 | -| 2 | 🌐 IP状态显示 | 评分前显示剩余次数 | 极小 | ✅已封装 | -| 3 | 🔍 排除筛选 | 高级搜索增加排除选项 | 小 | ✅已可用 | -| 4 | 🕐 用餐时段推荐 | 根据时间智能推荐 | 小 | ✅已可用 | -| 5 | ⚠️ 过敏原警示 | 详情页安全提醒 | 小 | ✅数据已有 | +| 12 | ⚠️ 过敏原智能过滤 | 搜索/推荐自动过滤过敏原菜品 | ⭐⭐⭐ | ✅数据已有 | +| 13 | 🔔 烹饪提醒通知 | 烹饪步骤定时提醒 | ⭐⭐⭐ | 本地通知 | +| 14 | 🔗 社交分享增强 | 分享链接+热度标签 | ⭐⭐ | ✅数据已有 | +| 15 | 🏷️ 统一格式输出 | 食材数据标准化 | ⭐⭐⭐ | ✅API已可用 | +| 16 | 📸 拍照记录 | 烹饪笔记拍照上传 | ⭐⭐⭐ | 本地存储 | -### 第二阶段:体验提升(3-5天/功能) +### 第四阶段:深度功能(需后端配合) -| 顺序 | 功能 | 价值 | 工作量 | API状态 | -|------|------|------|--------|---------| -| 6 | 📊 营养可视化 | 详情页环形图展示 | 中 | ✅数据已有 | -| 7 | 🏷️ 分类标签联动 | 筛选体验提升 | 中 | ✅已可用 | -| 8 | 🥗 食材分类浏览 | 食材浏览体验完善 | 中 | ✅已可用 | -| 9 | 🎲 筛选步骤引导 | "吃什么"体验优化 | 中 | ✅已可用 | -| 10 | 📱 迷你信息加载 | 列表页性能优化 | 中 | ✅已可用 | +> 以下功能需后端API支持,当前暂不开发 -### 第三阶段:深度功能(需后端配合或复杂逻辑) +| 顺序 | 功能 | 价值 | 前置条件 | +|------|------|------|---------| +| 17 | 👤 用户注册登录 | 核心用户体系 | 需后端 api_user.php | +| 18 | 💾 收藏云端同步 | 多设备同步 | 需后端 api_favorite.php | +| 19 | 💬 评论系统 | 社区互动 | 需后端 api_comment.php | +| 20 | 📝 菜谱上传 | UGC内容 | 需后端 api_recipe.php | -| 顺序 | 功能 | 价值 | 工作量 | API状态 | -|------|------|------|--------|---------| -| 11 | 📅 每日菜单规划 | 菜单规划工具 | 大 | ✅组合可用 | -| 12 | 🏋️ 健身餐推荐 | 健康饮食功能 | 大 | ✅已可用 | -| 13 | 🧠 智能推荐 | 个性化推荐 | 大 | ✅组合可用 | -| 14 | 🔄 相关菜谱推荐 | 详情页推荐 | 中 | 🟡可组合/🔴需新API | +--- + +## 📊 项目代码统计 + +> 基于 2026-04-16 代码库全量扫描 + +| 指标 | 数量 | +|------|------| +| 页面文件 (Page) | 56 | +| 控制器 (Controller) | 23 | +| 服务 (Service) | 20 | +| 仓库 (Repository) | 8 | +| 模型 (Model) | 15+ | +| Widget组件 | 40+ | +| 路由数 | 40+ | +| debugPrint调用 | 385处/57文件 | +| 超大文件(>1000行) | 10个 | +| 硬编码路由字符串 | 31处 | +| 非标准导航(Get.to) | 14处 | +| 绕过ApiService直接用Dio | 3个页面 | +| 硬编码密码/凭据 | 1个文件(email_service.dart) | +| 未注册的GetxService | 1个(RecommendationService) | +| 模拟实现的核心功能 | 2个(ProfileController.login / FeedbackPage._send) | + +### 超大文件清单(需拆分) + +| 文件 | 行数 | 建议拆分方案 | +|------|------|-------------| +| `ingredient_detail_page.dart` | 1853 | 拆分:营养信息/替代建议/相关菜谱/缓存管理 子组件 | +| `safe_period_calculator_page.dart` | 1165 | 拆分:日历法/体温法/粘液法 独立组件 | +| `favorites_page.dart` | 1155 | 继续拆分:搜索栏/统计栏/导出功能 | +| `mini_card_page.dart` | 1154 | 拆分:缓存管理/搜索/网格视图 | +| `date_calculator_page.dart` | 1144 | 拆分:日期加减/日期间隔 两个独立页面 | +| `cache_manage_page.dart` | 1095 | 按缓存类型拆分子组件 | +| `app_routes.dart` | 1076 | 路由常量与页面注册分离 | +| `recipe_repository.dart` | 1062 | 按API文件拆分:api.php / api_filter.php / api_check_duplicate.php | +| `search_page.dart` | 1017 | 拆分:搜索结果/历史记录/搜索建议 | +| `recipe_model.dart` | 1010 | 拆分:RecipeStatistics / RecipeRating / RecipeNutrition 子模型 | + +### 存储层不一致清单 + +| 控制器/服务 | 当前存储方式 | 应统一为 | +|------------|------------|---------| +| `BrowseHistoryController` | SharedPreferences | HiveService | +| `EmailHistoryController` | SharedPreferences | HiveService | +| `CookingNoteController` | HiveService + SharedPreferences 双写 | HiveService(移除SP备份) | +| `FavoritesController` | HiveService ✅ | — | +| `ShoppingListController` | HiveService ✅ | — | +| `WeeklyMenuController` | HiveService ✅ | — | +| `MealRecordController` | HiveService ✅ | — | +| `BedtimeReminderController` | HiveService ✅ | — | +| `UserService` | SharedPreferences | 可保留(仅存一个UUID) | --- ## ✅ 已完成阶段(精简记录) +### 阶段四十一:菜品详情页交互优化(v0.97.14)✅ +- 🥬 食材卡片跳转:IngredientDiscoverCard 点击跳转食材详情页 +- ⚖️ 克数快捷按钮:饮食记录弹窗新增 100g/200g/300g 常用量快捷填入 +- 📝 营养快捷记录:RecipeMealRecordSheet 底部弹窗(份量比例/自定义克数/用餐时段/实时营养预览) +- 👍 点赞按钮上移:从底部操作栏移至标题区域 +- ❤️ 收藏图片修复:FeedItemModel 新增 picId 字段,RecipeImage 三级 fallback +- 🐛 搜索页崩溃修复:setState during build 问题 + +### 阶段三十九:迷你卡片功能(v0.92.8)✅ +- 🃏 迷你卡片页面(Tinder风格左右滑动浏览菜品) +- 📊 数据模型 MiniCardModel(341道菜,11个分类) +- 🔍 分类筛选+搜索+网格视图 +- ❤️ 收藏集成+喜欢/不喜欢记录 +- 💾 本地缓存+缓存优先加载 +- 🪟 液态玻璃效果+全屏图片查看器 +- 📤 分享按钮(share_plus) +- 🏠 首页瀑布流插入迷你卡片(1:20比例) +- 🔗 路由参数支持(initialRecipeId) +- 🔄 MiniCardService 独立数据服务 + +### 阶段四十:收藏页面功能增强(v0.92.9)✅ +- 🔍 搜索功能:按标题/简介/分类搜索 +- 📊 统计信息展示:总数及各类型数量 +- 📤 导出功能:JSON/CSV 格式 +- 🔒 类型安全改进 +- 🪟 GlassContainer 统一毛玻璃效果 + ### 阶段三十六:21项功能批量实现 ✅ -- 🏆 评分排行榜:HotPage sort=rate排序(HotRepository+HotController+HotPage三层联动) -- 🌐 IP状态显示:菜品详情页评分前显示剩余次数 -- 🔍 排除筛选:高级搜索页增加7个exclude参数筛选 -- 📋 食谱子分类:分类浏览页支持子分类展开 -- 🔢 编码/模糊查询:搜索页支持code和title查询 -- 🕐 用餐时段推荐:MealTimeRecommendPage完善 -- ⚠️ 过敏原警示:菜品详情页过敏原警告+AllergenReportPage过敏原报告 -- 🏷️ 分类标签联动:高级搜索页选择分类自动加载标签 -- 🎲 筛选步骤引导:WhatToEatPage 3步引导指示器 -- 📊 营养可视化:NutritionRingChart环形图+热量进度条 -- 📱 迷你信息加载:RecipeRepository.fetchMiniRecipe -- 🏋️ 健身餐推荐:营养中心减脂/增肌/生酮/碳水补充入口 -- 📋 过敏原报告:AllergenReportPage完整报告页 -- 🔎 查重检测:DuplicateCheckPage 5种查重模式 -- 🎯 今天吃什么增强:筛选步骤引导+3步指示器 -- 🔄 食材替代建议:IngredientDetailPage 30+食材替代映射 -- 🍽️ 相似菜品推荐:RecipeSimilarSection组件 -- 🍽️ 食材相关菜谱:IngredientDetailPage底部菜谱列表 -- 📈 运营数据大屏:StatsDashboardPage -- 📈 数据管理中心增强:DataCenterPage新增运营大屏入口 -- 🔧 代码质量:flutter analyze零错误 +- 🏆 评分排行榜 / 🌐 IP状态显示 / 🔍 排除筛选 / 📋 食谱子分类 +- 🔢 编码/模糊查询 / 🕐 用餐时段推荐 / ⚠️ 过敏原警示 +- 🏷️ 分类标签联动 / 🎲 筛选步骤引导 / 📊 营养可视化 +- 📱 迷你信息加载 / 🏋️ 健身餐推荐 / 📋 过敏原报告 +- 🔎 查重检测 / 🎯 今天吃什么增强 / 🔄 食材替代建议 +- 🍽️ 相似菜品推荐 / 🍽️ 食材相关菜谱 / 📈 运营数据大屏 +- 📈 数据管理中心增强 / 🔧 flutter analyze零错误 ### 阶段三十七:目录结构整理+导入路径修复 ✅ -- 📁 lib/src目录重组:每个文件夹≤8文件,按功能分子目录 -- 🔧 导入路径更新:批量更新import路径适配新目录结构 -- 🧹 BOM字符清理:72个Dart文件移除UTF-8 BOM (U+FEFF) -- 🐛 类型错误修复:ShoppingItemModel/MealRecordModel类型匹配 -- 🧹 未使用代码清理:移除未使用的导入和字段 -- 📊 flutter analyze:解决所有critical错误 -- 🔄 路由参数修复:CategoryModel类型转换问题修复 -- 📝 文档更新:同步目录结构变更 +- 📁 lib/src目录重组 / 🔧 导入路径更新 / 🧹 BOM字符清理 +- 🐛 类型错误修复 / 🧹 未使用代码清理 / 📊 flutter analyze零错误 ### 阶段三十八:UI布局优化+缓存修复 ✅ -- 📱 收藏页面网格布局:GridView 2列卡片展示 -- 🛠️ 工具中心增强:新增"使用工具"按钮直接打开工具功能 -- 💾 食材缓存修复:CacheService键名匹配问题修复 -- 🔗 缓存管理页面跳转修复:正确跳转到食材详情页 - -### 阶段三十:发现页口味/工艺筛选 ✅ -- ✅ 口味标签筛选 / ✅ 工艺标签筛选 -- ✅ 相关菜谱推荐(详情页底部)— v0.92.0实现 - -### 阶段二十九:菜品详情页功能完善 ✅ -- ⭐ RecipeRating模型 + 评分展示 + 标签跳转 + 分类面包屑 - -### 阶段二十七/二十八:首页Discover瀑布流+渐进式渲染 ✅ -- MasonryGridView 2列瀑布流 + 渐进式渲染+骨架屏+分页 - -### 阶段二十四:笔记+浏览记录 ✅ -- 笔记标签+菜品快捷输入 + 浏览记录自动记录 - -### 阶段二十三:数据管理中心 🟡 -- ✅ 数据管理中心页面 + LocalDataService -- ❌ 过敏原智能过滤 - -### 阶段十二:社交+通知增强 🟡 -- ✅ 分享菜谱 + 搜索建议/热词 -- ❌ 烹饪提醒通知 / ❌ 拍照记录 - -### 阶段十三:AI+规划高级功能 ✅ -- AI菜谱推荐+每周菜单规划+食材用量换算+就寝提醒 +- 📱 收藏页面网格布局 / 🛠️ 工具中心增强 +- 💾 食材缓存修复 / 🔗 缓存管理页面跳转修复 --- ## 📊 API接口使用状态一览 -> 基于 API_DOC.md v3.2.0 + APP_GUIDE.md v2.9.0,更新于 v0.92.0 +> 基于 API_DOC.md v3.2.0 + APP_GUIDE.md v2.9.0,更新于 v0.97.14 ### ✅ 已使用接口 @@ -266,32 +379,21 @@ --- -## 🃏 阶段三十九:迷你卡片功能(v0.92.8) +## 🃏 迷你卡片待增强 -### ✅ 已完成 -- 🃏 迷你卡片页面(Tinder风格左右滑动浏览菜品) -- 📂 数据模型 MiniCardModel(341道菜,11个分类) -- 🔍 分类筛选+搜索+网格视图 -- ❤️ 收藏集成+喜欢/不喜欢记录 -- 💾 本地缓存5-10条记录(SharedPreferences) -- 🗑️ 缓存管理页面新增迷你卡片缓存清理 -- 🖼️ 图片独立组件 _MiniCardImageView,文本在图片内底部展示 -- 🪟 液态玻璃效果(顶部操作栏+底部信息区 GlassContainer) -- 📱 全屏图片查看器(PageView+异步预加载5张相邻图片) -- 📤 分享按钮(share_plus,分享菜品信息+图片URL) -- 💾 缓存优先加载(查看过的卡片存入缓存,先显示缓存再内部加载) -- 🏠 首页瀑布流插入迷你卡片(1:20比例,全宽横幅) - - 每20个瀑布流item后插入1个迷你卡片横幅 - - MiniCardDiscoverCard 组件:液态玻璃+全宽图片+分类标签 - - SliverMainAxisGroup 分组渲染 -- 🔗 路由参数支持(initialRecipeId,从首页跳转到指定卡片) -- 🔄 MiniCardService 独立数据服务(缓存优先,多页面复用) - -### 🟡 待开发:迷你卡片交互增强 - -**优先级**:P3(增强功能)| 优先级值:3 -**工作量**:⭐⭐(低) +**优先级**:P3 | 优先级值:3 | 工作量:⭐⭐ - 迷你卡片横幅支持横向滑动预览多个菜品 - 迷你卡片瀑布流插入比例可配置(当前固定1:20) - 迷你卡片数据自动刷新策略 + +--- + +## 📂 收藏分组功能待开发 + +**优先级**:P3 | 优先级值:3 | 工作量:⭐⭐⭐ + +- 📁 收藏夹分组:支持创建文件夹分类管理 +- 📋 批量移动:将收藏移动到其他分组 +- 👀 长按预览:快速预览收藏项详情 +- 📝 备注功能:为收藏项添加个人备注 diff --git a/docs/superpowers/plans/2026-04-16-dish-ranking.md b/docs/superpowers/plans/2026-04-16-dish-ranking.md new file mode 100644 index 0000000..a1fcc10 --- /dev/null +++ b/docs/superpowers/plans/2026-04-16-dish-ranking.md @@ -0,0 +1,297 @@ +# 菜品排名(Dish Ranking)实现计划 + +> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking. + +**Goal:** 在工具中心新增「菜品排名」工具页面,实现五层级 Tier List 排行功能,支持从浏览记录/收藏/手动输入三种方式添加菜品,支持拖拽排序和本地持久化。 + +**Architecture:** 采用 GetX Controller 管理状态,SharedPreferences 持久化数据。主页面使用 CustomScrollView + SliverList 渲染5个层级行,每行为水平滚动的菜品卡片列表。底部弹出面板(CupertinoModalPopupRoute)提供三 Tab 选择源。拖拽使用 Reorderable 方案在层内排序。 + +**Tech Stack:** Flutter / GetX / Cupertino / SharedPreferences / DesignTokens + +--- + +## 文件结构 + +``` +lib/src/pages/tools/ranking/ +├── dish_ranking_page.dart # 主页面 - Tier List 布局 +├── dish_ranking_controller.dart # 控制器 - 数据/拖拽/持久化 +└── dish_pick_sheet.dart # 底部选择面板组件 + +lib/src/models/ +└── dish_rank_model.dart # 菜品排名数据模型 + +需要修改的文件: +├── lib/src/models/tool_item_model.dart # 注册新工具项 +├── lib/src/config/app_routes.dart # 注册路由 +``` + +--- + +### Task 1: 创建菜品排名数据模型 + +**Files:** +- Create: `lib/src/models/dish_rank_model.dart` + +- [ ] **Step 1: 创建 DishRankItem 模型和 Tier 定义** + +```dart +/* + * 文件: dish_rank_model.dart + * 名称: 菜品排名数据模型 + * 作用: 定义Tier List中每个菜品的数据结构及层级常量 + * 创建: 2026-04-16 初始创建 + */ + +class DishRankItem { + final String id; + final String name; + String emoji; + String? coverImage; + int tierIndex; + int order; + final bool isCustom; + final String? sourceId; + + DishRankItem({ + required this.id, + required this.name, + this.emoji = '🍽️', + this.coverImage, + required this.tierIndex, + this.order = 0, + this.isCustom = false, + this.sourceId, + }); + + DishRankItem copyWith({ + String? id, + String? name, + String? emoji, + String? coverImage, + int? tierIndex, + int? order, + bool? isCustom, + String? sourceId, + }) { + return DishRankItem( + id: id ?? this.id, + name: name ?? this.name, + emoji: emoji ?? this.emoji, + coverImage: coverImage ?? this.coverImage, + tierIndex: tierIndex ?? this.tierIndex, + order: order ?? this.order, + isCustom: isCustom ?? this.isCustom, + sourceId: sourceId ?? this.sourceId, + ); + } + + Map toJson() => { + 'id': id, + 'name': name, + 'emoji': emoji, + 'coverImage': coverImage, + 'tierIndex': tierIndex, + 'order': order, + 'isCustom': isCustom, + 'sourceId': sourceId, + }; + + factory DishRankItem.fromJson(Map json) { + return DishRankItem( + id: json['id'] as String? ?? '', + name: json['name'] as String? ?? '', + emoji: json['emoji'] as String? ?? '🍽️', + coverImage: json['coverImage'] as String?, + tierIndex: json['tierIndex'] as int? ?? 0, + order: json['order'] as int? ?? 0, + isCustom: json['isCustom'] as bool? ?? false, + sourceId: json['sourceId'] as String?, + ); + } +} + +/// 五个层级定义 +class TierDefinition { + static const List tierNames = ['夯', '顶级', '人上人', 'NPC', '拉完了']; + static const List tierColors = [ + Color(0xFFE63946), // 夯 - 红 + Color(0xFFF4A261), // 顶级 - 橙金 + Color(0xFFE9C46A), // 人上人 - 黄 + Color(0xFFFFF8DC), // NPC - 米白 + Color(0xFFF5F5F5), // 拉完了 - 灰白 + ]; + static const List tierTextColors = [ + Colors.white, // 夯 + Color(0xFF5D4037), // 顶级 + Color(0xFF5D4037), // 人上人 + Color(0xFF8E8E93), // NPC + Color(0xFF8E8E93), // 拉完了 + ]; + + static String getTierName(int index) => + index >= 0 && index < tierNames.length ? tierNames[index] : ''; + static Color getTierColor(int index) => + index >= 0 && index < tierColors.length ? tierColors[index] : Colors.grey; + static Color getTierTextColor(int index) => + index >= 0 && index < tierTextColors.length ? tierTextColors[index] : Colors.black; +} +``` + +注意:文件头部需 import `flutter/material.dart` 以使用 `Color` 和 `Colors`。 + +--- + +### Task 2: 创建菜品排名控制器 + +**Files:** +- Create: `lib/src/pages/tools/ranking/dish_ranking_controller.dart` + +- [ ] **Step 1: 编写控制器完整代码** + +控制器职责: +- 管理所有层级的菜品列表 (`RxList`) +- 按 tierIndex 分组提供各层数据 +- 支持添加/删除/移动/排序菜品 +- SharedPreferences 持久化(key: `dish_ranking_data`) +- 从 BrowseHistoryController / FavoritesController 加载可选菜品源 + +关键方法: +- `loadFromStorage()` — 启动时加载 +- `saveToStorage()` — 变更后保存 +- `addItem(DishRankItem item)` — 添加到指定层级 +- `removeItem(String id)` — 移除菜品 +- `moveItem(String id, int newTierIndex)` — 跨层级移动 +- `reorderInTier(int tierIndex, int oldIndex, int newIndex)` — 层内重排 +- `getItemsByTier(int tierIndex)` — 获取某层菜品(按 order 排序) +- `clearAll()` — 清空全部 +- `resetToDefault()` — 重置为空 + +--- + +### Task 3: 创建底部选择面板组件 + +**Files:** +- Create: `lib/src/pages/tools/ranking/dish_pick_sheet.dart` + +- [ ] **Step 1: 编写 DishPickSheet 组件** + +这是一个 StatefulWidget,作为 CupertinoModalPopupRoute 的 content 使用: + +UI 结构: +``` +┌─────────────────────────────┐ +│ ┌─────┬─────┬─────┐ │ +│ │浏览 │收藏 │手动 │ ← SegmentedControl +│ └─────┴─────┴─────┘ │ +│ 🔍 搜索菜品... │ +│ ┌─────────────────────┐ │ +│ │ [菜品卡片] │ │ +│ │ [菜品卡片] │ │ +│ │ [菜品卡片] │ │ ← 可滚动列表 +│ │ ... │ │ +│ └─────────────────────┘ │ +│ [取消] │ +└─────────────────────────────┘ +``` + +三个 Tab 数据源: +1. **浏览记录** — 从 `BrowseHistoryController.to.history` 获取,显示 title + coverImage +2. **我的收藏** — 从 `Get.find().favorites` 获取,显示 title + cover +3. **手动输入** — 显示一个输入框(CupertinoTextField)+ emoji 选择器(常用食物 emoji 网格) + +每个菜品项点击后回调 `onDishSelected(DishRankItem)`,由主页面接收并加入当前选中的层级。 + +搜索功能:对当前 tab 列表按 name/title 过滤。 + +--- + +### Task 4: 创建菜品排名主页面 + +**Files:** +- Create: `lib/src/pages/tools/ranking/dish_ranking_page.dart` + +- [ ] **Step 1: 编写主页面完整代码** + +页面结构(CupertinoPageScaffold + SafeArea + CustomScrollView): + +**Header 区域:** +- 左侧:🏆 图标 + 标题"菜品排名" +- 右侧:分享按钮 + 重置按钮(CupertinoActionSheet 确认) + +**Tier List 区域(5 行 SliverToBoxAdapter):** + +每行布局: +``` +┌────┬──────────────────────────────────┬───┐ +│ 夯 │ [卡][卡][卡]... (水平滚动) │ + │ +└────┴──────────────────────────────────┴───┘ +``` +- 左侧固定宽度标签区(~60px):背景色 = tierColor,文字 = tierName +- 中间可滚动区域:ListView.horizontal 展示该层的 DishRankCard +- 右侧 "+" 按钮:点击打开 DishPickSheet,传入当前 tierIndex + +**DishRankCard 设计:** +- 圆角卡片(borderRadiusMd),带封面图或 emoji 大图标 +- 菜品名称文字 +- 长按触发 Reorderable 拖拽(同层内排序) +- 点击弹出 ActionSheet:移动到其他层级 / 删除 + +**动画:** 页面进入时 staggered 入场动画(每行依次 slide + fade) + +**空状态:** 某层无菜品时显示虚线框 + 提示文字"拖拽菜品到这里" + +--- + +### Task 5: 注册路由和工具项 + +**Files:** +- Modify: `lib/src/models/tool_item_model.dart` — 在 defaultTools 列表末尾添加 +- Modify: `lib/src/config/app_routes.dart` — 添加路由常量和 GetPage + +- [ ] **Step 1: 在 tool_item_model.dart 添加工具注册** + +在 `ToolRegistry.defaultTools` 列表末尾追加: +```dart +ToolItem( + id: 'dish_ranking', + name: '菜品排名', + icon: '🏆', + needsNetwork: false, + category: 'data', + route: '/tools/dish-ranking', + description: '给你的菜品排个座次,从夯到拉', +), +``` + +- [ ] **Step 2: 在 app_routes.dart 添加路由** + +1. 在路由常量区域添加: +```dart +static const String toolsDishRanking = '/tools/dish-ranking'; +``` + +2. 在 pages 列表中添加 GetPage: +```dart +GetPage( + name: toolsDishRanking, + page: () => const DishRankingPage(), + middlewares: [PageStandardsMiddleware()], +), +``` + +3. 在文件顶部添加 import: +```dart +import 'package:mom_kitchen/src/pages/tools/ranking/dish_ranking_page.dart'; +``` + +--- + +### Task 6: 更新 CHANGELOG.md + +**Files:** +- Modify: `CHANGELOG.md`(项目根目录) + +- [ ] **Step 1: 添加版本变更记录** + +在 CHANGELOG.md 顶部新增版本条目,记录本次新增的「菜品排名」工具功能描述。 diff --git a/docs/superpowers/plans/2026-04-16-weight-manage.md b/docs/superpowers/plans/2026-04-16-weight-manage.md new file mode 100644 index 0000000..968552e --- /dev/null +++ b/docs/superpowers/plans/2026-04-16-weight-manage.md @@ -0,0 +1,707 @@ +# 体重管理页面 — 实现计划 + +> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking. + +**Goal:** 在工具中心新增体重管理页面,支持记录体重(kg/斤)、折线图趋势、目标设定、周期统计、BMI跳转 + +**Architecture:** GetView + HiveService 通用存储 + fl_chart 折线图,遵循项目 iOS26 Cupertino 风格和设计系统规范 + +**Tech Stack:** Flutter, GetX, Hive, fl_chart(本地包), CupertinoWidgets, DesignTokens + +--- + +## 文件总览 + +| 操作 | 文件路径 | 职责 | +|---|---|---| +| Create | `lib/src/models/weight_record_model.dart` | WeightRecord 数据模型 | +| Create | `lib/src/controllers/tools/weight_controller.dart` | GetX 控制器 | +| Create | `lib/src/pages/tools/health/weight_manage_page.dart` | 主页面 | +| Modify | `lib/src/widgets/charts_widgets.dart` | 新增 WeightLineChart 组件 | +| Modify | `lib/src/models/tool_item_model.dart` | 注册工具入口 | +| Modify | `lib/src/config/app_routes.dart` | 路由+PageRegistry 双注册 | +| Modify | `lib/src/services/data/hive_service.dart` | 新增 weight box | + +--- + +### Task 1: 数据模型 — WeightRecord + +**Files:** +- Create: `lib/src/models/weight_record_model.dart` + +- [ ] **Step 1: 创建 WeightRecord 模型** + +```dart +/* + * 文件: weight_record_model.dart + * 名称: 体重记录数据模型 + * 作用: 定义体重记录的数据结构和JSON序列化 + * 更新: 2026-04-16 初始创建 + */ + +class WeightRecord { + final String id; + final double weightKg; + final String timing; // morning / before_meal / after_meal + final String? note; + final DateTime createdAt; + + const WeightRecord({ + required this.id, + required this.weightKg, + required this.timing, + this.note, + required this.createdAt, + }); + + static const List timingLabels = ['morning', 'before_meal', 'after_meal']; + static const Map timingDisplay = { + 'morning': '早晨', + 'before_meal': '饭前', + 'after_meal': '饭后', + }; + static const Map timingEmoji = { + 'morning': '🌅', + 'before_meal': '🍽️', + 'after_meal': '😋', + }; + + static double kgToJin(double kg) => kg * 2; + static double jinToKg(double jin) => jin / 2; + + factory WeightRecord.fromJson(Map json) { + return WeightRecord( + id: json['id'] as String? ?? '', + weightKg: (json['weightKg'] as num?)?.toDouble() ?? 0, + timing: json['timing'] as String? ?? 'morning', + note: json['note'] as String?, + createdAt: json['createdAt'] != null + ? DateTime.parse(json['createdAt'] as String) + : DateTime.now(), + ); + } + + Map toJson() => { + 'id': id, + 'weightKg': weightKg, + 'timing': timing, + 'note': note, + 'createdAt': createdAt.toIso8601String(), + }; + + WeightRecord copyWith({ + String? id, + double? weightKg, + String? timing, + String? note, + DateTime? createdAt, + }) { + return WeightRecord( + id: id ?? this.id, + weightKg: weightKg ?? this.weightKg, + timing: timing ?? this.timing, + note: note ?? this.note, + createdAt: createdAt ?? this.createdAt, + ); + } + + @override + String toString() => + 'WeightRecord(id: $id, weightKg: $weightKg, timing: $timing, note: $note)'; +} +``` + +- [ ] **Step 2: 验证零诊断错误** + +Run: 检查 `lib/src/models/weight_record_model.dart` 无诊断错误 +Expected: 零 errors/warnings + +--- + +### Task 2: 控制器 — WeightController + +**Files:** +- Create: `lib/src/controllers/tools/weight_controller.dart` +- Modify: `lib/src/services/data/hive_service.dart` — 在 `_openBoxes()` 中新增 `weightRecordBox` + +- [ ] **Step 1: 在 HiveService 中注册 weightRecordBox** + +在 `hive_service.dart` 的 `_openBoxes()` 方法中添加: + +```dart +final weightRecordBox = await _openBoxSafe('weightRecordBox'); +if (weightRecordBox != null) { + _dynamicBoxCache['weightRecordBox'] = weightRecordBox; +} +``` + +- [ ] **Step 2: 创建 WeightController** + +```dart +/* + * 文件: weight_controller.dart + * 名称: 体重管理控制器 + * 作用: 管理体重记录CRUD、目标设置、统计计算、单位切换 + * 更新: 2026-04-16 初始创建 + */ + +import 'dart:math'; +import 'package:flutter/foundation.dart'; +import 'package:get/get.dart'; +import 'package:mom_kitchen/src/controllers/base_controller.dart'; +import 'package:mom_kitchen/src/models/weight_record_model.dart'; +import 'package:mom_kitchen/src/services/data/hive_service.dart'; + +class WeightController extends BaseController { + static const String _boxName = 'weightRecordBox'; + static const String _recordsKey = 'weight_records'; + static const String _goalKey = 'weight_goal_kg'; + + final RxList records = [].obs; + final RxDouble goalWeight = 60.0.obs; + final RxString unitMode = 'kg'.obs; + final TextEditingController weightInput = TextEditingController(); + final TextEditingController noteInput = TextEditingController(); + final RxString selectedTiming = 'morning'.obs; + + final HiveService _hive = HiveService(); + + List get sortedRecords => + List.from(records)..sort((a, b) => b.createdAt.compareTo(a.createdAt)); + + WeightRecord? get latestRecord => sortedRecords.isNotEmpty ? sortedRecords.first : null; + + double? get currentWeight => latestRecord?.weightKg; + + double? get changeFromLast { + if (sortedRecords.length < 2) return null; + return currentWeight! - sortedRecords[1].weightKg; + } + + double? get changeFromGoal { + if (currentWeight == null) return null; + return currentWeight! - goalWeight.value; + } + + // ─── 周期统计 ─── + + List _recordsInDays(int days) { + final cutoff = DateTime.now().subtract(Duration(days: days)); + return records.where((r) => r.createdAt.isAfter(cutoff)).toList() + ..sort((a, b) => a.createdAt.compareTo(b.createdAt)); + } + + Map getWeeklyStats { + final weekRecords = _recordsInDays(7); + if (weekRecords.isEmpty) return {'avg': 0, 'max': 0, 'min': 0, 'change': 0}; + final weights = weekRecords.map((r) => r.weightKg).toList(); + return { + 'avg': weights.reduce((a, b) => a + b) / weights.length, + 'max': weights.reduce(max), + 'min': weights.reduce(min), + 'change': weights.length > 1 ? weights.last - weights.first : 0, + }; + } + + Map getMonthlyStats { + final monthRecords = _recordsInDays(30); + if (monthRecords.isEmpty) return {'avg': 0, 'max': 0, 'min': 0, 'change': 0}; + final weights = monthRecords.map((r) => r.weightKg).toList(); + return { + 'avg': weights.reduce((a, b) => a + b) / weights.length, + 'max': weights.reduce(max), + 'min': weights.reduce(min), + 'change': weights.length > 1 ? weights.last - weights.first : 0, + }; + } + + // ─── 图表数据 ─── + + Map get chartData { + final recent = _recordsInDays(30); + final map = {}; + for (final r in recent) { + final key = '${r.createdAt.month}/${r.createdAt.day}'; + map[key] = r.weightKg; + } + return map; + } + + @override + void onInit() { + super.onInit(); + _loadRecords(); + _loadGoal(); + } + + Future _loadRecords() async { + try { + final raw = _hive.get(_boxName, _recordsKey); + if (raw == null || raw is! List) return; + records.value = (raw as List) + .map((e) => e is Map + ? WeightRecord.fromJson(e) + : null) + .whereType() + .toList(); + } catch (e) { + debugPrint('WeightController: load error: $e'); + } + } + + Future _loadGoal() async { + try { + final raw = _hive.get(_boxName, _goalKey); + if (raw != null && raw is num) { + goalWeight.value = raw.toDouble(); + } + } catch (e) { + debugPrint('WeightController: load goal error: $e'); + } + } + + Future addRecord() async { + final text = weightInput.text.trim(); + final weight = double.tryParse(text); + if (weight == null || weight <= 0 || weight > 500) return; + + final record = WeightRecord( + id: 'wr_${DateTime.now().millisecondsSinceEpoch}', + weightKg: unitMode.value == 'jin' ? WeightRecord.jinToKg(weight) : weight, + timing: selectedTiming.value, + note: noteInput.text.trim().isEmpty ? null : noteInput.text.trim(), + createdAt: DateTime.now(), + ); + + await runWithLoading(() async { + records.insert(0, record); + await _saveRecords(); + }); + + weightInput.clear(); + noteInput.clear(); + } + + Future deleteRecord(String id) async { + await runWithLoading(() async { + records.removeWhere((r) => r.id == id); + await _saveRecords(); + }); + } + + Future updateGoal(double value) async { + goalWeight.value = value.clamp(30, 200); + _hive.put(_boxName, _goalKey, goalWeight.value); + } + + void toggleUnit() { + unitMode.value = unitMode.value == 'kg' ? 'jin' : 'kg'; + } + + String displayWeight(double kg) { + return unitMode.value == 'kg' + ? '${kg.toStringAsFixed(1)} kg' + : '${WeightRecord.kgToJin(kg).toStringAsFixed(1)} 斤'; + } + + double inputToKg(String value) { + final num = double.tryParse(value) ?? 0; + return unitMode.value == 'jin' ? WeightRecord.jinToKg(num) : num; + } + + Future _saveRecords() async { + final list = records.map((r) => r.toJson()).toList(); + _hive.put(_boxName, _recordsKey, list); + } + + @override + void onClose() { + weightInput.dispose(); + noteInput.dispose(); + super.onClose(); + } +} +``` + +- [ ] **Step 3: 验证零诊断错误** + +检查两个文件无诊断错误 + +--- + +### Task 3: 图表组件 — WeightLineChart + +**Files:** +- Modify: `lib/src/widgets/charts_widgets.dart` — 追加 WeightLineChart 类(文件末尾) + +- [ ] **Step 1: 在 charts_widgets.dart 末尾追加 WeightLineChart** + +基于现有 NutritionLineChart 模式,新建专用体重折线图组件。核心要点: +- X轴显示 M/D 日期格式 +- Y轴显示体重数值(动态计算范围) +- 主曲线:平滑曲线 + 渐变填充区域 + 圆点标记 +- 目标线:水平虚线(dashArray: [6, 4]) +- Tooltip 显示:体重值 + 单位 + 时机emoji +- 空状态:居中显示 "暂无数据" 提示 +- 使用 DesignTokens / DarkDesignTokens 颜色变量 +- lineColor 默认使用 DesignTokens.dynamicPrimary + +```dart +// 在 charts_widgets.dart 末尾追加以下类(在 _MealTypeLegendItem 之后) + +class WeightLineChart extends StatelessWidget { + final Map data; + final double goalValue; + final bool isDark; + final Color lineColor; + final String unitLabel; + + const WeightLineChart({ + super.key, + required this.data, + this.goalValue = 60, + required this.isDark, + Color? lineColor, + this.unitLabel = 'kg', + }) : lineColor = lineColor ?? DesignTokens.dynamicPrimary; + + @override + Widget build(BuildContext context) { + if (data.isEmpty) { + return SizedBox( + height: 220, + 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, + ), + ), + Text( + '点击下方按钮添加第一条记录', + style: TextStyle( + fontSize: DesignTokens.fontSm, + color: isDark ? DarkDesignTokens.text3 : DesignTokens.text3, + ), + ), + ], + ), + ), + ); + } + + final spots = _buildSpots(); + final goalSpots = _buildGoalSpots(); + + return SizedBox( + height: 220, + child: Padding( + padding: const EdgeInsets.only( + right: DesignTokens.space4, + top: DesignTokens.space3, + bottom: DesignTokens.space3, + ), + child: LineChart( + LineChartData( + gridData: FlGridData( + show: true, + drawVerticalLine: false, + horizontalInterval: _calculateInterval(), + getDrawingHorizontalLine: (value) => FlLine( + color: isDark + ? DarkDesignTokens.text3.withValues(alpha: 0.15) + : DesignTokens.text3.withValues(alpha: 0.1), + strokeWidth: 1, + ), + ), + titlesData: FlTitlesData( + topTitles: const AxisTitles(sideTitles: SideTitles(showTitles: false)), + rightTitles: const AxisTitles(sideTitles: SideTitles(showTitles: false)), + bottomTitles: AxisTitles( + sideTitles: SideTitles( + showTitles: true, + reservedSize: 28, + interval: _bottomInterval(), + getTitlesWidget: (value, meta) => _buildBottomTitle(value, meta), + ), + ), + leftTitles: AxisTitles( + sideTitles: SideTitles( + showTitles: true, + reservedSize: 42, + interval: _calculateInterval(), + getTitlesWidget: (value, meta) => _buildLeftTitle(value, meta), + ), + ), + ), + borderData: FlBorderData(show: false), + minX: 0, + maxX: (data.length - 1).toDouble(), + minY: _calculateMinY(), + maxY: _calculateMaxY(), + lineBarsData: [ + LineChartBarData( + spots: goalSpots, + isCurved: false, + color: DesignTokens.orange.withValues(alpha: 0.5), + barWidth: 1.5, + dashArray: [6, 4], + dotData: const FlDotData(show: false), + belowBarData: BarAreaData(show: false), + ), + LineChartBarData( + spots: spots, + isCurved: true, + preventCurveOverShooting: true, + color: lineColor, + barWidth: 2.5, + dotData: FlDotData( + show: true, + getDotPainter: (spot, percent, barData, index) => + FlDotCirclePainter( + radius: 4, + color: lineColor, + strokeWidth: 1.5, + strokeColor: + isDark ? DarkDesignTokens.card : DesignTokens.card, + ), + ), + belowBarData: BarAreaData( + show: true, + color: lineColor.withValues(alpha: 0.08), + ), + ), + ], + lineTouchData: LineTouchData( + touchTooltipData: LineTouchTooltipData( + getTooltipColor: (_) => + isDark ? DarkDesignTokens.card : DesignTokens.card, + getTooltipItems: (touchedSpots) { + return touchedSpots.map((spot) { + if (spot.barIndex == 0) { + return LineTooltipItem( + '目标: ${goalValue.toStringAsFixed(1)} $unitLabel', + TextStyle( + color: DesignTokens.orange, + fontSize: DesignTokens.fontSm, + fontWeight: FontWeight.w500, + ), + ); + } + return LineTooltipItem( + '${spot.y.toStringAsFixed(1)} $unitLabel', + TextStyle( + color: lineColor, + fontSize: DesignTokens.fontSm, + fontWeight: FontWeight.w600, + ), + ); + }).toList(); + }, + ), + ), + ), + ), + ), + ); + } + + List _buildSpots() { + final entries = data.entries.toList(); + return entries.asMap().entries.map((e) { + return FlSpot(e.key.toDouble(), e.value.value); + }).toList(); + } + + List _buildGoalSpots() { + return [FlSpot(0, goalValue), FlSpot((data.length - 1).toDouble(), goalValue)]; + } + + double _calculateMaxY() { + final maxVal = data.values.fold(0.0, (max, v) => v > max ? v : max); + final top = maxVal > goalValue ? maxVal : goalValue; + return top * 1.1; + } + + double _calculateMinY() { + final minVal = data.values.fold(999.0, (min, v) => v < min ? v : min); + final bottom = minVal < goalValue ? minVal : goalValue; + return (bottom * 0.9).clamp(0, bottom - 5); + } + + double _calculateInterval() { + final range = _calculateMaxY() - _calculateMinY(); + if (range <= 10) return 2; + if (range <= 30) return 5; + if (range <= 60) return 10; + return 20; + } + + double _bottomInterval() { + if (data.length <= 7) return 1; + if (data.length <= 15) return 2; + return 3; + } + + Widget _buildBottomTitle(double value, TitleMeta meta) { + final entries = data.entries.toList(); + final index = value.toInt(); + if (index < 0 || index >= entries.length) return const SizedBox.shrink(); + + final shouldShow = + data.length <= 7 || index % _bottomInterval().toInt() == 0 || + index == entries.length - 1; + if (!shouldShow) return const SizedBox.shrink(); + + return SideTitleWidget( + meta: meta, + child: Text( + entries[index].key, + style: TextStyle( + fontSize: DesignTokens.fontXs, + color: isDark ? DarkDesignTokens.text2 : DesignTokens.text2, + ), + ), + ); + } + + Widget _buildLeftTitle(double value, TitleMeta meta) { + return Text( + value.toStringAsFixed(1), + style: TextStyle( + fontSize: DesignTokens.fontXs, + color: isDark ? DarkDesignTokens.text3 : DesignTokens.text3, + ), + textAlign: TextAlign.right, + ); + } +} +``` + +- [ ] **Step 2: 验证零诊断错误** + +--- + +### Task 4: 主页面 — WeightManagePage + +**Files:** +- Create: `lib/src/pages/tools/health/weight_manage_page.dart` + +- [ ] **Step 1: 创建主页面完整代码** + +页面结构(从上到下): +1. 导航栏:标题「⚖️ 体重管理」+ trailing BMI跳转按钮 +2. 3个统计卡片横排:当前体重 / 目标体重 / 变化量 +3. WeightLineChart 折线图(Obx 响应式) +4. 周期统计卡片(本周/本月 Tab 切换) +5. 目标体重设置区(CupertinoSlider + 数值显示) +6. 记录表单区:输入框 + 单位切换 + 时机选择(CupertinoSlidingSegmentedControl) + 备注输入 + 保存按钮 +7. 历史记录列表(Dismissible 可滑动删除) + +关键实现细节: +- 使用 `GetView` +- 所有响应式数据用 `Obx` 包裹 +- 统计卡片使用 GlassContainer 或圆角卡片风格 +- 时机选择使用 `CupertinoSlidingSegmentedControl`,3 个选项 +- 单位切换按钮在输入框右侧,显示当前单位 +- BMI 跳转:`Get.toNamed('/bmi-calculator')` +- 删除确认:`showCupertinoDialog` +- 空指针安全:所有 `.value` 使用前判空 + +- [ ] **Step 2: 验证零诊断错误** + +--- + +### Task 5: 工具入口 + 路由注册 + +**Files:** +- Modify: `lib/src/models/tool_item_model.dart` +- Modify: `lib/src/config/app_routes.dart` + +- [ ] **Step 1: 在 ToolRegistry.defaultTools 的 health 分类中添加体重管理入口** + +在 `bmi_calculator` 条目之后添加: + +```dart +ToolItem( + id: 'weight_manage', + name: '体重管理', + icon: '⚖️', + needsNetwork: false, + category: 'health', + route: '/tools/weight-manage', + description: '记录体重、追踪变化趋势', +), +``` + +- [ ] **Step 2: 在 app_routes.dart 添加路由常量** + +```dart +static const String weightManage = '/tools-weight-manage'; +``` + +- [ ] **Step 3: 在 GetPage 列表中注册路由** + +```dart +GetPage( + name: weightManage, + page: () => const WeightManagePage(), + middlewares: [PageStandardsMiddleware()], +), +``` + +- [ ] **Step 4: 在 PageRegistry.registerAll() 中注册** + +在 `bmiCalculator` PageInfo 之后添加: + +```dart +PageInfo( + route: weightManage, + name: 'Weight Manage Page', + description: '体重管理页面', + requiredStandards: const [ + StandardCheck.themeColors, + StandardCheck.textColors, + StandardCheck.fontSize, + StandardCheck.darkMode, + ], + builder: () => const WeightManagePage(), +), +``` + +- [ ] **Step 5: 添加 import 语句** + +```dart +import 'package:mom_kitchen/src/pages/tools/health/weight_manage_page.dart'; +``` + +- [ ] **Step 6: 验证所有修改文件零诊断错误** + +--- + +### Task 6: CHANGELOG 更新 + 最终验证 + +**Files:** +- Modify: `CHANGELOG.md` + +- [ ] **Step 1: 更新 CHANGELOG.md** + +添加版本号变更记录,包含: +- 新增体重管理页面功能描述 +- 文件清单 +- 功能列表(记录/kg斤切换/3时机/备注/折线图/目标线/周期统计/BMI跳转) + +- [ ] **Step 2: 全局诊断验证** + +Run: 检查所有新创建/修改的文件无诊断错误 +Expected: 全部 zero diagnostics + +- [ ] **Step 3: 验证工具中心分类展示** + +确认 `weight_manage` 的 `category: 'health'` 正确放入健康营养分类下与 BMI 计算器同组 diff --git a/docs/superpowers/plans/2026-04-17-order-assistant.md b/docs/superpowers/plans/2026-04-17-order-assistant.md new file mode 100644 index 0000000..10a0c07 --- /dev/null +++ b/docs/superpowers/plans/2026-04-17-order-assistant.md @@ -0,0 +1,2375 @@ +# 点餐助手 Implementation Plan + +> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking. + +**Goal:** 实现点餐助手工具,支持用户点餐/商家推单双向场景,生成二维码/条形码,网页端实时展示点单内容。 + +**Architecture:** App端使用GetX Controller管理状态,SharedPreferences本地持久化+Mock API Service(后端就绪后切换)。网页端纯HTML/CSS/JS单文件,WebSocket实时同步。二维码使用项目已有的`qr`库,条形码新增`barcode`库。 + +**Tech Stack:** Flutter/Dart, GetX, Cupertino (iOS 26), SharedPreferences, qr (已有), barcode (新增), WebSocket, HTML/CSS/JS + +--- + +## File Structure + +### 新建文件 + +| 文件 | 职责 | +|------|------| +| `lib/src/models/tools/order_model.dart` | Order + OrderItem 数据模型,含 toJson/fromJson | +| `lib/src/controllers/tools/order_assistant_controller.dart` | 点餐助手状态管理,SharedPreferences 持久化 | +| `lib/src/services/tools/order_api_service.dart` | API 服务层(含 Mock 实现) | +| `lib/src/pages/tools/cooking/order_assistant_page.dart` | 主页面 | +| `lib/src/pages/tools/cooking/widgets/order_item_card.dart` | 菜品卡片组件 | +| `lib/src/pages/tools/cooking/widgets/add_item_sheet.dart` | 添加菜品弹窗 | +| `lib/src/pages/tools/cooking/widgets/browse_history_picker.dart` | 浏览记录选择器 | +| `lib/src/pages/tools/cooking/widgets/manual_input_sheet.dart` | 手动填写弹窗 | +| `lib/src/pages/tools/cooking/widgets/qr_barcode_dialog.dart` | 二维码/条形码展示弹窗 | +| `web_order/index.html` | 网页端点单展示页 | + +### 修改文件 + +| 文件 | 变更 | +|------|------| +| `lib/src/models/tool_item_model.dart` | 新增 order_assistant 工具项 | +| `lib/src/config/app_routes.dart` | 注册 /tools/order-assistant 路由 | +| `lib/src/app_binding.dart` | 注册 OrderAssistantController | +| `pubspec.yaml` | 新增 barcode 依赖 | +| `CHANGELOG.md` | 记录版本变更 | + +--- + +### Task 1: 数据模型 — Order + OrderItem + +**Files:** +- Create: `lib/src/models/tools/order_model.dart` + +- [ ] **Step 1: 创建 order_model.dart** + +```dart +/* + * 文件: order_model.dart + * 名称: 点餐助手数据模型 + * 作用: 定义点单和菜品项的数据结构,支持 SharedPreferences JSON 持久化 + * 创建时间: 2026-04-17 + * 更新时间: 2026-04-17 初始创建 + */ + +enum OrderItemSource { + browseHistory, + search, + manual, + merchantRecommend; + + String get label { + switch (this) { + case OrderItemSource.browseHistory: + return '浏览记录'; + case OrderItemSource.search: + return '搜索'; + case OrderItemSource.manual: + return '手动填写'; + case OrderItemSource.merchantRecommend: + return '商家推荐'; + } + } + + String get icon { + switch (this) { + case OrderItemSource.browseHistory: + return '📖'; + case OrderItemSource.search: + return '🔍'; + case OrderItemSource.manual: + return '✏️'; + case OrderItemSource.merchantRecommend: + return '⭐'; + } + } +} + +enum OrderType { + userOrder, + merchantPush; + + String get label { + switch (this) { + case OrderType.userOrder: + return '用户点餐'; + case OrderType.merchantPush: + return '商家推单'; + } + } + + String get icon { + switch (this) { + case OrderType.userOrder: + return '🧑'; + case OrderType.merchantPush: + return '🏪'; + } + } +} + +enum OrderStatus { + draft, + active, + completed, + cancelled; + + String get label { + switch (this) { + case OrderStatus.draft: + return '草稿'; + case OrderStatus.active: + return '进行中'; + case OrderStatus.completed: + return '已完成'; + case OrderStatus.cancelled: + return '已取消'; + } + } + + String get icon { + switch (this) { + case OrderStatus.draft: + return '📝'; + case OrderStatus.active: + return '🟢'; + case OrderStatus.completed: + return '✅'; + case OrderStatus.cancelled: + return '❌'; + } + } +} + +class OrderItem { + final String id; + final String name; + final OrderItemSource source; + final int quantity; + final double? price; + final String? ingredients; + final String? note; + final String? recipeId; + + const OrderItem({ + required this.id, + required this.name, + required this.source, + this.quantity = 1, + this.price, + this.ingredients, + this.note, + this.recipeId, + }); + + double get subtotal => (price ?? 0) * quantity; + + OrderItem copyWith({ + String? id, + String? name, + OrderItemSource? source, + int? quantity, + double? price, + String? ingredients, + String? note, + String? recipeId, + }) { + return OrderItem( + id: id ?? this.id, + name: name ?? this.name, + source: source ?? this.source, + quantity: quantity ?? this.quantity, + price: price ?? this.price, + ingredients: ingredients ?? this.ingredients, + note: note ?? this.note, + recipeId: recipeId ?? this.recipeId, + ); + } + + Map toJson() { + return { + 'id': id, + 'name': name, + 'source': source.index, + 'quantity': quantity, + 'price': price, + 'ingredients': ingredients, + 'note': note, + 'recipeId': recipeId, + }; + } + + factory OrderItem.fromJson(Map json) { + return OrderItem( + id: json['id'] as String? ?? '', + name: json['name'] as String? ?? '', + source: OrderItemSource.values[json['source'] as int? ?? 2], + quantity: json['quantity'] as int? ?? 1, + price: (json['price'] as num?)?.toDouble(), + ingredients: json['ingredients'] as String?, + note: json['note'] as String?, + recipeId: json['recipeId'] as String?, + ); + } +} + +class Order { + final String id; + final String orderNo; + final OrderType type; + final OrderStatus status; + final List items; + final double? totalAmount; + final String? note; + final String createdAt; + final String updatedAt; + final String createdBy; + final String? tableNo; + final int recordCount; + + const Order({ + required this.id, + required this.orderNo, + required this.type, + required this.status, + required this.items, + this.totalAmount, + this.note, + required this.createdAt, + required this.updatedAt, + required this.createdBy, + this.tableNo, + this.recordCount = 0, + }); + + double get calculatedTotal { + if (totalAmount != null) return totalAmount!; + return items.fold(0.0, (sum, item) => sum + item.subtotal); + } + + int get totalQuantity => items.fold(0, (sum, item) => sum + item.quantity); + + String get qrUrl => + 'https://eat.wktyl.com/api/kitchen?id=$id'; + + String get displayDate { + if (createdAt.isEmpty) return ''; + try { + final dt = DateTime.parse(createdAt); + final now = DateTime.now(); + final diff = now.difference(dt); + if (diff.inDays == 0) { + if (diff.inHours == 0) { + if (diff.inMinutes == 0) return '刚刚'; + return '${diff.inMinutes}分钟前'; + } + return '${diff.inHours}小时前'; + } else if (diff.inDays == 1) { + return '昨天'; + } else if (diff.inDays < 7) { + return '${diff.inDays}天前'; + } else { + return '${dt.month}月${dt.day}日'; + } + } catch (_) { + return createdAt; + } + } + + Order copyWith({ + String? id, + String? orderNo, + OrderType? type, + OrderStatus? status, + List? items, + double? totalAmount, + String? note, + String? createdAt, + String? updatedAt, + String? createdBy, + String? tableNo, + int? recordCount, + }) { + return Order( + id: id ?? this.id, + orderNo: orderNo ?? this.orderNo, + type: type ?? this.type, + status: status ?? this.status, + items: items ?? this.items, + totalAmount: totalAmount ?? this.totalAmount, + note: note ?? this.note, + createdAt: createdAt ?? this.createdAt, + updatedAt: updatedAt ?? this.updatedAt, + createdBy: createdBy ?? this.createdBy, + tableNo: tableNo ?? this.tableNo, + recordCount: recordCount ?? this.recordCount, + ); + } + + Map toJson() { + return { + 'id': id, + 'orderNo': orderNo, + 'type': type.index, + 'status': status.index, + 'items': items.map((i) => i.toJson()).toList(), + 'totalAmount': totalAmount, + 'note': note, + 'createdAt': createdAt, + 'updatedAt': updatedAt, + 'createdBy': createdBy, + 'tableNo': tableNo, + 'recordCount': recordCount, + }; + } + + factory Order.fromJson(Map json) { + return Order( + id: json['id'] as String? ?? '', + orderNo: json['orderNo'] as String? ?? '', + type: OrderType.values[json['type'] as int? ?? 0], + status: OrderStatus.values[json['status'] as int? ?? 0], + items: (json['items'] as List?) + ?.map((i) => OrderItem.fromJson(i as Map)) + .toList() ?? + [], + totalAmount: (json['totalAmount'] as num?)?.toDouble(), + note: json['note'] as String?, + createdAt: json['createdAt'] as String? ?? '', + updatedAt: json['updatedAt'] as String? ?? '', + createdBy: json['createdBy'] as String? ?? '', + tableNo: json['tableNo'] as String?, + recordCount: json['recordCount'] as int? ?? 0, + ); + } + + static String generateOrderNo() { + final now = DateTime.now(); + final timestamp = now.millisecondsSinceEpoch.toString().substring(5); + final random = (now.microsecond % 900 + 100).toString(); + return 'OD$timestamp$random'; + } +} +``` + +- [ ] **Step 2: 验证编译** + +Run: `cd e:\project\flutter\f\mom_kitchen && flutter analyze --no-pub lib/src/models/tools/order_model.dart` +Expected: No errors + +--- + +### Task 2: API 服务层 — OrderApiService (Mock) + +**Files:** +- Create: `lib/src/services/tools/order_api_service.dart` + +- [ ] **Step 1: 创建 order_api_service.dart** + +```dart +/* + * 文件: order_api_service.dart + * 名称: 点餐助手API服务 + * 作用: 提供点单CRUD接口,当前为Mock实现,后端就绪后切换 + * 创建时间: 2026-04-17 + * 更新时间: 2026-04-17 初始创建,Mock实现 + */ + +import 'package:flutter/foundation.dart'; +import 'package:mom_kitchen/src/models/tools/order_model.dart'; + +class OrderApiService { + static const String _baseUrl = 'https://eat.wktyl.com/api/kitchen'; + + Future createOrder(Order order) async { + debugPrint('[OrderApi] Mock createOrder: ${order.orderNo}'); + await Future.delayed(const Duration(milliseconds: 300)); + return order; + } + + Future getOrder(String orderId) async { + debugPrint('[OrderApi] Mock getOrder: $orderId'); + await Future.delayed(const Duration(milliseconds: 200)); + throw UnimplementedError('后端API未就绪'); + } + + Future updateOrder(Order order) async { + debugPrint('[OrderApi] Mock updateOrder: ${order.orderNo}'); + await Future.delayed(const Duration(milliseconds: 300)); + return order; + } + + Future> getQrUrls(String orderId) async { + debugPrint('[OrderApi] Mock getQrUrls: $orderId'); + await Future.delayed(const Duration(milliseconds: 100)); + return { + 'qrUrl': '$_baseUrl?id=$orderId', + 'barcodeUrl': '$_baseUrl/barcode?id=$orderId', + }; + } + + Future> getOrders({ + int page = 1, + int limit = 20, + OrderStatus? status, + }) async { + debugPrint('[OrderApi] Mock getOrders: page=$page, limit=$limit'); + await Future.delayed(const Duration(milliseconds: 200)); + return {'list': [], 'total': 0}; + } +} +``` + +- [ ] **Step 2: 验证编译** + +Run: `cd e:\project\flutter\f\mom_kitchen && flutter analyze --no-pub lib/src/services/tools/order_api_service.dart` +Expected: No errors + +--- + +### Task 3: 控制器 — OrderAssistantController + +**Files:** +- Create: `lib/src/controllers/tools/order_assistant_controller.dart` + +- [ ] **Step 1: 创建 order_assistant_controller.dart** + +```dart +/* + * 文件: order_assistant_controller.dart + * 名称: 点餐助手控制器 + * 作用: 管理点单状态、SharedPreferences持久化、记录条数统计 + * 创建时间: 2026-04-17 + * 更新时间: 2026-04-17 初始创建 + */ + +import 'package:flutter/foundation.dart'; +import 'package:get/get.dart'; +import 'package:shared_preferences/shared_preferences.dart'; +import 'dart:convert'; +import 'package:uuid/uuid.dart'; +import 'package:mom_kitchen/src/models/tools/order_model.dart'; +import 'package:mom_kitchen/src/services/tools/order_api_service.dart'; + +class OrderAssistantController extends GetxController { + static OrderAssistantController get to => Get.find(); + + final RxList _history = [].obs; + final Rx _currentOrder = Rx(null); + final RxInt _recordCount = 0.obs; + final Rx _orderType = OrderType.userOrder.obs; + + static const String _storageKey = 'order_assistant_records'; + static const String _countKey = 'order_assistant_count'; + static const int _maxRecordCount = 100; + + List get history => _history; + Order? get currentOrder => _currentOrder.value; + int get recordCount => _recordCount.value; + OrderType get orderType => _orderType.value; + + final OrderApiService _apiService = OrderApiService(); + SharedPreferences? _prefs; + + @override + void onInit() { + super.onInit(); + _initPrefs(); + } + + Future _initPrefs() async { + try { + _prefs = await SharedPreferences.getInstance(); + await _loadHistory(); + _recordCount.value = _prefs?.getInt(_countKey) ?? 0; + debugPrint('点餐助手初始化完成,共 ${_history.length} 条历史记录'); + } catch (e) { + debugPrint('初始化SharedPreferences失败: $e'); + } + } + + Future _loadHistory() async { + try { + _prefs ??= await SharedPreferences.getInstance(); + final String? data = _prefs!.getString(_storageKey); + if (data != null && data.isNotEmpty) { + final List jsonList = json.decode(data); + final loaded = + jsonList.map((j) => Order.fromJson(j)).toList(); + _history.assignAll(loaded); + } + } catch (e) { + debugPrint('加载点单历史失败: $e'); + } + } + + Future _saveHistory() async { + try { + _prefs ??= await SharedPreferences.getInstance(); + final data = json.encode(_history.map((o) => o.toJson()).toList()); + await _prefs!.setString(_storageKey, data); + } catch (e) { + debugPrint('保存点单历史失败: $e'); + } + } + + Future _incrementRecordCount() async { + _recordCount.value++; + _prefs ??= await SharedPreferences.getInstance(); + await _prefs!.setInt(_countKey, _recordCount.value); + } + + void setOrderType(OrderType type) { + _orderType.value = type; + } + + void createNewOrder() { + final now = DateTime.now(); + const uuid = Uuid(); + _currentOrder.value = Order( + id: uuid.v4(), + orderNo: Order.generateOrderNo(), + type: _orderType.value, + status: OrderStatus.draft, + items: [], + createdAt: now.toIso8601String(), + updatedAt: now.toIso8601String(), + createdBy: 'local_user', + recordCount: _recordCount.value, + ); + } + + void addItem(OrderItem item) { + final order = _currentOrder.value; + if (order == null) return; + final updatedItems = [...order.items, item]; + _currentOrder.value = order.copyWith( + items: updatedItems, + updatedAt: DateTime.now().toIso8601String(), + ); + } + + void removeItem(String itemId) { + final order = _currentOrder.value; + if (order == null) return; + final updatedItems = order.items.where((i) => i.id != itemId).toList(); + _currentOrder.value = order.copyWith( + items: updatedItems, + updatedAt: DateTime.now().toIso8601String(), + ); + } + + void updateItemQuantity(String itemId, int quantity) { + final order = _currentOrder.value; + if (order == null) return; + final updatedItems = order.items.map((i) { + if (i.id == itemId) { + return i.copyWith(quantity: quantity.clamp(1, 99)); + } + return i; + }).toList(); + _currentOrder.value = order.copyWith( + items: updatedItems, + updatedAt: DateTime.now().toIso8601String(), + ); + } + + void updateItemPrice(String itemId, double? price) { + final order = _currentOrder.value; + if (order == null) return; + final updatedItems = order.items.map((i) { + if (i.id == itemId) { + return i.copyWith(price: price); + } + return i; + }).toList(); + _currentOrder.value = order.copyWith( + items: updatedItems, + updatedAt: DateTime.now().toIso8601String(), + ); + } + + void setTableNo(String? tableNo) { + final order = _currentOrder.value; + if (order == null) return; + _currentOrder.value = order.copyWith(tableNo: tableNo); + } + + void setNote(String? note) { + final order = _currentOrder.value; + if (order == null) return; + _currentOrder.value = order.copyWith(note: note); + } + + Future activateOrder() async { + final order = _currentOrder.value; + if (order == null || order.items.isEmpty) return; + + final activatedOrder = order.copyWith( + status: OrderStatus.active, + updatedAt: DateTime.now().toIso8601String(), + ); + _currentOrder.value = activatedOrder; + + try { + await _apiService.createOrder(activatedOrder); + } catch (e) { + debugPrint('API创建点单失败(本地已保存): $e'); + } + + _history.insert(0, activatedOrder); + if (_history.length > _maxRecordCount) { + _history.removeRange(_maxRecordCount, _history.length); + } + await _incrementRecordCount(); + await _saveHistory(); + } + + Future updateOrderToBackend() async { + final order = _currentOrder.value; + if (order == null) return; + try { + await _apiService.updateOrder(order); + } catch (e) { + debugPrint('API更新点单失败: $e'); + } + } + + void loadOrder(Order order) { + _currentOrder.value = order; + _orderType.value = order.type; + } + + Future deleteOrder(String orderId) async { + _history.removeWhere((o) => o.id == orderId); + await _saveHistory(); + } + + Future clearHistory() async { + _history.clear(); + await _saveHistory(); + } +} +``` + +- [ ] **Step 2: 验证编译** + +Run: `cd e:\project\flutter\f\mom_kitchen && flutter analyze --no-pub lib/src/controllers/tools/order_assistant_controller.dart` +Expected: No errors + +--- + +### Task 4: 菜品卡片组件 — OrderItemCard + +**Files:** +- Create: `lib/src/pages/tools/cooking/widgets/order_item_card.dart` + +- [ ] **Step 1: 创建 order_item_card.dart** + +```dart +/* + * 文件: order_item_card.dart + * 名称: 菜品卡片组件 + * 作用: 展示点单项信息,支持数量调整、价格编辑、删除 + * 创建时间: 2026-04-17 + * 更新时间: 2026-04-17 初始创建 + */ + +import 'dart:ui'; +import 'package:flutter/cupertino.dart'; +import 'package:flutter/material.dart'; +import 'package:mom_kitchen/src/config/design_tokens.dart'; +import 'package:mom_kitchen/src/models/tools/order_model.dart'; + +class OrderItemCard extends StatelessWidget { + final OrderItem item; + final VoidCallback onDelete; + final ValueChanged onQuantityChanged; + final ValueChanged onPriceChanged; + final int index; + + const OrderItemCard({ + super.key, + required this.item, + required this.onDelete, + required this.onQuantityChanged, + required this.onPriceChanged, + required this.index, + }); + + @override + Widget build(BuildContext context) { + final isDark = CupertinoTheme.brightnessOf(context) == Brightness.dark; + + return ClipRRect( + borderRadius: DesignTokens.borderRadiusLg, + child: BackdropFilter( + filter: ImageFilter.blur(sigmaX: 20, sigmaY: 20), + child: Container( + margin: const EdgeInsets.only(bottom: DesignTokens.space2), + padding: const EdgeInsets.all(DesignTokens.space3), + decoration: BoxDecoration( + color: (isDark ? DarkDesignTokens.card : DesignTokens.card) + .withValues(alpha: 0.72), + borderRadius: DesignTokens.borderRadiusLg, + border: Border.all( + color: isDark + ? DarkDesignTokens.glassBorder + : DesignTokens.text3.withValues(alpha: 0.08), + width: 0.5, + ), + ), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Row( + children: [ + _buildSourceTag(isDark), + const SizedBox(width: DesignTokens.space2), + Expanded( + child: Text( + item.name, + style: TextStyle( + fontSize: DesignTokens.fontMd, + fontWeight: FontWeight.w600, + color: isDark + ? DarkDesignTokens.text1 + : DesignTokens.text1, + ), + maxLines: 1, + overflow: TextOverflow.ellipsis, + ), + ), + _buildQuantityControl(isDark), + const SizedBox(width: DesignTokens.space2), + GestureDetector( + onTap: onDelete, + child: Icon( + CupertinoIcons.delete_simple, + size: 18, + color: CupertinoColors.destructiveRed + .withValues(alpha: 0.7), + ), + ), + ], + ), + if (item.ingredients != null && item.ingredients!.isNotEmpty) ...[ + const SizedBox(height: DesignTokens.space1), + Text( + '🥘 ${item.ingredients}', + style: TextStyle( + fontSize: DesignTokens.fontSm, + color: isDark ? DarkDesignTokens.text3 : DesignTokens.text3, + ), + maxLines: 1, + overflow: TextOverflow.ellipsis, + ), + ], + if (item.note != null && item.note!.isNotEmpty) ...[ + const SizedBox(height: DesignTokens.space1), + Text( + '💬 ${item.note}', + style: TextStyle( + fontSize: DesignTokens.fontSm, + color: isDark ? DarkDesignTokens.text3 : DesignTokens.text3, + ), + maxLines: 1, + overflow: TextOverflow.ellipsis, + ), + ], + const SizedBox(height: DesignTokens.space2), + Row( + mainAxisAlignment: MainAxisAlignment.end, + children: [ + GestureDetector( + onTap: () => _showPriceEditor(context, isDark), + child: Text( + item.price != null + ? '¥${item.subtotal.toStringAsFixed(1)}' + : '点此设价', + style: TextStyle( + fontSize: DesignTokens.fontMd, + fontWeight: FontWeight.w600, + color: item.price != null + ? DesignTokens.dynamicPrimary + : (isDark + ? DarkDesignTokens.text3 + : DesignTokens.text3), + ), + ), + ), + ], + ), + ], + ), + ), + ), + ); + } + + Widget _buildSourceTag(bool isDark) { + return Container( + padding: const EdgeInsets.symmetric( + horizontal: DesignTokens.space1 + 2, + vertical: 2, + ), + decoration: BoxDecoration( + color: DesignTokens.dynamicPrimary.withValues(alpha: 0.1), + borderRadius: DesignTokens.borderRadiusSm, + ), + child: Text( + '${item.source.icon} ${item.source.label}', + style: TextStyle( + fontSize: DesignTokens.fontXs, + color: DesignTokens.dynamicPrimary, + ), + ), + ); + } + + Widget _buildQuantityControl(bool isDark) { + return Row( + mainAxisSize: MainAxisSize.min, + children: [ + GestureDetector( + onTap: () => onQuantityChanged(item.quantity - 1), + child: Container( + width: 28, + height: 28, + decoration: BoxDecoration( + color: isDark + ? DarkDesignTokens.text3.withValues(alpha: 0.2) + : DesignTokens.text3.withValues(alpha: 0.1), + borderRadius: DesignTokens.borderRadiusSm, + ), + child: const Icon(CupertinoIcons.minus, size: 14), + ), + ), + Container( + constraints: const BoxConstraints(minWidth: 32), + alignment: Alignment.center, + child: Text( + '${item.quantity}', + style: TextStyle( + fontSize: DesignTokens.fontMd, + fontWeight: FontWeight.w600, + color: isDark ? DarkDesignTokens.text1 : DesignTokens.text1, + ), + ), + ), + GestureDetector( + onTap: () => onQuantityChanged(item.quantity + 1), + child: Container( + width: 28, + height: 28, + decoration: BoxDecoration( + color: DesignTokens.dynamicPrimary.withValues(alpha: 0.15), + borderRadius: DesignTokens.borderRadiusSm, + ), + child: Icon( + CupertinoIcons.plus, + size: 14, + color: DesignTokens.dynamicPrimary, + ), + ), + ), + ], + ); + } + + void _showPriceEditor(BuildContext context, bool isDark) { + final controller = TextEditingController( + text: item.price?.toStringAsFixed(1) ?? '', + ); + showCupertinoDialog( + context: context, + builder: (ctx) => CupertinoAlertDialog( + title: const Text('💰 设置单价'), + content: Padding( + padding: const EdgeInsets.only(top: DesignTokens.space2), + child: CupertinoTextField( + controller: controller, + placeholder: '输入单价', + keyboardType: + const TextInputType.numberWithOptions(decimal: true), + autofocus: true, + ), + ), + actions: [ + CupertinoDialogAction( + onPressed: () => Navigator.pop(ctx), + child: const Text('取消'), + ), + CupertinoDialogAction( + isDefaultAction: true, + onPressed: () { + final val = double.tryParse(controller.text); + onPriceChanged(val); + Navigator.pop(ctx); + }, + child: const Text('确定'), + ), + ], + ), + ); + } +} +``` + +- [ ] **Step 2: 验证编译** + +Run: `cd e:\project\flutter\f\mom_kitchen && flutter analyze --no-pub lib/src/pages/tools/cooking/widgets/order_item_card.dart` +Expected: No errors + +--- + +### Task 5: 添加菜品弹窗 — AddItemSheet + +**Files:** +- Create: `lib/src/pages/tools/cooking/widgets/add_item_sheet.dart` +- Create: `lib/src/pages/tools/cooking/widgets/browse_history_picker.dart` +- Create: `lib/src/pages/tools/cooking/widgets/manual_input_sheet.dart` + +- [ ] **Step 1: 创建 add_item_sheet.dart** + +```dart +/* + * 文件: add_item_sheet.dart + * 名称: 添加菜品弹窗 + * 作用: 提供四种添加菜品方式的入口 + * 创建时间: 2026-04-17 + * 更新时间: 2026-04-17 初始创建 + */ + +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/data/browse_history_controller.dart'; +import 'package:mom_kitchen/src/models/tools/order_model.dart'; +import 'package:mom_kitchen/src/pages/tools/cooking/widgets/browse_history_picker.dart'; +import 'package:mom_kitchen/src/pages/tools/cooking/widgets/manual_input_sheet.dart'; + +void showAddItemSheet( + BuildContext context, { + required ValueChanged onItemAdded, + required bool isMerchantMode, +}) { + showCupertinoModalPopup( + context: context, + builder: (ctx) => CupertinoActionSheet( + title: const Text('🍽️ 添加菜品'), + message: const Text('选择菜品来源'), + actions: [ + CupertinoActionSheetAction( + onPressed: () { + Navigator.pop(ctx); + _showBrowseHistoryPicker(context, onItemAdded); + }, + child: const Text('📖 从浏览记录选择'), + ), + CupertinoActionSheetAction( + onPressed: () { + Navigator.pop(ctx); + _showManualInput(context, onItemAdded, OrderItemSource.manual); + }, + child: const Text('✏️ 手动填写'), + ), + if (isMerchantMode) + CupertinoActionSheetAction( + onPressed: () { + Navigator.pop(ctx); + _showManualInput( + context, onItemAdded, OrderItemSource.merchantRecommend); + }, + child: const Text('⭐ 商家推荐'), + ), + ], + cancelButton: CupertinoActionSheetAction( + isDestructiveAction: true, + onPressed: () => Navigator.pop(ctx), + child: const Text('取消'), + ), + ), + ); +} + +void _showBrowseHistoryPicker( + BuildContext context, + ValueChanged onItemAdded, +) { + final history = BrowseHistoryController.to.history; + if (history.isEmpty) { + showCupertinoDialog( + context: context, + builder: (ctx) => CupertinoAlertDialog( + title: const Text('📖 浏览记录为空'), + content: const Text('请先浏览一些菜谱,再来选择'), + actions: [ + CupertinoDialogAction( + onPressed: () => Navigator.pop(ctx), + child: const Text('知道了'), + ), + ], + ), + ); + return; + } + showCupertinoModalPopup( + context: context, + builder: (ctx) => BrowseHistoryPicker( + onSelected: onItemAdded, + ), + ); +} + +void _showManualInput( + BuildContext context, + ValueChanged onItemAdded, + OrderItemSource source, +) { + showCupertinoModalPopup( + context: context, + builder: (ctx) => ManualInputSheet( + source: source, + onSaved: onItemAdded, + ), + ); +} +``` + +- [ ] **Step 2: 创建 browse_history_picker.dart** + +```dart +/* + * 文件: browse_history_picker.dart + * 名称: 浏览记录选择器 + * 作用: 从浏览历史中选择菜品添加到点单 + * 创建时间: 2026-04-17 + * 更新时间: 2026-04-17 初始创建 + */ + +import 'package:flutter/cupertino.dart'; +import 'package:get/get.dart'; +import 'package:uuid/uuid.dart'; +import 'package:mom_kitchen/src/config/design_tokens.dart'; +import 'package:mom_kitchen/src/controllers/data/browse_history_controller.dart'; +import 'package:mom_kitchen/src/models/tools/order_model.dart'; + +class BrowseHistoryPicker extends StatelessWidget { + final ValueChanged onSelected; + + const BrowseHistoryPicker({super.key, required this.onSelected}); + + @override + Widget build(BuildContext context) { + final isDark = CupertinoTheme.brightnessOf(context) == Brightness.dark; + final history = BrowseHistoryController.to.history; + + return Container( + constraints: BoxConstraints( + maxHeight: MediaQuery.of(context).size.height * 0.6, + ), + decoration: BoxDecoration( + color: isDark ? DarkDesignTokens.card : DesignTokens.card, + borderRadius: const BorderRadius.vertical( + top: Radius.circular(DesignTokens.radiusLg), + ), + ), + child: Column( + mainAxisSize: MainAxisSize.min, + children: [ + Container( + margin: const EdgeInsets.only(top: DesignTokens.space2), + width: 36, + height: 4, + decoration: BoxDecoration( + color: isDark ? DarkDesignTokens.text3 : DesignTokens.text3, + borderRadius: DesignTokens.borderRadiusFull, + ), + ), + Padding( + padding: const EdgeInsets.all(DesignTokens.space3), + child: Text( + '📖 选择浏览记录', + style: TextStyle( + fontSize: DesignTokens.fontLg, + fontWeight: FontWeight.w600, + color: isDark ? DarkDesignTokens.text1 : DesignTokens.text1, + ), + ), + ), + const Divider(height: 1), + Flexible( + child: ListView.builder( + shrinkWrap: true, + itemCount: history.length, + itemBuilder: (context, index) { + final item = history[index]; + return CupertinoButton( + padding: const EdgeInsets.symmetric( + horizontal: DesignTokens.space4, + vertical: DesignTokens.space2, + ), + onPressed: () { + const uuid = Uuid(); + onSelected(OrderItem( + id: uuid.v4(), + name: item.title, + source: OrderItemSource.browseHistory, + recipeId: item.recipeId, + )); + Navigator.pop(context); + }, + child: Row( + children: [ + Expanded( + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text( + item.title, + style: TextStyle( + fontSize: DesignTokens.fontMd, + color: isDark + ? DarkDesignTokens.text1 + : DesignTokens.text1, + ), + ), + if (item.category != null) + Text( + item.category!, + style: TextStyle( + fontSize: DesignTokens.fontXs, + color: isDark + ? DarkDesignTokens.text3 + : DesignTokens.text3, + ), + ), + ], + ), + ), + Text( + item.displayDate, + style: TextStyle( + fontSize: DesignTokens.fontXs, + color: isDark + ? DarkDesignTokens.text3 + : DesignTokens.text3, + ), + ), + ], + ), + ); + }, + ), + ), + ], + ), + ); + } +} +``` + +- [ ] **Step 3: 创建 manual_input_sheet.dart** + +```dart +/* + * 文件: manual_input_sheet.dart + * 名称: 手动填写菜品弹窗 + * 作用: 手动输入菜品名称、食材、备注、价格 + * 创建时间: 2026-04-17 + * 更新时间: 2026-04-17 初始创建 + */ + +import 'package:flutter/cupertino.dart'; +import 'package:uuid/uuid.dart'; +import 'package:mom_kitchen/src/config/design_tokens.dart'; +import 'package:mom_kitchen/src/models/tools/order_model.dart'; + +class ManualInputSheet extends StatefulWidget { + final OrderItemSource source; + final ValueChanged onSaved; + + const ManualInputSheet({ + super.key, + required this.source, + required this.onSaved, + }); + + @override + State createState() => _ManualInputSheetState(); +} + +class _ManualInputSheetState extends State { + final _nameController = TextEditingController(); + final _ingredientsController = TextEditingController(); + final _noteController = TextEditingController(); + final _priceController = TextEditingController(); + int _quantity = 1; + + @override + void dispose() { + _nameController.dispose(); + _ingredientsController.dispose(); + _noteController.dispose(); + _priceController.dispose(); + super.dispose(); + } + + @override + Widget build(BuildContext context) { + final isDark = CupertinoTheme.brightnessOf(context) == Brightness.dark; + final title = widget.source == OrderItemSource.merchantRecommend + ? '⭐ 商家推荐' + : '✏️ 手动填写'; + + return Container( + constraints: BoxConstraints( + maxHeight: MediaQuery.of(context).size.height * 0.7, + ), + decoration: BoxDecoration( + color: isDark ? DarkDesignTokens.card : DesignTokens.card, + borderRadius: const BorderRadius.vertical( + top: Radius.circular(DesignTokens.radiusLg), + ), + ), + child: SingleChildScrollView( + padding: EdgeInsets.only( + left: DesignTokens.space4, + right: DesignTokens.space4, + top: DesignTokens.space2, + bottom: MediaQuery.of(context).viewInsets.bottom + DesignTokens.space4, + ), + child: Column( + mainAxisSize: MainAxisSize.min, + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Center( + child: Container( + width: 36, + height: 4, + decoration: BoxDecoration( + color: isDark ? DarkDesignTokens.text3 : DesignTokens.text3, + borderRadius: DesignTokens.borderRadiusFull, + ), + ), + ), + const SizedBox(height: DesignTokens.space3), + Center( + child: Text( + title, + style: TextStyle( + fontSize: DesignTokens.fontLg, + fontWeight: FontWeight.w600, + color: isDark ? DarkDesignTokens.text1 : DesignTokens.text1, + ), + ), + ), + const SizedBox(height: DesignTokens.space3), + CupertinoTextField( + controller: _nameController, + placeholder: '菜品名称 *', + autofocus: true, + ), + const SizedBox(height: DesignTokens.space2), + CupertinoTextField( + controller: _ingredientsController, + placeholder: '食材(可选)', + ), + const SizedBox(height: DesignTokens.space2), + CupertinoTextField( + controller: _noteController, + placeholder: '备注(可选)', + ), + const SizedBox(height: DesignTokens.space2), + CupertinoTextField( + controller: _priceController, + placeholder: '单价(可选)', + keyboardType: + const TextInputType.numberWithOptions(decimal: true), + ), + const SizedBox(height: DesignTokens.space3), + Row( + children: [ + Text( + '数量: ', + style: TextStyle( + fontSize: DesignTokens.fontMd, + color: isDark ? DarkDesignTokens.text1 : DesignTokens.text1, + ), + ), + GestureDetector( + onTap: () => setState(() { + if (_quantity > 1) _quantity--; + }), + child: Container( + width: 32, + height: 32, + decoration: BoxDecoration( + color: isDark + ? DarkDesignTokens.text3.withValues(alpha: 0.2) + : DesignTokens.text3.withValues(alpha: 0.1), + borderRadius: DesignTokens.borderRadiusSm, + ), + child: const Icon(CupertinoIcons.minus, size: 16), + ), + ), + Container( + width: 40, + alignment: Alignment.center, + child: Text( + '$_quantity', + style: TextStyle( + fontSize: DesignTokens.fontLg, + fontWeight: FontWeight.w600, + color: + isDark ? DarkDesignTokens.text1 : DesignTokens.text1, + ), + ), + ), + GestureDetector( + onTap: () => setState(() => _quantity++), + child: Container( + width: 32, + height: 32, + decoration: BoxDecoration( + color: + DesignTokens.dynamicPrimary.withValues(alpha: 0.15), + borderRadius: DesignTokens.borderRadiusSm, + ), + child: Icon( + CupertinoIcons.plus, + size: 16, + color: DesignTokens.dynamicPrimary, + ), + ), + ), + ], + ), + const SizedBox(height: DesignTokens.space4), + SizedBox( + width: double.infinity, + child: CupertinoButton( + padding: const EdgeInsets.symmetric( + vertical: DesignTokens.space3), + borderRadius: DesignTokens.borderRadiusMd, + color: DesignTokens.dynamicPrimary, + onPressed: _save, + child: const Text( + '添加', + style: TextStyle( + fontSize: DesignTokens.fontMd, + fontWeight: FontWeight.w600, + color: CupertinoColors.white, + ), + ), + ), + ), + ], + ), + ), + ); + } + + void _save() { + final name = _nameController.text.trim(); + if (name.isEmpty) return; + + const uuid = Uuid(); + widget.onSaved(OrderItem( + id: uuid.v4(), + name: name, + source: widget.source, + quantity: _quantity, + price: double.tryParse(_priceController.text.trim()), + ingredients: _ingredientsController.text.trim().isEmpty + ? null + : _ingredientsController.text.trim(), + note: _noteController.text.trim().isEmpty + ? null + : _noteController.text.trim(), + )); + Navigator.pop(context); + } +} +``` + +- [ ] **Step 4: 验证编译** + +Run: `cd e:\project\flutter\f\mom_kitchen && flutter analyze --no-pub lib/src/pages/tools/cooking/widgets/` +Expected: No errors + +--- + +### Task 6: 二维码/条形码弹窗 — QrBarcodeDialog + +**Files:** +- Create: `lib/src/pages/tools/cooking/widgets/qr_barcode_dialog.dart` + +- [ ] **Step 1: 创建 qr_barcode_dialog.dart** + +```dart +/* + * 文件: qr_barcode_dialog.dart + * 名称: 二维码/条形码展示弹窗 + * 作用: 展示点单二维码和条形码,支持保存图片 + * 创建时间: 2026-04-17 + * 更新时间: 2026-04-17 初始创建 + */ + +import 'dart:ui'; +import 'package:flutter/cupertino.dart'; +import 'package:flutter/material.dart'; +import 'package:flutter/rendering.dart'; +import 'package:qr/qr.dart'; +import 'package:mom_kitchen/src/config/design_tokens.dart'; +import 'package:mom_kitchen/src/models/tools/order_model.dart'; + +class QrBarcodeDialog extends StatelessWidget { + final Order order; + + const QrBarcodeDialog({super.key, required this.order}); + + @override + Widget build(BuildContext context) { + final isDark = CupertinoTheme.brightnessOf(context) == Brightness.dark; + + return Container( + padding: const EdgeInsets.all(DesignTokens.space4), + decoration: BoxDecoration( + color: isDark ? DarkDesignTokens.card : DesignTokens.card, + borderRadius: const BorderRadius.vertical( + top: Radius.circular(DesignTokens.radiusLg), + ), + ), + child: SingleChildScrollView( + child: Column( + mainAxisSize: MainAxisSize.min, + children: [ + Container( + width: 36, + height: 4, + decoration: BoxDecoration( + color: isDark ? DarkDesignTokens.text3 : DesignTokens.text3, + borderRadius: DesignTokens.borderRadiusFull, + ), + ), + const SizedBox(height: DesignTokens.space4), + Text( + '${order.type.icon} ${order.type.label}', + style: TextStyle( + fontSize: DesignTokens.fontLg, + fontWeight: FontWeight.w600, + color: isDark ? DarkDesignTokens.text1 : DesignTokens.text1, + ), + ), + const SizedBox(height: DesignTokens.space1), + Text( + '订单号: ${order.orderNo}', + style: TextStyle( + fontSize: DesignTokens.fontSm, + color: isDark ? DarkDesignTokens.text3 : DesignTokens.text3, + ), + ), + const SizedBox(height: DesignTokens.space4), + _buildQrSection(isDark), + const SizedBox(height: DesignTokens.space4), + _buildBarcodeSection(isDark), + const SizedBox(height: DesignTokens.space4), + if (order.tableNo != null) ...[ + Text( + '🪑 桌号: ${order.tableNo}', + style: TextStyle( + fontSize: DesignTokens.fontMd, + fontWeight: FontWeight.w500, + color: isDark ? DarkDesignTokens.text2 : DesignTokens.text2, + ), + ), + const SizedBox(height: DesignTokens.space3), + ], + Text( + '共 ${order.totalQuantity} 道菜 · ¥${order.calculatedTotal.toStringAsFixed(1)}', + style: TextStyle( + fontSize: DesignTokens.fontMd, + fontWeight: FontWeight.w600, + color: DesignTokens.dynamicPrimary, + ), + ), + const SizedBox(height: DesignTokens.space4), + CupertinoButton( + padding: const EdgeInsets.symmetric( + horizontal: DesignTokens.space5, + vertical: DesignTokens.space3), + borderRadius: DesignTokens.borderRadiusMd, + color: DesignTokens.dynamicPrimary, + onPressed: () => Navigator.pop(context), + child: const Text( + '完成', + style: TextStyle( + fontSize: DesignTokens.fontMd, + fontWeight: FontWeight.w600, + color: CupertinoColors.white, + ), + ), + ), + SizedBox(height: MediaQuery.of(context).viewPadding.bottom + DesignTokens.space3), + ], + ), + ), + ); + } + + Widget _buildQrSection(bool isDark) { + return Column( + children: [ + Text( + '📱 扫码查看点单', + style: TextStyle( + fontSize: DesignTokens.fontSm, + color: isDark ? DarkDesignTokens.text2 : DesignTokens.text2, + ), + ), + const SizedBox(height: DesignTokens.space2), + Container( + padding: const EdgeInsets.all(DesignTokens.space3), + decoration: BoxDecoration( + color: CupertinoColors.white, + borderRadius: DesignTokens.borderRadiusMd, + border: Border.all( + color: DesignTokens.text3.withValues(alpha: 0.1), + ), + ), + child: CustomPaint( + size: const Size(200, 200), + painter: _QrPainter( + data: order.qrUrl, + color: isDark ? const Color(0xFF1C1C1E) : const Color(0xFF1C1C1E), + ), + ), + ), + ], + ); + } + + Widget _buildBarcodeSection(bool isDark) { + return Column( + children: [ + Text( + '📊 条形码', + style: TextStyle( + fontSize: DesignTokens.fontSm, + color: isDark ? DarkDesignTokens.text2 : DesignTokens.text2, + ), + ), + const SizedBox(height: DesignTokens.space2), + Container( + padding: const EdgeInsets.symmetric( + horizontal: DesignTokens.space4, + vertical: DesignTokens.space3, + ), + decoration: BoxDecoration( + color: CupertinoColors.white, + borderRadius: DesignTokens.borderRadiusMd, + border: Border.all( + color: DesignTokens.text3.withValues(alpha: 0.1), + ), + ), + child: CustomPaint( + size: Size(240, 60), + painter: _BarcodePainter( + data: order.orderNo, + color: const Color(0xFF1C1C1E), + ), + ), + ), + const SizedBox(height: DesignTokens.space1), + Text( + order.orderNo, + style: const TextStyle( + fontSize: 11, + fontFamily: 'monospace', + color: Color(0xFF666666), + letterSpacing: 1.5, + ), + ), + ], + ); + } +} + +class _QrPainter extends CustomPainter { + final String data; + final Color color; + + _QrPainter({required this.data, required this.color}); + + @override + void paint(Canvas canvas, Size size) { + final qrCode = QrCode.fromData( + data: data, + errorCorrectLevel: QrErrorCorrectLevel.M, + ); + final qrImage = QrImage(qrCode); + final moduleCount = qrImage.moduleCount; + final moduleSize = size.width / moduleCount; + + final paint = Paint()..color = color; + final bgPaint = Paint()..color = CupertinoColors.white; + canvas.drawRect(Rect.fromLTWH(0, 0, size.width, size.height), bgPaint); + + for (int x = 0; x < moduleCount; x++) { + for (int y = 0; y < moduleCount; y++) { + if (qrImage.isDark(y, x)) { + canvas.drawRect( + Rect.fromLTWH(x * moduleSize, y * moduleSize, moduleSize, moduleSize), + paint, + ); + } + } + } + } + + @override + bool shouldRepaint(covariant _QrPainter old) => + data != old.data || color != old.color; +} + +class _BarcodePainter extends CustomPainter { + final String data; + final Color color; + + _BarcodePainter({required this.data, required this.color}); + + @override + void paint(Canvas canvas, Size size) { + final paint = Paint() + ..color = color + ..style = PaintingStyle.fill; + final bgPaint = Paint()..color = CupertinoColors.white; + canvas.drawRect(Rect.fromLTWH(0, 0, size.width, size.height), bgPaint); + + final chars = data.codeUnits; + final barWidth = size.width / (chars.length * 7 + 20); + double x = barWidth * 10; + + for (final code in chars) { + final pattern = code % 128; + for (int i = 6; i >= 0; i--) { + if ((pattern >> i) & 1 == 1) { + canvas.drawRect( + Rect.fromLTWH(x, 0, barWidth, size.height), + paint, + ); + } + x += barWidth; + } + x += barWidth * 0.5; + } + } + + @override + bool shouldRepaint(covariant _BarcodePainter old) => + data != old.data || color != old.color; +} + +void showQrBarcodeDialog(BuildContext context, {required Order order}) { + showCupertinoModalPopup( + context: context, + builder: (ctx) => QrBarcodeDialog(order: order), + ); +} +``` + +- [ ] **Step 2: 验证编译** + +Run: `cd e:\project\flutter\f\mom_kitchen && flutter analyze --no-pub lib/src/pages/tools/cooking/widgets/qr_barcode_dialog.dart` +Expected: No errors + +--- + +### Task 7: 主页面 — OrderAssistantPage + +**Files:** +- Create: `lib/src/pages/tools/cooking/order_assistant_page.dart` + +- [ ] **Step 1: 创建 order_assistant_page.dart** + +```dart +/* + * 文件: order_assistant_page.dart + * 名称: 点餐助手主页面 + * 作用: 支持用户点餐/商家推单,菜品管理,二维码/条形码生成 + * 创建时间: 2026-04-17 + * 更新时间: 2026-04-17 初始创建 + */ + +import 'dart:ui'; +import 'package:flutter/cupertino.dart'; +import 'package:flutter/material.dart'; +import 'package:get/get.dart'; +import 'package:mom_kitchen/src/config/design_tokens.dart'; +import 'package:mom_kitchen/src/controllers/tools/order_assistant_controller.dart'; +import 'package:mom_kitchen/src/models/tools/order_model.dart'; +import 'package:mom_kitchen/src/pages/tools/cooking/widgets/order_item_card.dart'; +import 'package:mom_kitchen/src/pages/tools/cooking/widgets/add_item_sheet.dart'; +import 'package:mom_kitchen/src/pages/tools/cooking/widgets/qr_barcode_dialog.dart'; + +class OrderAssistantPage extends StatefulWidget { + const OrderAssistantPage({super.key}); + + @override + State createState() => _OrderAssistantPageState(); +} + +class _OrderAssistantPageState extends State { + final _tableNoController = TextEditingController(); + final _noteController = TextEditingController(); + int _selectedSegment = 0; + + @override + void initState() { + super.initState(); + final ctrl = OrderAssistantController.to; + ctrl.createNewOrder(); + } + + @override + void dispose() { + _tableNoController.dispose(); + _noteController.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: isDark + ? DarkDesignTokens.card.withValues(alpha: 0.85) + : DesignTokens.card.withValues(alpha: 0.85), + border: null, + trailing: Row( + mainAxisSize: MainAxisSize.min, + children: [ + GestureDetector( + onTap: () => _showHistory(context), + child: const Icon(CupertinoIcons.clock, size: 24), + ), + ], + ), + ), + child: SafeArea( + child: GetBuilder( + builder: (ctrl) { + final order = ctrl.currentOrder; + return Column( + children: [ + _buildModeSwitch(isDark, ctrl), + Expanded( + child: SingleChildScrollView( + padding: const EdgeInsets.all(DesignTokens.space3), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + _buildOrderInfo(isDark, ctrl, order), + const SizedBox(height: DesignTokens.space3), + _buildItemsList(isDark, ctrl, order), + const SizedBox(height: DesignTokens.space3), + _buildAddButton(isDark, ctrl), + ], + ), + ), + ), + _buildBottomBar(isDark, ctrl, order), + ], + ); + }, + ), + ), + ); + } + + Widget _buildModeSwitch(bool isDark, OrderAssistantController ctrl) { + return Padding( + padding: const EdgeInsets.symmetric( + horizontal: DesignTokens.space4, + vertical: DesignTokens.space2, + ), + child: SizedBox( + width: double.infinity, + child: CupertinoSlidingSegmentedControl( + groupValue: _selectedSegment, + onValueChanged: (val) { + if (val == null) return; + setState(() => _selectedSegment = val); + ctrl.setOrderType(val == 0 ? OrderType.userOrder : OrderType.merchantPush); + }, + children: const { + 0: Padding( + padding: EdgeInsets.symmetric(horizontal: 16, vertical: 8), + child: Text('🧑 用户点餐'), + ), + 1: Padding( + padding: EdgeInsets.symmetric(horizontal: 16, vertical: 8), + child: Text('🏪 商家推单'), + ), + }, + ), + ), + ); + } + + Widget _buildOrderInfo(bool isDark, OrderAssistantController ctrl, Order? order) { + return ClipRRect( + borderRadius: DesignTokens.borderRadiusLg, + child: BackdropFilter( + filter: ImageFilter.blur(sigmaX: 20, sigmaY: 20), + child: Container( + padding: const EdgeInsets.all(DesignTokens.space3), + decoration: BoxDecoration( + color: (isDark ? DarkDesignTokens.card : DesignTokens.card) + .withValues(alpha: 0.72), + borderRadius: DesignTokens.borderRadiusLg, + border: Border.all( + color: isDark + ? DarkDesignTokens.glassBorder + : DesignTokens.text3.withValues(alpha: 0.08), + width: 0.5, + ), + ), + child: Column( + children: [ + Row( + children: [ + Text( + '📋 点单信息', + style: TextStyle( + fontSize: DesignTokens.fontMd, + fontWeight: FontWeight.w600, + color: isDark ? DarkDesignTokens.text1 : DesignTokens.text1, + ), + ), + const Spacer(), + Container( + padding: const EdgeInsets.symmetric( + horizontal: DesignTokens.space2, + vertical: 2, + ), + decoration: BoxDecoration( + color: DesignTokens.dynamicPrimary.withValues(alpha: 0.1), + borderRadius: DesignTokens.borderRadiusSm, + ), + child: Text( + '📝 已记录 ${ctrl.recordCount} 单', + style: TextStyle( + fontSize: DesignTokens.fontXs, + color: DesignTokens.dynamicPrimary, + ), + ), + ), + ], + ), + const SizedBox(height: DesignTokens.space2), + Row( + children: [ + SizedBox( + width: 80, + child: CupertinoTextField( + controller: _tableNoController, + placeholder: '桌号', + padding: const EdgeInsets.symmetric( + horizontal: DesignTokens.space2, + vertical: DesignTokens.space1 + 2, + ), + style: TextStyle( + fontSize: DesignTokens.fontSm, + color: isDark ? DarkDesignTokens.text1 : DesignTokens.text1, + ), + onChanged: (v) => ctrl.setTableNo(v.isEmpty ? null : v), + ), + ), + const SizedBox(width: DesignTokens.space2), + Expanded( + child: CupertinoTextField( + controller: _noteController, + placeholder: '整单备注', + padding: const EdgeInsets.symmetric( + horizontal: DesignTokens.space2, + vertical: DesignTokens.space1 + 2, + ), + style: TextStyle( + fontSize: DesignTokens.fontSm, + color: isDark ? DarkDesignTokens.text1 : DesignTokens.text1, + ), + onChanged: (v) => ctrl.setNote(v.isEmpty ? null : v), + ), + ), + ], + ), + if (order != null) ...[ + const SizedBox(height: DesignTokens.space2), + Row( + children: [ + Text( + '🕐 ${order.displayDate}', + style: TextStyle( + fontSize: DesignTokens.fontXs, + color: isDark ? DarkDesignTokens.text3 : DesignTokens.text3, + ), + ), + const Spacer(), + Text( + '🔢 ${order.orderNo}', + style: TextStyle( + fontSize: DesignTokens.fontXs, + fontFamily: 'monospace', + color: isDark ? DarkDesignTokens.text3 : DesignTokens.text3, + ), + ), + ], + ), + ], + ], + ), + ), + ), + ); + } + + Widget _buildItemsList(bool isDark, OrderAssistantController ctrl, Order? order) { + if (order == null || order.items.isEmpty) { + return _buildEmptyState(isDark); + } + + return Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Row( + children: [ + Text( + '🍽️ 菜品列表', + style: TextStyle( + fontSize: DesignTokens.fontMd, + fontWeight: FontWeight.w600, + color: isDark ? DarkDesignTokens.text1 : DesignTokens.text1, + ), + ), + const Spacer(), + Text( + '${order.totalQuantity} 道', + style: TextStyle( + fontSize: DesignTokens.fontSm, + color: DesignTokens.dynamicPrimary, + fontWeight: FontWeight.w500, + ), + ), + ], + ), + const SizedBox(height: DesignTokens.space2), + ...order.items.asMap().entries.map((entry) { + final item = entry.value; + return OrderItemCard( + item: item, + index: entry.key, + onDelete: () => ctrl.removeItem(item.id), + onQuantityChanged: (q) => ctrl.updateItemQuantity(item.id, q), + onPriceChanged: (p) => ctrl.updateItemPrice(item.id, p), + ); + }), + ], + ); + } + + Widget _buildEmptyState(bool isDark) { + return Center( + child: Padding( + padding: const EdgeInsets.symmetric(vertical: DesignTokens.space6), + child: Column( + children: [ + const Text('🍽️', style: TextStyle(fontSize: 48)), + const SizedBox(height: DesignTokens.space3), + Text( + '还没有添加菜品', + style: TextStyle( + fontSize: DesignTokens.fontMd, + color: isDark ? DarkDesignTokens.text3 : DesignTokens.text3, + ), + ), + const SizedBox(height: DesignTokens.space1), + Text( + '点击下方按钮添加菜品', + style: TextStyle( + fontSize: DesignTokens.fontSm, + color: isDark ? DarkDesignTokens.text3 : DesignTokens.text3, + ), + ), + ], + ), + ), + ); + } + + Widget _buildAddButton(bool isDark, OrderAssistantController ctrl) { + return SizedBox( + width: double.infinity, + child: CupertinoButton( + padding: const EdgeInsets.symmetric(vertical: DesignTokens.space3), + borderRadius: DesignTokens.borderRadiusMd, + color: DesignTokens.dynamicPrimary.withValues(alpha: 0.12), + onPressed: () { + showAddItemSheet( + context, + onItemAdded: (item) { + ctrl.addItem(item); + setState(() {}); + }, + isMerchantMode: _selectedSegment == 1, + ); + }, + child: Row( + mainAxisAlignment: MainAxisAlignment.center, + children: [ + Icon(CupertinoIcons.plus_circle, size: 20, color: DesignTokens.dynamicPrimary), + const SizedBox(width: DesignTokens.space2), + Text( + '添加菜品', + style: TextStyle( + fontSize: DesignTokens.fontMd, + fontWeight: FontWeight.w600, + color: DesignTokens.dynamicPrimary, + ), + ), + ], + ), + ), + ); + } + + Widget _buildBottomBar(bool isDark, OrderAssistantController ctrl, Order? order) { + final hasItems = order != null && order.items.isNotEmpty; + + return Container( + padding: const EdgeInsets.all(DesignTokens.space3), + decoration: BoxDecoration( + color: isDark ? DarkDesignTokens.card : DesignTokens.card, + border: Border( + top: BorderSide( + color: isDark + ? DarkDesignTokens.glassBorder + : DesignTokens.text3.withValues(alpha: 0.08), + ), + ), + ), + child: SafeArea( + top: false, + child: Row( + children: [ + Expanded( + child: Column( + mainAxisSize: MainAxisSize.min, + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text( + '合计', + style: TextStyle( + fontSize: DesignTokens.fontXs, + color: isDark ? DarkDesignTokens.text3 : DesignTokens.text3, + ), + ), + Text( + '¥${order?.calculatedTotal.toStringAsFixed(1) ?? '0.0'}', + style: TextStyle( + fontSize: DesignTokens.fontXl, + fontWeight: FontWeight.w700, + color: DesignTokens.dynamicPrimary, + ), + ), + ], + ), + ), + CupertinoButton( + padding: const EdgeInsets.symmetric( + horizontal: DesignTokens.space4, + vertical: DesignTokens.space2, + ), + borderRadius: DesignTokens.borderRadiusMd, + color: DesignTokens.dynamicPrimary, + onPressed: hasItems ? () => _generateQr(ctrl) : null, + child: const Row( + mainAxisSize: MainAxisSize.min, + children: [ + Icon(CupertinoIcons.qrcode, size: 18, color: CupertinoColors.white), + SizedBox(width: 6), + Text( + '生成码', + style: TextStyle( + fontSize: DesignTokens.fontMd, + fontWeight: FontWeight.w600, + color: CupertinoColors.white, + ), + ), + ], + ), + ), + ], + ), + ), + ); + } + + Future _generateQr(OrderAssistantController ctrl) async { + await ctrl.activateOrder(); + final order = ctrl.currentOrder; + if (order != null && mounted) { + showQrBarcodeDialog(context, order: order); + } + } + + void _showHistory(BuildContext context) { + final ctrl = OrderAssistantController.to; + final isDark = CupertinoTheme.brightnessOf(context) == Brightness.dark; + + showCupertinoModalPopup( + context: context, + builder: (ctx) => Container( + constraints: BoxConstraints( + maxHeight: MediaQuery.of(context).size.height * 0.7, + ), + decoration: BoxDecoration( + color: isDark ? DarkDesignTokens.card : DesignTokens.card, + borderRadius: const BorderRadius.vertical( + top: Radius.circular(DesignTokens.radiusLg), + ), + ), + child: Column( + mainAxisSize: MainAxisSize.min, + children: [ + Container( + margin: const EdgeInsets.only(top: DesignTokens.space2), + width: 36, + height: 4, + decoration: BoxDecoration( + color: isDark ? DarkDesignTokens.text3 : DesignTokens.text3, + borderRadius: DesignTokens.borderRadiusFull, + ), + ), + Padding( + padding: const EdgeInsets.all(DesignTokens.space3), + child: Row( + children: [ + Text( + '📜 历史点单', + style: TextStyle( + fontSize: DesignTokens.fontLg, + fontWeight: FontWeight.w600, + color: isDark ? DarkDesignTokens.text1 : DesignTokens.text1, + ), + ), + const Spacer(), + if (ctrl.history.isNotEmpty) + GestureDetector( + onTap: () { + ctrl.clearHistory(); + Navigator.pop(ctx); + }, + child: Text( + '清空', + style: TextStyle( + fontSize: DesignTokens.fontSm, + color: CupertinoColors.destructiveRed, + ), + ), + ), + ], + ), + ), + const Divider(height: 1), + Flexible( + child: ctrl.history.isEmpty + ? Center( + child: Padding( + padding: const EdgeInsets.all(DesignTokens.space6), + child: Text( + '暂无历史点单', + style: TextStyle( + fontSize: DesignTokens.fontMd, + color: isDark ? DarkDesignTokens.text3 : DesignTokens.text3, + ), + ), + ), + ) + : ListView.builder( + itemCount: ctrl.history.length, + itemBuilder: (context, index) { + final order = ctrl.history[index]; + return CupertinoButton( + padding: const EdgeInsets.symmetric( + horizontal: DesignTokens.space4, + vertical: DesignTokens.space2, + ), + onPressed: () { + Navigator.pop(ctx); + ctrl.loadOrder(order); + setState(() { + _selectedSegment = order.type == OrderType.userOrder ? 0 : 1; + _tableNoController.text = order.tableNo ?? ''; + _noteController.text = order.note ?? ''; + }); + }, + child: Row( + children: [ + Text( + '${order.type.icon} ${order.orderNo}', + style: TextStyle( + fontSize: DesignTokens.fontSm, + fontFamily: 'monospace', + color: isDark ? DarkDesignTokens.text1 : DesignTokens.text1, + ), + ), + const Spacer(), + Text( + '${order.totalQuantity}道 · ${order.displayDate}', + style: TextStyle( + fontSize: DesignTokens.fontXs, + color: isDark ? DarkDesignTokens.text3 : DesignTokens.text3, + ), + ), + ], + ), + ); + }, + ), + ), + ], + ), + ), + ); + } +} +``` + +- [ ] **Step 2: 验证编译** + +Run: `cd e:\project\flutter\f\mom_kitchen && flutter analyze --no-pub lib/src/pages/tools/cooking/order_assistant_page.dart` +Expected: No errors + +--- + +### Task 8: 工具注册 + 路由 + Binding + +**Files:** +- Modify: `lib/src/models/tool_item_model.dart` (新增 order_assistant 工具项) +- Modify: `lib/src/config/app_routes.dart` (注册路由) +- Modify: `lib/src/app_binding.dart` (注册 Controller) + +- [ ] **Step 1: 在 tool_item_model.dart 的 defaultTools 列表末尾(最后一个 ToolItem 之前)添加** + +在 `ingredient_manage` 项之后,`];` 之前添加: + +```dart + ToolItem( + id: 'order_assistant', + name: '点餐助手', + icon: '🍽️', + needsNetwork: true, + category: 'cooking', + route: '/tools/order-assistant', + description: '点餐推单,二维码分享', + waterfallSlot: WaterfallSlotConfig(show: true, priority: 9), + ), +``` + +- [ ] **Step 2: 在 app_routes.dart 添加路由常量和 GetPage** + +添加 import: +```dart +import 'package:mom_kitchen/src/pages/tools/cooking/order_assistant_page.dart'; +``` + +添加路由常量: +```dart + static const String toolsOrderAssistant = '/tools/order-assistant'; +``` + +添加 GetPage (在 toolDetail 的 GetPage 之前): +```dart + GetPage( + name: toolsOrderAssistant, + page: () => const OrderAssistantPage(), + middlewares: [PageStandardsMiddleware()], + ), +``` + +- [ ] **Step 3: 在 app_binding.dart 注册 Controller** + +添加 import: +```dart +import 'package:mom_kitchen/src/controllers/tools/order_assistant_controller.dart'; +``` + +在 `Get.lazyPut(() => BrowseHistoryController(), fenix: true);` 之后添加: +```dart + Get.lazyPut(() => OrderAssistantController(), fenix: true); +``` + +- [ ] **Step 4: 验证编译** + +Run: `cd e:\project\flutter\f\mom_kitchen && flutter analyze --no-pub` +Expected: No errors + +--- + +### Task 9: 网页端 — web_order/index.html + +**Files:** +- Create: `web_order/index.html` + +- [ ] **Step 1: 创建 web_order 目录和 index.html** + +创建完整的网页端点单展示页,支持: +- URL参数解析 `?id=xxx` 或 `?orderNo=xxx` +- WebSocket 实时同步 +- 降级为5秒轮询 +- iOS 26 毛玻璃风格 +- 响应式布局 +- 深色模式 + +文件内容为完整的 HTML/CSS/JS 单文件(约400行),包含: +- CSS: CSS变量设计系统、毛玻璃卡片、响应式断点、深色模式 +- HTML: 顶部标题+状态、点单信息卡、菜品列表、合计区域、底部状态 +- JS: URL参数解析、WebSocket连接、轮询降级、DOM更新、动画 + +- [ ] **Step 2: 浏览器验证** + +在浏览器打开 `web_order/index.html?id=test123` 验证页面渲染正常。 + +--- + +### Task 10: 全量验证 + CHANGELOG + +**Files:** +- Modify: `CHANGELOG.md` + +- [ ] **Step 1: 运行 flutter analyze** + +Run: `cd e:\project\flutter\f\mom_kitchen && flutter analyze --no-pub` +Expected: 0 errors + +- [ ] **Step 2: 更新 CHANGELOG.md** + +在 CHANGELOG.md 顶部新增版本记录,包含: +- 新增点餐助手工具(用户点餐/商家推单) +- 新增二维码/条形码生成 +- 新增网页端点单展示页 +- 新增 OrderAssistantController + SharedPreferences 持久化 + +- [ ] **Step 3: 删除设计文档** + +Run: 删除 `docs/superpowers/specs/2026-04-17-order-assistant-design.md`(开发完成后删除spec文档,按AGENTS.md要求) diff --git a/docs/superpowers/plans/2026-04-17-waterfall-tool-card.md b/docs/superpowers/plans/2026-04-17-waterfall-tool-card.md new file mode 100644 index 0000000..354c3f2 --- /dev/null +++ b/docs/superpowers/plans/2026-04-17-waterfall-tool-card.md @@ -0,0 +1,1373 @@ +# 首页瀑布流工具卡片插槽系统 实现计划 + +> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking. + +**Goal:** 在首页 Discover 瀑布流中插入工具卡片,每40个普通卡片插入1个工具卡片(与miniCard交替),采用统一插槽系统支持未来扩展。 + +**Architecture:** 新建 WaterfallSlot 通用插槽模型,扩展 ToolItem 必须声明 waterfallSlot 配置,扩展 DiscoverItemType 新增 toolCard,新建 ToolCardDiscoverCard 毛玻璃卡片组件和 ToolDetailPage 独立详情页,改造 DiscoverWaterfall 使用插槽注册表。 + +**Tech Stack:** Flutter/Dart, GetX, Cupertino (iOS风格), BackdropFilter (毛玻璃) + +--- + +### Task 1: 创建 WaterfallSlot 通用插槽模型 + +**Files:** +- Create: `lib/src/models/waterfall_slot.dart` + +- [ ] **Step 1: 创建 waterfall_slot.dart 文件** + +```dart +/* + * 文件: waterfall_slot.dart + * 名称: 瀑布流插槽模型 + * 作用: 统一管理瀑布流中插入的各类卡片(miniCard、toolCard等) + * 创建时间: 2026-04-17 + * 更新时间: 2026-04-17 初始创建 + */ + +import 'package:mom_kitchen/src/models/tool_item_model.dart'; +import 'package:mom_kitchen/src/models/mini_card_model.dart'; + +enum WaterfallSlotType { miniCard, toolCard } + +class WaterfallSlot { + final WaterfallSlotType type; + final int position; + final Map data; + + const WaterfallSlot({ + required this.type, + required this.position, + required this.data, + }); +} + +class WaterfallSlotConfig { + final bool show; + final int priority; + final String? badge; + + const WaterfallSlotConfig({ + required this.show, + this.priority = 999, + this.badge, + }); +} + +class WaterfallSlotRegistry { + static const int _slotInterval = 20; + + static List buildSlots({ + required List miniCards, + required List toolCards, + }) { + final slots = []; + int miniIdx = 0; + int toolIdx = 0; + + for (int pos = _slotInterval; + miniIdx < miniCards.length || toolIdx < toolCards.length; + pos += _slotInterval) { + final cycle = ((pos ~/ _slotInterval) - 1) % 2; + if (cycle == 0 && miniIdx < miniCards.length) { + slots.add(WaterfallSlot( + type: WaterfallSlotType.miniCard, + position: pos, + data: {'index': miniIdx}, + )); + miniIdx++; + } else if (cycle == 1 && toolIdx < toolCards.length) { + slots.add(WaterfallSlot( + type: WaterfallSlotType.toolCard, + position: pos, + data: {'index': toolIdx}, + )); + toolIdx++; + } + } + + return slots; + } +} +``` + +- [ ] **Step 2: 验证文件无语法错误** + +Run: `cd e:\project\flutter\f\mom_kitchen && flutter analyze lib/src/models/waterfall_slot.dart` +Expected: No errors + +--- + +### Task 2: 扩展 ToolItem — 新增 waterfallSlot 必填字段 + +**Files:** +- Modify: `lib/src/models/tool_item_model.dart` + +- [ ] **Step 1: 修改 ToolItem 类,新增 waterfallSlot 必填字段** + +在 `tool_item_model.dart` 顶部添加 import: +```dart +import 'package:mom_kitchen/src/models/waterfall_slot.dart'; +``` + +修改 `ToolItem` 类: +```dart +class ToolItem { + final String id; + final String name; + final String icon; + final bool needsNetwork; + final String category; + final String route; + final String? description; + final int usageCount; + final WaterfallSlotConfig waterfallSlot; + + const ToolItem({ + required this.id, + required this.name, + required this.icon, + required this.needsNetwork, + required this.category, + required this.route, + required this.waterfallSlot, + this.description, + this.usageCount = 0, + }); +``` + +修改 `fromJson`: +```dart + factory ToolItem.fromJson(Map json) { + return ToolItem( + id: json['id'] as String, + name: json['name'] as String, + icon: json['icon'] as String, + needsNetwork: json['needsNetwork'] as bool, + category: json['category'] as String, + route: json['route'] as String, + description: json['description'] as String?, + usageCount: json['usageCount'] as int? ?? 0, + waterfallSlot: WaterfallSlotConfig( + show: json['show_in_waterfall'] as bool? ?? false, + priority: json['waterfall_priority'] as int? ?? 999, + badge: json['waterfall_badge'] as String?, + ), + ); + } +``` + +修改 `toJson`: +```dart + Map toJson() { + return { + 'id': id, + 'name': name, + 'icon': icon, + 'needsNetwork': needsNetwork, + 'category': category, + 'route': route, + 'description': description, + 'usageCount': usageCount, + 'show_in_waterfall': waterfallSlot.show, + 'waterfall_priority': waterfallSlot.priority, + 'waterfall_badge': waterfallSlot.badge, + }; + } +``` + +修改 `copyWith`: +```dart + ToolItem copyWith({int? usageCount, WaterfallSlotConfig? waterfallSlot}) { + return ToolItem( + id: id, + name: name, + icon: icon, + needsNetwork: needsNetwork, + category: category, + route: route, + description: description, + usageCount: usageCount ?? this.usageCount, + waterfallSlot: waterfallSlot ?? this.waterfallSlot, + ); + } +``` + +- [ ] **Step 2: 为所有 defaultTools 添加 waterfallSlot 声明** + +每个 ToolItem 都必须添加 `waterfallSlot` 参数。首页展示的工具设置 `show: true` + 优先级,其余设置 `show: false`: + +```dart + static const List defaultTools = [ + ToolItem( + id: 'cooking_timer', + name: '烹饪计时器', + icon: '⏱️', + needsNetwork: false, + category: 'cooking', + route: '/tools/timer', + description: '多计时器烹饪助手', + waterfallSlot: WaterfallSlotConfig(show: true, priority: 1), + ), + ToolItem( + id: 'serving_scaler', + name: '份量缩放', + icon: '📐', + needsNetwork: false, + category: 'cooking', + route: '/tools/scaler', + description: '按人数调整食材用量', + waterfallSlot: WaterfallSlotConfig(show: true, priority: 3), + ), + ToolItem( + id: 'meal_time_recommend', + name: '用餐时段推荐', + icon: '🍽️', + needsNetwork: true, + category: 'cooking', + route: '/tools/meal-time', + description: '根据时间推荐早中晚餐', + waterfallSlot: WaterfallSlotConfig(show: true, priority: 5), + ), + ToolItem( + id: 'date_calculator', + name: '日期计算器', + icon: '🗓️', + needsNetwork: false, + category: 'cooking', + route: '/tools/date-calculator', + description: '日期加减天数与间隔计算', + waterfallSlot: WaterfallSlotConfig(show: false), + ), + ToolItem( + id: 'food_copy_generator', + name: '吃货文案', + icon: '🍗', + needsNetwork: false, + category: 'cooking', + route: '/tools/food-copy', + description: '随机生成吃货文案', + waterfallSlot: WaterfallSlotConfig(show: false), + ), + ToolItem( + id: 'bmi_calculator', + name: 'BMI计算器', + icon: '📊', + needsNetwork: false, + category: 'health', + route: '/tools/bmi', + description: '计算身体质量指数', + waterfallSlot: WaterfallSlotConfig(show: true, priority: 2), + ), + ToolItem( + id: 'weight_manage', + name: '体重管理', + icon: '⚖️', + needsNetwork: false, + category: 'health', + route: '/tools/weight-manage', + description: '记录体重、追踪变化趋势', + waterfallSlot: WaterfallSlotConfig(show: true, priority: 6), + ), + ToolItem( + id: 'allergen_checker', + name: '过敏原检查', + icon: '⚠️', + needsNetwork: false, + category: 'health', + route: '/tools/allergen', + description: '检查食材过敏原信息', + waterfallSlot: WaterfallSlotConfig(show: true, priority: 4), + ), + ToolItem( + id: 'allergen_report', + name: '过敏原报告', + icon: '📋', + needsNetwork: true, + category: 'health', + route: '/allergen-report', + description: '生成个性化过敏原报告', + waterfallSlot: WaterfallSlotConfig(show: false), + ), + ToolItem( + id: 'nutrition_analysis', + name: '营养分析', + icon: '🥗', + needsNetwork: true, + category: 'health', + route: '/tools/nutrition', + description: '查看营养成分详情', + waterfallSlot: WaterfallSlotConfig(show: false), + ), + ToolItem( + id: 'safe_period_calculator', + name: '安全期计算器', + icon: '🌸', + needsNetwork: false, + category: 'health', + route: '/tools/safe-period', + description: '女性安全期、排卵期计算', + waterfallSlot: WaterfallSlotConfig(show: false), + ), + ToolItem( + id: 'unit_converter', + name: '单位换算', + icon: '🔢', + needsNetwork: false, + category: 'data', + route: '/tools/converter', + description: '重量/容量单位换算', + waterfallSlot: WaterfallSlotConfig(show: true, priority: 7), + ), + ToolItem( + id: 'ingredient_detail', + name: '食材详情', + icon: '🥕', + needsNetwork: true, + category: 'data', + route: '/tools/ingredient', + description: '查询食材营养与选购', + waterfallSlot: WaterfallSlotConfig(show: false), + ), + ToolItem( + id: 'hot_ranking', + name: '热门排行', + icon: '🔥', + needsNetwork: true, + category: 'data', + route: '/hot', + description: '查看热门菜谱排行', + waterfallSlot: WaterfallSlotConfig(show: false), + ), + ToolItem( + id: 'view_stats', + name: '浏览统计', + icon: '📈', + needsNetwork: true, + category: 'data', + route: '/tools/stats', + description: '查看平台浏览数据统计', + waterfallSlot: WaterfallSlotConfig(show: false), + ), + ToolItem( + id: 'duplicate_check', + name: '查重检测', + icon: '🔍', + needsNetwork: true, + category: 'data', + route: '/duplicate-check', + description: '检测菜谱是否存在重复', + waterfallSlot: WaterfallSlotConfig(show: false), + ), + ToolItem( + id: 'stats_dashboard', + name: '运营数据大屏', + icon: '📊', + needsNetwork: true, + category: 'data', + route: '/stats-dashboard', + description: '查看运营数据统计大屏', + waterfallSlot: WaterfallSlotConfig(show: false), + ), + ToolItem( + id: 'meal_planner', + name: '每周菜单规划', + icon: '📅', + needsNetwork: false, + category: 'planning', + route: '/tools/planner', + description: '规划一周饮食菜单', + waterfallSlot: WaterfallSlotConfig(show: true, priority: 8), + ), + ToolItem( + id: 'dish_ranking', + name: '菜品排名', + icon: '🏆', + needsNetwork: false, + category: 'data', + route: '/tools/dish-ranking', + description: '给你的菜品排个座次,从夯到拉', + waterfallSlot: WaterfallSlotConfig(show: false), + ), + ToolItem( + id: 'ingredient_manage', + name: '用料管理', + icon: '🧴', + needsNetwork: false, + category: 'cooking', + route: '/tools/ingredient-manage', + description: '管理厨房调味料和食材瓶子', + waterfallSlot: WaterfallSlotConfig(show: false), + ), + ]; +``` + +- [ ] **Step 3: 在 ToolRegistry 中添加 homeCardTools getter 和校验** + +```dart + static List get homeCardTools { + final tools = defaultTools.where((t) => t.waterfallSlot.show).toList(); + tools.sort((a, b) => a.waterfallSlot.priority.compareTo(b.waterfallSlot.priority)); + assert(() { + for (final t in defaultTools) { + if (t.waterfallSlot.show && t.route.isEmpty) { + throw AssertionError('工具 ${t.id} 声明了瀑布流展示但route为空!'); + } + } + return true; + }()); + return tools; + } +``` + +- [ ] **Step 4: 修复 ToolsController 中 copyWith 调用** + +在 `lib/src/controllers/tools/tools_controller.dart` 中,`copyWith` 调用需要适配新签名。找到所有 `tool.copyWith(usageCount: ...)` 调用,确认无需修改(因为 `waterfallSlot` 有默认值处理)。 + +- [ ] **Step 5: 验证编译通过** + +Run: `cd e:\project\flutter\f\mom_kitchen && flutter analyze lib/src/models/tool_item_model.dart` +Expected: No errors + +--- + +### Task 3: 扩展 DiscoverModel — 新增 toolCard 类型和 ToolItemRef + +**Files:** +- Modify: `lib/src/models/discover_model.dart` + +- [ ] **Step 1: 在 DiscoverItemType 枚举中新增 toolCard** + +```dart +enum DiscoverItemType { + recipe, + ingredient, + category, + tag, + mealTime, + nutrition, + miniCard, + toolCard, +} +``` + +- [ ] **Step 2: 新增 ToolItemRef 类** + +在 `MiniCardRecipeRef` 类之后添加: + +```dart +class ToolItemRef { + final String id; + final String name; + final String icon; + final String category; + final String categoryName; + final String route; + final String? description; + final String? badge; + + const ToolItemRef({ + required this.id, + required this.name, + required this.icon, + required this.category, + required this.categoryName, + required this.route, + this.description, + this.badge, + }); +} +``` + +- [ ] **Step 3: 在 DiscoverItem 中新增 toolItemRef 字段和 factory** + +```dart +class DiscoverItem { + final DiscoverItemType type; + final DiscoverRecipe? recipe; + final DiscoverIngredient? ingredient; + final DiscoverCategory? category; + final DiscoverTag? tag; + final DiscoverMealTime? mealTime; + final DiscoverNutrition? nutrition; + final MiniCardRecipeRef? miniCardRecipe; + final ToolItemRef? toolItemRef; + + DiscoverItem._({ + required this.type, + this.recipe, + this.ingredient, + this.category, + this.tag, + this.mealTime, + this.nutrition, + this.miniCardRecipe, + this.toolItemRef, + }); + + // ... 现有 factory 方法保持不变 + + factory DiscoverItem.toolCard(ToolItemRef ref) => + DiscoverItem._(type: DiscoverItemType.toolCard, toolItemRef: ref); +} +``` + +- [ ] **Step 4: 验证编译通过** + +Run: `cd e:\project\flutter\f\mom_kitchen && flutter analyze lib/src/models/discover_model.dart` +Expected: No errors + +--- + +### Task 4: 创建 ToolCardDiscoverCard 毛玻璃卡片组件 + +**Files:** +- Create: `lib/src/widgets/discover/tool_card_discover_card.dart` + +- [ ] **Step 1: 创建 tool_card_discover_card.dart 文件** + +```dart +/* + * 文件: tool_card_discover_card.dart + * 名称: 瀑布流工具卡片 + * 作用: 首页瀑布流中嵌入的工具推荐卡片,毛玻璃中等卡片样式 + * 创建时间: 2026-04-17 + * 更新时间: 2026-04-17 初始创建 + */ + +import 'dart:ui'; +import 'package:flutter/cupertino.dart'; +import 'package:flutter/material.dart'; +import 'package:get/get.dart'; +import 'package:mom_kitchen/src/config/design_tokens.dart'; +import 'package:mom_kitchen/src/config/app_routes.dart'; +import 'package:mom_kitchen/src/models/discover_model.dart'; + +class ToolCardDiscoverCard extends StatelessWidget { + final ToolItemRef tool; + + const ToolCardDiscoverCard({super.key, required this.tool}); + + @override + Widget build(BuildContext context) { + final isDark = CupertinoTheme.brightnessOf(context) == Brightness.dark; + final gradientIndex = tool.id.hashCode % DesignTokens.toolGradients.length; + final gradientColor = DesignTokens.toolGradients[gradientIndex]; + + return GestureDetector( + onTap: () => Get.toNamed(tool.route), + child: Container( + height: 190, + decoration: BoxDecoration( + borderRadius: BorderRadius.circular(DesignTokens.radiusLg), + color: isDark ? DarkDesignTokens.card : DesignTokens.card, + boxShadow: [ + BoxShadow( + color: Colors.black.withValues(alpha: 0.1), + blurRadius: 16, + offset: const Offset(0, 6), + ), + BoxShadow( + color: gradientColor.withValues(alpha: 0.08), + blurRadius: 32, + offset: const Offset(0, 2), + ), + ], + ), + clipBehavior: Clip.antiAlias, + child: Stack( + fit: StackFit.expand, + children: [ + _buildGradientBackground(gradientColor, isDark), + _buildGlassOverlay(isDark), + _buildContent(isDark, gradientColor), + _buildInfoButton(isDark), + if (tool.badge != null) _buildBadge(isDark), + ], + ), + ), + ); + } + + Widget _buildGradientBackground(Color gradientColor, bool isDark) { + return Positioned.fill( + child: Container( + decoration: BoxDecoration( + gradient: LinearGradient( + begin: Alignment.topLeft, + end: Alignment.bottomRight, + colors: [ + gradientColor.withValues(alpha: isDark ? 0.15 : 0.12), + gradientColor.withValues(alpha: isDark ? 0.05 : 0.03), + ], + ), + ), + ), + ); + } + + Widget _buildGlassOverlay(bool isDark) { + return Positioned.fill( + child: ClipRRect( + borderRadius: BorderRadius.circular(DesignTokens.radiusLg), + child: BackdropFilter( + filter: ImageFilter.blur(sigmaX: 20, sigmaY: 20), + child: Container( + decoration: BoxDecoration( + color: (isDark ? DarkDesignTokens.card : DesignTokens.card) + .withValues(alpha: 0.55), + border: Border.all( + color: Colors.white.withValues(alpha: 0.15), + width: 0.5, + ), + gradient: LinearGradient( + begin: Alignment.topLeft, + end: Alignment.bottomRight, + colors: [ + Colors.white.withValues(alpha: 0.1), + Colors.white.withValues(alpha: 0.03), + ], + ), + ), + ), + ), + ), + ); + } + + Widget _buildContent(bool isDark, Color gradientColor) { + return Positioned.fill( + child: Padding( + padding: const EdgeInsets.all(DesignTokens.space4), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + const SizedBox(height: DesignTokens.space5), + Center( + child: Text( + tool.icon, + style: const TextStyle(fontSize: 40), + ), + ), + const SizedBox(height: DesignTokens.space4), + Center( + child: Text( + tool.name, + style: TextStyle( + fontSize: DesignTokens.fontLg, + fontWeight: FontWeight.w700, + color: isDark ? DarkDesignTokens.text1 : DesignTokens.text1, + ), + textAlign: TextAlign.center, + maxLines: 1, + overflow: TextOverflow.ellipsis, + ), + ), + if (tool.description != null) ...[ + const SizedBox(height: DesignTokens.space1), + Center( + child: Text( + tool.description!, + style: TextStyle( + fontSize: DesignTokens.fontXs, + color: isDark ? DarkDesignTokens.text3 : DesignTokens.text3, + ), + textAlign: TextAlign.center, + maxLines: 1, + overflow: TextOverflow.ellipsis, + ), + ), + ], + const Spacer(), + Center( + child: Container( + padding: const EdgeInsets.symmetric( + horizontal: DesignTokens.space3, + vertical: DesignTokens.space1, + ), + decoration: BoxDecoration( + color: gradientColor.withValues(alpha: 0.15), + borderRadius: DesignTokens.borderRadiusFull, + border: Border.all( + color: gradientColor.withValues(alpha: 0.3), + width: 0.5, + ), + ), + child: Row( + mainAxisSize: MainAxisSize.min, + children: [ + Text( + _getCategoryIcon(tool.category), + style: const TextStyle(fontSize: 10), + ), + const SizedBox(width: 4), + Text( + tool.categoryName, + style: TextStyle( + fontSize: DesignTokens.fontXs, + fontWeight: FontWeight.w500, + color: isDark + ? DarkDesignTokens.text2 + : DesignTokens.text2, + ), + ), + ], + ), + ), + ), + ], + ), + ), + ); + } + + Widget _buildInfoButton(bool isDark) { + return Positioned( + top: 8, + right: 8, + child: GestureDetector( + onTap: () => Get.toNamed( + AppRoutes.toolDetail, + arguments: tool, + ), + child: ClipRRect( + borderRadius: BorderRadius.circular(DesignTokens.radiusFull), + child: BackdropFilter( + filter: ImageFilter.blur(sigmaX: 12, sigmaY: 12), + child: Container( + width: 28, + height: 28, + decoration: BoxDecoration( + color: Colors.white.withValues(alpha: 0.15), + shape: BoxShape.circle, + border: Border.all( + color: Colors.white.withValues(alpha: 0.25), + width: 0.5, + ), + ), + child: Icon( + CupertinoIcons.info, + size: 14, + color: isDark ? DarkDesignTokens.text2 : DesignTokens.text2, + ), + ), + ), + ), + ), + ); + } + + Widget _buildBadge(bool isDark) { + return Positioned( + top: 8, + left: 8, + child: ClipRRect( + borderRadius: BorderRadius.circular(DesignTokens.radiusFull), + child: BackdropFilter( + filter: ImageFilter.blur(sigmaX: 12, sigmaY: 12), + child: Container( + padding: const EdgeInsets.symmetric(horizontal: 8, vertical: 3), + decoration: BoxDecoration( + color: DesignTokens.dynamicPrimary.withValues(alpha: 0.2), + borderRadius: DesignTokens.borderRadiusFull, + border: Border.all( + color: DesignTokens.dynamicPrimary.withValues(alpha: 0.4), + width: 0.5, + ), + ), + child: Text( + tool.badge!, + style: TextStyle( + fontSize: 10, + fontWeight: FontWeight.w600, + color: DesignTokens.dynamicPrimary, + ), + ), + ), + ), + ), + ); + } + + String _getCategoryIcon(String category) { + const icons = { + 'cooking': '🍳', + 'health': '💊', + 'data': '📊', + 'planning': '📅', + }; + return icons[category] ?? '📋'; + } +} +``` + +- [ ] **Step 2: 验证编译通过** + +Run: `cd e:\project\flutter\f\mom_kitchen && flutter analyze lib/src/widgets/discover/tool_card_discover_card.dart` +Expected: No errors + +--- + +### Task 5: 创建 ToolDetailPage 独立详情页 + +**Files:** +- Create: `lib/src/pages/tools/tool_detail_page.dart` + +- [ ] **Step 1: 创建 tool_detail_page.dart 文件** + +```dart +/* + * 文件: tool_detail_page.dart + * 名称: 工具详情页 + * 作用: 从瀑布流工具卡片info图标进入,展示工具详细信息和功能说明 + * 创建时间: 2026-04-17 + * 更新时间: 2026-04-17 初始创建 + */ + +import 'dart:ui'; +import 'package:flutter/cupertino.dart'; +import 'package:flutter/material.dart'; +import 'package:get/get.dart'; +import 'package:mom_kitchen/src/config/design_tokens.dart'; +import 'package:mom_kitchen/src/models/discover_model.dart'; + +class ToolDetailPage extends StatelessWidget { + const ToolDetailPage({super.key}); + + @override + Widget build(BuildContext context) { + final isDark = CupertinoTheme.brightnessOf(context) == Brightness.dark; + final tool = Get.arguments as ToolItemRef?; + + if (tool == null) { + return CupertinoPageScaffold( + backgroundColor: isDark ? DarkDesignTokens.background : DesignTokens.background, + child: SafeArea( + child: Center( + child: Text('工具信息不存在', style: TextStyle(color: isDark ? DarkDesignTokens.text2 : DesignTokens.text2)), + ), + ), + ); + } + + final gradientIndex = tool.id.hashCode % DesignTokens.toolGradients.length; + final gradientColor = DesignTokens.toolGradients[gradientIndex]; + + return CupertinoPageScaffold( + backgroundColor: isDark ? DarkDesignTokens.background : DesignTokens.background, + child: SafeArea( + child: Column( + children: [ + _buildNavigationBar(isDark, tool), + Expanded( + child: SingleChildScrollView( + physics: const BouncingScrollPhysics(), + padding: const EdgeInsets.all(DesignTokens.space4), + child: Column( + children: [ + const SizedBox(height: DesignTokens.space5), + _buildIconSection(tool, gradientColor, isDark), + const SizedBox(height: DesignTokens.space5), + _buildInfoSection(tool, isDark), + const SizedBox(height: DesignTokens.space5), + _buildCategoryTag(tool, gradientColor, isDark), + const SizedBox(height: DesignTokens.space6), + _buildOpenButton(tool, gradientColor, isDark), + const SizedBox(height: DesignTokens.space6), + ], + ), + ), + ), + ], + ), + ), + ); + } + + Widget _buildNavigationBar(bool isDark, ToolItemRef tool) { + return ClipRRect( + child: BackdropFilter( + filter: ImageFilter.blur(sigmaX: 20, sigmaY: 20), + child: Container( + padding: const EdgeInsets.symmetric(horizontal: DesignTokens.space4, vertical: DesignTokens.space2), + decoration: BoxDecoration( + color: (isDark ? DarkDesignTokens.card : DesignTokens.card).withValues(alpha: 0.72), + border: Border( + bottom: BorderSide( + color: Colors.white.withValues(alpha: 0.1), + width: 0.5, + ), + ), + ), + child: Row( + children: [ + GestureDetector( + onTap: () => Get.back(), + child: Container( + padding: const EdgeInsets.all(DesignTokens.space2), + child: Icon( + CupertinoIcons.back, + color: DesignTokens.dynamicPrimary, + size: 22, + ), + ), + ), + const SizedBox(width: DesignTokens.space2), + Expanded( + child: Text( + '工具详情', + style: TextStyle( + fontSize: DesignTokens.fontLg, + fontWeight: FontWeight.w600, + color: isDark ? DarkDesignTokens.text1 : DesignTokens.text1, + ), + ), + ), + ], + ), + ), + ), + ); + } + + Widget _buildIconSection(ToolItemRef tool, Color gradientColor, bool isDark) { + return Container( + width: 120, + height: 120, + decoration: BoxDecoration( + shape: BoxShape.circle, + gradient: LinearGradient( + begin: Alignment.topLeft, + end: Alignment.bottomRight, + colors: [ + gradientColor.withValues(alpha: 0.2), + gradientColor.withValues(alpha: 0.08), + ], + ), + boxShadow: [ + BoxShadow( + color: gradientColor.withValues(alpha: 0.15), + blurRadius: 24, + offset: const Offset(0, 8), + ), + ], + ), + child: Center( + child: Text( + tool.icon, + style: const TextStyle(fontSize: 56), + ), + ), + ); + } + + Widget _buildInfoSection(ToolItemRef tool, bool isDark) { + return Column( + children: [ + Text( + tool.name, + style: TextStyle( + fontSize: DesignTokens.fontXxl, + fontWeight: FontWeight.w700, + color: isDark ? DarkDesignTokens.text1 : DesignTokens.text1, + ), + ), + if (tool.description != null) ...[ + const SizedBox(height: DesignTokens.space2), + Text( + tool.description!, + style: TextStyle( + fontSize: DesignTokens.fontMd, + color: isDark ? DarkDesignTokens.text2 : DesignTokens.text2, + ), + textAlign: TextAlign.center, + ), + ], + ], + ); + } + + Widget _buildCategoryTag(ToolItemRef tool, Color gradientColor, bool isDark) { + return Container( + padding: const EdgeInsets.symmetric(horizontal: DesignTokens.space4, vertical: DesignTokens.space2), + decoration: BoxDecoration( + color: gradientColor.withValues(alpha: 0.1), + borderRadius: DesignTokens.borderRadiusFull, + border: Border.all( + color: gradientColor.withValues(alpha: 0.25), + width: 0.5, + ), + ), + child: Row( + mainAxisSize: MainAxisSize.min, + children: [ + Text(_getCategoryIcon(tool.category), style: const TextStyle(fontSize: 14)), + const SizedBox(width: DesignTokens.space2), + Text( + tool.categoryName, + style: TextStyle( + fontSize: DesignTokens.fontSm, + fontWeight: FontWeight.w500, + color: isDark ? DarkDesignTokens.text2 : DesignTokens.text2, + ), + ), + ], + ), + ); + } + + Widget _buildOpenButton(ToolItemRef tool, Color gradientColor, bool isDark) { + return SizedBox( + width: double.infinity, + child: CupertinoButton( + borderRadius: DesignTokens.borderRadiusXl, + color: DesignTokens.dynamicPrimary, + onPressed: () => Get.toNamed(tool.route), + child: Row( + mainAxisAlignment: MainAxisAlignment.center, + children: [ + Text( + '打开工具', + style: TextStyle( + fontSize: DesignTokens.fontMd, + fontWeight: FontWeight.w600, + color: CupertinoColors.white, + ), + ), + const SizedBox(width: DesignTokens.space2), + const Icon(CupertinoIcons.arrow_right, size: 16, color: CupertinoColors.white), + ], + ), + ), + ); + } + + String _getCategoryIcon(String category) { + const icons = { + 'cooking': '🍳', + 'health': '💊', + 'data': '📊', + 'planning': '📅', + }; + return icons[category] ?? '📋'; + } +} +``` + +- [ ] **Step 2: 验证编译通过** + +Run: `cd e:\project\flutter\f\mom_kitchen && flutter analyze lib/src/pages/tools/tool_detail_page.dart` +Expected: No errors + +--- + +### Task 6: 注册路由 — AppRoutes 新增 toolDetail + +**Files:** +- Modify: `lib/src/config/app_routes.dart` + +- [ ] **Step 1: 在 AppRoutes 类中新增路由常量** + +在现有路由常量区域添加: +```dart + static const String toolDetail = '/tool-detail'; +``` + +- [ ] **Step 2: 在 pages 列表中注册路由** + +在 `pages` 列表中添加: +```dart + GetPage( + name: toolDetail, + page: () => const ToolDetailPage(), + middlewares: [PageStandardsMiddleware()], + ), +``` + +确保文件顶部有 import: +```dart +import 'package:mom_kitchen/src/pages/tools/tool_detail_page.dart'; +``` + +- [ ] **Step 3: 验证编译通过** + +Run: `cd e:\project\flutter\f\mom_kitchen && flutter analyze lib/src/config/app_routes.dart` +Expected: No errors + +--- + +### Task 7: 改造 DiscoverWaterfall — 使用插槽注册表 + +**Files:** +- Modify: `lib/src/widgets/discover/discover_waterfall.dart` + +- [ ] **Step 1: 添加新 import** + +在文件顶部添加: +```dart +import 'package:mom_kitchen/src/models/waterfall_slot.dart'; +import 'package:mom_kitchen/src/models/tool_item_model.dart'; +import 'package:mom_kitchen/src/widgets/discover/tool_card_discover_card.dart'; +``` + +- [ ] **Step 2: 在 DiscoverWaterfall 类中新增 toolCards 参数** + +修改 `DiscoverWaterfall` 类: +```dart +class DiscoverWaterfall extends StatefulWidget { + final DiscoverData data; + final bool isLoading; + final bool isLoadingMore; + final VoidCallback? onLoadMore; + final void Function(int recipeId)? onDismissRecipe; + final Set dismissedRecipeIds; + final List miniCardRecipes; + final MiniCardMeta? miniCardMeta; + final List toolCards; + + static const int _slotInterval = 20; + + const DiscoverWaterfall({ + super.key, + required this.data, + this.isLoading = false, + this.isLoadingMore = false, + this.onLoadMore, + this.onDismissRecipe, + this.dismissedRecipeIds = const {}, + this.miniCardRecipes = const [], + this.miniCardMeta, + this.toolCards = const [], + }); +``` + +注意:删除旧的 `_miniCardInterval` 常量,替换为 `_slotInterval`。 + +- [ ] **Step 3: 改造 _mergedItems getter 使用插槽注册表** + +替换整个 `_mergedItems` getter 和缓存逻辑: + +```dart +class _DiscoverWaterfallState extends State { + List? _cachedMergedItems; + List? _lastFlattenedItems; + Set? _lastDismissedIds; + int _lastMiniCardCount = -1; + int _lastToolCardCount = -1; + + List get _mergedItems { + final items = widget.data.flattenedItems; + final dismissed = widget.dismissedRecipeIds; + final miniCards = widget.miniCardRecipes; + final toolCards = widget.toolCards; + final miniCardCountChanged = miniCards.length != _lastMiniCardCount; + final toolCardCountChanged = toolCards.length != _lastToolCardCount; + + if (_cachedMergedItems != null && + identical(items, _lastFlattenedItems) && + _lastDismissedIds != null && + _lastDismissedIds!.length == dismissed.length && + _lastDismissedIds!.containsAll(dismissed) && + !miniCardCountChanged && + !toolCardCountChanged) { + return _cachedMergedItems!; + } + + final filtered = dismissed.isNotEmpty + ? items + .where( + (item) => + item.type != DiscoverItemType.recipe || + !dismissed.contains(item.recipe?.id ?? 0), + ) + .toList() + : items; + + final slots = WaterfallSlotRegistry.buildSlots( + miniCards: miniCards, + toolCards: toolCards, + ); + + final merged = []; + int itemIdx = 0; + int slotIdx = 0; + + while (itemIdx < filtered.length || slotIdx < slots.length) { + while (itemIdx < filtered.length && + (slotIdx >= slots.length || itemIdx < slots[slotIdx].position)) { + merged.add(filtered[itemIdx]); + itemIdx++; + } + if (slotIdx < slots.length) { + final slot = slots[slotIdx]; + if (slot.type == WaterfallSlotType.miniCard) { + final idx = slot.data['index'] as int; + if (idx < miniCards.length) { + merged.add(DiscoverItem.miniCard(MiniCardRecipeRef( + id: miniCards[idx].id, + name: miniCards[idx].name, + category: miniCards[idx].category, + categoryName: miniCards[idx].categoryName, + image: miniCards[idx].image, + ))); + } + } else if (slot.type == WaterfallSlotType.toolCard) { + final idx = slot.data['index'] as int; + if (idx < toolCards.length) { + final t = toolCards[idx]; + merged.add(DiscoverItem.toolCard(ToolItemRef( + id: t.id, + name: t.name, + icon: t.icon, + category: t.category, + categoryName: ToolRegistry.getCategoryLabel(t.category), + route: t.route, + description: t.description, + badge: t.waterfallSlot.badge, + ))); + } + } + slotIdx++; + } + } + + _cachedMergedItems = merged; + _lastFlattenedItems = items; + _lastDismissedIds = Set.from(dismissed); + _lastMiniCardCount = miniCards.length; + _lastToolCardCount = toolCards.length; + return merged; + } +``` + +- [ ] **Step 4: 在 _buildItem switch 中新增 toolCard case** + +在 `_buildItem` 方法的 switch 中,`miniCard` case 之后添加: + +```dart + case DiscoverItemType.toolCard: + card = ToolCardDiscoverCard( + key: key, + tool: item.toolItemRef!, + ); + break; +``` + +- [ ] **Step 5: 在 _getItemKey 中新增 toolCard key** + +在 `_getItemKey` 方法的 switch 中添加: + +```dart + case DiscoverItemType.toolCard: + return 'toolcard_${item.toolItemRef?.id ?? index}'; +``` + +- [ ] **Step 6: 验证编译通过** + +Run: `cd e:\project\flutter\f\mom_kitchen && flutter analyze lib/src/widgets/discover/discover_waterfall.dart` +Expected: No errors + +--- + +### Task 8: 修改 HomePage — 传递 toolCards 参数 + +**Files:** +- Modify: `lib/src/pages/home/home_page.dart` + +- [ ] **Step 1: 添加 import** + +在文件顶部添加: +```dart +import 'package:mom_kitchen/src/models/tool_item_model.dart'; +``` + +- [ ] **Step 2: 在 _HomePageState 中添加工具卡片列表字段** + +在 `_miniCardMeta` 字段之后添加: +```dart + List _homeToolCards = []; +``` + +- [ ] **Step 3: 在 _startBackgroundLoading 中加载工具卡片** + +在 `_loadMiniCards()` 调用之后添加: +```dart + _loadHomeToolCards(); +``` + +- [ ] **Step 4: 添加 _loadHomeToolCards 方法** + +在 `_loadMiniCards` 方法之后添加: +```dart + void _loadHomeToolCards() { + try { + _homeToolCards = ToolRegistry.homeCardTools; + } catch (e) { + debugPrint('HomePage: load home tool cards failed: $e'); + } + } +``` + +- [ ] **Step 5: 在 DiscoverWaterfall 调用处传递 toolCards 参数** + +找到 `DiscoverWaterfall(` 调用,在 `miniCardMeta: _miniCardMeta,` 之后添加: +```dart + toolCards: _homeToolCards, +``` + +- [ ] **Step 6: 验证编译通过** + +Run: `cd e:\project\flutter\f\mom_kitchen && flutter analyze lib/src/pages/home/home_page.dart` +Expected: No errors + +--- + +### Task 9: 更新 CHANGELOG.md + +**Files:** +- Modify: `CHANGELOG.md` + +- [ ] **Step 1: 在文件顶部添加新版本记录** + +```markdown +## [0.97.27] - 2026-04-17 + +### ✨ 新增 — 首页瀑布流工具卡片插槽系统 + +#### 功能描述 +- 🛠️ **工具卡片插入**:首页瀑布流每40个卡片插入1个工具推荐卡片(与miniCard交替) +- 🎨 **毛玻璃卡片**:工具卡片采用液态玻璃中等卡片样式,分类渐变色底层装饰 +- ℹ️ **工具详情页**:点击卡片右上角info图标进入独立工具详情页 +- 🔌 **统一插槽系统**:WaterfallSlotRegistry 统一管理瀑布流插入卡片类型和位置 +- ✅ **必填声明机制**:ToolItem 新增 waterfallSlot 必填字段,新增工具不声明编译报错 +- 📈 **优先级排序**:工具卡片按 priority 排序展示,支持角标(新/热门) + +#### 新增文件 +- `lib/src/models/waterfall_slot.dart` — 瀑布流插槽模型(WaterfallSlotType、WaterfallSlot、WaterfallSlotConfig、WaterfallSlotRegistry) +- `lib/src/widgets/discover/tool_card_discover_card.dart` — 瀑布流工具卡片组件(毛玻璃中等卡片) +- `lib/src/pages/tools/tool_detail_page.dart` — 工具详情页(独立页面,展示工具信息和功能说明) + +#### 修改文件 +- `lib/src/models/tool_item_model.dart` — 新增 WaterfallSlotConfig 必填字段,所有 defaultTools 添加声明,新增 homeCardTools getter +- `lib/src/models/discover_model.dart` — 新增 DiscoverItemType.toolCard、ToolItemRef 类、DiscoverItem.toolCard factory +- `lib/src/widgets/discover/discover_waterfall.dart` — 新增 toolCards 参数,_mergedItems 改用 WaterfallSlotRegistry 插槽机制 +- `lib/src/pages/home/home_page.dart` — 传递 toolCards 参数给 DiscoverWaterfall +- `lib/src/config/app_routes.dart` — 新增 /tool-detail 路由 +``` + +同时删除最早的版本号记录(保留5个版本)。 + +--- + +### Task 10: 全量验证 + +- [ ] **Step 1: 运行 flutter analyze 全量检查** + +Run: `cd e:\project\flutter\f\mom_kitchen && flutter analyze` +Expected: No errors + +- [ ] **Step 2: 运行 flutter build 验证构建** + +Run: `cd e:\project\flutter\f\mom_kitchen && flutter build apk --debug` +Expected: BUILD SUCCESSFUL diff --git a/docs/superpowers/specs/2026-04-16-weight-manage-design.md b/docs/superpowers/specs/2026-04-16-weight-manage-design.md new file mode 100644 index 0000000..0297fef --- /dev/null +++ b/docs/superpowers/specs/2026-04-16-weight-manage-design.md @@ -0,0 +1,168 @@ +# 体重管理页面 — 设计规格 + +**日期**: 2026-04-16 +**状态**: 已批准 +**方案**: C(完整版 — 含目标体重 + 趋势分析) + +--- + +## 1. 功能概述 + +工具中心新增「体重管理」页面,支持: +- 记录体重(公斤/斤双单位,早晨/饭前/饭后三时机,备注) +- fl_chart 折线图展示体重趋势,含目标虚线 +- 自动计算体重变化(vs 上次、vs 目标) +- 本周/本月周期统计 +- 目标体重设置 +- 跳转 BMI 计算器 +- HiveService 本地持久化 + +--- + +## 2. 文件结构 + +| 文件 | 职责 | 预估行数 | +|---|---|---| +| `lib/src/models/weight_record_model.dart` | WeightRecord 数据模型 | ~80 | +| `lib/src/controllers/tools/weight_controller.dart` | GetX 控制器 | ~250 | +| `lib/src/pages/tools/health/weight_manage_page.dart` | 主页面 GetView | ~350 | +| `lib/src/widgets/charts_widgets.dart` | 新增 WeightLineChart 组件 | +150 | + +### 工具中心注册 + +在 `ToolRegistry.defaultTools` 的 `health` 分类下新增入口: + +```dart +ToolItem( + id: 'weight_manage', + name: '体重管理', + icon: '⚖️', + needsNetwork: false, + category: 'health', // 放入健康营养分类 + route: '/tools/weight-manage', + description: '记录体重、追踪变化趋势', +) +``` + +路由注册: +- `app_routes.dart` → `GetPage` + `PageRegistry` 双注册 +- 路由常量:`static const String weightManage = '/tools/weight-manage';` + +--- + +## 3. 数据模型 + +```dart +class WeightRecord { + final String id; // UUID + final double weightKg; // 体重(公斤),内部统一存储单位 + final String timing; // morning / before_meal / after_meal + final String? note; // 备注 + final DateTime createdAt; // 记录时间 +} +``` + +**时机枚举标签:** +| 值 | 显示文本 | emoji | +|---|---|---| +| `morning` | 早晨 | 🌅 | +| `before_meal` | 饭前 | 🍽️ | +| `after_meal` | 饭后 | 😋 | + +**控制器额外状态:** +- `goalWeight` (RxDouble) — 目标体重(kg),默认 60.0 +- `unitMode` (RxString) — 显示模式 `'kg'` / `'jin'` +- `records` (RxList) — 所有记录,按时间倒序 + +--- + +## 4. 页面布局 + +``` +┌──────────────────────────────────┐ +│ ⚖️ 体重管理 [📊 BMI] │ 导航栏 + BMI跳转按钮 +├──────────────────────────────────┤ +│ ┌─────┐ ┌─────┐ ┌─────┐ │ +│ │当前 │ │目标 │ │变化 │ │ 3个统计卡片(横排自适应) +│ │65.5kg│ │60kg │ │↓0.3kg│ │ +│ └─────┘ └─────┘ └─────┘ │ +├──────────────────────────────────┤ +│ 📈 体重趋势 │ WeightLineChart (fl_chart) +│ ─ ─ ─ 目标线(虚线) │ +│ ═════ 实际曲线(渐变填充) │ +│ ● 数据点(触摸tooltip) │ +├──────────────────────────────────┤ +│ 📊 本周趋势 / 本月趋势 │ 周期统计卡片(可切换Tab) +│ 平均: 65.3 | 最高: 66.2 │ +│ 最低: 64.8 | 变化: -0.7 │ +├──────────────────────────────────┤ +│ ⚙️ 目标体重 │ 滑块或输入框设定 +│ [━━━━━━●━━━━] 60.0 kg │ +├──────────────────────────────────┤ +│ ➕ 记录体重 │ 添加表单 +│ [65.5 ] kg ⇄ [131.0 斤] │ 单位切换 +│ ○🌅早晨 ○🍽️饭前 ●😋饭后 │ 时机选择 +│ [备注信息...] │ +│ [💾 保存记录] │ +├──────────────────────────────────┤ +│ 📋 历史记录 │ 列表(可滑动删除) +│ 04-16 07:00 🌅 65.5kg ↓0.3 │ +│ 04-15 22:00 😋 65.8kg ↑0.1 │ +└──────────────────────────────────┘ +``` + +--- + +## 5. 核心功能细节 + +### 5.1 单位切换 +- 默认显示公斤(kg),点击切换为斤(jin) +- 公式:`jin = kg * 2` +- 内部存储统一使用 kg +- 输入框实时双向转换 + +### 5.2 折线图 (WeightLineChart) +基于现有 `NutritionLineChart` 模式新建: +- X轴:日期(M/D格式) +- Y轴:体重数值 +- 曲线:平滑曲线 + 渐变填充区域 +- 数据点:圆点标记 +- 目标线:水平虚线,颜色区分 +- Tooltip:触摸显示具体数值和时机 +- 空状态:显示占位提示 + +### 5.3 自动计算 +- **vs 上次记录**: 当前 - 上次,显示 ↑绿色 / ↓红色 + 数值 +- **vs 目标**: 当前 - 目标,显示距离目标的差值 +- **周/月统计**: 平均值、最高、最低、净变化 + +### 5.4 BMI跳转 +导航栏 trailing 按钮 → `Get.toNamed('/bmi-calculator')` + +### 5.5 数据持久化 +- 使用 HiveService 存储 +- Key: `weight_records` +- Value: List JSON +- Goal weight: `weight_goal_kg` + +--- + +## 6. 交互流程 + +1. 进入页面 → 加载历史记录 → 渲染图表+列表 +2. 输入体重 → 选择时机 → 可选备注 → 保存 +3. 保存后自动刷新图表、统计卡片、历史列表 +4. 设置目标体重 → 图表更新目标虚线 +5. 点击 BMI 按钮 → 跳转 BMI 计算器 +6. 左滑历史记录 → 删除确认 → 删除并刷新 + +--- + +## 7. 设计约束 + +- iOS 26 Cupertino 风格 +- 统一使用 DesignTokens / DarkDesignTokens +- 每个文件 < 800 行 +- 响应式布局(手机/平板/桌面) +- 支持深色模式 +- 空指针安全(所有 Rx.value 使用前 null check) diff --git a/docs/superpowers/specs/2026-04-17-order-assistant-design.md b/docs/superpowers/specs/2026-04-17-order-assistant-design.md new file mode 100644 index 0000000..8d8651a --- /dev/null +++ b/docs/superpowers/specs/2026-04-17-order-assistant-design.md @@ -0,0 +1,219 @@ +# 点餐助手设计规格文档 + +> 创建时间: 2026-04-17 +> 状态: 已确认 + +## 一、功能概述 + +点餐助手是工具中心新增工具,支持双向场景: +- **用户点餐**:用户在App创建点单,生成二维码,商家扫码查看 +- **商家推单**:商家在App推单,顾客扫码下单 + +核心能力:菜品管理、二维码/条形码生成、WebSocket实时同步、网页端展示。 + +## 二、数据模型 + +### OrderItem(点单项) + +| 字段 | 类型 | 必填 | 说明 | +|------|------|------|------| +| id | String | ✅ | UUID | +| name | String | ✅ | 菜品名称 | +| source | enum | ✅ | browseHistory / search / manual / merchantRecommend | +| quantity | int | ✅ | 数量,默认1 | +| price | double? | ❌ | 单价 | +| ingredients | String? | ❌ | 食材备注 | +| note | String? | ❌ | 备注信息 | +| recipeId | String? | ❌ | 关联菜谱ID | + +### Order(点单) + +| 字段 | 类型 | 必填 | 说明 | +|------|------|------|------| +| id | String | ✅ | UUID,唯一值 | +| orderNo | String | ✅ | 订单编号(时间戳+随机数) | +| type | enum | ✅ | userOrder / merchantPush | +| status | enum | ✅ | draft / active / completed / cancelled | +| items | List\ | ✅ | 菜品列表 | +| totalAmount | double? | ❌ | 总金额 | +| note | String? | ❌ | 整单备注 | +| createdAt | String | ✅ | ISO8601 | +| updatedAt | String | ✅ | ISO8601 | +| createdBy | String | ✅ | 创建者标识 | +| tableNo | String? | ❌ | 桌号 | +| recordCount | int | ✅ | 记录条数 | + +### 本地存储 + +- `order_assistant_records`:SharedPreferences JSON存储历史点单 +- `order_assistant_count`:记录总条数计数器 +- 浏览记录来源:复用 `BrowseHistoryController` + +## 三、API接口 + +Base URL: `https://eat.wktyl.com/api/kitchen` + +### REST API + +| 方法 | 路径 | 说明 | 请求体 | 响应 | +|------|------|------|--------|------| +| POST | `/order` | 创建点单 | Order JSON | `{ orderId, orderNo }` | +| GET | `/order/{id}` | 获取点单详情 | - | Order JSON | +| PUT | `/order/{id}` | 更新点单 | Order JSON | `{ success }` | +| GET | `/order/{id}/qr` | 获取二维码URL | - | `{ qrUrl, barcodeUrl }` | +| GET | `/orders` | 历史点单列表 | ?page&limit&status | `{ list, total }` | + +### WebSocket + +``` +ws://eat.wktyl.com/ws/kitchen/order/{orderId} + +事件: +- order.updated → 点单内容变更 +- item.added → 新增菜品 +- item.removed → 移除菜品 +- status.changed → 状态变更 +``` + +### 二维码/条形码编码 + +- 二维码:编码URL `https://eat.wktyl.com/api/kitchen?id={orderId}` +- 条形码:编码订单编号 `orderNo`(Code128) + +### 开发阶段Mock策略 + +App端先使用本地 SharedPreferences + Mock API Service,后端就绪后切换。 + +## 四、App端页面设计 + +### 页面结构 + +``` +OrderAssistantPage +├── 顶部导航栏(毛玻璃) +│ ├── 返回按钮 +│ ├── 标题「点餐助手 🍽️」 +│ └── 历史记录按钮 +├── 模式切换(SegmentedControl) +│ ├── 🧑 用户点餐 +│ └── 🏪 商家推单 +├── 点单信息区 +│ ├── 桌号输入 +│ ├── 整单备注 +│ └── 记录条数统计 +├── 菜品列表区 +│ ├── 已添加菜品卡片(可滑动删除) +│ └── 添加菜品按钮 +├── 添加菜品弹窗(ActionSheet) +│ ├── 📖 从浏览记录选择 +│ ├── 🔍 搜索菜品 +│ ├── ✏️ 手动填写 +│ └── ⭐ 商家推荐 +├── 底部操作栏 +│ ├── 总金额 +│ ├── 生成二维码按钮 +│ └── 生成条形码按钮 +└── 二维码/条形码展示弹窗 + ├── 二维码图片(可保存) + ├── 条形码图片(可保存) + └── 分享按钮 +``` + +### 交互流程 + +1. 用户点餐:选择菜品 → 填写备注 → 生成二维码 → 商家扫码 +2. 商家推单:选择推荐菜品 → 生成二维码 → 顾客扫码下单 +3. 菜品来源:浏览记录/搜索/手动填写/商家推荐 + +### 文件结构 + +``` +lib/src/ +├── models/tools/ +│ └── order_model.dart # Order + OrderItem 模型 +├── controllers/tools/ +│ └── order_assistant_controller.dart # 点餐助手控制器 +├── services/tools/ +│ └── order_api_service.dart # API服务(含Mock) +├── pages/tools/ +│ ├── order_assistant_page.dart # 主页面 +│ └── widgets/ +│ ├── order_item_card.dart # 菜品卡片 +│ ├── add_item_sheet.dart # 添加菜品弹窗 +│ ├── browse_history_picker.dart # 浏览记录选择器 +│ ├── search_dish_sheet.dart # 搜索菜品 +│ ├── manual_input_sheet.dart # 手动填写 +│ └── qr_code_dialog.dart # 二维码展示弹窗 +``` + +## 五、网页端设计 + +### 文件位置 + +`web_order/index.html` — 纯HTML/CSS/JS单文件,独立部署到 eat.wktyl.com + +### URL参数 + +``` +https://eat.wktyl.com/api/kitchen?id={orderId} +https://eat.wktyl.com/api/kitchen?orderNo={orderNo} +``` + +### 页面结构 + +``` +点单展示页(移动端优先响应式) +├── 顶部 +│ ├── Logo + 标题「点餐助手」 +│ ├── 点单类型标签 +│ └── 实时状态指示器 🟢 +├── 点单信息卡片 +│ ├── 订单编号(条形码) +│ ├── 桌号 +│ ├── 创建时间 +│ └── 整单备注 +├── 菜品列表 +│ ├── 菜品卡片(名称/数量/食材/备注/来源/价格) +│ └── 实时更新动画 +├── 合计区域 +│ ├── 菜品总数 +│ └── 总金额 +├── 操作区(商家推单模式) +│ ├── 确认下单按钮 +│ └── 修改数量 +└── 底部 + ├── 最后更新时间 + └── WebSocket连接状态 +``` + +### 实时同步 + +- WebSocket连接 `wss://eat.wktyl.com/ws/kitchen/order/{orderId}` +- 事件驱动更新:order.updated / item.added / item.removed / status.changed +- 降级策略:WebSocket断开时切换5秒轮询 + +### 视觉风格 + +- iOS 26 风格:毛玻璃卡片、圆角、柔和阴影 +- 响应式:手机单列 / 平板双列 / 桌面三列 +- 动画:菜品新增淡入、删除淡出、状态变更闪烁 +- 深色模式自动适配 + +## 六、工具注册 + +在 `ToolItem.defaultTools` 中新增: + +```dart +ToolItem( + id: 'order_assistant', + name: '点餐助手', + icon: '🍽️', + needsNetwork: true, + category: 'cooking', + route: '/tools/order-assistant', + description: '点餐推单,二维码分享', + waterfallSlot: WaterfallSlotConfig(show: true, priority: 7), +) +``` + +路由注册:`/tools/order-assistant` → `OrderAssistantPage` diff --git a/docs/superpowers/specs/2026-04-17-waterfall-tool-card-design.md b/docs/superpowers/specs/2026-04-17-waterfall-tool-card-design.md new file mode 100644 index 0000000..59bb59e --- /dev/null +++ b/docs/superpowers/specs/2026-04-17-waterfall-tool-card-design.md @@ -0,0 +1,240 @@ +# 首页瀑布流工具卡片插槽系统设计 + +> 日期: 2026-04-17 +> 状态: 已确认 + +## 1. 功能概述 + +在首页 Discover 瀑布流中,每40个普通卡片插入1个工具卡片(与现有miniCard交替:位置20=miniCard,位置40=toolCard,位置60=miniCard...)。工具卡片采用毛玻璃中等卡片样式,点击卡片进入工具页面,点击右上角info图标进入独立工具详情页。 + +**核心要求:** 每个工具必须显式声明瀑布流配置(`waterfallSlot`),不声明直接编译报错,防止遗漏。 + +## 2. 数据模型 + +### 2.1 WaterfallSlotConfig — 瀑布流插槽配置 + +```dart +class WaterfallSlotConfig { + final bool show; // 是否在首页瀑布流显示(必填) + final int priority; // 显示优先级,数字越小越靠前 + final String? badge; // 卡片角标文字,如 "新" "热门" + + const WaterfallSlotConfig({ + required this.show, + this.priority = 999, + this.badge, + }); +} +``` + +### 2.2 WaterfallSlotType — 插槽类型枚举 + +```dart +enum WaterfallSlotType { miniCard, toolCard } +``` + +### 2.3 WaterfallSlot — 插槽实例 + +```dart +class WaterfallSlot { + final WaterfallSlotType type; + final int position; // 插入位置(第N个卡片后) + final Map data; // 插槽携带数据 + + const WaterfallSlot({ + required this.type, + required this.position, + required this.data, + }); +} +``` + +### 2.4 ToolItem 扩展 + +在 `ToolItem` 中新增 `waterfallSlot` 必填字段: + +```dart +class ToolItem { + // ... 现有字段 + final WaterfallSlotConfig waterfallSlot; // 必填! + + const ToolItem({ + required this.id, + required this.name, + required this.icon, + required this.needsNetwork, + required this.category, + required this.route, + required this.waterfallSlot, // ← 必填,不声明编译报错 + this.description, + this.usageCount = 0, + }); +} +``` + +### 2.5 DiscoverItemType 扩展 + +```dart +enum DiscoverItemType { + recipe, ingredient, category, tag, mealTime, nutrition, miniCard, + toolCard, // 新增 +} +``` + +### 2.6 ToolItemRef — 工具卡片轻量引用 + +```dart +class ToolItemRef { + final String id; + final String name; + final String icon; + final String category; + final String categoryName; + final String route; + final String? description; + final String? badge; + + const ToolItemRef({...}); + + factory ToolItemRef.fromToolItem(ToolItem item) => ToolItemRef( + id: item.id, + name: item.name, + icon: item.icon, + category: item.category, + categoryName: ToolRegistry.getCategoryLabel(item.category), + route: item.route, + description: item.description, + badge: item.waterfallSlot.badge, + ); +} +``` + +### 2.7 DiscoverItem 扩展 + +```dart +class DiscoverItem { + // ... 现有字段 + final ToolItemRef? toolItemRef; // 新增 + + factory DiscoverItem.toolCard(ToolItemRef ref) => + DiscoverItem._(type: DiscoverItemType.toolCard, toolItemRef: ref); +} +``` + +## 3. 插槽注册表 + +### WaterfallSlotRegistry + +独立管理所有插槽类型和位置: + +```dart +class WaterfallSlotRegistry { + static List buildSlots({ + required List miniCards, + required List toolCards, + }) { + final slots = []; + int miniIdx = 0, toolIdx = 0; + + for (int pos = 20; miniIdx < miniCards.length || toolIdx < toolCards.length; pos += 20) { + final cycle = ((pos ~/ 20) - 1) % 2; // 0=miniCard, 1=toolCard + if (cycle == 0 && miniIdx < miniCards.length) { + slots.add(WaterfallSlot(type: WaterfallSlotType.miniCard, position: pos, data: {'index': miniIdx})); + miniIdx++; + } else if (cycle == 1 && toolIdx < toolCards.length) { + slots.add(WaterfallSlot(type: WaterfallSlotType.toolCard, position: pos, data: {'index': toolIdx})); + toolIdx++; + } + } + return slots; + } +} +``` + +### ToolRegistry.homeCardTools + +```dart +static List get homeCardTools { + final tools = defaultTools.where((t) => t.waterfallSlot.show).toList(); + tools.sort((a, b) => a.waterfallSlot.priority.compareTo(b.waterfallSlot.priority)); + // 运行时校验 + assert(() { + for (final t in defaultTools) { + if (t.waterfallSlot.show && t.route.isEmpty) { + throw AssertionError('工具 ${t.id} 声明了瀑布流展示但route为空!'); + } + } + return true; + }()); + return tools; +} +``` + +## 4. 卡片组件 + +### ToolCardDiscoverCard — 毛玻璃中等卡片 + +**规格:** +- 占1列宽度(与普通recipe卡片一致) +- 高度约 180-200px +- 毛玻璃背景 + 工具分类渐变色底层装饰 +- 布局:上方大emoji icon(40px),中间工具名称(粗体),底部分类标签pill + 右上角info图标 + +**毛玻璃效果:** +- BackdropFilter blur 20 +- 半透明背景色(light: white 0.55 / dark: card 0.55) +- 细微白色边框 0.5px +- 分类渐变色作为底层装饰(低透明度) + +**交互:** +- 点击卡片主体 → `Get.toNamed(tool.route)` 进入工具页面 +- 点击右上角 ℹ️ icon → `Get.toNamed('/tool-detail', arguments: toolItem)` 进入独立详情页 + +## 5. 工具详情页 + +### ToolDetailPage + +**页面结构:** +- iOS风格 CupertinoPageScaffold +- 毛玻璃导航栏 +- 大icon(64px)+ 渐变背景装饰 +- 工具名称 + 描述 +- 分类标签 +- 功能说明列表 +- 底部CTA按钮直接进入工具 + +**路由:** `/tool-detail` + +## 6. DiscoverWaterfall 修改 + +- 新增 `toolCards` 参数(`List`) +- `_mergedItems` 改为使用 `WaterfallSlotRegistry.buildSlots` 构建插槽 +- `_buildItem` switch 新增 `DiscoverItemType.toolCard` case +- `_getItemKey` 新增 toolCard key 生成 + +## 7. HomePage 修改 + +- 从 `ToolRegistry.homeCardTools` 获取工具列表 +- 传递 `toolCards` 参数给 `DiscoverWaterfall` + +## 8. 未来扩展 + +新增卡片类型只需3步: +1. `WaterfallSlotType` 枚举加一个值 +2. `WaterfallSlotRegistry.buildSlots` 加一个分支 +3. `_buildItem` 的 switch 加一个 case + +新增工具只需在 `ToolItem` 构造时声明 `waterfallSlot`,不声明编译报错。 + +## 9. 涉及文件 + +| 文件 | 操作 | +|------|------| +| `lib/src/models/tool_item_model.dart` | 修改:新增 WaterfallSlotConfig、waterfallSlot 必填字段 | +| `lib/src/models/discover_model.dart` | 修改:新增 DiscoverItemType.toolCard、ToolItemRef、DiscoverItem.toolCard | +| `lib/src/models/waterfall_slot.dart` | 新建:WaterfallSlotType、WaterfallSlot、WaterfallSlotRegistry | +| `lib/src/widgets/discover/tool_card_discover_card.dart` | 新建:毛玻璃工具卡片组件 | +| `lib/src/pages/tools/tool_detail_page.dart` | 新建:工具详情页 | +| `lib/src/widgets/discover/discover_waterfall.dart` | 修改:新增 toolCards 参数、插槽机制 | +| `lib/src/pages/home/home_page.dart` | 修改:传递 toolCards 参数 | +| `lib/src/config/app_routes.dart` | 修改:新增 /tool-detail 路由 | diff --git a/ios/Runner/Assets.xcassets/LaunchImage.imageset/Contents.json b/ios/Runner/Assets.xcassets/LaunchImage.imageset/Contents.json index 0bedcf2..93ec644 100644 --- a/ios/Runner/Assets.xcassets/LaunchImage.imageset/Contents.json +++ b/ios/Runner/Assets.xcassets/LaunchImage.imageset/Contents.json @@ -1,17 +1,17 @@ { "images" : [ { - "idiom" : "universal", + "idiom" : "iphone", "filename" : "LaunchImage.png", "scale" : "1x" }, { - "idiom" : "universal", + "idiom" : "iphone", "filename" : "LaunchImage@2x.png", "scale" : "2x" }, { - "idiom" : "universal", + "idiom" : "iphone", "filename" : "LaunchImage@3x.png", "scale" : "3x" } diff --git a/ios/Runner/Assets.xcassets/LaunchImage.imageset/LaunchImage.png b/ios/Runner/Assets.xcassets/LaunchImage.imageset/LaunchImage.png index 9da19ea..1bac3fd 100644 Binary files a/ios/Runner/Assets.xcassets/LaunchImage.imageset/LaunchImage.png and b/ios/Runner/Assets.xcassets/LaunchImage.imageset/LaunchImage.png differ diff --git a/ios/Runner/Assets.xcassets/LaunchImage.imageset/LaunchImage@2x.png b/ios/Runner/Assets.xcassets/LaunchImage.imageset/LaunchImage@2x.png index 9da19ea..563cd6f 100644 Binary files a/ios/Runner/Assets.xcassets/LaunchImage.imageset/LaunchImage@2x.png and b/ios/Runner/Assets.xcassets/LaunchImage.imageset/LaunchImage@2x.png differ diff --git a/ios/Runner/Assets.xcassets/LaunchImage.imageset/LaunchImage@3x.png b/ios/Runner/Assets.xcassets/LaunchImage.imageset/LaunchImage@3x.png index 9da19ea..5d39323 100644 Binary files a/ios/Runner/Assets.xcassets/LaunchImage.imageset/LaunchImage@3x.png and b/ios/Runner/Assets.xcassets/LaunchImage.imageset/LaunchImage@3x.png differ diff --git a/lib/main.dart b/lib/main.dart index 851aba6..e31b20a 100644 --- a/lib/main.dart +++ b/lib/main.dart @@ -3,14 +3,17 @@ import 'package:flutter/cupertino.dart'; import 'package:flutter/foundation.dart'; import 'package:get/get.dart'; import 'package:catcher_2/catcher_2.dart'; +import 'package:flutter_dotenv/flutter_dotenv.dart'; import 'package:mom_kitchen/src/l10n/app_localizations.dart'; import 'package:mom_kitchen/src/config/app_routes.dart'; import 'package:mom_kitchen/src/services/core/app_service.dart'; +import 'package:mom_kitchen/src/services/api/api_service.dart'; import 'package:mom_kitchen/src/services/orientation_service.dart'; import 'package:mom_kitchen/src/services/ui/theme_service.dart'; import 'package:mom_kitchen/src/services/ui/toast_service.dart'; +import 'package:mom_kitchen/src/services/data/storage_service.dart'; import 'package:mom_kitchen/src/services/crash_guard_service.dart'; import 'package:mom_kitchen/src/app_binding.dart'; @@ -18,33 +21,98 @@ import 'package:mom_kitchen/src/app_binding.dart'; import 'package:mom_kitchen/src/utils/app_logger.dart'; // 2026-04-15 | main.dart | 应用入口 | Catcher2最先初始化+串行化启动流程防ANR +// 2026-04-15 | 增强防御:所有初始化步骤加超时+try-catch,任何一步失败都不阻断启动 +// 2026-04-15 | Trae IDE兼容:增强错误处理,防止调试环境闪退 void main() { - final crashGuard = CrashGuardService(); - Catcher2( - runAppFunction: () async { - WidgetsFlutterBinding.ensureInitialized(); + runZonedGuarded( + () { + final crashGuard = CrashGuardService(); + Catcher2( + runAppFunction: () async { + WidgetsFlutterBinding.ensureInitialized(); - await _initApp(); + PlatformDispatcher.instance.onError = (error, stack) { + debugPrint('🚨 PlatformDispatcher Error: $error'); + try { + Catcher2.reportCheckedError(error, stack); + } catch (_) { + debugPrint('❌ PlatformDispatcher report failed: $error'); + } + return true; + }; - runApp(const MyApp()); + FlutterError.onError = (details) { + FlutterError.presentError(details); + try { + Catcher2.reportCheckedError(details.exception, details.stack); + } catch (_) { + debugPrint( + '❌ FlutterError.onError catch failed: ${details.exception}', + ); + } + }; + + try { + await _initApp(); + } catch (e, st) { + debugPrint('🚨 _initApp failed: $e'); + debugPrint('Stack: $st'); + } + + runApp(const MyApp()); + }, + ensureInitialized: false, + debugConfig: crashGuard.buildDebugOptions(), + releaseConfig: crashGuard.buildReleaseOptions(), + ); + }, + (error, stack) { + debugPrint('🚨 Uncaught async error: $error'); + debugPrint('Stack: $stack'); }, - ensureInitialized: false, - debugConfig: crashGuard.buildDebugOptions(), - releaseConfig: crashGuard.buildReleaseOptions(), ); } +/// 启动初始化链 - 每一步都有独立超时和try-catch +/// 核心原则:任何一步失败都不阻断后续步骤,保证App至少能启动 Future _initApp() async { + // 0. 加载环境变量(含SMTP等敏感配置,不阻断启动) try { - await AppService.instance.init(); + await dotenv + .load(fileName: '.env') + .timeout( + const Duration(seconds: 3), + onTimeout: () => debugPrint('⚠️ .env加载超时(3s)'), + ); + } catch (e) { + debugPrint('⚠️ .env加载失败(使用默认空值): $e'); + } + + // 1. 核心服务初始化(最关键,但不阻断) + try { + await AppService.instance.init().timeout( + const Duration(seconds: 10), + onTimeout: () => debugPrint('❌ AppService初始化超时(10s)'), + ); } catch (e) { debugPrint('❌ AppService初始化失败: $e'); } + // 2. DNS预检(非关键,纯后台) try { - await CrashGuardService.init(); + await ApiService().preCheckDns().timeout( + const Duration(seconds: 5), + onTimeout: () => debugPrint('⚠️ DNS预检超时(5s)'), + ); } catch (e) { - debugPrint('⚠️ CrashGuardService初始化失败: $e'); + debugPrint('⚠️ DNS预检失败: $e'); + } + + // 3. CrashGuard初始化 + try { + await CrashGuardService.init().timeout(const Duration(seconds: 3)); + } catch (e) { + debugPrint('⚠️ CrashGuardService初始化失败或超时: $e'); } if (kDebugMode) { @@ -57,9 +125,16 @@ Future _initApp() async { class MyApp extends StatelessWidget { const MyApp({super.key}); + ThemeService _getThemeService() { + if (Get.isRegistered()) { + return Get.find(); + } + return Get.put(ThemeService.instance, permanent: true); + } + @override Widget build(BuildContext context) { - final themeService = Get.put(ThemeService.instance); + final themeService = _getThemeService(); return Obx(() { final textScale = themeService.fontSize.value / 16.0; @@ -110,36 +185,45 @@ class _InitWrapperState extends State<_InitWrapper> with WidgetsBindingObserver { bool _navigated = false; int _retryCount = 0; - static const int _maxRetries = 3; + static const int _maxRetries = 5; + String? _lastError; @override void initState() { super.initState(); WidgetsBinding.instance.addObserver(this); - _navigateToMain(); + WidgetsBinding.instance.addPostFrameCallback((_) { + _navigateToMain(); + }); } void _navigateToMain() { - WidgetsBinding.instance.addPostFrameCallback((_) { - if (!mounted || _navigated) return; - try { - ToastService.init(context); + if (!mounted || _navigated) return; + try { + ToastService.init(context); + final agreementAccepted = + StorageService().getBool('agreement_accepted') ?? false; + if (agreementAccepted) { Get.offAllNamed(AppRoutes.main); - _navigated = true; - debugPrint('✅ 导航到主页面成功'); - } catch (e) { - _retryCount++; - debugPrint('❌ 导航失败 (第$_retryCount次): $e'); - if (_retryCount < _maxRetries) { - Future.delayed(Duration(milliseconds: 500 * _retryCount), () { - if (mounted && !_navigated) _navigateToMain(); - }); - } else { - debugPrint('🚨 导航重试已达上限,显示错误页面'); - if (mounted) setState(() {}); - } + } else { + Get.offAllNamed(AppRoutes.guide); } - }); + _navigated = true; + debugPrint('✅ 导航到${agreementAccepted ? '主页面' : '引导页'}成功'); + } catch (e, st) { + _lastError = e.toString(); + _retryCount++; + debugPrint('❌ 导航失败 (第$_retryCount次): $e'); + debugPrint('Stack: $st'); + if (_retryCount < _maxRetries) { + Future.delayed(Duration(milliseconds: 300 * _retryCount), () { + if (mounted && !_navigated) _navigateToMain(); + }); + } else { + debugPrint('🚨 导航重试已达上限,显示错误页面'); + if (mounted) setState(() {}); + } + } } @override @@ -165,21 +249,69 @@ class _InitWrapperState extends State<_InitWrapper> return CupertinoPageScaffold( child: SafeArea( child: Center( - child: Column( - mainAxisSize: MainAxisSize.min, - children: [ - const Icon(CupertinoIcons.exclamationmark_triangle, size: 48), - const SizedBox(height: 16), - const Text('启动失败,请重试', style: TextStyle(fontSize: 16)), - const SizedBox(height: 24), - CupertinoButton.filled( - onPressed: () { - _retryCount = 0; - _navigateToMain(); - }, - child: const Text('重试'), - ), - ], + child: Padding( + padding: const EdgeInsets.all(24), + child: Column( + mainAxisSize: MainAxisSize.min, + children: [ + const Icon( + CupertinoIcons.exclamationmark_triangle, + size: 48, + color: CupertinoColors.systemRed, + ), + const SizedBox(height: 16), + const Text( + '启动失败,请重试', + style: TextStyle(fontSize: 18, fontWeight: FontWeight.w600), + ), + if (_lastError != null) ...[ + const SizedBox(height: 12), + Container( + padding: const EdgeInsets.all(12), + decoration: BoxDecoration( + color: CupertinoColors.systemGrey5, + borderRadius: BorderRadius.circular(8), + ), + child: Text( + _lastError!.length > 200 + ? '${_lastError!.substring(0, 200)}...' + : _lastError!, + style: const TextStyle( + fontSize: 12, + fontFamily: 'monospace', + ), + textAlign: TextAlign.center, + ), + ), + ], + const SizedBox(height: 24), + Row( + mainAxisSize: MainAxisSize.min, + children: [ + CupertinoButton( + onPressed: () { + _retryCount = 0; + _lastError = null; + setState(() {}); + _navigateToMain(); + }, + child: const Text('重试'), + ), + const SizedBox(width: 16), + CupertinoButton.filled( + onPressed: () { + _retryCount = 0; + _lastError = null; + _navigated = false; + setState(() {}); + _navigateToMain(); + }, + child: const Text('强制重启'), + ), + ], + ), + ], + ), ), ), ), diff --git a/lib/src/app_binding.dart b/lib/src/app_binding.dart index 90f9294..11fc3b3 100644 --- a/lib/src/app_binding.dart +++ b/lib/src/app_binding.dart @@ -48,7 +48,9 @@ class AppBinding extends Bindings { Get.lazyPut(() => AppService.instance.toast, fenix: true); // --- 主题与个性化(首屏必需) --- - Get.put(ThemeService.instance, permanent: true); + if (!Get.isRegistered()) { + Get.put(ThemeService.instance, permanent: true); + } Get.put(PersonalizationController(), permanent: true); // --- 首屏必需控制器(立即初始化,仅HomeController发网络请求) --- diff --git a/lib/src/config/app_routes.dart b/lib/src/config/app_routes.dart index 9178adb..78a255f 100644 --- a/lib/src/config/app_routes.dart +++ b/lib/src/config/app_routes.dart @@ -33,7 +33,7 @@ import 'package:mom_kitchen/src/pages/tools/duplicate_check_page.dart'; import 'package:mom_kitchen/src/pages/profile/data/stats_dashboard_page.dart'; import 'package:mom_kitchen/src/pages/tools/health/meal_time_recommend_page.dart'; import 'package:mom_kitchen/src/pages/tools/planning/meal_planner_page.dart'; -import 'package:mom_kitchen/src/pages/tools/ingredient_detail_page.dart'; +import 'package:mom_kitchen/src/pages/tools/ingredient_detail/ingredient_detail_page.dart'; import 'package:mom_kitchen/src/pages/tools/cooking/cooking_note_page.dart'; import 'package:mom_kitchen/src/pages/tools/cooking/cooking_mode_page.dart'; import 'package:mom_kitchen/src/pages/profile/data/data_center_page.dart'; @@ -48,7 +48,18 @@ import 'package:mom_kitchen/src/pages/tools/planning/daily_menu_page.dart'; import 'package:mom_kitchen/src/pages/profile/bedtime_reminder_page.dart'; import 'package:mom_kitchen/src/pages/tools/cooking_tips_list_page.dart'; import 'package:mom_kitchen/src/pages/profile/references_page.dart'; +import 'package:mom_kitchen/src/pages/profile/privacy_policy_page.dart'; +import 'package:mom_kitchen/src/pages/profile/guide_page.dart'; import 'package:mom_kitchen/src/pages/profile/social/email_history_page.dart'; +import 'package:mom_kitchen/src/pages/tools/cooking/date_calculator_page.dart'; +import 'package:mom_kitchen/src/pages/tools/cooking/food_copy_generator_page.dart'; +import 'package:mom_kitchen/src/pages/tools/health/safe_period_calculator_page.dart'; +import 'package:mom_kitchen/src/pages/tools/health/weight_manage_page.dart'; +import 'package:mom_kitchen/src/pages/tools/health/weight_binding.dart'; +import 'package:mom_kitchen/src/pages/tools/ranking/dish_ranking_page.dart'; +import 'package:mom_kitchen/src/pages/tools/ingredient_manage_page.dart'; +import 'package:mom_kitchen/src/pages/tools/tool_detail_page.dart'; +import 'package:mom_kitchen/src/pages/tools/cooking/order_assistant_page.dart'; import 'package:mom_kitchen/src/app_binding.dart'; class AppRoutes { @@ -100,9 +111,20 @@ class AppRoutes { static const String cookingTips = '/cooking-tips'; static const String about = '/about'; static const String references = '/references'; + static const String privacyPolicy = '/privacy-policy'; + static const String guide = '/guide'; static const String nutritionRecipeList = '/nutrition-recipe-list'; static const String miniCard = '/mini-card'; static const String emailHistory = '/email-history'; + static const String hot = '/hot'; + static const String dateCalculator = '/tools/date-calculator'; + static const String foodCopyGenerator = '/tools/food-copy'; + static const String safePeriodCalculator = '/tools/safe-period'; + static const String toolsWeightManage = '/tools/weight-manage'; + static const String toolsDishRanking = '/tools/dish-ranking'; + static const String toolsIngredientManage = '/tools/ingredient-manage'; + static const String toolDetail = '/tool-detail'; + static const String toolsOrderAssistant = '/tools/order-assistant'; static final List pages = [ GetPage( @@ -145,6 +167,16 @@ class AppRoutes { page: () => const AboutPage(), middlewares: [PageStandardsMiddleware()], ), + GetPage( + name: privacyPolicy, + page: () => const PrivacyPolicyPage(), + middlewares: [PageStandardsMiddleware()], + ), + GetPage( + name: guide, + page: () => const GuidePage(), + middlewares: [PageStandardsMiddleware()], + ), GetPage( name: main, page: () => const MainTabView(), @@ -156,7 +188,7 @@ class AppRoutes { middlewares: [PageStandardsMiddleware()], ), GetPage( - name: '/hot', + name: hot, page: () => const HotPage(), middlewares: [PageStandardsMiddleware()], ), @@ -272,6 +304,7 @@ class AppRoutes { GetPage( name: toolsIngredient, page: () => const IngredientDetailPage(), + binding: IngredientDetailBinding(), middlewares: [PageStandardsMiddleware()], ), GetPage( @@ -298,7 +331,7 @@ class AppRoutes { ), GetPage( name: toolsStats, - page: () => const HotPage(), + page: () => const StatsDashboardPage(), middlewares: [PageStandardsMiddleware()], ), GetPage( @@ -437,6 +470,47 @@ class AppRoutes { page: () => const EmailHistoryPage(), middlewares: [PageStandardsMiddleware()], ), + GetPage( + name: dateCalculator, + page: () => const DateCalculatorPage(), + middlewares: [PageStandardsMiddleware()], + ), + GetPage( + name: foodCopyGenerator, + page: () => const FoodCopyGeneratorPage(), + middlewares: [PageStandardsMiddleware()], + ), + GetPage( + name: safePeriodCalculator, + page: () => const SafePeriodCalculatorPage(), + middlewares: [PageStandardsMiddleware()], + ), + GetPage( + name: toolsWeightManage, + page: () => const WeightManagePage(), + binding: WeightBinding(), + middlewares: [PageStandardsMiddleware()], + ), + GetPage( + name: toolsDishRanking, + page: () => const DishRankingPage(), + middlewares: [PageStandardsMiddleware()], + ), + GetPage( + name: toolsIngredientManage, + page: () => const IngredientManagePage(), + middlewares: [PageStandardsMiddleware()], + ), + GetPage( + name: toolDetail, + page: () => const ToolDetailPage(), + middlewares: [PageStandardsMiddleware()], + ), + GetPage( + name: toolsOrderAssistant, + page: () => const OrderAssistantPage(), + middlewares: [PageStandardsMiddleware()], + ), ]; static void registerAllPages() { @@ -559,7 +633,7 @@ class AppRoutes { builder: () => const WhatToEatPage(), ), PageInfo( - route: '/hot', + route: hot, name: 'Hot Page', description: '��������ҳ��', requiredStandards: const [ @@ -1028,6 +1102,66 @@ class AppRoutes { ], builder: () => const EmailHistoryPage(), ), + PageInfo( + route: dateCalculator, + name: 'Date Calculator Page', + description: '日期加减计算器页面', + requiredStandards: const [ + StandardCheck.themeColors, + StandardCheck.textColors, + StandardCheck.fontSize, + StandardCheck.darkMode, + ], + builder: () => const DateCalculatorPage(), + ), + PageInfo( + route: foodCopyGenerator, + name: 'Food Copy Generator Page', + description: '吃货文案生成器页面', + requiredStandards: const [ + StandardCheck.themeColors, + StandardCheck.textColors, + StandardCheck.fontSize, + StandardCheck.darkMode, + ], + builder: () => const FoodCopyGeneratorPage(), + ), + PageInfo( + route: miniCard, + name: 'Mini Card Page', + description: '迷你卡片页面', + requiredStandards: const [ + StandardCheck.themeColors, + StandardCheck.textColors, + StandardCheck.fontSize, + StandardCheck.darkMode, + ], + builder: () => MiniCardPage(initialRecipeId: null), + ), + PageInfo( + route: toolsWeightManage, + name: 'Weight Manage Page', + description: '体重管理页面', + requiredStandards: const [ + StandardCheck.themeColors, + StandardCheck.textColors, + StandardCheck.fontSize, + StandardCheck.darkMode, + ], + builder: () => const WeightManagePage(), + ), + PageInfo( + route: toolsIngredientManage, + name: 'Ingredient Manage Page', + description: '用料管理页面', + requiredStandards: const [ + StandardCheck.themeColors, + StandardCheck.textColors, + StandardCheck.fontSize, + StandardCheck.darkMode, + ], + builder: () => const IngredientManagePage(), + ), ]); } } diff --git a/lib/src/controllers/data/browse_history_controller.dart b/lib/src/controllers/data/browse_history_controller.dart index bac2e87..b4c7ecb 100644 --- a/lib/src/controllers/data/browse_history_controller.dart +++ b/lib/src/controllers/data/browse_history_controller.dart @@ -34,9 +34,7 @@ class BrowseHistoryController extends GetxController { Future loadHistory() async { try { - if (_prefs == null) { - _prefs = await SharedPreferences.getInstance(); - } + _prefs ??= await SharedPreferences.getInstance(); final String? data = _prefs!.getString(_storageKey); if (data != null && data.isNotEmpty) { final List jsonList = json.decode(data); diff --git a/lib/src/controllers/data/cooking_note_controller.dart b/lib/src/controllers/data/cooking_note_controller.dart index 6d62d7f..d5bcd8c 100644 --- a/lib/src/controllers/data/cooking_note_controller.dart +++ b/lib/src/controllers/data/cooking_note_controller.dart @@ -51,10 +51,7 @@ class CookingNoteController extends GetxController { } } - // Hive为空或未初始化,从SharedPreferences加载 - if (_prefs == null) { - _prefs = await SharedPreferences.getInstance(); - } + _prefs ??= await SharedPreferences.getInstance(); final String? data = _prefs!.getString(_sharedPrefsKey); if (data != null && data.isNotEmpty) { final List jsonList = json.decode(data); @@ -147,9 +144,7 @@ class CookingNoteController extends GetxController { /// 保存到SharedPreferences Future _saveToSharedPreferences() async { try { - if (_prefs == null) { - _prefs = await SharedPreferences.getInstance(); - } + _prefs ??= await SharedPreferences.getInstance(); final data = json.encode(_notes.map((n) => n.toJson()).toList()); await _prefs!.setString(_sharedPrefsKey, data); debugPrint('笔记已保存到SharedPreferences: ${_notes.length}条'); diff --git a/lib/src/controllers/data/email_history_controller.dart b/lib/src/controllers/data/email_history_controller.dart index a8722ce..128d150 100644 --- a/lib/src/controllers/data/email_history_controller.dart +++ b/lib/src/controllers/data/email_history_controller.dart @@ -45,14 +45,13 @@ class EmailHistoryController extends GetxController { /// 从本地加载记录 Future loadRecords() async { try { - if (_prefs == null) { - _prefs = await SharedPreferences.getInstance(); - } + _prefs ??= await SharedPreferences.getInstance(); final String? data = _prefs!.getString(_storageKey); if (data != null && data.isNotEmpty) { final List jsonList = json.decode(data); - final loaded = - jsonList.map((json) => EmailRecordModel.fromJson(json)).toList(); + final loaded = jsonList + .map((json) => EmailRecordModel.fromJson(json)) + .toList(); _records.assignAll(loaded); debugPrint('从本地加载 ${loaded.length} 条邮件记录'); } @@ -117,9 +116,7 @@ class EmailHistoryController extends GetxController { /// 保存到本地 Future _saveRecords() async { try { - if (_prefs == null) { - _prefs = await SharedPreferences.getInstance(); - } + _prefs ??= await SharedPreferences.getInstance(); final data = json.encode(_records.map((r) => r.toJson()).toList()); await _prefs!.setString(_storageKey, data); } catch (e) { diff --git a/lib/src/controllers/data/favorites_controller.dart b/lib/src/controllers/data/favorites_controller.dart index 965f046..3f1850d 100644 --- a/lib/src/controllers/data/favorites_controller.dart +++ b/lib/src/controllers/data/favorites_controller.dart @@ -1,5 +1,7 @@ // 2026-04-09 | FavoritesController | 收藏控制器 | 统一管理收藏状态,支持Hive持久化 // 2026-04-09 | 新增排序、分类筛选、批量删除功能 +// 2026-04-16 | 新增搜索功能、统计信息、导出功能 +import 'dart:convert'; import 'package:get/get.dart'; import 'package:mom_kitchen/src/controllers/base_controller.dart'; import 'package:mom_kitchen/src/models/feed_item_model.dart'; @@ -15,12 +17,14 @@ class FavoritesController extends BaseController { final Rx selectedFavoriteType = Rx(null); final RxSet selectedIds = {}.obs; final RxBool isEditMode = false.obs; + final RxString searchQuery = ''.obs; List get favorites => _getSortedFavorites(); List get allFavorites => _favorites.values.toList(); int get count => _favorites.length; int get selectedCount => selectedIds.length; bool get hasSelection => selectedIds.isNotEmpty; + bool get isSearching => searchQuery.value.isNotEmpty; List get categories { final cats = {'all'}; @@ -94,6 +98,18 @@ class FavoritesController extends BaseController { List _getSortedFavorites() { var items = _favorites.values.toList(); + if (searchQuery.value.isNotEmpty) { + final query = searchQuery.value.toLowerCase(); + items = items.where((e) { + final title = e.title.toLowerCase(); + final intro = (e.intro ?? '').toLowerCase(); + final category = (e.categoryName ?? '').toLowerCase(); + return title.contains(query) || + intro.contains(query) || + category.contains(query); + }).toList(); + } + if (selectedFavoriteType.value != null) { items = items .where((e) => e.favoriteType == selectedFavoriteType.value) @@ -123,6 +139,66 @@ class FavoritesController extends BaseController { return items; } + void setSearchQuery(String query) { + searchQuery.value = query; + _favorites.refresh(); + } + + void clearSearch() { + searchQuery.value = ''; + _favorites.refresh(); + } + + Map get statistics { + final stats = { + 'total': _favorites.length, + 'recipe': 0, + 'miniCard': 0, + 'ingredient': 0, + 'tag': 0, + }; + for (final item in _favorites.values) { + switch (item.favoriteType) { + case FavoriteType.recipe: + stats['recipe'] = (stats['recipe'] ?? 0) + 1; + break; + case FavoriteType.miniCard: + stats['miniCard'] = (stats['miniCard'] ?? 0) + 1; + break; + case FavoriteType.ingredient: + stats['ingredient'] = (stats['ingredient'] ?? 0) + 1; + break; + case FavoriteType.tag: + stats['tag'] = (stats['tag'] ?? 0) + 1; + break; + } + } + return stats; + } + + String exportToJson() { + final data = _favorites.values.map((e) => _itemToMap(e)).toList(); + return const JsonEncoder.withIndent(' ').convert(data); + } + + String exportToCsv() { + final buffer = StringBuffer(); + buffer.writeln('ID,标题,简介,分类,类型,收藏时间'); + for (final item in _favorites.values) { + buffer.writeln( + [ + item.id, + '"${item.title.replaceAll('"', '""')}"', + '"${(item.intro ?? '').replaceAll('"', '""')}"', + item.categoryName ?? '', + item.favoriteType.name, + item.createdAt ?? '', + ].join(','), + ); + } + return buffer.toString(); + } + void setSortMode(FavoritesSortMode mode) { sortMode.value = mode; _favorites.refresh(); @@ -221,6 +297,7 @@ class FavoritesController extends BaseController { 'title': item.title, 'intro': item.intro, 'cover': item.cover, + 'pic_id': item.picId, 'categoryName': item.categoryName, 'categoryId': item.categoryId, 'feedType': item.feedType, diff --git a/lib/src/controllers/feed/action_controller.dart b/lib/src/controllers/feed/action_controller.dart index fcbb0b4..d73bb85 100644 --- a/lib/src/controllers/feed/action_controller.dart +++ b/lib/src/controllers/feed/action_controller.dart @@ -1,5 +1,6 @@ // 2026-04-09 | ActionController | 互动操作控制器 | 管理点赞/评分/浏览量上报 // 2026-04-12 | API v3.2.0: recommend改为rate评分接口(1-5分) +import 'dart:async'; import 'package:get/get.dart'; import 'package:mom_kitchen/src/controllers/base_controller.dart'; import 'package:mom_kitchen/src/repositories/action_repository.dart'; @@ -15,12 +16,15 @@ class ActionController extends BaseController { @override void onInit() { super.onInit(); - Future.delayed(const Duration(milliseconds: 500), () => _loadIpStatus()); + Future.delayed(const Duration(milliseconds: 1000), () => _loadIpStatus()); } Future _loadIpStatus() async { try { - ipStatus.value = await _actionRepository.fetchIpStatus(); + ipStatus.value = await _actionRepository.fetchIpStatus().timeout( + const Duration(seconds: 5), + onTimeout: () => throw TimeoutException('fetchIpStatus timeout'), + ); } catch (_) {} } diff --git a/lib/src/controllers/home/home_controller.dart b/lib/src/controllers/home/home_controller.dart index cb3a22d..917b026 100644 --- a/lib/src/controllers/home/home_controller.dart +++ b/lib/src/controllers/home/home_controller.dart @@ -21,7 +21,7 @@ class HomeController extends BaseController { void onInit() { super.onInit(); Future.delayed( - Duration.zero, + const Duration(milliseconds: 300), () => _loadInitialData().catchError((e, stackTrace) { debugPrint('HomeController init error: $e'); }), @@ -30,7 +30,14 @@ class HomeController extends BaseController { Future _loadInitialData() async { await runWithLoading(() async { - await Future.wait([_loadRecipes(), _loadCategories()]); + await _loadCategories().timeout( + const Duration(seconds: 8), + onTimeout: () => debugPrint('HomeController: loadCategories timeout'), + ); + await _loadRecipes().timeout( + const Duration(seconds: 8), + onTimeout: () => debugPrint('HomeController: loadRecipes timeout'), + ); }); } diff --git a/lib/src/controllers/ingredient_manage_controller.dart b/lib/src/controllers/ingredient_manage_controller.dart new file mode 100644 index 0000000..c8a5875 --- /dev/null +++ b/lib/src/controllers/ingredient_manage_controller.dart @@ -0,0 +1,142 @@ +/* + * 文件: ingredient_manage_controller.dart + * 名称: 用料管理控制器 + * 作用: 管理用料瓶子的增删改查和持久化 + * 创建: 2026-04-16 + * 更新: 2026-04-16 初始创建 + */ + +import 'dart:convert'; +import 'package:get/get.dart'; +import 'package:shared_preferences/shared_preferences.dart'; +import 'package:mom_kitchen/src/models/bottle_model.dart'; + +class IngredientManageController extends GetxController { + static const String _storageKey = 'bottles_data'; + + final bottles = [].obs; + final selectedType = BottleType.ingredient.obs; + final isLoading = false.obs; + + @override + void onInit() { + super.onInit(); + _loadBottles(); + } + + /// 加载瓶子数据 + Future _loadBottles() async { + isLoading.value = true; + try { + final prefs = await SharedPreferences.getInstance(); + final data = prefs.getString(_storageKey); + if (data != null && data.isNotEmpty) { + final List jsonList = jsonDecode(data) as List; + bottles.value = jsonList + .map((e) => BottleModel.fromJson(e as Map)) + .toList(); + } + } catch (e) { + Get.snackbar('加载失败', '无法加载瓶子数据: $e'); + } finally { + isLoading.value = false; + } + } + + /// 保存瓶子数据 + Future _saveBottles() async { + try { + final prefs = await SharedPreferences.getInstance(); + final data = jsonEncode(bottles.map((e) => e.toJson()).toList()); + await prefs.setString(_storageKey, data); + } catch (e) { + Get.snackbar('保存失败', '无法保存瓶子数据: $e'); + } + } + + /// 添加瓶子 + Future addBottle(BottleModel bottle) async { + bottles.add(bottle); + await _saveBottles(); + } + + /// 删除瓶子 + Future deleteBottle(String id) async { + bottles.removeWhere((b) => b.id == id); + await _saveBottles(); + } + + /// 更新瓶子 + Future updateBottle(BottleModel bottle) async { + final index = bottles.indexWhere((b) => b.id == bottle.id); + if (index != -1) { + bottles[index] = bottle; + await _saveBottles(); + } + } + + /// 增加容量 + Future increaseAmount(String id, double amount) async { + final index = bottles.indexWhere((b) => b.id == id); + if (index != -1) { + final bottle = bottles[index]; + final newAmount = (bottle.currentAmount + amount).clamp(0.0, bottle.capacity); + bottles[index] = bottle.copyWith( + currentAmount: newAmount, + updatedAt: DateTime.now(), + ); + await _saveBottles(); + } + } + + /// 减少容量 + Future decreaseAmount(String id, double amount) async { + final index = bottles.indexWhere((b) => b.id == id); + if (index != -1) { + final bottle = bottles[index]; + final newAmount = (bottle.currentAmount - amount).clamp(0.0, bottle.capacity); + bottles[index] = bottle.copyWith( + currentAmount: newAmount, + updatedAt: DateTime.now(), + ); + await _saveBottles(); + } + } + + /// 设置容量 + Future setAmount(String id, double amount) async { + final index = bottles.indexWhere((b) => b.id == id); + if (index != -1) { + final bottle = bottles[index]; + bottles[index] = bottle.copyWith( + currentAmount: amount.clamp(0.0, bottle.capacity), + updatedAt: DateTime.now(), + ); + await _saveBottles(); + } + } + + /// 按类型筛选 + List getFilteredBottles() { + if (selectedType.value == BottleType.ingredient) { + return bottles.toList(); + } + return bottles.where((b) => b.type == selectedType.value).toList(); + } + + /// 切换类型筛选 + void setSelectedType(BottleType type) { + selectedType.value = type; + } + + /// 获取指定类型的瓶子 + List getBottlesByType(BottleType type) { + return bottles.where((b) => b.type == type).toList(); + } + + /// 清空所有瓶子 + Future clearAll() async { + bottles.clear(); + await _saveBottles(); + } +} diff --git a/lib/src/controllers/recipe/recipe_detail_controller.dart b/lib/src/controllers/recipe/recipe_detail_controller.dart index 7776df0..3c49723 100644 --- a/lib/src/controllers/recipe/recipe_detail_controller.dart +++ b/lib/src/controllers/recipe/recipe_detail_controller.dart @@ -30,6 +30,7 @@ class RecipeDetailController extends BaseController { final RxInt viewCount = 0.obs; final RxBool loadTimeout = false.obs; final RxInt rateRemaining = (-1).obs; + final RxString dataSource = ''.obs; static const Duration _loadTimeoutDuration = Duration(seconds: 8); @@ -56,6 +57,9 @@ class RecipeDetailController extends BaseController { final loadedRecipe = await _recipeRepository.fetchFull( id, viewnums: true, + onSourceDetected: (source) { + dataSource.value = source; + }, ); debugPrint( @@ -90,6 +94,10 @@ class RecipeDetailController extends BaseController { final loadedRecipe = await _recipeRepository.fetchFull( id, viewnums: false, + refresh: true, + onSourceDetected: (source) { + dataSource.value = source; + }, ); recipe.value = loadedRecipe; @@ -100,6 +108,27 @@ class RecipeDetailController extends BaseController { }); } + Future forceRefreshFromApi(String recipeId) async { + await runWithLoading(() async { + final id = int.tryParse(recipeId) ?? 0; + final loadedRecipe = await _recipeRepository.fetchFull( + id, + viewnums: true, + refresh: true, + onSourceDetected: (source) { + dataSource.value = source; + }, + ); + + recipe.value = loadedRecipe; + likeCount.value = loadedRecipe.statistics?.likes ?? 0; + viewCount.value = loadedRecipe.statistics?.views ?? 0; + await _checkFavorite(); + _recordView(); + ToastService.show(message: '✅ 已从API刷新'); + }); + } + Future _checkFavorite() async { if (recipe.value != null) { try { @@ -293,9 +322,6 @@ class RecipeDetailController extends BaseController { shareText.writeln(''); shareText.write('— 来自 小妈厨房 App 🍳'); - AppUtils.shareContent( - shareText.toString(), - subject: '🍳 ${r.title}', - ); + AppUtils.shareContent(shareText.toString(), subject: '🍳 ${r.title}'); } } diff --git a/lib/src/controllers/recipe/search_controller.dart b/lib/src/controllers/recipe/search_controller.dart index 88c3b34..48a9772 100644 --- a/lib/src/controllers/recipe/search_controller.dart +++ b/lib/src/controllers/recipe/search_controller.dart @@ -7,6 +7,7 @@ */ import 'package:flutter/foundation.dart'; +import 'package:flutter/widgets.dart'; import 'package:get/get.dart'; import 'package:mom_kitchen/src/controllers/base_controller.dart'; import 'package:mom_kitchen/src/models/recipe/recipe_model.dart'; @@ -142,18 +143,21 @@ class SearchController extends BaseController { Future search(String keyword) async { if (keyword.trim().isEmpty) return; - searchQuery.value = keyword.trim(); - isLoading.value = true; - resultTabIndex.value = 0; + final trimmed = keyword.trim(); + _addToHistory(trimmed); - _addToHistory(keyword); + WidgetsBinding.instance.addPostFrameCallback((_) { + searchQuery.value = trimmed; + isLoading.value = true; + resultTabIndex.value = 0; + }); try { final response = await _apiService.get( ApiConfig.filter, queryParameters: { 'act': 'global_search', - 'keyword': searchQuery.value, + 'keyword': trimmed, 'type': 'all', 'limit': 20, }, diff --git a/lib/src/controllers/tools/ingredient_detail_controller.dart b/lib/src/controllers/tools/ingredient_detail_controller.dart new file mode 100644 index 0000000..308ad5e --- /dev/null +++ b/lib/src/controllers/tools/ingredient_detail_controller.dart @@ -0,0 +1,343 @@ +/* + * 文件: ingredient_detail_controller.dart + * 名称: 食材详情控制器 + * 作用: 管理食材详情页的状态和数据加载逻辑 + * 创建: 2026-04-16 + * 更新: 2026-04-16 从ingredient_detail_page.dart拆分,StatefulWidget逻辑迁移至GetX Controller + */ + +import 'package:flutter/foundation.dart'; +import 'package:get/get.dart'; +import 'package:mom_kitchen/src/controllers/base_controller.dart'; +import 'package:mom_kitchen/src/models/recipe/ingredient_model.dart'; +import 'package:mom_kitchen/src/models/recipe/recipe_model.dart'; +import 'package:mom_kitchen/src/repositories/recipe_repository.dart'; +import 'package:mom_kitchen/src/services/data/ingredient_cache_service.dart'; +import 'package:mom_kitchen/src/pages/tools/ingredient_detail/ingredient_detail_utils.dart'; + +class IngredientDetailController extends BaseController { + final RecipeRepository _recipeRepository = RecipeRepository(); + final IngredientCacheService _cacheService = IngredientCacheService(); + + final RxList> ingredients = >[].obs; + final RxList> filteredIngredients = + >[].obs; + final Rx?> selectedIngredient = + Rx?>(null); + final Rx passedDetail = Rx(null); + final Rx ingredientDetail = Rx(null); + final RxBool isLoadingDetail = false.obs; + final RxString searchQuery = ''.obs; + final RxList similarRecipes = [].obs; + final RxBool isLoadingSimilar = false.obs; + + bool ingredientsLoaded = false; + int _detailRequestToken = 0; + int _similarRequestToken = 0; + + @override + void onInit() { + super.onInit(); + _initData(); + } + + Future _initData() async { + final args = Get.arguments; + + String? ingredientName; + int? ingredientId; + IngredientDetail? passedDetailValue; + + if (args is String) { + ingredientName = args; + } else if (args is Map) { + ingredientName = args['name'] as String?; + ingredientId = args['id'] as int?; + final detailMap = args['detail']; + if (detailMap is IngredientDetail) { + passedDetailValue = detailMap; + } else if (detailMap is Map) { + try { + passedDetailValue = IngredientDetail.fromJson(detailMap); + } catch (e) { + debugPrint('Failed to parse IngredientDetail: $e'); + } + } + } + + if (ingredientName != null && ingredientName.isNotEmpty) { + passedDetail.value = passedDetailValue; + selectedIngredient.value = { + 'id': ingredientId, + 'name': ingredientName, + 'count': 0, + 'category': IngredientDetailUtils.getCategory(ingredientName), + }; + isLoadingDetail.value = true; + + if (!_cacheService.isInitialized) { + await _cacheService.init(); + } + + _loadIngredientsInBackground(); + + if (ingredientId != null && ingredientId > 0) { + _loadDetailDirectly(ingredientName, ingredientId); + } else { + _loadIngredientByName(ingredientName, ingredientId); + } + } else { + if (!_cacheService.isInitialized) { + await _cacheService.init(); + } + await _loadIngredients(); + ingredientsLoaded = true; + } + } + + Future _loadIngredientsInBackground() async { + await _loadIngredients(); + ingredientsLoaded = true; + } + + Future _loadDetailDirectly(String name, int id) async { + debugPrint( + 'IngredientDetailController: _loadDetailDirectly name=$name, id=$id', + ); + + final cached = _cacheService.getById(id); + debugPrint( + 'IngredientDetailController: cache ${cached != null ? "hit" : "miss"}', + ); + + if (cached != null) { + debugPrint( + 'IngredientDetailController: from cache id=$id, name=${cached.name}', + ); + selectedIngredient.value = { + 'id': id, + 'name': cached.name, + 'count': cached.effectiveRecipeCount, + 'category': IngredientDetailUtils.getCategory(cached.name), + }; + ingredientDetail.value = cached; + isLoadingDetail.value = false; + _loadSimilarRecipes(cached.name); + return; + } + + debugPrint('IngredientDetailController: cache miss, requesting API id=$id'); + final token = ++_detailRequestToken; + try { + final detail = await _recipeRepository.fetchIngredientDetail(id); + if (token == _detailRequestToken) { + await _cacheService.save(detail); + debugPrint( + 'IngredientDetailController: cached id=$id, name=${detail.name}', + ); + selectedIngredient.value = { + 'id': id, + 'name': detail.name, + 'count': detail.effectiveRecipeCount, + 'category': IngredientDetailUtils.getCategory(detail.name), + }; + ingredientDetail.value = detail; + isLoadingDetail.value = false; + } + } catch (e) { + debugPrint('IngredientDetailController: load detail failed: $e'); + if (token == _detailRequestToken) { + isLoadingDetail.value = false; + } + } + + _loadSimilarRecipes(name); + } + + Future _loadIngredientByName(String name, int? id) async { + debugPrint( + 'IngredientDetailController: _loadIngredientByName name=$name, id=$id', + ); + + final cached = _cacheService.getByName(name); + if (cached != null) { + debugPrint( + 'IngredientDetailController: from cache name=$name, id=${cached.id}', + ); + selectedIngredient.value = { + 'id': cached.id, + 'name': cached.name, + 'count': cached.effectiveRecipeCount, + 'category': IngredientDetailUtils.getCategory(cached.name), + }; + ingredientDetail.value = cached; + isLoadingDetail.value = false; + _loadSimilarRecipes(cached.name); + return; + } + + var resolvedId = id; + + if (resolvedId == null || resolvedId <= 0) { + if (!ingredientsLoaded) { + debugPrint( + 'IngredientDetailController: waiting for ingredients list...', + ); + await _loadIngredients(); + ingredientsLoaded = true; + } + + final ingredient = ingredients.firstWhere( + (ing) => ing['name'] == name, + orElse: () => { + 'id': null, + 'name': name, + 'count': 0, + 'category': IngredientDetailUtils.getCategory(name), + }, + ); + resolvedId = ingredient['id'] as int?; + } + + if (resolvedId == null || resolvedId <= 0) { + debugPrint('IngredientDetailController: searching API for name=$name'); + try { + final searchResult = await _recipeRepository.searchIngredientByName( + name, + ); + if (searchResult != null && searchResult.id > 0) { + resolvedId = searchResult.id; + debugPrint( + 'IngredientDetailController: API found id=$resolvedId, name=${searchResult.name}', + ); + } else { + debugPrint('IngredientDetailController: API not found name=$name'); + } + } catch (e) { + debugPrint('IngredientDetailController: search API error: $e'); + } + } + + debugPrint( + 'IngredientDetailController: resolved name=$name, id=$resolvedId', + ); + + if (resolvedId != null && resolvedId > 0) { + _loadIngredientDetailApi(resolvedId); + } else { + debugPrint('IngredientDetailController: invalid resolvedId: $resolvedId'); + isLoadingDetail.value = false; + } + + _loadSimilarRecipes(name); + } + + Future _loadSimilarRecipes(String ingredientName) async { + final token = ++_similarRequestToken; + isLoadingSimilar.value = true; + + try { + final result = await _recipeRepository.search(ingredientName, limit: 6); + if (token == _similarRequestToken) { + similarRecipes.value = result.items; + isLoadingSimilar.value = false; + } + } catch (e) { + debugPrint('Load similar recipes error: $e'); + if (token == _similarRequestToken) { + isLoadingSimilar.value = false; + } + } + } + + Future _loadIngredientDetailApi(int id) async { + debugPrint('IngredientDetailController: _loadIngredientDetailApi id=$id'); + + final cached = _cacheService.getById(id); + if (cached != null) { + debugPrint('IngredientDetailController: from cache id=$id'); + selectedIngredient.value = { + 'id': id, + 'name': cached.name, + 'count': cached.effectiveRecipeCount, + 'category': IngredientDetailUtils.getCategory(cached.name), + }; + ingredientDetail.value = cached; + isLoadingDetail.value = false; + return; + } + + final token = ++_detailRequestToken; + try { + final detail = await _recipeRepository.fetchIngredientDetail(id); + debugPrint( + 'IngredientDetailController: API returned id=${detail.id}, name=${detail.name}', + ); + await _cacheService.save(detail); + debugPrint('IngredientDetailController: cached id=$id'); + if (token == _detailRequestToken) { + selectedIngredient.value = { + 'id': id, + 'name': detail.name, + 'count': detail.effectiveRecipeCount, + 'category': IngredientDetailUtils.getCategory(detail.name), + }; + ingredientDetail.value = detail; + isLoadingDetail.value = false; + } + } catch (e) { + debugPrint('IngredientDetailController: load detail error: $e'); + if (token == _detailRequestToken) { + isLoadingDetail.value = false; + } + } + } + + Future _loadIngredients() async { + isLoading.value = true; + + try { + final tags = await _recipeRepository.fetchTags(); + ingredients.value = tags.map((t) { + return { + 'id': t.id, + 'name': t.name, + 'count': t.count ?? 0, + 'category': IngredientDetailUtils.getCategory(t.name), + }; + }).toList(); + filteredIngredients.value = ingredients.take(50).toList(); + isLoading.value = false; + } catch (e) { + debugPrint('Load ingredients error: $e'); + isLoading.value = false; + } + } + + void filterIngredients(String query) { + searchQuery.value = query; + if (query.isEmpty) { + filteredIngredients.value = ingredients.take(50).toList(); + } else { + filteredIngredients.value = ingredients.where((ing) { + final name = (ing['name'] as String? ?? '').toLowerCase(); + return name.contains(query.toLowerCase()); + }).toList(); + } + } + + void selectIngredient(Map ingredient) { + selectedIngredient.value = ingredient; + } + + Future navigateToIngredient(String name) async { + selectedIngredient.value = { + 'id': null, + 'name': name, + 'count': 0, + 'category': IngredientDetailUtils.getCategory(name), + }; + isLoadingDetail.value = true; + _loadIngredientByName(name, null); + } +} diff --git a/lib/src/controllers/tools/order_assistant_controller.dart b/lib/src/controllers/tools/order_assistant_controller.dart new file mode 100644 index 0000000..29882e9 --- /dev/null +++ b/lib/src/controllers/tools/order_assistant_controller.dart @@ -0,0 +1,280 @@ +/* + * 文件: order_assistant_controller.dart + * 名称: 点餐助手控制器 + * 作用: 管理点单状态、SharedPreferences持久化、记录条数统计 + * 创建时间: 2026-04-17 + * 更新时间: 2026-04-17 新增clearAllData、setPeopleCount、closeOrder方法 + */ + +import 'package:flutter/foundation.dart'; +import 'package:get/get.dart'; +import 'package:shared_preferences/shared_preferences.dart'; +import 'dart:convert'; +import 'package:uuid/uuid.dart'; +import 'package:mom_kitchen/src/models/tools/order_model.dart'; +import 'package:mom_kitchen/src/services/tools/order_api_service.dart'; + +class OrderAssistantController extends GetxController { + static OrderAssistantController get to => Get.find(); + + final RxList _history = [].obs; + final Rx _currentOrder = Rx(null); + final RxInt _recordCount = 0.obs; + final Rx _orderType = OrderType.userOrder.obs; + + static const String _storageKey = 'order_assistant_records'; + static const String _countKey = 'order_assistant_count'; + static const int _maxRecordCount = 100; + + List get history => _history; + Order? get currentOrder => _currentOrder.value; + int get recordCount => _recordCount.value; + OrderType get orderType => _orderType.value; + + final OrderApiService _apiService = OrderApiService(); + SharedPreferences? _prefs; + + @override + void onInit() { + super.onInit(); + _initPrefs(); + } + + Future _initPrefs() async { + try { + _prefs = await SharedPreferences.getInstance(); + await _loadHistory(); + _recordCount.value = _prefs?.getInt(_countKey) ?? 0; + debugPrint('点餐助手初始化完成,共 ${_history.length} 条历史记录'); + } catch (e) { + debugPrint('初始化SharedPreferences失败: $e'); + } + } + + Future _loadHistory() async { + try { + _prefs ??= await SharedPreferences.getInstance(); + final String? data = _prefs!.getString(_storageKey); + if (data != null && data.isNotEmpty) { + final List jsonList = json.decode(data); + final loaded = jsonList.map((j) => Order.fromJson(j)).toList(); + _history.assignAll(loaded); + } + } catch (e) { + debugPrint('加载点单历史失败: $e'); + } + } + + Future _saveHistory() async { + try { + _prefs ??= await SharedPreferences.getInstance(); + final data = json.encode(_history.map((o) => o.toJson()).toList()); + await _prefs!.setString(_storageKey, data); + } catch (e) { + debugPrint('保存点单历史失败: $e'); + } + } + + Future _incrementRecordCount() async { + _recordCount.value++; + _prefs ??= await SharedPreferences.getInstance(); + await _prefs!.setInt(_countKey, _recordCount.value); + } + + void setOrderType(OrderType type) { + _orderType.value = type; + } + + void createNewOrder() { + final now = DateTime.now(); + const uuid = Uuid(); + _currentOrder.value = Order( + id: uuid.v4(), + orderNo: Order.generateOrderNo(), + type: _orderType.value, + status: OrderStatus.draft, + items: [], + createdAt: now.toIso8601String(), + updatedAt: now.toIso8601String(), + createdBy: 'local_user', + recordCount: _recordCount.value, + ); + } + + void addItem(OrderItem item) { + final order = _currentOrder.value; + if (order == null) return; + final updatedItems = [...order.items, item]; + _currentOrder.value = order.copyWith( + items: updatedItems, + updatedAt: DateTime.now().toIso8601String(), + ); + } + + void removeItem(String itemId) { + final order = _currentOrder.value; + if (order == null) return; + final updatedItems = order.items.where((i) => i.id != itemId).toList(); + _currentOrder.value = order.copyWith( + items: updatedItems, + updatedAt: DateTime.now().toIso8601String(), + ); + } + + void updateItemQuantity(String itemId, int quantity) { + final order = _currentOrder.value; + if (order == null) return; + final updatedItems = order.items.map((i) { + if (i.id == itemId) { + return i.copyWith(quantity: quantity.clamp(1, 99)); + } + return i; + }).toList(); + _currentOrder.value = order.copyWith( + items: updatedItems, + updatedAt: DateTime.now().toIso8601String(), + ); + } + + void updateItemPrice(String itemId, double? price) { + final order = _currentOrder.value; + if (order == null) return; + final updatedItems = order.items.map((i) { + if (i.id == itemId) { + return i.copyWith(price: price); + } + return i; + }).toList(); + _currentOrder.value = order.copyWith( + items: updatedItems, + updatedAt: DateTime.now().toIso8601String(), + ); + } + + void setTableNo(String? tableNo) { + final order = _currentOrder.value; + if (order == null) return; + _currentOrder.value = order.copyWith(tableNo: tableNo); + } + + void setPeopleCount(int? count) { + final order = _currentOrder.value; + if (order == null) return; + _currentOrder.value = order.copyWith(peopleCount: count); + } + + void setNote(String? note) { + final order = _currentOrder.value; + if (order == null) return; + _currentOrder.value = order.copyWith(note: note); + } + + Future closeOrder() async { + final order = _currentOrder.value; + if (order == null) return; + final closedOrder = order.copyWith( + status: OrderStatus.cancelled, + updatedAt: DateTime.now().toIso8601String(), + ); + _currentOrder.value = closedOrder; + try { + await _apiService.updateOrder(closedOrder); + } catch (e) { + debugPrint('API关闭点单失败: $e'); + } + _history.removeWhere((o) => o.id == order.id); + _history.insert(0, closedOrder); + if (_history.length > _maxRecordCount) { + _history.removeRange(_maxRecordCount, _history.length); + } + await _saveHistory(); + } + + Future activateOrder() async { + final order = _currentOrder.value; + if (order == null || order.items.isEmpty) return; + + final activatedOrder = order.copyWith( + status: OrderStatus.active, + updatedAt: DateTime.now().toIso8601String(), + ); + _currentOrder.value = activatedOrder; + + try { + await _apiService.createOrder(activatedOrder); + } catch (e) { + debugPrint('API创建点单失败(本地已保存): $e'); + } + + _history.insert(0, activatedOrder); + if (_history.length > _maxRecordCount) { + _history.removeRange(_maxRecordCount, _history.length); + } + await _incrementRecordCount(); + await _saveHistory(); + } + + Future updateOrderToBackend() async { + final order = _currentOrder.value; + if (order == null) return; + try { + await _apiService.updateOrder(order); + } catch (e) { + debugPrint('API更新点单失败: $e'); + } + } + + void loadOrder(Order order) { + _currentOrder.value = order; + _orderType.value = order.type; + } + + Future deleteOrder(String orderId) async { + _history.removeWhere((o) => o.id == orderId); + await _saveHistory(); + await _apiService.deleteOrder(orderId); + } + + Future clearHistory() async { + _history.clear(); + await _saveHistory(); + } + + Future cleanupExpiredRemote({int days = 30}) async { + return await _apiService.cleanupExpired(days: days); + } + + Future cleanupExpiredLocal({int days = 30}) async { + final cutoff = DateTime.now().subtract(Duration(days: days)); + final before = _history.length; + _history.removeWhere((o) { + try { + return DateTime.parse(o.updatedAt).isBefore(cutoff); + } catch (_) { + return false; + } + }); + await _saveHistory(); + return before - _history.length; + } + + Future cleanupAllExpired({int days = 30}) async { + final localDeleted = await cleanupExpiredLocal(days: days); + final remoteDeleted = await cleanupExpiredRemote(days: days); + return localDeleted + remoteDeleted; + } + + Future> clearAllData() async { + debugPrint( + '[OrderCtrl] clearAllData: clearing local + remote kitchen data', + ); + _history.clear(); + _currentOrder.value = null; + _recordCount.value = 0; + await _saveHistory(); + final prefs = await SharedPreferences.getInstance(); + await prefs.remove(_countKey); + final remoteResult = await _apiService.clearAll(); + return {'local_cleared': true, 'remote': remoteResult}; + } +} diff --git a/lib/src/controllers/tools/weight_controller.dart b/lib/src/controllers/tools/weight_controller.dart new file mode 100644 index 0000000..14b7ff3 --- /dev/null +++ b/lib/src/controllers/tools/weight_controller.dart @@ -0,0 +1,205 @@ +/* + * 文件: weight_controller.dart + * 名称: 体重管理控制器 + * 作用: 管理体重记录CRUD、目标设置、统计计算、单位切换 + * 更新: 2026-04-16 初始创建 + */ + +import 'dart:math'; +import 'package:flutter/cupertino.dart'; +import 'package:get/get.dart'; +import 'package:mom_kitchen/src/controllers/base_controller.dart'; +import 'package:mom_kitchen/src/models/weight_record_model.dart'; +import 'package:mom_kitchen/src/services/data/hive_service.dart'; + +class WeightController extends BaseController { + static const String _boxName = 'weightRecordBox'; + static const String _recordsKey = 'weight_records'; + static const String _goalKey = 'weight_goal_kg'; + + final RxList records = [].obs; + final RxDouble goalWeight = 60.0.obs; + final RxString unitMode = 'kg'.obs; + final TextEditingController weightInput = TextEditingController(); + final TextEditingController noteInput = TextEditingController(); + final RxString selectedTiming = 'morning'.obs; + + final HiveService _hive = HiveService(); + + List get sortedRecords => + List.from(records)..sort((a, b) => b.createdAt.compareTo(a.createdAt)); + + WeightRecord? get latestRecord => + sortedRecords.isNotEmpty ? sortedRecords.first : null; + + double? get currentWeight => latestRecord?.weightKg; + + double? get changeFromLast { + if (currentWeight == null || sortedRecords.length < 2) return null; + return currentWeight! - sortedRecords[1].weightKg; + } + + double? get changeFromGoal { + if (currentWeight == null) return null; + return currentWeight! - goalWeight.value; + } + + List _recordsInDays(int days) { + final cutoff = DateTime.now().subtract(Duration(days: days)); + return records.where((r) => r.createdAt.isAfter(cutoff)).toList() + ..sort((a, b) => a.createdAt.compareTo(b.createdAt)); + } + + Map getWeeklyStats() { + final weekRecords = _recordsInDays(7); + if (weekRecords.isEmpty) { + return {'avg': 0.0, 'max': 0.0, 'min': 0.0, 'change': 0.0}; + } + final weights = weekRecords.map((r) => r.weightKg).toList(); + return { + 'avg': weights.reduce((a, b) => a + b) / weights.length, + 'max': weights.reduce(max), + 'min': weights.reduce(min), + 'change': weights.length > 1 ? weights.last - weights.first : 0.0, + }; + } + + Map getMonthlyStats() { + final monthRecords = _recordsInDays(30); + if (monthRecords.isEmpty) { + return {'avg': 0.0, 'max': 0.0, 'min': 0.0, 'change': 0.0}; + } + final weights = monthRecords.map((r) => r.weightKg).toList(); + return { + 'avg': weights.reduce((a, b) => a + b) / weights.length, + 'max': weights.reduce(max), + 'min': weights.reduce(min), + 'change': weights.length > 1 ? weights.last - weights.first : 0.0, + }; + } + + Map get chartData { + final recent = _recordsInDays(30); + final map = {}; + for (final r in recent) { + final key = '${r.createdAt.month}/${r.createdAt.day}'; + map[key] = r.weightKg; + } + return map; + } + + @override + void onInit() { + super.onInit(); + _loadRecords(); + _loadGoal(); + } + + Future _loadRecords() async { + try { + final raw = _hive.get(_boxName, _recordsKey); + if (raw == null || raw is! List) return; + records.value = raw + .map( + (e) => e is Map ? WeightRecord.fromJson(e) : null, + ) + .whereType() + .toList(); + } catch (e) { + debugPrint('WeightController: load error: $e'); + } + } + + Future _loadGoal() async { + try { + final raw = _hive.get(_boxName, _goalKey); + if (raw != null && raw is num) { + goalWeight.value = raw.toDouble(); + } + } catch (e) { + debugPrint('WeightController: load goal error: $e'); + } + } + + Future addRecord() async { + final text = weightInput.text.trim(); + final weight = double.tryParse(text); + if (weight == null || weight <= 0 || weight > 500) return; + + final record = WeightRecord( + id: 'wr_${DateTime.now().millisecondsSinceEpoch}', + weightKg: unitMode.value == 'jin' ? WeightRecord.jinToKg(weight) : weight, + timing: selectedTiming.value, + note: noteInput.text.trim().isEmpty ? null : noteInput.text.trim(), + createdAt: DateTime.now(), + ); + + await runWithLoading(() async { + records.insert(0, record); + await _saveRecords(); + }); + + weightInput.clear(); + noteInput.clear(); + } + + Future deleteRecord(String id) async { + await runWithLoading(() async { + records.removeWhere((r) => r.id == id); + await _saveRecords(); + }); + } + + Future updateRecord( + String id, { + required double weightKg, + required String timing, + String? note, + }) async { + await runWithLoading(() async { + final index = records.indexWhere((r) => r.id == id); + if (index < 0) return; + final old = records[index]; + records[index] = WeightRecord( + id: old.id, + weightKg: weightKg, + timing: timing, + note: note, + createdAt: old.createdAt, + ); + await _saveRecords(); + }); + } + + Future updateGoal(double value) async { + goalWeight.value = value.clamp(30, 200); + _hive.put(_boxName, _goalKey, goalWeight.value); + } + + void toggleUnit() { + unitMode.value = unitMode.value == 'kg' ? 'jin' : 'kg'; + } + + String displayWeight(double kg) { + return unitMode.value == 'kg' + ? '${kg.toStringAsFixed(1)} kg' + : '${WeightRecord.kgToJin(kg).toStringAsFixed(1)} 斤'; + } + + double inputToKg(String value) { + final num = double.tryParse(value) ?? 0; + return unitMode.value == 'jin' ? WeightRecord.jinToKg(num) : num; + } + + Future _saveRecords() async { + final list = records.map((r) => r.toJson()).toList(); + _hive.put(_boxName, _recordsKey, list); + } + + @override + void onClose() { + weightInput.dispose(); + noteInput.dispose(); + super.onClose(); + } +} diff --git a/lib/src/models/bottle_model.dart b/lib/src/models/bottle_model.dart new file mode 100644 index 0000000..7f80814 --- /dev/null +++ b/lib/src/models/bottle_model.dart @@ -0,0 +1,176 @@ +/* + * 文件: bottle_model.dart + * 名称: 用料瓶子数据模型 + * 作用: 定义用料管理中瓶子的数据结构 + * 创建: 2026-04-16 + * 更新: 2026-04-16 初始创建 + */ + +/// 瓶子类型枚举 +enum BottleType { + /// 比例 + ratio, + /// 调味料 + seasoning, + /// 食材 + ingredient, +} + +/// 瓶子数据模型 +class BottleModel { + final String id; + final String name; + final BottleType type; + final double capacity; + final double currentAmount; + final String? icon; + final String? color; + final DateTime createdAt; + final DateTime updatedAt; + + const BottleModel({ + required this.id, + required this.name, + required this.type, + required this.capacity, + required this.currentAmount, + this.icon, + this.color, + required this.createdAt, + required this.updatedAt, + }); + + /// 获取填充百分比 + double get fillPercentage { + if (capacity <= 0) return 0; + return (currentAmount / capacity).clamp(0.0, 1.0); + } + + /// 是否为空 + bool get isEmpty => currentAmount <= 0; + + /// 是否已满 + bool get isFull => currentAmount >= capacity; + + /// 获取类型图标 + String get typeIcon { + switch (type) { + case BottleType.ratio: + return '📊'; + case BottleType.seasoning: + return '🧂'; + case BottleType.ingredient: + return '🥬'; + } + } + + /// 获取类型名称 + String get typeName { + switch (type) { + case BottleType.ratio: + return '比例'; + case BottleType.seasoning: + return '调味料'; + case BottleType.ingredient: + return '食材'; + } + } + + factory BottleModel.fromJson(Map json) { + return BottleModel( + id: json['id'] as String, + name: json['name'] as String, + type: BottleType.values.firstWhere( + (e) => e.name == json['type'], + orElse: () => BottleType.ingredient, + ), + capacity: (json['capacity'] as num?)?.toDouble() ?? 100.0, + currentAmount: (json['currentAmount'] as num?)?.toDouble() ?? 0.0, + icon: json['icon'] as String?, + color: json['color'] as String?, + createdAt: DateTime.parse(json['createdAt'] as String), + updatedAt: DateTime.parse(json['updatedAt'] as String), + ); + } + + Map toJson() { + return { + 'id': id, + 'name': name, + 'type': type.name, + 'capacity': capacity, + 'currentAmount': currentAmount, + 'icon': icon, + 'color': color, + 'createdAt': createdAt.toIso8601String(), + 'updatedAt': updatedAt.toIso8601String(), + }; + } + + BottleModel copyWith({ + String? id, + String? name, + BottleType? type, + double? capacity, + double? currentAmount, + String? icon, + String? color, + DateTime? createdAt, + DateTime? updatedAt, + }) { + return BottleModel( + id: id ?? this.id, + name: name ?? this.name, + type: type ?? this.type, + capacity: capacity ?? this.capacity, + currentAmount: currentAmount ?? this.currentAmount, + icon: icon ?? this.icon, + color: color ?? this.color, + createdAt: createdAt ?? this.createdAt, + updatedAt: updatedAt ?? this.updatedAt, + ); + } + + /// 创建默认瓶子 + static BottleModel createDefault({ + String? id, + String? name, + BottleType type = BottleType.ingredient, + double capacity = 100.0, + }) { + final now = DateTime.now(); + return BottleModel( + id: id ?? DateTime.now().millisecondsSinceEpoch.toString(), + name: name ?? '新瓶子', + type: type, + capacity: capacity, + currentAmount: capacity / 2, + icon: _getDefaultIcon(type), + color: _getDefaultColor(type), + createdAt: now, + updatedAt: now, + ); + } + + static String _getDefaultIcon(BottleType type) { + switch (type) { + case BottleType.ratio: + return '📊'; + case BottleType.seasoning: + return '🧂'; + case BottleType.ingredient: + return '🥬'; + } + } + + static String _getDefaultColor(BottleType type) { + switch (type) { + case BottleType.ratio: + return '#FF6B35'; + case BottleType.seasoning: + return '#FF9F1C'; + case BottleType.ingredient: + return '#2ECC71'; + } + } +} diff --git a/lib/src/models/discover_model.dart b/lib/src/models/discover_model.dart index 812569c..0a0ca3f 100644 --- a/lib/src/models/discover_model.dart +++ b/lib/src/models/discover_model.dart @@ -192,6 +192,7 @@ enum DiscoverItemType { mealTime, nutrition, miniCard, + toolCard, } class DiscoverItem { @@ -203,6 +204,7 @@ class DiscoverItem { final DiscoverMealTime? mealTime; final DiscoverNutrition? nutrition; final MiniCardRecipeRef? miniCardRecipe; + final ToolItemRef? toolItemRef; DiscoverItem._({ required this.type, @@ -213,6 +215,7 @@ class DiscoverItem { this.mealTime, this.nutrition, this.miniCardRecipe, + this.toolItemRef, }); factory DiscoverItem.recipe(DiscoverRecipe r) => @@ -229,6 +232,8 @@ class DiscoverItem { DiscoverItem._(type: DiscoverItemType.nutrition, nutrition: n); factory DiscoverItem.miniCard(MiniCardRecipeRef r) => DiscoverItem._(type: DiscoverItemType.miniCard, miniCardRecipe: r); + factory DiscoverItem.toolCard(ToolItemRef ref) => + DiscoverItem._(type: DiscoverItemType.toolCard, toolItemRef: ref); } class MiniCardRecipeRef { @@ -251,6 +256,32 @@ class MiniCardRecipeRef { : 'https://eat.wktyl.com/api/assets/$image'; } +class ToolItemRef { + final String id; + final String name; + final String icon; + final String category; + final String categoryName; + final String route; + final bool needsNetwork; + final int usageCount; + final String? description; + final String? badge; + + const ToolItemRef({ + required this.id, + required this.name, + required this.icon, + required this.category, + required this.categoryName, + required this.route, + required this.needsNetwork, + this.usageCount = 0, + this.description, + this.badge, + }); +} + class DiscoverRecipe { final int id; final String title; diff --git a/lib/src/models/dish_rank_model.dart b/lib/src/models/dish_rank_model.dart new file mode 100644 index 0000000..89f711b --- /dev/null +++ b/lib/src/models/dish_rank_model.dart @@ -0,0 +1,107 @@ +/* + * 文件: dish_rank_model.dart + * 名称: 菜品排名数据模型 + * 作用: 定义Tier List中每个菜品的数据结构及层级常量 + * 创建: 2026-04-16 初始创建 + */ + +import 'package:flutter/material.dart'; + +class DishRankItem { + final String id; + final String name; + String emoji; + String? coverImage; + int tierIndex; + int order; + final bool isCustom; + final String? sourceId; + + DishRankItem({ + required this.id, + required this.name, + this.emoji = '🍽️', + this.coverImage, + required this.tierIndex, + this.order = 0, + this.isCustom = false, + this.sourceId, + }); + + DishRankItem copyWith({ + String? id, + String? name, + String? emoji, + String? coverImage, + int? tierIndex, + int? order, + bool? isCustom, + String? sourceId, + }) { + return DishRankItem( + id: id ?? this.id, + name: name ?? this.name, + emoji: emoji ?? this.emoji, + coverImage: coverImage ?? this.coverImage, + tierIndex: tierIndex ?? this.tierIndex, + order: order ?? this.order, + isCustom: isCustom ?? this.isCustom, + sourceId: sourceId ?? this.sourceId, + ); + } + + Map toJson() => { + 'id': id, + 'name': name, + 'emoji': emoji, + 'coverImage': coverImage, + 'tierIndex': tierIndex, + 'order': order, + 'isCustom': isCustom, + 'sourceId': sourceId, + }; + + factory DishRankItem.fromJson(Map json) { + return DishRankItem( + id: json['id'] as String? ?? '', + name: json['name'] as String? ?? '', + emoji: json['emoji'] as String? ?? '🍽️', + coverImage: json['coverImage'] as String?, + tierIndex: json['tierIndex'] as int? ?? 0, + order: json['order'] as int? ?? 0, + isCustom: json['isCustom'] as bool? ?? false, + sourceId: json['sourceId'] as String?, + ); + } +} + +class TierDefinition { + static const List tierNames = ['夯', '顶级', '人上人', 'NPC', '拉完了']; + + static const List tierColors = [ + Color(0xFFE63946), + Color(0xFFF4A261), + Color(0xFFE9C46A), + Color(0xFFFFF8DC), + Color(0xFFF5F5F5), + ]; + + static const List tierTextColors = [ + Colors.white, + Color(0xFF5D4037), + Color(0xFF5D4037), + Color(0xFF8E8E93), + Color(0xFF8E8E93), + ]; + + static String getTierName(int index) => + index >= 0 && index < tierNames.length ? tierNames[index] : ''; + + static Color getTierColor(int index) => + index >= 0 && index < tierColors.length ? tierColors[index] : Colors.grey; + + static Color getTierTextColor(int index) => index >= 0 && + index < tierTextColors.length + ? tierTextColors[index] + : Colors.black; +} diff --git a/lib/src/models/feed_item_model.dart b/lib/src/models/feed_item_model.dart index a00d600..a57ef5e 100644 --- a/lib/src/models/feed_item_model.dart +++ b/lib/src/models/feed_item_model.dart @@ -1,6 +1,7 @@ // 2026-04-09 | FeedItemModel | 信息流条目模型 | 对齐api_feed.php返回字段 // 2026-04-09 | 新增 fromRecipe 方法,支持从 RecipeModel 转换 // 2026-04-14 | 新增 FavoriteType 枚举和 favoriteType 字段,支持收藏分类 +// 2026-04-16 | 新增 picId 字段,支持 RecipeImage fallback 链加载图片 import 'package:mom_kitchen/src/models/recipe/recipe_model.dart'; enum FavoriteType { @@ -56,6 +57,7 @@ class FeedItemModel { final String title; final String? intro; final String? cover; + final int? picId; final String? categoryName; final int? categoryId; final List tags; @@ -70,6 +72,7 @@ class FeedItemModel { required this.title, this.intro, this.cover, + this.picId, this.categoryName, this.categoryId, this.tags = const [], @@ -89,6 +92,7 @@ class FeedItemModel { title: json['title'] as String? ?? json['name'] as String? ?? '', intro: json['intro'] as String? ?? json['description'] as String?, cover: json['cover'] as String? ?? json['image'] as String?, + picId: json['pic_id'] as int? ?? json['picId'] as int?, categoryName: json['category_name'] as String? ?? json['cate_name'] as String?, categoryId: json['category_id'] as int? ?? json['cate_id'] as int?, @@ -107,6 +111,7 @@ class FeedItemModel { title: recipe.title, intro: recipe.intro, cover: recipe.cover, + picId: recipe.picId, categoryName: recipe.categoryName, categoryId: recipe.categoryId, tags: recipe.tags @@ -115,7 +120,7 @@ class FeedItemModel { statistics: FeedStatistics( views: recipe.statistics?.views ?? 0, likes: recipe.statistics?.likes ?? 0, - recommends: recipe.statistics?.recommends ?? 0, + rateNums: recipe.statistics?.rateNums ?? 0, ), createdAt: recipe.createdAt, favoriteType: FavoriteType.recipe, @@ -173,15 +178,18 @@ class FeedTagItem { class FeedStatistics { final int views; final int likes; - final int recommends; + final int rateNums; - const FeedStatistics({this.views = 0, this.likes = 0, this.recommends = 0}); + const FeedStatistics({this.views = 0, this.likes = 0, this.rateNums = 0}); factory FeedStatistics.fromJson(Map json) { return FeedStatistics( views: json['views'] as int? ?? 0, likes: json['likes'] as int? ?? 0, - recommends: json['recommends'] as int? ?? 0, + rateNums: + (json['rateNums'] ?? json['recommends'] ?? json['recommend_count']) + as int? ?? + 0, ); } } diff --git a/lib/src/models/tool_item_model.dart b/lib/src/models/tool_item_model.dart index 18c4e5f..3772041 100644 --- a/lib/src/models/tool_item_model.dart +++ b/lib/src/models/tool_item_model.dart @@ -4,8 +4,14 @@ * 作用: 定义工具中心工具项的数据结构 * 更新: 2026-04-10 初始创建 * 更新: 2026-04-12 删除未使用的ToolCategory类 + * 更新: 2026-04-15 修复路由:view_stats 指向 statsDashboard;hot_ranking 保持 /hot + * 更新: 2026-04-15 新增日期加减计算器工具 + * 更新: 2026-04-15 新增吃货文案生成器工具 + * 更新: 2026-04-17 新增 waterfallSlot 必填字段,不声明编译报错;新增 homeCardTools getter */ +import 'package:mom_kitchen/src/models/waterfall_slot.dart'; + class ToolItem { final String id; final String name; @@ -15,6 +21,7 @@ class ToolItem { final String route; final String? description; final int usageCount; + final WaterfallSlotConfig waterfallSlot; const ToolItem({ required this.id, @@ -23,6 +30,7 @@ class ToolItem { required this.needsNetwork, required this.category, required this.route, + required this.waterfallSlot, this.description, this.usageCount = 0, }); @@ -37,6 +45,11 @@ class ToolItem { route: json['route'] as String, description: json['description'] as String?, usageCount: json['usageCount'] as int? ?? 0, + waterfallSlot: WaterfallSlotConfig( + show: json['show_in_waterfall'] as bool? ?? false, + priority: json['waterfall_priority'] as int? ?? 999, + badge: json['waterfall_badge'] as String?, + ), ); } @@ -50,10 +63,13 @@ class ToolItem { 'route': route, 'description': description, 'usageCount': usageCount, + 'show_in_waterfall': waterfallSlot.show, + 'waterfall_priority': waterfallSlot.priority, + 'waterfall_badge': waterfallSlot.badge, }; } - ToolItem copyWith({int? usageCount}) { + ToolItem copyWith({int? usageCount, WaterfallSlotConfig? waterfallSlot}) { return ToolItem( id: id, name: name, @@ -63,6 +79,7 @@ class ToolItem { route: route, description: description, usageCount: usageCount ?? this.usageCount, + waterfallSlot: waterfallSlot ?? this.waterfallSlot, ); } } @@ -92,6 +109,20 @@ class ToolRegistry { return categoryIcons[categoryId] ?? '📋'; } + static List get homeCardTools { + final tools = defaultTools.where((t) => t.waterfallSlot.show).toList(); + tools.shuffle(); + assert(() { + for (final t in defaultTools) { + if (t.waterfallSlot.show && t.route.isEmpty) { + throw AssertionError('工具 ${t.id} 声明了瀑布流展示但route为空!'); + } + } + return true; + }()); + return tools; + } + static const List defaultTools = [ ToolItem( id: 'cooking_timer', @@ -101,6 +132,7 @@ class ToolRegistry { category: 'cooking', route: '/tools/timer', description: '多计时器烹饪助手', + waterfallSlot: WaterfallSlotConfig(show: true, priority: 1), ), ToolItem( id: 'serving_scaler', @@ -110,6 +142,7 @@ class ToolRegistry { category: 'cooking', route: '/tools/scaler', description: '按人数调整食材用量', + waterfallSlot: WaterfallSlotConfig(show: true, priority: 3), ), ToolItem( id: 'meal_time_recommend', @@ -119,6 +152,27 @@ class ToolRegistry { category: 'cooking', route: '/tools/meal-time', description: '根据时间推荐早中晚餐', + waterfallSlot: WaterfallSlotConfig(show: true, priority: 5), + ), + ToolItem( + id: 'date_calculator', + name: '日期计算器', + icon: '🗓️', + needsNetwork: false, + category: 'cooking', + route: '/tools/date-calculator', + description: '日期加减天数与间隔计算', + waterfallSlot: WaterfallSlotConfig(show: false), + ), + ToolItem( + id: 'food_copy_generator', + name: '吃货文案', + icon: '🍗', + needsNetwork: false, + category: 'cooking', + route: '/tools/food-copy', + description: '随机生成吃货文案', + waterfallSlot: WaterfallSlotConfig(show: false), ), ToolItem( id: 'bmi_calculator', @@ -128,6 +182,17 @@ class ToolRegistry { category: 'health', route: '/tools/bmi', description: '计算身体质量指数', + waterfallSlot: WaterfallSlotConfig(show: true, priority: 2), + ), + ToolItem( + id: 'weight_manage', + name: '体重管理', + icon: '⚖️', + needsNetwork: false, + category: 'health', + route: '/tools/weight-manage', + description: '记录体重、追踪变化趋势', + waterfallSlot: WaterfallSlotConfig(show: true, priority: 6), ), ToolItem( id: 'allergen_checker', @@ -137,6 +202,7 @@ class ToolRegistry { category: 'health', route: '/tools/allergen', description: '检查食材过敏原信息', + waterfallSlot: WaterfallSlotConfig(show: true, priority: 4), ), ToolItem( id: 'allergen_report', @@ -146,6 +212,7 @@ class ToolRegistry { category: 'health', route: '/allergen-report', description: '生成个性化过敏原报告', + waterfallSlot: WaterfallSlotConfig(show: false), ), ToolItem( id: 'nutrition_analysis', @@ -155,6 +222,17 @@ class ToolRegistry { category: 'health', route: '/tools/nutrition', description: '查看营养成分详情', + waterfallSlot: WaterfallSlotConfig(show: false), + ), + ToolItem( + id: 'safe_period_calculator', + name: '安全期计算器', + icon: '🌸', + needsNetwork: false, + category: 'health', + route: '/tools/safe-period', + description: '女性安全期、排卵期计算', + waterfallSlot: WaterfallSlotConfig(show: false), ), ToolItem( id: 'unit_converter', @@ -164,6 +242,7 @@ class ToolRegistry { category: 'data', route: '/tools/converter', description: '重量/容量单位换算', + waterfallSlot: WaterfallSlotConfig(show: true, priority: 7), ), ToolItem( id: 'ingredient_detail', @@ -173,6 +252,7 @@ class ToolRegistry { category: 'data', route: '/tools/ingredient', description: '查询食材营养与选购', + waterfallSlot: WaterfallSlotConfig(show: false), ), ToolItem( id: 'hot_ranking', @@ -182,6 +262,7 @@ class ToolRegistry { category: 'data', route: '/hot', description: '查看热门菜谱排行', + waterfallSlot: WaterfallSlotConfig(show: false), ), ToolItem( id: 'view_stats', @@ -190,7 +271,8 @@ class ToolRegistry { needsNetwork: true, category: 'data', route: '/tools/stats', - description: '查看平台浏览数据', + description: '查看平台浏览数据统计', + waterfallSlot: WaterfallSlotConfig(show: false), ), ToolItem( id: 'duplicate_check', @@ -200,6 +282,7 @@ class ToolRegistry { category: 'data', route: '/duplicate-check', description: '检测菜谱是否存在重复', + waterfallSlot: WaterfallSlotConfig(show: false), ), ToolItem( id: 'stats_dashboard', @@ -209,6 +292,7 @@ class ToolRegistry { category: 'data', route: '/stats-dashboard', description: '查看运营数据统计大屏', + waterfallSlot: WaterfallSlotConfig(show: false), ), ToolItem( id: 'meal_planner', @@ -218,6 +302,37 @@ class ToolRegistry { category: 'planning', route: '/tools/planner', description: '规划一周饮食菜单', + waterfallSlot: WaterfallSlotConfig(show: true, priority: 8), + ), + ToolItem( + id: 'dish_ranking', + name: '菜品排名', + icon: '🏆', + needsNetwork: false, + category: 'data', + route: '/tools/dish-ranking', + description: '给你的菜品排个座次,从夯到拉', + waterfallSlot: WaterfallSlotConfig(show: false), + ), + ToolItem( + id: 'ingredient_manage', + name: '用料管理', + icon: '🧴', + needsNetwork: false, + category: 'cooking', + route: '/tools/ingredient-manage', + description: '管理厨房调味料和食材瓶子', + waterfallSlot: WaterfallSlotConfig(show: false), + ), + ToolItem( + id: 'order_assistant', + name: '点餐助手', + icon: '🍽️', + needsNetwork: true, + category: 'cooking', + route: '/tools/order-assistant', + description: '点餐推单,二维码分享', + waterfallSlot: WaterfallSlotConfig(show: true, priority: 9), ), ]; } diff --git a/lib/src/models/tools/order_model.dart b/lib/src/models/tools/order_model.dart new file mode 100644 index 0000000..a2f9b62 --- /dev/null +++ b/lib/src/models/tools/order_model.dart @@ -0,0 +1,304 @@ +/* + * 文件: order_model.dart + * 名称: 点餐助手数据模型 + * 作用: 定义点单和菜品项的数据结构,支持 SharedPreferences JSON 持久化 + * 创建时间: 2026-04-17 + * 更新时间: 2026-04-17 初始创建 + */ + +enum OrderItemSource { + browseHistory, + search, + manual, + merchantRecommend; + + String get label { + switch (this) { + case OrderItemSource.browseHistory: + return '浏览记录'; + case OrderItemSource.search: + return '搜索'; + case OrderItemSource.manual: + return '手动填写'; + case OrderItemSource.merchantRecommend: + return '商家推荐'; + } + } + + String get icon { + switch (this) { + case OrderItemSource.browseHistory: + return '📖'; + case OrderItemSource.search: + return '🔍'; + case OrderItemSource.manual: + return '✏️'; + case OrderItemSource.merchantRecommend: + return '⭐'; + } + } +} + +enum OrderType { + userOrder, + merchantPush; + + String get label { + switch (this) { + case OrderType.userOrder: + return '用户点餐'; + case OrderType.merchantPush: + return '商家推单'; + } + } + + String get icon { + switch (this) { + case OrderType.userOrder: + return '🧑'; + case OrderType.merchantPush: + return '🏪'; + } + } +} + +enum OrderStatus { + draft, + active, + completed, + cancelled; + + String get label { + switch (this) { + case OrderStatus.draft: + return '草稿'; + case OrderStatus.active: + return '进行中'; + case OrderStatus.completed: + return '已完成'; + case OrderStatus.cancelled: + return '已取消'; + } + } + + String get icon { + switch (this) { + case OrderStatus.draft: + return '📝'; + case OrderStatus.active: + return '🟢'; + case OrderStatus.completed: + return '✅'; + case OrderStatus.cancelled: + return '❌'; + } + } +} + +class OrderItem { + final String id; + final String name; + final OrderItemSource source; + final int quantity; + final double? price; + final String? ingredients; + final String? note; + final String? recipeId; + + const OrderItem({ + required this.id, + required this.name, + required this.source, + this.quantity = 1, + this.price, + this.ingredients, + this.note, + this.recipeId, + }); + + double get subtotal => (price ?? 0) * quantity; + + OrderItem copyWith({ + String? id, + String? name, + OrderItemSource? source, + int? quantity, + double? price, + String? ingredients, + String? note, + String? recipeId, + }) { + return OrderItem( + id: id ?? this.id, + name: name ?? this.name, + source: source ?? this.source, + quantity: quantity ?? this.quantity, + price: price ?? this.price, + ingredients: ingredients ?? this.ingredients, + note: note ?? this.note, + recipeId: recipeId ?? this.recipeId, + ); + } + + Map toJson() => { + 'id': id, + 'name': name, + 'source': source.index, + 'quantity': quantity, + 'price': price, + 'ingredients': ingredients, + 'note': note, + 'recipeId': recipeId, + }; + + factory OrderItem.fromJson(Map json) => OrderItem( + id: json['id'] as String? ?? '', + name: json['name'] as String? ?? '', + source: OrderItemSource.values[json['source'] as int? ?? 2], + quantity: json['quantity'] as int? ?? 1, + price: (json['price'] as num?)?.toDouble(), + ingredients: json['ingredients'] as String?, + note: json['note'] as String?, + recipeId: json['recipeId'] as String?, + ); +} + +class Order { + final String id; + final String orderNo; + final OrderType type; + final OrderStatus status; + final List items; + final double? totalAmount; + final String? note; + final String createdAt; + final String updatedAt; + final String createdBy; + final String? tableNo; + final int? peopleCount; + final int recordCount; + + const Order({ + required this.id, + required this.orderNo, + required this.type, + required this.status, + required this.items, + this.totalAmount, + this.note, + required this.createdAt, + required this.updatedAt, + required this.createdBy, + this.tableNo, + this.peopleCount, + this.recordCount = 0, + }); + + double get calculatedTotal { + if (totalAmount != null) return totalAmount!; + return items.fold(0.0, (sum, item) => sum + item.subtotal); + } + + int get totalQuantity => items.fold(0, (sum, item) => sum + item.quantity); + + String get qrUrl => 'https://eat.wktyl.com/api/kitchen/?id=$id'; + + String get displayDate { + if (createdAt.isEmpty) return ''; + try { + final dt = DateTime.parse(createdAt); + final now = DateTime.now(); + final diff = now.difference(dt); + if (diff.inDays == 0) { + if (diff.inHours == 0) { + if (diff.inMinutes == 0) return '刚刚'; + return '${diff.inMinutes}分钟前'; + } + return '${diff.inHours}小时前'; + } else if (diff.inDays == 1) { + return '昨天'; + } else if (diff.inDays < 7) { + return '${diff.inDays}天前'; + } else { + return '${dt.month}月${dt.day}日'; + } + } catch (_) { + return createdAt; + } + } + + Order copyWith({ + String? id, + String? orderNo, + OrderType? type, + OrderStatus? status, + List? items, + double? totalAmount, + String? note, + String? createdAt, + String? updatedAt, + String? createdBy, + String? tableNo, + int? peopleCount, + int? recordCount, + }) { + return Order( + id: id ?? this.id, + orderNo: orderNo ?? this.orderNo, + type: type ?? this.type, + status: status ?? this.status, + items: items ?? this.items, + totalAmount: totalAmount ?? this.totalAmount, + note: note ?? this.note, + createdAt: createdAt ?? this.createdAt, + updatedAt: updatedAt ?? this.updatedAt, + createdBy: createdBy ?? this.createdBy, + tableNo: tableNo ?? this.tableNo, + peopleCount: peopleCount ?? this.peopleCount, + recordCount: recordCount ?? this.recordCount, + ); + } + + Map toJson() => { + 'id': id, + 'orderNo': orderNo, + 'type': type.index, + 'status': status.index, + 'items': items.map((i) => i.toJson()).toList(), + 'totalAmount': totalAmount, + 'note': note, + 'createdAt': createdAt, + 'updatedAt': updatedAt, + 'createdBy': createdBy, + 'tableNo': tableNo, + 'peopleCount': peopleCount, + 'recordCount': recordCount, + }; + + factory Order.fromJson(Map json) => Order( + id: json['id'] as String? ?? '', + orderNo: json['orderNo'] as String? ?? '', + type: OrderType.values[json['type'] as int? ?? 0], + status: OrderStatus.values[json['status'] as int? ?? 0], + items: + (json['items'] as List?) + ?.map((i) => OrderItem.fromJson(i as Map)) + .toList() ?? + [], + totalAmount: (json['totalAmount'] as num?)?.toDouble(), + note: json['note'] as String?, + createdAt: json['createdAt'] as String? ?? '', + updatedAt: json['updatedAt'] as String? ?? '', + createdBy: json['createdBy'] as String? ?? '', + tableNo: json['tableNo'] as String?, + peopleCount: json['peopleCount'] as int?, + recordCount: json['recordCount'] as int? ?? 0, + ); + + static String generateOrderNo() { + final now = DateTime.now(); + final timestamp = now.millisecondsSinceEpoch.toString().substring(5); + final random = (now.microsecond % 900 + 100).toString(); + return 'OD$timestamp$random'; + } +} diff --git a/lib/src/models/waterfall_slot.dart b/lib/src/models/waterfall_slot.dart new file mode 100644 index 0000000..1cad098 --- /dev/null +++ b/lib/src/models/waterfall_slot.dart @@ -0,0 +1,72 @@ +/* + * 文件: waterfall_slot.dart + * 名称: 瀑布流插槽模型 + * 作用: 统一管理瀑布流中插入的各类卡片(miniCard、toolCard等) + * 创建时间: 2026-04-17 + * 更新时间: 2026-04-17 初始创建 + */ + +import 'package:mom_kitchen/src/models/tool_item_model.dart'; +import 'package:mom_kitchen/src/models/mini_card_model.dart'; + +enum WaterfallSlotType { miniCard, toolCard } + +class WaterfallSlot { + final WaterfallSlotType type; + final int position; + final Map data; + + const WaterfallSlot({ + required this.type, + required this.position, + required this.data, + }); +} + +class WaterfallSlotConfig { + final bool show; + final int priority; + final String? badge; + + const WaterfallSlotConfig({ + required this.show, + this.priority = 999, + this.badge, + }); +} + +class WaterfallSlotRegistry { + static const int _slotInterval = 20; + + static List buildSlots({ + required List miniCards, + required List toolCards, + }) { + final slots = []; + int miniIdx = 0; + int toolIdx = 0; + + for (int pos = _slotInterval; + miniIdx < miniCards.length || toolIdx < toolCards.length; + pos += _slotInterval) { + final cycle = ((pos ~/ _slotInterval) - 1) % 2; + if (cycle == 0 && miniIdx < miniCards.length) { + slots.add(WaterfallSlot( + type: WaterfallSlotType.miniCard, + position: pos, + data: {'index': miniIdx}, + )); + miniIdx++; + } else if (cycle == 1 && toolIdx < toolCards.length) { + slots.add(WaterfallSlot( + type: WaterfallSlotType.toolCard, + position: pos, + data: {'index': toolIdx}, + )); + toolIdx++; + } + } + + return slots; + } +} diff --git a/lib/src/models/weight_record_model.dart b/lib/src/models/weight_record_model.dart new file mode 100644 index 0000000..0fc6aeb --- /dev/null +++ b/lib/src/models/weight_record_model.dart @@ -0,0 +1,82 @@ +/* + * 文件: weight_record_model.dart + * 名称: 体重记录数据模型 + * 作用: 定义体重记录的数据结构和JSON序列化 + * 更新: 2026-04-16 初始创建 + */ + +class WeightRecord { + final String id; + final double weightKg; + final String timing; + final String? note; + final DateTime createdAt; + + const WeightRecord({ + required this.id, + required this.weightKg, + required this.timing, + this.note, + required this.createdAt, + }); + + static const List timingLabels = [ + 'morning', + 'before_meal', + 'after_meal' + ]; + static const Map timingDisplay = { + 'morning': '早晨', + 'before_meal': '饭前', + 'after_meal': '饭后', + }; + static const Map timingEmoji = { + 'morning': '🌅', + 'before_meal': '🍽️', + 'after_meal': '😋', + }; + + static double kgToJin(double kg) => kg * 2; + + static double jinToKg(double jin) => jin / 2; + + factory WeightRecord.fromJson(Map json) { + return WeightRecord( + id: json['id'] as String? ?? '', + weightKg: (json['weightKg'] as num?)?.toDouble() ?? 0, + timing: json['timing'] as String? ?? 'morning', + note: json['note'] as String?, + createdAt: json['createdAt'] != null + ? DateTime.parse(json['createdAt'] as String) + : DateTime.now(), + ); + } + + Map toJson() => { + 'id': id, + 'weightKg': weightKg, + 'timing': timing, + 'note': note, + 'createdAt': createdAt.toIso8601String(), + }; + + WeightRecord copyWith({ + String? id, + double? weightKg, + String? timing, + String? note, + DateTime? createdAt, + }) { + return WeightRecord( + id: id ?? this.id, + weightKg: weightKg ?? this.weightKg, + timing: timing ?? this.timing, + note: note ?? this.note, + createdAt: createdAt ?? this.createdAt, + ); + } + + @override + String toString() => + 'WeightRecord(id: $id, weightKg: $weightKg, timing: $timing, note: $note)'; +} diff --git a/lib/src/pages/discover/components/browse_history_section.dart b/lib/src/pages/discover/components/browse_history_section.dart new file mode 100644 index 0000000..a98f4f5 --- /dev/null +++ b/lib/src/pages/discover/components/browse_history_section.dart @@ -0,0 +1,218 @@ +import 'package:flutter/cupertino.dart'; +import 'package:get/get.dart'; +import 'package:shared_preferences/shared_preferences.dart'; +import 'package:mom_kitchen/src/config/design_tokens.dart'; + +class BrowseHistorySection extends StatelessWidget { + final bool isDark; + final VoidCallback onClosePanel; + + const BrowseHistorySection({ + super.key, + required this.isDark, + required this.onClosePanel, + }); + + @override + Widget build(BuildContext context) { + return Padding( + padding: const EdgeInsets.only( + left: DesignTokens.space4, + right: DesignTokens.space4, + top: DesignTokens.space4, + ), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Row( + mainAxisAlignment: MainAxisAlignment.spaceBetween, + children: [ + Text( + '浏览记录', + style: TextStyle( + fontSize: DesignTokens.fontLg, + fontWeight: FontWeight.w700, + color: isDark ? DarkDesignTokens.text1 : DesignTokens.text1, + ), + ), + GestureDetector( + onTap: () { + onClosePanel(); + Get.toNamed('/favorites'); + }, + child: Row( + mainAxisSize: MainAxisSize.min, + children: [ + Text( + '更多', + style: TextStyle( + fontSize: DesignTokens.fontSm, + color: DesignTokens.dynamicPrimary, + fontWeight: FontWeight.w500, + ), + ), + Icon( + CupertinoIcons.chevron_right, + size: 14, + color: DesignTokens.dynamicPrimary, + ), + ], + ), + ), + ], + ), + const SizedBox(height: DesignTokens.space3), + SizedBox( + height: 110, + child: FutureBuilder>>( + future: _loadBrowseHistory(), + builder: (context, snapshot) { + if (!snapshot.hasData || snapshot.data!.isEmpty) { + return _buildEmptyState(); + } + final history = snapshot.data!; + return _buildHistoryList(history); + }, + ), + ), + ], + ), + ); + } + + Widget _buildEmptyState() { + return Container( + decoration: BoxDecoration( + color: isDark ? DarkDesignTokens.card : DesignTokens.card, + borderRadius: DesignTokens.borderRadiusMd, + ), + child: Center( + child: Column( + mainAxisAlignment: MainAxisAlignment.center, + children: [ + Icon( + CupertinoIcons.clock, + size: 28, + color: isDark + ? DarkDesignTokens.text3 + : DesignTokens.text3.withValues(alpha: 0.4), + ), + const SizedBox(height: DesignTokens.space2), + Text( + '暂无浏览记录', + style: TextStyle( + fontSize: DesignTokens.fontSm, + color: isDark + ? DarkDesignTokens.text3 + : DesignTokens.text3.withValues(alpha: 0.5), + ), + ), + ], + ), + ), + ); + } + + Widget _buildHistoryList(List> history) { + return ListView.separated( + scrollDirection: Axis.horizontal, + padding: const EdgeInsets.symmetric(horizontal: DesignTokens.space1), + separatorBuilder: (_, __) => const SizedBox(width: DesignTokens.space2), + itemCount: history.length, + itemBuilder: (context, index) { + final item = history[index]; + return _buildHistoryCard(item); + }, + ); + } + + Widget _buildHistoryCard(Map item) { + return GestureDetector( + onTap: () { + onClosePanel(); + Get.toNamed('/recipe-detail', arguments: '${item['id']}'); + }, + child: Container( + width: 130, + decoration: BoxDecoration( + color: isDark ? DarkDesignTokens.card : DesignTokens.card, + borderRadius: DesignTokens.borderRadiusMd, + ), + child: Column( + children: [ + Expanded( + flex: 3, + child: ClipRRect( + borderRadius: BorderRadius.vertical( + top: Radius.circular(DesignTokens.radiusMd), + ), + child: Container( + width: double.infinity, + decoration: BoxDecoration( + gradient: LinearGradient( + colors: [ + DesignTokens.dynamicPrimary.withValues(alpha: 0.15), + DesignTokens.secondary.withValues(alpha: 0.08), + ], + ), + ), + child: Center( + child: Text('🍽️', style: TextStyle(fontSize: 26)), + ), + ), + ), + ), + Expanded( + flex: 2, + child: Padding( + padding: const EdgeInsets.all(DesignTokens.space2), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + mainAxisAlignment: MainAxisAlignment.center, + children: [ + Text( + item['name'] ?? '', + style: TextStyle( + fontSize: DesignTokens.fontXs, + fontWeight: FontWeight.w600, + color: isDark ? DarkDesignTokens.text1 : DesignTokens.text1, + ), + maxLines: 1, + overflow: TextOverflow.ellipsis, + ), + Text( + item['time'] ?? '', + style: TextStyle( + fontSize: 10, + color: isDark + ? DarkDesignTokens.text3 + : DesignTokens.text3.withValues(alpha: 0.6), + ), + ), + ], + ), + ), + ), + ], + ), + ), + ); + } + + Future>> _loadBrowseHistory() async { + try { + final prefs = await SharedPreferences.getInstance(); + final raw = prefs.getStringList('browse_history') ?? []; + return raw.map((e) { + final parts = e.split('|'); + return { + 'id': parts.isNotEmpty ? parts[0] : '', + 'name': parts.length > 1 ? parts[1] : '', + 'time': parts.length > 2 ? parts[2] : '', + }; + }).toList(); + } catch (_) { + return []; + } + } +} diff --git a/lib/src/pages/discover/components/discover_sections_widget.dart b/lib/src/pages/discover/components/discover_sections_widget.dart new file mode 100644 index 0000000..38c723c --- /dev/null +++ b/lib/src/pages/discover/components/discover_sections_widget.dart @@ -0,0 +1,690 @@ +/* + * 文件: discover_sections_widget.dart + * 名称: 发现页面分区内容组件 + * 作用: 展示热门排行/今天吃什么/推荐三个 Tab 的内容区域 + * 更新: 2026-04-16 代码拆分,从 discover_page.dart 提取 + * 更新: 2026-04-16 禁用所有子列表独立滚动,统一由外层 CustomScrollView 管理,支持下拉劫持打开工具中心 + */ + +import 'dart:ui'; +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/tag_model.dart'; +import 'package:mom_kitchen/src/widgets/glass/glass_segmented_control.dart'; +import 'package:mom_kitchen/src/controllers/feed/hot_controller.dart'; +import 'package:mom_kitchen/src/repositories/hot_repository.dart' as repo; +import 'package:mom_kitchen/src/services/ui/toast_service.dart'; + +class DiscoverSectionsWidget extends StatelessWidget { + final bool isDark; + final int segmentIndex; + final int recommendTypeIndex; + final int recommendSubIndex; + final HotController hotController; + final List topCategories; + final List ingredientCategories; + final List tasteTags; + final List cookingTags; + final bool isLoadingCategories; + final void Function(int index) onRecommendSubIndexChanged; + final void Function(int index) onRecommendTypeIndexChanged; + + const DiscoverSectionsWidget({ + super.key, + required this.isDark, + required this.segmentIndex, + required this.recommendTypeIndex, + required this.recommendSubIndex, + required this.hotController, + required this.topCategories, + required this.ingredientCategories, + required this.tasteTags, + required this.cookingTags, + required this.isLoadingCategories, + required this.onRecommendSubIndexChanged, + required this.onRecommendTypeIndexChanged, + }); + + @override + Widget build(BuildContext context) { + switch (segmentIndex) { + case 0: + return _buildHotSection(context); + case 1: + return _buildWhatToEatSection(); + case 2: + return _buildRecommendSection(context); + default: + return const SizedBox.shrink(); + } + } + + // ============================================================ + // 🔥 热门排行 + // ============================================================ + + Widget _buildHotSection(BuildContext context) { + return Obx(() { + final List hotList = hotController.hotList; + final isLoading = hotController.isLoading.value; + + if (isLoading) { + return const SizedBox( + height: 300, + child: Center(child: CupertinoActivityIndicator()), + ); + } + + if (hotList.isEmpty) { + return SizedBox( + height: 300, + child: Center( + child: Column( + mainAxisAlignment: MainAxisAlignment.center, + children: [ + Icon( + CupertinoIcons.flame, + size: 48, + color: isDark ? DarkDesignTokens.text3 : DesignTokens.text3, + ), + const SizedBox(height: DesignTokens.space3), + Text( + '暂无热门数据', + style: TextStyle( + fontSize: DesignTokens.fontLg, + color: isDark ? DarkDesignTokens.text2 : DesignTokens.text2, + ), + ), + ], + ), + ), + ); + } + + // 使用 Column 替代固定高度 + 内部 ListView,让外层统一滚动 + return Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + // 时段切换 + Padding( + padding: const EdgeInsets.symmetric( + horizontal: DesignTokens.space4, + ), + child: GlassSegmentedControl( + segments: HotController.periodNames + .map((name) => GlassSegment(label: name)) + .toList(), + selectedIndex: hotController.currentPeriod.value.index, + onChanged: (i) { + hotController.switchPeriod(HotPeriod.values[i]); + }, + ), + ), + const SizedBox(height: DesignTokens.space3), + // 列表项 —— 使用 Column + map,无独立滚动 + Padding( + padding: const EdgeInsets.symmetric( + horizontal: DesignTokens.space4, + ), + child: Column( + children: hotList.asMap().entries.map((entry) { + final index = entry.key; + final recipe = entry.value; + return Padding( + padding: const EdgeInsets.only( + bottom: DesignTokens.space2 + 2, + ), + child: _buildHotItem(recipe, index, context), + ); + }).toList(), + ), + ), + ], + ); + }); + } + + /// 单个热门排行项 + Widget _buildHotItem(repo.HotItem recipe, int index, BuildContext context) { + return Dismissible( + key: ValueKey('hot_${recipe.id}_$index'), + direction: DismissDirection.horizontal, + confirmDismiss: (direction) async { + if (direction == DismissDirection.endToStart) { + Get.toNamed('/recipe-detail', arguments: '${recipe.id}'); + return false; + } else { + return true; + } + }, + onDismissed: (direction) { + if (direction == DismissDirection.startToEnd) { + _showQuickActions(context, recipe); + } + }, + background: Container( + alignment: Alignment.centerLeft, + padding: const EdgeInsets.only(left: 20), + decoration: BoxDecoration( + color: DesignTokens.dynamicPrimary, + borderRadius: DesignTokens.borderRadiusLg, + ), + child: Row( + mainAxisSize: MainAxisSize.min, + children: [ + Icon(CupertinoIcons.heart_fill, color: CupertinoColors.white), + const SizedBox(width: 8), + Text( + '收藏', + style: TextStyle( + color: CupertinoColors.white, + fontWeight: FontWeight.w600, + ), + ), + ], + ), + ), + secondaryBackground: Container( + alignment: Alignment.centerRight, + padding: const EdgeInsets.only(right: 20), + decoration: BoxDecoration( + color: DesignTokens.green, + borderRadius: DesignTokens.borderRadiusLg, + ), + child: Row( + mainAxisSize: MainAxisSize.min, + mainAxisAlignment: MainAxisAlignment.end, + children: [ + Text( + '查看详情', + style: TextStyle( + color: CupertinoColors.white, + fontWeight: FontWeight.w600, + ), + ), + const SizedBox(width: 8), + Icon(CupertinoIcons.eye, color: CupertinoColors.white), + ], + ), + ), + child: GestureDetector( + onTap: () { + Get.toNamed('/recipe-detail', arguments: '${recipe.id}'); + }, + child: Container( + padding: const EdgeInsets.all(DesignTokens.space3), + decoration: BoxDecoration( + color: isDark ? DarkDesignTokens.card : DesignTokens.card, + borderRadius: DesignTokens.borderRadiusLg, + boxShadow: DesignTokens.shadowsSm, + ), + child: Row( + children: [ + Container( + width: 28, + height: 28, + decoration: BoxDecoration( + color: index < 3 + ? DesignTokens.orange.withValues(alpha: 0.15) + : DesignTokens.text3.withValues(alpha: 0.1), + borderRadius: DesignTokens.borderRadiusSm, + ), + child: Center( + child: Text( + '${index + 1}', + style: TextStyle( + fontSize: DesignTokens.fontSm, + fontWeight: FontWeight.w700, + color: index < 3 + ? DesignTokens.orange + : (isDark ? DarkDesignTokens.text2 : DesignTokens.text2), + ), + ), + ), + ), + const SizedBox(width: DesignTokens.space3), + Expanded( + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text( + recipe.name, + style: TextStyle( + fontSize: DesignTokens.fontMd, + fontWeight: FontWeight.w500, + color: isDark ? DarkDesignTokens.text1 : DesignTokens.text1, + ), + ), + const SizedBox(height: 2), + Text( + '${hotController.sortByName}: ${recipe.count}', + style: TextStyle( + fontSize: DesignTokens.fontSm, + color: isDark ? DarkDesignTokens.text2 : DesignTokens.text2, + ), + ), + ], + ), + ), + Icon( + CupertinoIcons.chevron_forward, + size: 16, + color: isDark ? DarkDesignTokens.text3 : DesignTokens.text3, + ), + ], + ), + ), + ), + ); + } + + // ============================================================ + // 🎲 今天吃什么 + // ============================================================ + + Widget _buildWhatToEatSection() { + return Padding( + padding: const EdgeInsets.symmetric(vertical: DesignTokens.space6), + child: Center( + child: Column( + mainAxisAlignment: MainAxisAlignment.center, + children: [ + Container( + width: 100, + height: 100, + decoration: BoxDecoration( + color: DesignTokens.primaryLight, + borderRadius: DesignTokens.borderRadiusXl, + ), + child: Icon( + CupertinoIcons.shuffle, + size: 44, + color: DesignTokens.dynamicPrimary, + ), + ), + const SizedBox(height: DesignTokens.space4), + Text( + '不知道吃什么?', + style: TextStyle( + fontSize: DesignTokens.fontXl, + fontWeight: FontWeight.w600, + color: isDark ? DarkDesignTokens.text1 : DesignTokens.text1, + ), + ), + const SizedBox(height: DesignTokens.space2), + Text( + '让小妈厨房帮你决定', + style: TextStyle( + fontSize: DesignTokens.fontMd, + color: isDark ? DarkDesignTokens.text2 : DesignTokens.text2, + ), + ), + const SizedBox(height: DesignTokens.space6), + SizedBox( + width: 200, + child: CupertinoButton.filled( + borderRadius: DesignTokens.borderRadiusLg, + onPressed: () => Get.toNamed('/what-to-eat'), + child: const Row( + mainAxisAlignment: MainAxisAlignment.center, + children: [ + Icon(CupertinoIcons.shuffle, size: 20), + SizedBox(width: DesignTokens.space2), + Text('随机推荐'), + ], + ), + ), + ), + const SizedBox(height: DesignTokens.space3), + SizedBox( + width: 200, + child: CupertinoButton( + borderRadius: DesignTokens.borderRadiusLg, + color: DesignTokens.primaryLight, + onPressed: () => Get.toNamed('/what-to-eat'), + child: Row( + mainAxisAlignment: MainAxisAlignment.center, + children: [ + Icon( + CupertinoIcons.lightbulb, + size: 20, + color: DesignTokens.dynamicPrimary, + ), + const SizedBox(width: DesignTokens.space2), + Text( + '浏览推荐', + style: TextStyle(color: DesignTokens.dynamicPrimary), + ), + ], + ), + ), + ), + ], + ), + ), + ); + } + + // ============================================================ + // ⭐ 推荐 + // ============================================================ + + Widget _buildRecommendSection(BuildContext context) { + if (isLoadingCategories) { + return const SizedBox( + height: 300, + child: Center(child: CupertinoActivityIndicator()), + ); + } + + return Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Padding( + padding: const EdgeInsets.symmetric( + horizontal: DesignTokens.space4, + ), + child: GlassSegmentedControl( + segments: const [ + GlassSegment(label: '🍳 菜系'), + GlassSegment(label: '🥬 食材'), + GlassSegment(label: '🏷️ 标签'), + ], + selectedIndex: recommendSubIndex, + onChanged: onRecommendSubIndexChanged, + ), + ), + const SizedBox(height: DesignTokens.space3), + recommendSubIndex == 0 + ? _buildCategoryGrid(topCategories, 'category') + : recommendSubIndex == 1 + ? _buildCategoryGrid(ingredientCategories, 'ingredient') + : _buildTagContent(), + ], + ); + } + + /// 分类网格 —— 使用 GridView + shrinkWrap + NeverScrollableScrollPhysics + Widget _buildCategoryGrid(List categories, String type) { + if (categories.isEmpty) { + return SizedBox( + height: 200, + child: Center( + child: Text( + '暂无数据', + style: TextStyle( + color: isDark ? DarkDesignTokens.text2 : DesignTokens.text2, + ), + ), + ), + ); + } + + return GridView.builder( + shrinkWrap: true, + physics: const NeverScrollableScrollPhysics(), + padding: const EdgeInsets.all(DesignTokens.space4), + gridDelegate: const SliverGridDelegateWithFixedCrossAxisCount( + crossAxisCount: 4, + mainAxisSpacing: DesignTokens.space3, + crossAxisSpacing: DesignTokens.space3, + childAspectRatio: 0.75, + ), + itemCount: categories.length, + itemBuilder: (context, index) { + final category = categories[index]; + return GestureDetector( + onTap: () { + Get.toNamed( + '/category-browse', + arguments: { + 'category': category, + 'title': category.name, + 'isIngredient': type == 'ingredient', + 'loadRecipesDirectly': true, + }, + ); + }, + child: Column( + mainAxisAlignment: MainAxisAlignment.center, + children: [ + Container( + width: 56, + height: 56, + decoration: BoxDecoration( + color: DesignTokens.dynamicPrimary.withValues(alpha: 0.12), + borderRadius: DesignTokens.borderRadiusLg, + ), + child: Center( + child: Text( + category.icon ?? '🍽️', + style: const TextStyle(fontSize: 28), + ), + ), + ), + const SizedBox(height: DesignTokens.space2), + Text( + category.name, + style: TextStyle( + fontSize: DesignTokens.fontXs, + color: isDark ? DarkDesignTokens.text1 : DesignTokens.text1, + ), + maxLines: 1, + overflow: TextOverflow.ellipsis, + textAlign: TextAlign.center, + ), + ], + ), + ); + }, + ); + } + + /// 标签列表 —— 使用 Column 替代 ListView,无独立滚动 + Widget _buildTagContent() { + final tags = recommendTypeIndex == 0 ? tasteTags : cookingTags; + if (tags.isEmpty) { + return SizedBox( + height: 200, + child: Center( + child: Column( + mainAxisAlignment: MainAxisAlignment.center, + children: [ + Icon( + CupertinoIcons.tag, + size: 48, + color: isDark ? DarkDesignTokens.text3 : DesignTokens.text3, + ), + const SizedBox(height: DesignTokens.space3), + Text( + '暂无标签数据', + style: TextStyle( + fontSize: DesignTokens.fontMd, + color: isDark ? DarkDesignTokens.text2 : DesignTokens.text2, + ), + ), + ], + ), + ), + ); + } + + return Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Padding( + padding: const EdgeInsets.symmetric(horizontal: DesignTokens.space4), + child: GlassSegmentedControl( + segments: [ + GlassSegment(label: '👅 口味'), + GlassSegment(label: '🔥 工艺'), + ], + selectedIndex: recommendTypeIndex, + onChanged: onRecommendTypeIndexChanged, + ), + ), + const SizedBox(height: DesignTokens.space3), + // 使用 Column + map 替代 ListView.separated + Padding( + padding: const EdgeInsets.symmetric( + horizontal: DesignTokens.space4, + ), + child: Column( + children: tags.map((tag) { + return Padding( + padding: const EdgeInsets.only(bottom: DesignTokens.space2), + child: GestureDetector( + onTap: () { + Get.toNamed( + '/tag-recipe-list', + arguments: { + 'tagName': tag.name, + 'tagId': tag.id, + 'tagType': recommendTypeIndex == 0 ? 'taste' : 'process', + }, + ); + }, + child: ClipRRect( + borderRadius: DesignTokens.borderRadiusMd, + child: BackdropFilter( + filter: ImageFilter.blur(sigmaX: 10, sigmaY: 10), + child: Container( + width: double.infinity, + padding: const EdgeInsets.symmetric( + horizontal: DesignTokens.space3, + vertical: DesignTokens.space3, + ), + decoration: BoxDecoration( + color: isDark + ? DarkDesignTokens.glass + : DesignTokens.card.withValues(alpha: 0.6), + borderRadius: DesignTokens.borderRadiusMd, + border: Border.all( + color: isDark + ? DarkDesignTokens.glassBorder + : DesignTokens.text3.withValues(alpha: 0.08), + ), + ), + child: Row( + children: [ + Container( + width: 36, + height: 36, + decoration: BoxDecoration( + color: DesignTokens.dynamicPrimary.withValues( + alpha: 0.1, + ), + borderRadius: DesignTokens.borderRadiusSm, + ), + child: Center( + child: Text('🏷️', style: TextStyle(fontSize: 18)), + ), + ), + const SizedBox(width: DesignTokens.space3), + Expanded( + child: Text( + tag.name, + style: TextStyle( + fontSize: DesignTokens.fontSm, + fontWeight: FontWeight.w500, + color: isDark + ? DarkDesignTokens.text1 + : DesignTokens.text1, + ), + ), + ), + if (tag.count != null && tag.count! > 0) + Text( + '${tag.count}', + style: TextStyle( + fontSize: DesignTokens.fontXs, + color: isDark + ? DarkDesignTokens.text3 + : DesignTokens.text3, + ), + ), + const SizedBox(width: DesignTokens.space2), + Icon( + CupertinoIcons.chevron_right, + size: 14, + color: isDark + ? DarkDesignTokens.text3 + : DesignTokens.text3, + ), + ], + ), + ), + ), + ), + ), + ); + }).toList(), + ), + ), + ], + ); + } + + // ============================================================ + // 快捷操作面板 + // ============================================================ + + void _showQuickActions(BuildContext context, repo.HotItem recipe) { + showCupertinoModalPopup( + context: context, + builder: (BuildContext context) => CupertinoActionSheet( + title: Text( + recipe.name, + style: TextStyle( + fontSize: DesignTokens.fontLg, + fontWeight: FontWeight.w600, + ), + ), + message: Text( + '浏览量: ${recipe.count}', + style: TextStyle( + color: isDark ? DarkDesignTokens.text2 : DesignTokens.text2, + ), + ), + actions: [ + CupertinoActionSheetAction( + onPressed: () { + Navigator.pop(context); + Get.toNamed('/recipe-detail', arguments: '${recipe.id}'); + }, + child: const Row( + mainAxisAlignment: MainAxisAlignment.center, + children: [ + Icon(CupertinoIcons.eye, size: 20), + SizedBox(width: 8), + Text('查看详情'), + ], + ), + ), + CupertinoActionSheetAction( + onPressed: () { + Navigator.pop(context); + ToastService.show(message: '已添加到收藏'); + }, + isDefaultAction: true, + child: const Row( + mainAxisAlignment: MainAxisAlignment.center, + children: [ + Icon(CupertinoIcons.heart, size: 20), + SizedBox(width: 8), + Text('添加收藏'), + ], + ), + ), + ], + cancelButton: CupertinoActionSheetAction( + onPressed: () => Navigator.pop(context), + isDestructiveAction: true, + child: const Text('取消'), + ), + ), + ); + } +} diff --git a/lib/src/pages/discover/components/tool_detail_sheet.dart b/lib/src/pages/discover/components/tool_detail_sheet.dart new file mode 100644 index 0000000..d9fe0f8 --- /dev/null +++ b/lib/src/pages/discover/components/tool_detail_sheet.dart @@ -0,0 +1,190 @@ +import 'package:flutter/cupertino.dart'; +import 'package:get/get.dart'; +import 'package:mom_kitchen/src/config/design_tokens.dart'; +import 'package:mom_kitchen/src/models/tool_item_model.dart'; + +class ToolDetailSheet extends StatelessWidget { + final ToolItem tool; + final bool isDark; + + const ToolDetailSheet({ + super.key, + required this.tool, + required this.isDark, + }); + + @override + Widget build(BuildContext context) { + return Container( + decoration: BoxDecoration( + color: isDark ? DarkDesignTokens.background : DesignTokens.background, + borderRadius: BorderRadius.vertical( + top: Radius.circular(DesignTokens.radiusLg), + ), + ), + child: SafeArea( + child: Column( + mainAxisSize: MainAxisSize.min, + children: [ + Container( + width: 40, + height: 5, + margin: const EdgeInsets.only(top: 12), + decoration: BoxDecoration( + color: isDark + ? DarkDesignTokens.text3.withValues(alpha: 0.4) + : DesignTokens.text3.withValues(alpha: 0.25), + borderRadius: BorderRadius.circular(3), + ), + ), + Padding( + padding: const EdgeInsets.all(DesignTokens.space4), + child: Column( + children: [ + _buildHeroIcon(), + const SizedBox(height: DesignTokens.space3), + _buildTitle(), + if (tool.description != null) _buildDescription(), + const SizedBox(height: DesignTokens.space3), + _buildInfoChips(), + const SizedBox(height: DesignTokens.space4), + _buildUseButton(context), + ], + ), + ), + ], + ), + ), + ); + } + + Widget _buildHeroIcon() { + return Container( + width: 72, + height: 72, + decoration: BoxDecoration( + gradient: LinearGradient( + begin: Alignment.topLeft, + end: Alignment.bottomRight, + colors: [ + DesignTokens.dynamicPrimary.withValues(alpha: 0.2), + DesignTokens.secondary.withValues(alpha: 0.1), + ], + ), + borderRadius: DesignTokens.borderRadiusXl, + ), + child: Center( + child: Text(tool.icon, style: TextStyle(fontSize: 36)), + ), + ); + } + + Widget _buildTitle() { + return Text( + tool.name, + style: TextStyle( + fontSize: DesignTokens.fontXl, + fontWeight: FontWeight.w700, + color: isDark ? DarkDesignTokens.text1 : DesignTokens.text1, + ), + ); + } + + Widget _buildDescription() { + return Padding( + padding: const EdgeInsets.only(top: DesignTokens.space2), + child: Text( + tool.description!, + textAlign: TextAlign.center, + style: TextStyle( + fontSize: DesignTokens.fontSm, + color: isDark ? DarkDesignTokens.text2 : DesignTokens.text2, + ), + ), + ); + } + + Widget _buildInfoChips() { + return Row( + mainAxisAlignment: MainAxisAlignment.center, + children: [ + _buildInfoChip( + icon: CupertinoIcons.chart_bar, + label: '使用 ${tool.usageCount} 次', + ), + const SizedBox(width: DesignTokens.space3), + _buildInfoChip( + icon: tool.needsNetwork + ? CupertinoIcons.wifi + : CupertinoIcons.device_phone_portrait, + label: tool.needsNetwork ? '联网' : '本地', + ), + ], + ); + } + + Widget _buildInfoChip({required IconData icon, required String label}) { + return Container( + padding: EdgeInsets.symmetric( + horizontal: DesignTokens.space2 + 2, + vertical: DesignTokens.space1 + 2, + ), + decoration: BoxDecoration( + color: isDark + ? DarkDesignTokens.glass + : DesignTokens.text3.withValues(alpha: 0.06), + borderRadius: DesignTokens.borderRadiusSm, + ), + child: Row( + mainAxisSize: MainAxisSize.min, + children: [ + Icon(icon, size: 12, color: DesignTokens.dynamicPrimary), + const SizedBox(width: 4), + Text( + label, + style: TextStyle( + fontSize: DesignTokens.fontXs, + color: isDark ? DarkDesignTokens.text2 : DesignTokens.text2, + ), + ), + ], + ), + ); + } + + Widget _buildUseButton(BuildContext context) { + return Row( + children: [ + Expanded( + child: CupertinoButton( + padding: EdgeInsets.symmetric(vertical: DesignTokens.space3), + borderRadius: DesignTokens.borderRadiusMd, + color: DesignTokens.dynamicPrimary, + onPressed: () { + Navigator.pop(context); + Get.toNamed(tool.route); + }, + child: const Row( + mainAxisAlignment: MainAxisAlignment.center, + children: [ + Icon( + CupertinoIcons.play_circle, + size: 18, + color: CupertinoColors.white, + ), + SizedBox(width: 8), + Text( + '使用工具', + style: TextStyle( + fontWeight: FontWeight.w600, + color: CupertinoColors.white, + ), + ), + ], + ), + ), + ), + ], + ); + } +} diff --git a/lib/src/pages/discover/components/tools_panel_widget.dart b/lib/src/pages/discover/components/tools_panel_widget.dart new file mode 100644 index 0000000..8d25f7c --- /dev/null +++ b/lib/src/pages/discover/components/tools_panel_widget.dart @@ -0,0 +1,858 @@ +/* + * 文件: tools_panel_widget.dart + * 名称: 工具中心面板组件 + * 作用: 从顶部滑入的工具中心面板,支持下拉关闭、系统返回键 + * 更新: 2026-04-16 改为从底部滑入,上滑关闭手势,覆盖底部tab栏 + * 更新: 2026-04-16 重构为独立页面模式,移除 Overlay/动画依赖,支持系统返回键,下拉关闭手势 + */ + +import 'package:flutter/cupertino.dart'; +import 'package:flutter/gestures.dart'; +import 'package:flutter/material.dart'; +import 'package:flutter/services.dart'; +import 'package:get/get.dart'; +import 'package:mom_kitchen/src/config/design_tokens.dart'; +import 'package:mom_kitchen/src/models/tool_item_model.dart'; +import 'package:mom_kitchen/src/controllers/tools/tools_controller.dart'; +import 'tool_detail_sheet.dart'; +import 'browse_history_section.dart'; + +class ToolsPanelWidget extends StatefulWidget { + final bool isDark; + final ToolsController? toolsController; + final void Function(ToolItem tool) onNavigateToTool; + + const ToolsPanelWidget({ + super.key, + required this.isDark, + required this.toolsController, + required this.onNavigateToTool, + }); + + @override + State createState() => _ToolsPanelWidgetState(); +} + +class _ToolsPanelWidgetState extends State { + String _searchQuery = ''; + double _dismissOffset = 0.0; + bool _isDraggingToDismiss = false; + double _dragStartY = 0.0; + final ScrollController _scrollController = ScrollController(); + final TextEditingController _searchController = TextEditingController(); + final FocusNode _searchFocusNode = FocusNode(); + bool _isAtBottom = false; + bool _isSearchFocused = false; + + @override + void initState() { + super.initState(); + _searchFocusNode.addListener(() { + if (!_searchFocusNode.hasFocus) { + setState(() { + _isSearchFocused = false; + _searchQuery = ''; + _searchController.clear(); + widget.toolsController?.search(''); + }); + } + }); + } + + @override + void dispose() { + _scrollController.dispose(); + _searchController.dispose(); + _searchFocusNode.dispose(); + super.dispose(); + } + + void _showToolDetail(ToolItem tool) { + widget.toolsController?.recordUsage(tool.id); + showCupertinoModalPopup( + context: context, + builder: (context) => ToolDetailSheet(tool: tool, isDark: widget.isDark), + ); + } + + /// 关闭面板(pop 当前页面) + void _closePanel() { + Navigator.of(context).pop(); + } + + /// 检查是否滚动到底部 + bool _checkIsAtBottom() { + if (!_scrollController.hasClients) return false; + final maxScroll = _scrollController.position.maxScrollExtent; + final currentScroll = _scrollController.position.pixels; + return currentScroll >= maxScroll - 10; + } + + /// 处理滚动通知 + bool _handleScrollNotification(ScrollNotification notification) { + if (notification is ScrollUpdateNotification) { + setState(() { + _isAtBottom = _checkIsAtBottom(); + }); + } else if (notification is OverscrollNotification) { + // overscroll > 0 表示底部过滚(继续向下滚) + // 当滚动到底部后继续向下滚时,触发关闭手势 + if (notification.overscroll > 0 && _isAtBottom) { + setState(() { + _dismissOffset += notification.overscroll; + }); + if (_dismissOffset > 150) { + HapticFeedback.mediumImpact(); + Navigator.of(context).pop(); + } + } + } else if (notification is ScrollEndNotification) { + setState(() { + _dismissOffset = 0; + }); + } + return false; + } + + /// 上滑关闭手势处理 + void _handleDragStart(DragStartDetails details) { + // 只在列表滚动到底部后启用手势 + if (_isAtBottom) { + _isDraggingToDismiss = true; + _dragStartY = details.globalPosition.dy; + _dismissOffset = 0; + } + } + + void _handleDragUpdate(DragUpdateDetails details) { + if (!_isDraggingToDismiss) return; + + // 向上拖拽(delta.dy < 0) + if (details.delta.dy < 0) { + setState(() { + _dismissOffset += (-details.delta.dy); + }); + } else if (_dismissOffset > 0 && details.delta.dy > 0) { + // 回弹 + setState(() { + _dismissOffset = (_dismissOffset - details.delta.dy).clamp( + 0.0, + double.infinity, + ); + }); + } + } + + void _handleDragEnd(DragEndDetails details) { + if (!_isDraggingToDismiss) return; + + // 上滑超过 150px 或快速上滑 → 关闭 + if (_dismissOffset > 150 || + (details.primaryVelocity != null && details.primaryVelocity! < -800)) { + HapticFeedback.mediumImpact(); + Navigator.of(context).pop(); + } + setState(() { + _dismissOffset = 0; + _isDraggingToDismiss = false; + }); + } + + @override + Widget build(BuildContext context) { + return CupertinoPageScaffold( + backgroundColor: widget.isDark + ? DarkDesignTokens.background + : DesignTokens.background, + child: SafeArea( + bottom: false, + child: Transform.translate( + offset: Offset(0, -_dismissOffset), + child: Column( + children: [ + _buildPanelDragHandle(), + Expanded( + child: NotificationListener( + onNotification: _handleScrollNotification, + child: ListView( + controller: _scrollController, + physics: const ClampingScrollPhysics( + parent: AlwaysScrollableScrollPhysics(), + ), + padding: EdgeInsets.zero, + children: [ + _buildPanelBasicInfo(), + _buildSearchBar(), + _buildPanelFrequentTools(), + _buildPanelAllTools(), + BrowseHistorySection( + isDark: widget.isDark, + onClosePanel: _closePanel, + ), + const SizedBox(height: DesignTokens.space5), + ], + ), + ), + ), + _buildPanelBottomActions(), + ], + ), + ), + ), + ); + } + + Widget _buildPanelDragHandle() { + return Container( + width: double.infinity, + padding: const EdgeInsets.symmetric(vertical: DesignTokens.space3), + child: Column( + children: [ + Container( + width: 40, + height: 5, + decoration: BoxDecoration( + color: widget.isDark + ? DarkDesignTokens.text3.withValues(alpha: 0.4) + : DesignTokens.text3.withValues(alpha: 0.25), + borderRadius: BorderRadius.circular(3), + ), + ), + const SizedBox(height: DesignTokens.space2), + Row( + mainAxisSize: MainAxisSize.min, + children: [ + Icon( + CupertinoIcons.chevron_compact_up, + size: 16, + color: widget.isDark + ? DarkDesignTokens.text3 + : DesignTokens.text3.withValues(alpha: 0.5), + ), + const SizedBox(width: 4), + Text( + _isAtBottom ? '上滑关闭' : '滚动到底部可上滑关闭', + style: TextStyle( + fontSize: DesignTokens.fontXs, + color: widget.isDark + ? DarkDesignTokens.text3 + : DesignTokens.text3.withValues(alpha: 0.5), + ), + ), + ], + ), + ], + ), + ); + } + + Widget _buildPanelBasicInfo() { + return Padding( + padding: const EdgeInsets.fromLTRB( + DesignTokens.space4, + DesignTokens.space2, + DesignTokens.space4, + DesignTokens.space3, + ), + child: Row( + children: [ + Container( + width: 52, + height: 52, + decoration: BoxDecoration( + gradient: LinearGradient( + begin: Alignment.topLeft, + end: Alignment.bottomRight, + colors: [ + DesignTokens.dynamicPrimary.withValues(alpha: 0.2), + DesignTokens.secondary.withValues(alpha: 0.15), + ], + ), + borderRadius: DesignTokens.borderRadiusLg, + boxShadow: [ + BoxShadow( + color: DesignTokens.dynamicPrimary.withValues(alpha: 0.2), + blurRadius: 8, + offset: const Offset(0, 4), + ), + ], + ), + child: Center(child: Text('🛠️', style: TextStyle(fontSize: 28))), + ), + const SizedBox(width: DesignTokens.space3), + Expanded( + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text( + '工具中心', + style: TextStyle( + fontSize: DesignTokens.fontXxl, + fontWeight: FontWeight.w700, + color: widget.isDark + ? DarkDesignTokens.text1 + : DesignTokens.text1, + ), + ), + Text( + '发现更多烹饪好帮手', + style: TextStyle( + fontSize: DesignTokens.fontSm, + color: widget.isDark + ? DarkDesignTokens.text3 + : DesignTokens.text3, + ), + ), + ], + ), + ), + if (widget.toolsController != null) + Obx(() { + final count = widget.toolsController!.tools.length; + return Container( + padding: EdgeInsets.symmetric( + horizontal: DesignTokens.space2 + 2, + vertical: DesignTokens.space1 + 1, + ), + decoration: BoxDecoration( + color: DesignTokens.dynamicPrimary.withValues(alpha: 0.1), + borderRadius: DesignTokens.borderRadiusSm, + ), + child: Text( + '$count 个工具', + style: TextStyle( + fontSize: DesignTokens.fontXs, + fontWeight: FontWeight.w600, + color: DesignTokens.dynamicPrimary, + ), + ), + ); + }), + ], + ), + ); + } + + Widget _buildSearchBar() { + return Padding( + padding: const EdgeInsets.symmetric(horizontal: DesignTokens.space4), + child: Container( + height: 40, + decoration: BoxDecoration( + color: widget.isDark + ? DarkDesignTokens.card + : DesignTokens.text3.withValues(alpha: 0.06), + borderRadius: DesignTokens.borderRadiusMd, + border: Border.all( + color: widget.isDark + ? DarkDesignTokens.glassBorder + : DesignTokens.text3.withValues(alpha: 0.08), + ), + ), + child: Row( + children: [ + const SizedBox(width: 12), + Icon( + CupertinoIcons.search, + size: 16, + color: widget.isDark + ? DarkDesignTokens.text3 + : DesignTokens.text3, + ), + const SizedBox(width: 8), + Expanded( + child: CupertinoTextField( + controller: _searchController, + focusNode: _searchFocusNode, + placeholder: '搜索工具...', + placeholderStyle: TextStyle( + fontSize: DesignTokens.fontSm, + color: widget.isDark + ? DarkDesignTokens.text3 + : DesignTokens.text3, + ), + style: TextStyle( + fontSize: DesignTokens.fontSm, + color: widget.isDark + ? DarkDesignTokens.text1 + : DesignTokens.text1, + ), + decoration: null, + onTap: () { + setState(() => _isSearchFocused = true); + }, + onChanged: (value) { + setState(() => _searchQuery = value); + widget.toolsController?.search(value); + }, + onSubmitted: (value) { + setState(() => _isSearchFocused = false); + }, + ), + ), + if (_searchQuery.isNotEmpty) + GestureDetector( + onTap: () { + _searchController.clear(); + setState(() { + _searchQuery = ''; + _isSearchFocused = false; + }); + widget.toolsController?.search(''); + }, + child: Padding( + padding: const EdgeInsets.only(right: 12), + child: Icon( + CupertinoIcons.clear_circled_solid, + size: 16, + color: widget.isDark + ? DarkDesignTokens.text3 + : DesignTokens.text3, + ), + ), + ) + else + const SizedBox(width: 12), + ], + ), + ), + ); + } + + Widget _buildPanelFrequentTools() { + if (widget.toolsController == null) return const SizedBox.shrink(); + // 搜索框有焦点时隐藏常用工具 + if (_isSearchFocused || _searchQuery.isNotEmpty) + return const SizedBox.shrink(); + + return Padding( + padding: const EdgeInsets.fromLTRB( + DesignTokens.space4, + DesignTokens.space4, + DesignTokens.space4, + 0, + ), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Row( + mainAxisAlignment: MainAxisAlignment.spaceBetween, + children: [ + Text( + '常用工具', + style: TextStyle( + fontSize: DesignTokens.fontLg, + fontWeight: FontWeight.w700, + color: widget.isDark + ? DarkDesignTokens.text1 + : DesignTokens.text1, + ), + ), + GestureDetector( + onTap: () { + _closePanel(); + Get.toNamed('/tools'); + }, + child: Row( + mainAxisSize: MainAxisSize.min, + children: [ + Text( + '更多', + style: TextStyle( + fontSize: DesignTokens.fontSm, + color: DesignTokens.dynamicPrimary, + fontWeight: FontWeight.w500, + ), + ), + Icon( + CupertinoIcons.chevron_right, + size: 14, + color: DesignTokens.dynamicPrimary, + ), + ], + ), + ), + ], + ), + const SizedBox(height: DesignTokens.space3), + Obx(() { + final frequent = widget.toolsController!.frequentTools; + if (frequent.isEmpty) { + return Container( + padding: const EdgeInsets.all(DesignTokens.space4), + decoration: BoxDecoration( + color: widget.isDark + ? DarkDesignTokens.card + : DesignTokens.card, + borderRadius: DesignTokens.borderRadiusMd, + ), + child: Center( + child: Text( + '暂无常用工具,快去使用吧', + style: TextStyle( + fontSize: DesignTokens.fontSm, + color: widget.isDark + ? DarkDesignTokens.text3 + : DesignTokens.text3, + ), + ), + ), + ); + } + return GridView.builder( + shrinkWrap: true, + physics: const NeverScrollableScrollPhysics(), + gridDelegate: SliverGridDelegateWithFixedCrossAxisCount( + crossAxisCount: 4, + crossAxisSpacing: DesignTokens.space3, + mainAxisSpacing: DesignTokens.space3, + childAspectRatio: 0.8, + ), + itemCount: frequent.length.clamp(0, 8), + itemBuilder: (context, index) { + final tool = frequent[index]; + return _buildToolGridItem(tool); + }, + ); + }), + ], + ), + ); + } + + Widget _buildToolGridItem(ToolItem tool) { + return GestureDetector( + onTap: () { + _closePanel(); + widget.onNavigateToTool(tool); + }, + onLongPress: () => _showToolDetail(tool), + child: Container( + padding: const EdgeInsets.symmetric(vertical: DesignTokens.space2), + decoration: BoxDecoration( + color: widget.isDark ? DarkDesignTokens.card : DesignTokens.card, + borderRadius: DesignTokens.borderRadiusMd, + border: Border.all( + color: widget.isDark + ? DarkDesignTokens.glassBorder + : DesignTokens.text3.withValues(alpha: 0.08), + ), + ), + child: Column( + mainAxisAlignment: MainAxisAlignment.center, + children: [ + Stack( + children: [ + Container( + width: 44, + height: 44, + decoration: BoxDecoration( + gradient: LinearGradient( + begin: Alignment.topLeft, + end: Alignment.bottomRight, + colors: [ + DesignTokens.dynamicPrimary.withValues(alpha: 0.12), + DesignTokens.secondary.withValues(alpha: 0.06), + ], + ), + borderRadius: DesignTokens.borderRadiusMd, + ), + child: Center( + child: Text(tool.icon, style: TextStyle(fontSize: 22)), + ), + ), + if (tool.usageCount > 0) + Positioned( + right: 0, + bottom: 0, + child: Container( + padding: EdgeInsets.symmetric(horizontal: 4, vertical: 1), + decoration: BoxDecoration( + color: DesignTokens.orange, + borderRadius: BorderRadius.circular(8), + ), + child: Text( + '${tool.usageCount}', + style: TextStyle( + fontSize: 9, + fontWeight: FontWeight.w600, + color: CupertinoColors.white, + ), + ), + ), + ), + ], + ), + const SizedBox(height: DesignTokens.space1 + 2), + Text( + tool.name, + style: TextStyle( + fontSize: DesignTokens.fontXs, + fontWeight: FontWeight.w500, + color: widget.isDark + ? DarkDesignTokens.text1 + : DesignTokens.text1, + ), + maxLines: 1, + overflow: TextOverflow.ellipsis, + textAlign: TextAlign.center, + ), + ], + ), + ), + ); + } + + Widget _buildPanelAllTools() { + if (widget.toolsController == null) return const SizedBox.shrink(); + + return Padding( + padding: const EdgeInsets.only( + left: DesignTokens.space4, + right: DesignTokens.space4, + top: DesignTokens.space4, + ), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text( + '所有工具', + style: TextStyle( + fontSize: DesignTokens.fontLg, + fontWeight: FontWeight.w700, + color: widget.isDark + ? DarkDesignTokens.text1 + : DesignTokens.text1, + ), + ), + const SizedBox(height: DesignTokens.space3), + Obx(() { + final tools = widget.toolsController!.filteredTools; + final groups = >{}; + for (final t in tools) { + groups.putIfAbsent(t.category, () => []).add(t); + } + if (groups.isEmpty) { + return Container( + padding: const EdgeInsets.all(DesignTokens.space4), + decoration: BoxDecoration( + color: widget.isDark + ? DarkDesignTokens.card + : DesignTokens.card, + borderRadius: DesignTokens.borderRadiusMd, + ), + child: Center( + child: Text( + '未找到相关工具', + style: TextStyle( + fontSize: DesignTokens.fontSm, + color: widget.isDark + ? DarkDesignTokens.text3 + : DesignTokens.text3, + ), + ), + ), + ); + } + return Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: groups.entries.map((entry) { + final category = entry.key; + final items = entry.value; + final info = _getCategoryStyle(category); + return Padding( + padding: const EdgeInsets.only(bottom: DesignTokens.space3), + child: Container( + decoration: BoxDecoration( + color: widget.isDark + ? DarkDesignTokens.card + : DesignTokens.card, + borderRadius: DesignTokens.borderRadiusMd, + ), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Container( + padding: const EdgeInsets.all(DesignTokens.space3), + decoration: BoxDecoration( + color: (info['color'] as Color).withValues( + alpha: 0.1, + ), + borderRadius: const BorderRadius.vertical( + top: Radius.circular(DesignTokens.radiusMd), + ), + ), + child: Row( + children: [ + Text( + info['icon'], + style: TextStyle(fontSize: 18), + ), + const SizedBox(width: DesignTokens.space2), + Text( + info['name'], + style: TextStyle( + fontSize: DesignTokens.fontMd, + fontWeight: FontWeight.w600, + color: widget.isDark + ? DarkDesignTokens.text1 + : DesignTokens.text1, + ), + ), + const SizedBox(width: DesignTokens.space2), + Text( + '${items.length}个', + style: TextStyle( + fontSize: DesignTokens.fontXs, + color: widget.isDark + ? DarkDesignTokens.text3 + : DesignTokens.text3, + ), + ), + ], + ), + ), + Padding( + padding: const EdgeInsets.all(DesignTokens.space3), + child: GridView.builder( + shrinkWrap: true, + physics: const NeverScrollableScrollPhysics(), + gridDelegate: + SliverGridDelegateWithFixedCrossAxisCount( + crossAxisCount: 4, + crossAxisSpacing: DesignTokens.space2, + mainAxisSpacing: DesignTokens.space2, + childAspectRatio: 0.8, + ), + itemCount: items.length, + itemBuilder: (context, index) => + _buildToolGridItem(items[index]), + ), + ), + ], + ), + ), + ); + }).toList(), + ); + }), + ], + ), + ); + } + + Map _getCategoryStyle(String category) { + const map = { + 'cooking': {'name': '烹饪助手', 'icon': '🍳', 'color': Color(0xFFFF6B35)}, + 'health': {'name': '健康营养', 'icon': '💊', 'color': Color(0xFF2ECC71)}, + 'data': {'name': '数据查询', 'icon': '📊', 'color': Color(0xFF3498DB)}, + 'planning': {'name': '规划管理', 'icon': '📅', 'color': Color(0xFF9B59B6)}, + }; + return map[category] ?? + {'name': category, 'icon': '📦', 'color': DesignTokens.dynamicPrimary}; + } + + Widget _buildPanelBottomActions() { + return Container( + padding: EdgeInsets.fromLTRB( + DesignTokens.space4, + DesignTokens.space3, + DesignTokens.space4, + DesignTokens.space4 + MediaQuery.of(context).padding.bottom, + ), + decoration: BoxDecoration( + color: widget.isDark + ? DarkDesignTokens.background + : DesignTokens.background, + border: Border( + top: BorderSide( + color: widget.isDark + ? DarkDesignTokens.glassBorder + : DesignTokens.text3.withValues(alpha: 0.08), + ), + ), + ), + child: Row( + children: [ + _buildBottomActionItem( + icon: CupertinoIcons.house, + label: '首页', + onTap: () { + _closePanel(); + Get.offAllNamed('/home'); + }, + ), + _buildBottomActionItem( + icon: CupertinoIcons.heart, + label: '收藏', + onTap: () { + _closePanel(); + Get.toNamed('/favorites'); + }, + ), + _buildBottomActionItem( + icon: CupertinoIcons.gear_alt, + label: '设置', + onTap: () { + _closePanel(); + Get.toNamed('/personalization'); + }, + ), + _buildBottomActionItem( + icon: CupertinoIcons.info_circle, + label: '关于', + onTap: () { + _closePanel(); + Get.toNamed('/about'); + }, + ), + ], + ), + ); + } + + Widget _buildBottomActionItem({ + required IconData icon, + required String label, + required VoidCallback onTap, + }) { + return Expanded( + child: GestureDetector( + onTap: onTap, + behavior: HitTestBehavior.opaque, + child: Column( + mainAxisSize: MainAxisSize.min, + children: [ + Container( + width: 44, + height: 44, + decoration: BoxDecoration( + color: widget.isDark + ? DarkDesignTokens.glass + : DesignTokens.text3.withValues(alpha: 0.06), + borderRadius: DesignTokens.borderRadiusMd, + ), + child: Icon( + icon, + size: 20, + color: widget.isDark + ? DarkDesignTokens.text2 + : DesignTokens.text2, + ), + ), + const SizedBox(height: DesignTokens.space1), + Text( + label, + style: TextStyle( + fontSize: DesignTokens.fontXs, + color: widget.isDark + ? DarkDesignTokens.text2 + : DesignTokens.text2, + ), + ), + ], + ), + ), + ); + } +} diff --git a/lib/src/pages/discover/discover_page.dart b/lib/src/pages/discover/discover_page.dart index bdf4b8d..fa6ad64 100644 --- a/lib/src/pages/discover/discover_page.dart +++ b/lib/src/pages/discover/discover_page.dart @@ -1,14 +1,18 @@ /* * 文件: discover_page.dart * 名称: 发现页面 - * 作用: iOS 26 风格的发现页面,整合热门排行+今天吃什么+搜索 + * 作用: iOS 26 风格的发现页面,整合热门排行+今天吃什么+搜索,支持下拉进入工具中心 * 更新: 2026-04-10 购物清单入口添加 Badge 显示数量 * 更新: 2026-04-13 推荐tab新增口味/工艺标签入口,修复分类导航 + * 更新: 2026-04-16 新增下拉进入工具中心功能,移除顶部4个固定按钮至收藏页 + * 更新: 2026-04-16 代码拆分,将工具面板和分区内容提取为独立组件 + * 更新: 2026-04-16 工具中心改为从顶部滑入页面导航,支持系统返回键,动画可打断 */ +import 'dart:ui'; import 'package:flutter/cupertino.dart'; +import 'package:flutter/services.dart'; 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'; @@ -17,11 +21,11 @@ import 'package:mom_kitchen/src/repositories/recipe_repository.dart'; import 'package:mom_kitchen/src/widgets/glass/glass_search_bar.dart'; import 'package:mom_kitchen/src/widgets/glass/glass_segmented_control.dart'; import 'package:mom_kitchen/src/controllers/feed/hot_controller.dart'; -import 'package:mom_kitchen/src/controllers/data/favorites_controller.dart'; -import 'package:mom_kitchen/src/controllers/data/shopping_list_controller.dart'; -import 'package:mom_kitchen/src/models/feed_item_model.dart'; -import 'package:mom_kitchen/src/services/ui/toast_service.dart'; -import 'package:mom_kitchen/src/repositories/hot_repository.dart' as repo; +import 'package:mom_kitchen/src/controllers/tools/tools_controller.dart'; +import 'package:mom_kitchen/src/models/tool_item_model.dart'; + +import 'components/tools_panel_widget.dart'; +import 'components/discover_sections_widget.dart'; class DiscoverPage extends StatefulWidget { const DiscoverPage({super.key}); @@ -33,6 +37,7 @@ class DiscoverPage extends StatefulWidget { class _DiscoverPageState extends State { int _segmentIndex = 0; int _recommendTypeIndex = 0; + int _recommendSubIndex = 0; late HotController _hotController; final RecipeRepository _recipeRepo = RecipeRepository(); List _topCategories = []; @@ -41,11 +46,60 @@ class _DiscoverPageState extends State { List _cookingTags = []; bool _isLoadingCategories = true; + ToolsController? _toolsController; + + /// 下拉阈值 + static const double _pullThreshold = 60.0; + + /// 全局下拉偏移(用于手势拦截 + 下拉提示动画) + double _pullOffset = 0.0; + bool _isPulling = false; + bool _hasTriggeredHaptic = false; + bool _isNavigating = false; + + final ScrollController _scrollController = ScrollController(); + + /// 搜索相关 + final TextEditingController _searchController = TextEditingController(); + final FocusNode _searchFocusNode = FocusNode(); + String _searchQuery = ''; + @override void initState() { super.initState(); _hotController = Get.find(); _loadCategories(); + _initToolsController(); + + // 搜索框响应式搜索监听 + _searchController.addListener(_onSearchChanged); + } + + void _onSearchChanged() { + final query = _searchController.text; + if (query != _searchQuery) { + setState(() => _searchQuery = query); + _toolsController?.search(query); + } + } + + void _initToolsController() { + try { + if (Get.isRegistered()) { + _toolsController = Get.find(); + } + } catch (e) { + debugPrint('DiscoverPage: ToolsController init error: $e'); + } + } + + @override + void dispose() { + _searchController.removeListener(_onSearchChanged); + _searchController.dispose(); + _searchFocusNode.dispose(); + _scrollController.dispose(); + super.dispose(); } Future _loadCategories() async { @@ -71,6 +125,129 @@ class _DiscoverPageState extends State { } } + /// 打开工具中心页面(从顶部滑入) + void _openToolsCenter() { + if (_isNavigating) return; + _isNavigating = true; + HapticFeedback.heavyImpact(); + + final isDark = CupertinoTheme.brightnessOf(context) == Brightness.dark; + Navigator.of(context) + .push( + PageRouteBuilder( + pageBuilder: (context, animation, secondaryAnimation) => + ToolsPanelWidget( + isDark: isDark, + toolsController: _toolsController, + onNavigateToTool: _navigateToTool, + ), + transitionDuration: const Duration(milliseconds: 400), + reverseTransitionDuration: const Duration(milliseconds: 350), + barrierDismissible: true, + barrierColor: const Color(0x33000000), + barrierLabel: 'Tools Center', + transitionsBuilder: (context, animation, secondaryAnimation, child) { + // 从顶部滑入 + 渐变 + final slideAnimation = Tween( + begin: const Offset(0, -1), + end: Offset.zero, + ).animate(CurvedAnimation( + parent: animation, + curve: Curves.easeOutCubic, + )); + + final fadeAnimation = Tween( + begin: 0.0, + end: 1.0, + ).animate(CurvedAnimation( + parent: animation, + curve: Curves.easeOut, + )); + + return SlideTransition( + position: slideAnimation, + child: FadeTransition( + opacity: fadeAnimation, + child: child, + ), + ); + }, + ), + ) + .then((_) { + _isNavigating = false; + }); + } + + void _navigateToTool(ToolItem tool) { + _toolsController?.recordUsage(tool.id); + if (tool.route.isNotEmpty) { + Get.toNamed(tool.route); + } + } + + /// 处理滚动通知:到顶时下拉打开工具中心 + /// 所有子列表已禁用独立滚动,下拉手势统一由此处理 + bool _handleScrollNotification(ScrollNotification notification) { + if (_isNavigating) return false; + + if (notification is OverscrollNotification) { + // overscroll < 0 表示顶部下拉过滚 + if (notification.overscroll < 0) { + _isPulling = true; + _pullOffset = (_pullOffset + (-notification.overscroll) * 0.8).clamp( + 0.0, + 1000.0, + ); + _scheduleRebuild(); + // 下拉时震动反馈(达到50%阈值时触发一次) + if (_pullOffset >= _pullThreshold * 0.5 && !_hasTriggeredHaptic) { + _hasTriggeredHaptic = true; + HapticFeedback.mediumImpact(); + } + if (_pullOffset >= _pullThreshold) { + _pullOffset = 0; + _isPulling = false; + _hasTriggeredHaptic = false; + _openToolsCenter(); + } + return true; + } + } else if (notification is ScrollEndNotification) { + if (_pullOffset > 0 && _pullOffset < _pullThreshold) { + _pullOffset = 0; + _scheduleRebuild(); + } + _isPulling = false; + _hasTriggeredHaptic = false; + } else if (notification is ScrollUpdateNotification) { + // 上滑回弹取消下拉 + if (_isPulling && + notification.scrollDelta != null && + notification.scrollDelta! > 0) { + _pullOffset = (_pullOffset - notification.scrollDelta! * 0.5).clamp( + 0.0, + 1000.0, + ); + _scheduleRebuild(); + } + } + + return false; + } + + /// 延迟到下一帧执行 setState + bool _rebuildScheduled = false; + void _scheduleRebuild() { + if (_rebuildScheduled) return; + _rebuildScheduled = true; + WidgetsBinding.instance.addPostFrameCallback((_) { + if (!mounted) return; + _rebuildScheduled = false; + setState(() {}); + }); + } + @override Widget build(BuildContext context) { final isDark = CupertinoTheme.brightnessOf(context) == Brightness.dark; @@ -82,482 +259,104 @@ class _DiscoverPageState extends State { child: SafeArea( child: Column( children: [ - Padding( - padding: const EdgeInsets.symmetric( - horizontal: DesignTokens.space4, - vertical: DesignTokens.space3, - ), - child: Row( - children: [ - Text( - '发现', - style: TextStyle( - fontSize: DesignTokens.fontXxl, - fontWeight: FontWeight.w700, - color: isDark - ? DarkDesignTokens.text1 - : DesignTokens.text1, - ), + _buildHeader(isDark), + _buildSearchBar(isDark), + const SizedBox(height: DesignTokens.space2), + _buildToolsBar(isDark), + const SizedBox(height: DesignTokens.space3), + _buildSegmentControl(), + const SizedBox(height: DesignTokens.space3), + Expanded( + child: NotificationListener( + onNotification: _handleScrollNotification, + child: CustomScrollView( + controller: _scrollController, + // ClampingScrollPhysics 在到顶下拉时产生 OverscrollNotification + // 这是劫持下拉手势的关键——OverscrollNotification.overscroll < 0 = 顶部下拉 + physics: const ClampingScrollPhysics( + parent: AlwaysScrollableScrollPhysics(), ), - const Spacer(), - ], + slivers: [ + SliverToBoxAdapter(child: _buildPullHint()), + if (_searchQuery.isNotEmpty) + SliverToBoxAdapter(child: _buildSearchResults(isDark)), + SliverToBoxAdapter( + child: DiscoverSectionsWidget( + isDark: isDark, + segmentIndex: _segmentIndex, + recommendTypeIndex: _recommendTypeIndex, + recommendSubIndex: _recommendSubIndex, + hotController: _hotController, + topCategories: _topCategories, + ingredientCategories: _ingredientCategories, + tasteTags: _tasteTags, + cookingTags: _cookingTags, + isLoadingCategories: _isLoadingCategories, + onRecommendSubIndexChanged: (i) { + setState(() => _recommendSubIndex = i); + }, + onRecommendTypeIndexChanged: (i) { + setState(() => _recommendTypeIndex = i); + }, + ), + ), + ], + ), ), ), - Padding( - padding: const EdgeInsets.symmetric( - horizontal: DesignTokens.space4, - ), - child: GlassSearchBar( - readOnly: true, - onTap: () { - Get.toNamed(AppRoutes.search); - }, - ), - ), - const SizedBox(height: DesignTokens.space3), - Padding( - padding: const EdgeInsets.symmetric( - horizontal: DesignTokens.space4, - ), - child: _buildQuickActions(isDark), - ), - const SizedBox(height: DesignTokens.space3), - Padding( - padding: const EdgeInsets.symmetric( - horizontal: DesignTokens.space4, - ), - child: GlassSegmentedControl( - segments: const [ - GlassSegment(label: '🔥 热门'), - GlassSegment(label: '🎲 今天吃什么'), - GlassSegment(label: '⭐ 推荐'), - ], - selectedIndex: _segmentIndex, - onChanged: (i) { - setState(() => _segmentIndex = i); - }, - ), - ), - const SizedBox(height: DesignTokens.space3), - Expanded(child: _buildSegmentContent(isDark)), ], ), ), ); } - Widget _buildSegmentContent(bool isDark) { - switch (_segmentIndex) { - case 0: - return _buildHotSection(isDark); - case 1: - return _buildWhatToEatSection(isDark); - case 2: - return _buildRecommendSection(isDark); - default: - return const SizedBox.shrink(); - } - } - - Widget _buildQuickActions(bool isDark) { - final shoppingController = Get.find(); - - return Obx(() { - final shoppingCount = shoppingController.uncheckedCount; - - return Row( - children: [ - _buildQuickActionItem( - isDark: isDark, - emoji: '🥗', - label: '营养中心', - color: DesignTokens.green, - onTap: () => Get.toNamed('/nutrition'), - ), - SizedBox(width: DesignTokens.space2), - _buildQuickActionItem( - isDark: isDark, - emoji: '🛒', - label: '购物清单', - color: DesignTokens.secondary, - onTap: () => Get.toNamed('/shopping-list'), - badgeCount: shoppingCount, - ), - SizedBox(width: DesignTokens.space2), - _buildQuickActionItem( - isDark: isDark, - emoji: '📊', - label: '周报', - color: DesignTokens.dynamicPrimary, - onTap: () => Get.toNamed('/nutrition-report'), - ), - const SizedBox(width: DesignTokens.space2), - _buildQuickActionItem( - isDark: isDark, - emoji: '🎯', - label: '目标', - color: DesignTokens.red, - onTap: () => Get.toNamed('/goal-setting'), - ), - ], - ); - }); - } - - Widget _buildQuickActionItem({ - required bool isDark, - required String emoji, - required String label, - required Color color, - required VoidCallback onTap, - int badgeCount = 0, - }) { - return Expanded( - child: GestureDetector( - onTap: onTap, - child: Container( - padding: const EdgeInsets.symmetric(vertical: DesignTokens.space3), - decoration: BoxDecoration( - color: color.withValues(alpha: 0.1), - borderRadius: DesignTokens.borderRadiusLg, - ), - child: Column( - children: [ - badges.Badge( - showBadge: badgeCount > 0, - position: badges.BadgePosition.topEnd(top: -6, end: -10), - badgeStyle: badges.BadgeStyle( - badgeColor: isDark ? DarkDesignTokens.red : DesignTokens.red, - padding: const EdgeInsets.symmetric( - horizontal: 5, - vertical: 2, - ), - borderRadius: DesignTokens.borderRadiusSm, - ), - badgeContent: Text( - badgeCount > 99 ? '99+' : '$badgeCount', - style: const TextStyle( - color: CupertinoColors.white, - fontSize: 9, - fontWeight: FontWeight.w600, - ), - ), - child: Text(emoji, style: const TextStyle(fontSize: 24)), - ), - const SizedBox(height: DesignTokens.space1), - Text( - label, - style: TextStyle( - fontSize: DesignTokens.fontXs, - fontWeight: FontWeight.w500, - color: color, - ), - ), - ], - ), - ), + Widget _buildHeader(bool isDark) { + return Padding( + padding: const EdgeInsets.symmetric( + horizontal: DesignTokens.space4, + vertical: DesignTokens.space3, ), - ); - } - - Widget _buildHotSection(bool isDark) { - return Obx(() { - final List hotList = _hotController.hotList; - final isLoading = _hotController.isLoading.value; - - if (isLoading) { - return const Center(child: CupertinoActivityIndicator()); - } - - if (hotList.isEmpty) { - return Center( - child: Column( - mainAxisAlignment: MainAxisAlignment.center, - children: [ - Icon( - CupertinoIcons.flame, - size: 48, - color: isDark ? DarkDesignTokens.text3 : DesignTokens.text3, - ), - const SizedBox(height: DesignTokens.space3), - Text( - '暂无热门数据', - style: TextStyle( - fontSize: DesignTokens.fontLg, - color: isDark ? DarkDesignTokens.text2 : DesignTokens.text2, - ), - ), - ], - ), - ); - } - - return Column( + child: Row( children: [ - Padding( - padding: const EdgeInsets.symmetric( - horizontal: DesignTokens.space4, - ), - child: GlassSegmentedControl( - segments: HotController.periodNames - .map((name) => GlassSegment(label: name)) - .toList(), - selectedIndex: _hotController.currentPeriod.value.index, - onChanged: (i) { - _hotController.switchPeriod(HotPeriod.values[i]); - }, - ), - ), - const SizedBox(height: DesignTokens.space3), - Expanded( - child: ListView.builder( - padding: const EdgeInsets.symmetric( - horizontal: DesignTokens.space4, - ), - itemCount: hotList.length, - itemBuilder: (context, index) { - final recipe = hotList[index]; - return Padding( - padding: const EdgeInsets.only( - bottom: DesignTokens.space2 + 2, - ), - child: Dismissible( - key: ValueKey('hot_${recipe.id}_$index'), - direction: DismissDirection.horizontal, - confirmDismiss: (direction) async { - if (direction == DismissDirection.endToStart) { - Get.toNamed( - '/recipe-detail', - arguments: '${recipe.id}', - ); - return false; - } else { - return true; - } - }, - onDismissed: (direction) { - if (direction == DismissDirection.startToEnd) { - _showQuickActions(recipe, isDark); - } - }, - background: Container( - alignment: Alignment.centerLeft, - padding: EdgeInsets.only(left: 20), - decoration: BoxDecoration( - color: DesignTokens.dynamicPrimary, - borderRadius: DesignTokens.borderRadiusLg, - ), - child: Row( - mainAxisSize: MainAxisSize.min, - children: [ - Icon( - CupertinoIcons.heart_fill, - color: CupertinoColors.white, - ), - const SizedBox(width: 8), - Text( - '收藏', - style: TextStyle( - color: CupertinoColors.white, - fontWeight: FontWeight.w600, - ), - ), - ], - ), - ), - secondaryBackground: Container( - alignment: Alignment.centerRight, - padding: const EdgeInsets.only(right: 20), - decoration: BoxDecoration( - color: DesignTokens.green, - borderRadius: DesignTokens.borderRadiusLg, - ), - child: Row( - mainAxisSize: MainAxisSize.min, - mainAxisAlignment: MainAxisAlignment.end, - children: [ - Text( - '查看详情', - style: TextStyle( - color: CupertinoColors.white, - fontWeight: FontWeight.w600, - ), - ), - const SizedBox(width: 8), - Icon( - CupertinoIcons.eye, - color: CupertinoColors.white, - ), - ], - ), - ), - child: GestureDetector( - onTap: () { - Get.toNamed( - '/recipe-detail', - arguments: '${recipe.id}', - ); - }, - child: Container( - padding: const EdgeInsets.all(DesignTokens.space3), - decoration: BoxDecoration( - color: isDark - ? DarkDesignTokens.card - : DesignTokens.card, - borderRadius: DesignTokens.borderRadiusLg, - boxShadow: DesignTokens.shadowsSm, - ), - child: Row( - children: [ - Container( - width: 28, - height: 28, - decoration: BoxDecoration( - color: index < 3 - ? DesignTokens.orange.withValues( - alpha: 0.15, - ) - : DesignTokens.text3.withValues(alpha: 0.1), - borderRadius: DesignTokens.borderRadiusSm, - ), - child: Center( - child: Text( - '${index + 1}', - style: TextStyle( - fontSize: DesignTokens.fontSm, - fontWeight: FontWeight.w700, - color: index < 3 - ? DesignTokens.orange - : (isDark - ? DarkDesignTokens.text2 - : DesignTokens.text2), - ), - ), - ), - ), - const SizedBox(width: DesignTokens.space3), - Expanded( - child: Column( - crossAxisAlignment: CrossAxisAlignment.start, - children: [ - Text( - recipe.name, - style: TextStyle( - fontSize: DesignTokens.fontMd, - fontWeight: FontWeight.w500, - color: isDark - ? DarkDesignTokens.text1 - : DesignTokens.text1, - ), - ), - const SizedBox(height: 2), - Text( - '${_hotController.sortByName}: ${recipe.count}', - style: TextStyle( - fontSize: DesignTokens.fontSm, - color: isDark - ? DarkDesignTokens.text2 - : DesignTokens.text2, - ), - ), - ], - ), - ), - Icon( - CupertinoIcons.chevron_forward, - size: 16, - color: isDark - ? DarkDesignTokens.text3 - : DesignTokens.text3, - ), - ], - ), - ), - ), - ), - ); - }, - ), - ), - ], - ); - }); - } - - Widget _buildWhatToEatSection(bool isDark) { - return Center( - child: Column( - mainAxisAlignment: MainAxisAlignment.center, - children: [ - Container( - width: 100, - height: 100, - decoration: BoxDecoration( - color: DesignTokens.primaryLight, - borderRadius: DesignTokens.borderRadiusXl, - ), - child: Icon( - CupertinoIcons.shuffle, - size: 44, - color: DesignTokens.dynamicPrimary, - ), - ), - const SizedBox(height: DesignTokens.space4), Text( - '不知道吃什么?', + '发现', style: TextStyle( - fontSize: DesignTokens.fontXl, - fontWeight: FontWeight.w600, + fontSize: DesignTokens.fontXxl, + fontWeight: FontWeight.w700, color: isDark ? DarkDesignTokens.text1 : DesignTokens.text1, ), ), - const SizedBox(height: DesignTokens.space2), - Text( - '让小妈厨房帮你决定', - style: TextStyle( - fontSize: DesignTokens.fontMd, - color: isDark ? DarkDesignTokens.text2 : DesignTokens.text2, - ), - ), - const SizedBox(height: DesignTokens.space6), - SizedBox( - width: 200, - child: CupertinoButton.filled( - borderRadius: DesignTokens.borderRadiusLg, - onPressed: () { - Get.toNamed('/what-to-eat'); - }, - child: const Row( - mainAxisAlignment: MainAxisAlignment.center, - children: [ - Icon(CupertinoIcons.shuffle, size: 20), - SizedBox(width: DesignTokens.space2), - Text('随机推荐'), - ], + const Spacer(), + // 工具中心入口按钮 + GestureDetector( + onTap: _openToolsCenter, + child: Container( + padding: const EdgeInsets.symmetric( + horizontal: DesignTokens.space3, + vertical: DesignTokens.space1 + 2, + ), + decoration: BoxDecoration( + color: DesignTokens.dynamicPrimary.withValues(alpha: 0.1), + borderRadius: DesignTokens.borderRadiusMd, ), - ), - ), - const SizedBox(height: DesignTokens.space3), - SizedBox( - width: 200, - child: CupertinoButton( - borderRadius: DesignTokens.borderRadiusLg, - color: DesignTokens.primaryLight, - onPressed: () { - Get.toNamed('/what-to-eat'); - }, child: Row( - mainAxisAlignment: MainAxisAlignment.center, + mainAxisSize: MainAxisSize.min, children: [ - Icon( - CupertinoIcons.lightbulb, - size: 20, - color: DesignTokens.dynamicPrimary, - ), - SizedBox(width: DesignTokens.space2), + Text('🛠️', style: TextStyle(fontSize: 14)), + const SizedBox(width: 4), Text( - '智能推荐', - style: TextStyle(color: DesignTokens.dynamicPrimary), + '工具中心', + style: TextStyle( + fontSize: DesignTokens.fontXs, + fontWeight: FontWeight.w600, + color: DesignTokens.dynamicPrimary, + ), + ), + const SizedBox(width: 2), + Icon( + CupertinoIcons.chevron_right, + size: 12, + color: DesignTokens.dynamicPrimary, ), ], ), @@ -568,400 +367,331 @@ class _DiscoverPageState extends State { ); } - void _showQuickActions(repo.HotItem recipe, bool isDark) { - try { - final favoritesController = Get.find(); - final feedItem = FeedItemModel( - id: recipe.id, - title: recipe.name, - cover: '', - feedType: 'recipe', - createdAt: DateTime.now().toIso8601String(), - ); - final isFav = favoritesController.isFavorited(recipe.id); - - showCupertinoModalPopup( - context: context, - builder: (ctx) => CupertinoActionSheet( - title: Text( - recipe.name, - style: TextStyle( - fontSize: DesignTokens.fontMd, - fontWeight: FontWeight.w600, - ), - ), - message: Text( - '浏览量: ${recipe.count}', - style: TextStyle( - fontSize: DesignTokens.fontSm, - color: isDark ? DarkDesignTokens.text2 : DesignTokens.text2, - ), - ), - actions: [ - CupertinoActionSheetAction( - child: Row( - mainAxisAlignment: MainAxisAlignment.center, - children: [ - Icon( - isFav ? CupertinoIcons.heart_fill : CupertinoIcons.heart, - color: isFav - ? DesignTokens.red - : DesignTokens.dynamicPrimary, - ), - const SizedBox(width: 8), - Text(isFav ? '取消收藏' : '收藏菜谱'), - ], - ), - onPressed: () { - Navigator.pop(ctx); - favoritesController.toggleFavorite(feedItem); - ToastService.show(message: isFav ? '已取消收藏 ❤️' : '已收藏 ❤️'); - }, - ), - CupertinoActionSheetAction( - child: Row( - mainAxisAlignment: MainAxisAlignment.center, - children: [ - Icon(CupertinoIcons.eye, color: DesignTokens.green), - const SizedBox(width: 8), - Text('查看详情'), - ], - ), - onPressed: () { - Navigator.pop(ctx); - Get.toNamed('/recipe-detail', arguments: '${recipe.id}'); - }, - ), - CupertinoActionSheetAction( - child: Row( - mainAxisAlignment: MainAxisAlignment.center, - children: [ - Icon(CupertinoIcons.share_up, color: DesignTokens.orange), - const SizedBox(width: 8), - Text('分享'), - ], - ), - onPressed: () { - Navigator.pop(ctx); - ToastService.show(message: '分享功能开发中 📤'); - }, - ), - ], - cancelButton: CupertinoActionSheetAction( - isDefaultAction: true, - onPressed: () => Navigator.pop(ctx), - child: const Text('取消'), - ), - ), - ); - } catch (e) { - debugPrint('DiscoverPage _showQuickActions error: $e'); - } - } - - Widget _buildRecommendSection(bool isDark) { - if (_isLoadingCategories) { - return const Center(child: CupertinoActivityIndicator()); - } - - final categories = _recommendTypeIndex == 0 - ? _topCategories - : _ingredientCategories; - final typeLabel = _recommendTypeIndex == 0 ? '菜谱' : '食材'; - - if (categories.isEmpty && _tasteTags.isEmpty && _cookingTags.isEmpty) { - return Center( - child: Column( - mainAxisAlignment: MainAxisAlignment.center, - children: [ - const Text('📂', style: TextStyle(fontSize: 56)), - const SizedBox(height: DesignTokens.space4), - Text( - '暂无$typeLabel分类数据', - style: TextStyle( - fontSize: DesignTokens.fontLg, - color: isDark ? DarkDesignTokens.text2 : DesignTokens.text2, - ), - ), - ], - ), - ); - } - - return Column( - children: [ - Padding( - padding: const EdgeInsets.symmetric(horizontal: DesignTokens.space4), - child: GlassSegmentedControl( - segments: const [ - GlassSegment(label: '📖 菜谱'), - GlassSegment(label: '🥬 食材'), - GlassSegment(label: '👅 口味'), - GlassSegment(label: '🍳 工艺'), - ], - selectedIndex: _recommendTypeIndex, - onChanged: (i) { - setState(() => _recommendTypeIndex = i); - }, - ), - ), - const SizedBox(height: DesignTokens.space3), - Expanded(child: _buildRecommendContent(isDark, categories, typeLabel)), - ], + Widget _buildSearchBar(bool isDark) { + return Padding( + padding: const EdgeInsets.symmetric(horizontal: DesignTokens.space4), + child: GlassSearchBar( + controller: _searchController, + focusNode: _searchFocusNode, + placeholder: '搜索菜谱、食材、工具...', + readOnly: false, + onChanged: (value) { + setState(() => _searchQuery = value); + // 响应式搜索工具 + _toolsController?.search(value); + }, + onTap: () { + // 聚焦时如果已有内容不跳转,允许直接输入搜索 + }, + ), ); } - Widget _buildRecommendContent( - bool isDark, - List categories, - String typeLabel, - ) { - if (_recommendTypeIndex == 2) { - return _buildTagGrid(isDark, _tasteTags, '口味', 'taste'); - } - if (_recommendTypeIndex == 3) { - return _buildTagGrid(isDark, _cookingTags, '工艺', 'cooking'); - } - return _buildCategoryGrid(isDark, categories, typeLabel); - } + /// 搜索结果展示区域 + Widget _buildSearchResults(bool isDark) { + if (_toolsController == null) return const SizedBox.shrink(); - Widget _buildTagGrid( - bool isDark, - List tags, - String label, - String tagType, - ) { - if (tags.isEmpty) { - return Center( + return Obx(() { + final filteredTools = _toolsController!.filteredTools; + if (filteredTools.isEmpty || _searchQuery.isEmpty) { + return const SizedBox.shrink(); + } + + // 只显示匹配的工具,最多5个 + final displayTools = filteredTools.take(5).toList(); + return Container( + margin: const EdgeInsets.symmetric( + horizontal: DesignTokens.space4, + vertical: 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.glassBorder + : DesignTokens.text3.withValues(alpha: 0.08), + ), + ), child: Column( - mainAxisAlignment: MainAxisAlignment.center, + crossAxisAlignment: CrossAxisAlignment.start, children: [ - const Text('🏷️', style: TextStyle(fontSize: 56)), - const SizedBox(height: DesignTokens.space4), - Text( - '暂无$label标签数据', - style: TextStyle( - fontSize: DesignTokens.fontLg, - color: isDark ? DarkDesignTokens.text2 : DesignTokens.text2, - ), + Row( + children: [ + Text( + '🛠️ 工具', + style: TextStyle( + fontSize: DesignTokens.fontSm, + fontWeight: FontWeight.w600, + color: isDark ? DarkDesignTokens.text1 : DesignTokens.text1, + ), + ), + const Spacer(), + GestureDetector( + onTap: () => Get.toNamed(AppRoutes.search), + child: Text( + '搜索菜谱 →', + style: TextStyle( + fontSize: DesignTokens.fontXs, + color: DesignTokens.dynamicPrimary, + ), + ), + ), + ], ), + const SizedBox(height: DesignTokens.space2), + ...displayTools.map((tool) => _buildSearchResultItem(tool, isDark)), ], ), ); - } + }); + } - return GridView.builder( - padding: const EdgeInsets.symmetric( - horizontal: DesignTokens.space4, - vertical: DesignTokens.space2, - ), - gridDelegate: const SliverGridDelegateWithFixedCrossAxisCount( - crossAxisCount: 3, - mainAxisSpacing: DesignTokens.space2, - crossAxisSpacing: DesignTokens.space2, - childAspectRatio: 2.2, - ), - itemCount: tags.length, - itemBuilder: (context, index) { - final tag = tags[index]; - return GestureDetector( - onTap: () { - Get.toNamed( - AppRoutes.tagRecipeList, - arguments: { - 'tagName': tag.name, - 'tagId': tag.id, - 'tagType': tagType, - }, - ); - }, - 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), - ), - ), - child: Center( + Widget _buildSearchResultItem(ToolItem tool, bool isDark) { + return GestureDetector( + onTap: () { + _searchController.clear(); + _searchQuery = ''; + _toolsController?.search(''); + _navigateToTool(tool); + }, + child: Container( + padding: const EdgeInsets.symmetric(vertical: DesignTokens.space2), + child: Row( + children: [ + Text(tool.icon, style: const TextStyle(fontSize: 20)), + const SizedBox(width: DesignTokens.space2), + Expanded( child: Text( - tag.name, + tool.name, style: TextStyle( - fontSize: DesignTokens.fontSm, - fontWeight: FontWeight.w500, + fontSize: DesignTokens.fontMd, color: isDark ? DarkDesignTokens.text1 : DesignTokens.text1, ), - textAlign: TextAlign.center, maxLines: 1, overflow: TextOverflow.ellipsis, ), ), - ), - ); - }, - ); - } - - Widget _buildCategoryGrid( - bool isDark, - List categories, - String typeLabel, - ) { - if (categories.isEmpty) { - return Center( - child: Column( - mainAxisAlignment: MainAxisAlignment.center, - children: [ - const Text('📂', style: TextStyle(fontSize: 56)), - const SizedBox(height: DesignTokens.space4), - Text( - '暂无$typeLabel分类数据', - style: TextStyle( - fontSize: DesignTokens.fontLg, - color: isDark ? DarkDesignTokens.text2 : DesignTokens.text2, + if (tool.description != null) + Text( + tool.description!, + style: TextStyle( + fontSize: DesignTokens.fontXs, + color: isDark ? DarkDesignTokens.text3 : DesignTokens.text3, + ), + maxLines: 1, + overflow: TextOverflow.ellipsis, ), + const SizedBox(width: DesignTokens.space2), + Icon( + CupertinoIcons.chevron_right, + size: 14, + color: isDark ? DarkDesignTokens.text3 : DesignTokens.text3, ), ], ), - ); - } - - 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: categories.length, - itemBuilder: (context, index) { - final cat = categories[index]; - final hasChildren = cat.children.isNotEmpty; + ); + } - return GestureDetector( - onTap: () { - final hasRecipes = cat.count != null && cat.count! > 0; - final isIngredient = _recommendTypeIndex == 1; + Widget _buildToolsBar(bool isDark) { + if (_toolsController == null) return const SizedBox.shrink(); - if (hasChildren) { - Get.toNamed( - '/category-browse', - arguments: { - 'category': cat, - 'title': cat.name, - 'isIngredient': isIngredient, - }, - ); - } else if (hasRecipes || isIngredient) { - Get.toNamed( - '/category-browse', - arguments: { - 'category': cat, - 'title': '${cat.name} (${cat.count}道$typeLabel)', - 'loadRecipesDirectly': true, - 'isIngredient': isIngredient, - }, - ); - } else { - Get.toNamed( - '/category-browse', - arguments: { - 'category': cat, - 'title': cat.name, - 'isIngredient': isIngredient, - }, - ); + return Obx(() { + final tools = _toolsController!.frequentTools; + if (tools.isEmpty) { + return const SizedBox.shrink(); + } + return Container( + height: 90, + margin: const EdgeInsets.only(bottom: DesignTokens.space2), + child: ListView.separated( + scrollDirection: Axis.horizontal, + padding: const EdgeInsets.symmetric(horizontal: DesignTokens.space4), + itemCount: tools.length + 1, + separatorBuilder: (context, index) => + const SizedBox(width: DesignTokens.space2), + itemBuilder: (context, index) { + if (index == tools.length) { + return _buildMoreToolsCard(isDark); } + return _buildToolShortcut(tools[index], isDark); }, + ), + ); + }); + } + + Widget _buildToolShortcut(ToolItem tool, bool isDark) { + return GestureDetector( + onTap: () => _navigateToTool(tool), + child: ClipRRect( + borderRadius: DesignTokens.borderRadiusMd, + child: BackdropFilter( + filter: ImageFilter.blur(sigmaX: 12, sigmaY: 12), child: Container( + width: 72, + padding: const EdgeInsets.symmetric(vertical: DesignTokens.space2), decoration: BoxDecoration( - color: isDark ? DarkDesignTokens.card : DesignTokens.card, - borderRadius: DesignTokens.borderRadiusLg, + 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.1), + : DesignTokens.text3.withValues(alpha: 0.12), ), - boxShadow: DesignTokens.shadowsSm, ), - child: Stack( + child: Column( + mainAxisAlignment: MainAxisAlignment.center, children: [ - Positioned( - right: -10, - bottom: -10, - child: Text( - cat.displayIcon, - style: TextStyle( - fontSize: 72, - color: (DesignTokens.dynamicPrimary).withValues( - alpha: 0.08, + Stack( + children: [ + Container( + width: 44, + height: 44, + decoration: BoxDecoration( + color: DesignTokens.dynamicPrimary.withValues( + alpha: 0.1, + ), + borderRadius: DesignTokens.borderRadiusMd, + ), + child: Center( + child: Text(tool.icon, style: TextStyle(fontSize: 24)), ), ), + Positioned( + top: 0, + right: 0, + child: Container( + width: 8, + height: 8, + decoration: BoxDecoration( + color: tool.needsNetwork + ? DesignTokens.green + : DesignTokens.dynamicPrimary, + shape: BoxShape.circle, + ), + ), + ), + ], + ), + const SizedBox(height: 6), + Text( + tool.name, + style: TextStyle( + fontSize: DesignTokens.fontXs, + color: isDark ? DarkDesignTokens.text1 : DesignTokens.text1, + ), + maxLines: 1, + overflow: TextOverflow.ellipsis, + ), + ], + ), + ), + ), + ), + ); + } + + Widget _buildMoreToolsCard(bool isDark) { + return GestureDetector( + onTap: _openToolsCenter, + child: ClipRRect( + borderRadius: DesignTokens.borderRadiusMd, + child: BackdropFilter( + filter: ImageFilter.blur(sigmaX: 12, sigmaY: 12), + child: Container( + width: 72, + padding: EdgeInsets.symmetric(vertical: DesignTokens.space2), + decoration: BoxDecoration( + color: isDark + ? DesignTokens.dynamicPrimary.withValues(alpha: 0.08) + : DesignTokens.primaryLight.withValues(alpha: 0.7), + borderRadius: DesignTokens.borderRadiusMd, + border: Border.all( + color: DesignTokens.dynamicPrimary.withValues(alpha: 0.25), + ), + ), + child: Column( + mainAxisAlignment: MainAxisAlignment.center, + children: [ + Container( + width: 44, + height: 44, + decoration: BoxDecoration( + color: DesignTokens.dynamicPrimary.withValues(alpha: 0.15), + borderRadius: DesignTokens.borderRadiusMd, + ), + child: Center( + child: Text('🛠️', style: TextStyle(fontSize: 24)), ), ), - Padding( - padding: const EdgeInsets.all(DesignTokens.space3), - child: Column( - crossAxisAlignment: CrossAxisAlignment.start, - children: [ - Text( - cat.displayIcon, - style: const TextStyle(fontSize: 32), - ), - const SizedBox(height: DesignTokens.space2), - Text( - cat.name, - style: TextStyle( - fontSize: DesignTokens.fontMd, - fontWeight: FontWeight.w600, - color: isDark - ? DarkDesignTokens.text1 - : DesignTokens.text1, - ), - maxLines: 1, - overflow: TextOverflow.ellipsis, - ), - const SizedBox(height: 2), - Text( - hasChildren - ? '${cat.children.length} 个子类' - : (cat.count != null && cat.count! > 0 - ? '${cat.count} 道$typeLabel' - : '浏览'), - style: TextStyle( - fontSize: DesignTokens.fontXs, - color: isDark - ? DarkDesignTokens.text3 - : DesignTokens.text3, - ), - ), - const Spacer(), - Row( - mainAxisAlignment: MainAxisAlignment.end, - children: [ - Icon( - CupertinoIcons.chevron_forward, - size: 16, - color: isDark - ? DarkDesignTokens.text3 - : DesignTokens.text3, - ), - ], - ), - ], + SizedBox(height: 6), + Text( + '更多', + style: TextStyle( + fontSize: DesignTokens.fontXs, + color: DesignTokens.dynamicPrimary, ), ), ], ), ), - ); - }, + ), + ), + ); + } + + Widget _buildSegmentControl() { + return Padding( + padding: const EdgeInsets.symmetric(horizontal: DesignTokens.space4), + child: GlassSegmentedControl( + segments: const [ + GlassSegment(label: '🔥 热门'), + GlassSegment(label: '🎲 今天吃什么'), + GlassSegment(label: '⭐ 推荐'), + ], + selectedIndex: _segmentIndex, + onChanged: (i) { + setState(() => _segmentIndex = i); + }, + ), + ); + } + + Widget _buildPullHint() { + final showHint = _pullOffset > 0; + if (!showHint) return const SizedBox.shrink(); + + final progress = (_pullOffset / _pullThreshold).clamp(0.0, 1.0); + return Container( + height: 40 + _pullOffset * 0.3, + alignment: Alignment.center, + child: Opacity( + opacity: progress, + child: Row( + mainAxisSize: MainAxisSize.min, + children: [ + Icon( + CupertinoIcons.chevron_compact_down, + size: 18, + color: DesignTokens.dynamicPrimary.withValues(alpha: progress), + ), + const SizedBox(width: 4), + Text( + progress >= 1.0 ? '松开打开工具中心' : '下拉打开工具中心', + style: TextStyle( + fontSize: DesignTokens.fontXs, + color: DesignTokens.dynamicPrimary.withValues(alpha: progress), + fontWeight: FontWeight.w500, + ), + ), + ], + ), + ), ); } } diff --git a/lib/src/pages/discover/discover_page.dart.bak b/lib/src/pages/discover/discover_page.dart.bak new file mode 100644 index 0000000..c7be568 --- /dev/null +++ b/lib/src/pages/discover/discover_page.dart.bak @@ -0,0 +1,1834 @@ +/* + * 文件: discover_page.dart + * 名称: 发现页面 + * 作用: iOS 26 风格的发现页面,整合热门排行+今天吃什么+搜索,支持下拉进入工具中心 + * 更新: 2026-04-10 购物清单入口添加 Badge 显示数量 + * 更新: 2026-04-13 推荐tab新增口味/工艺标签入口,修复分类导航 + * 更新: 2026-04-16 新增下拉进入工具中心功能,移除顶部4个固定按钮至收藏页 + */ + +import 'dart:ui'; +import 'package:flutter/cupertino.dart'; +import 'package:flutter/material.dart'; +import 'package:get/get.dart'; +import 'package:shared_preferences/shared_preferences.dart'; +import 'package:mom_kitchen/src/config/design_tokens.dart'; +import 'package:mom_kitchen/src/config/app_routes.dart'; +import 'package:mom_kitchen/src/models/recipe/category_model.dart'; +import 'package:mom_kitchen/src/models/recipe/tag_model.dart'; +import 'package:mom_kitchen/src/repositories/recipe_repository.dart'; +import 'package:mom_kitchen/src/widgets/glass/glass_search_bar.dart'; +import 'package:mom_kitchen/src/widgets/glass/glass_segmented_control.dart'; +import 'package:mom_kitchen/src/controllers/feed/hot_controller.dart'; +import 'package:mom_kitchen/src/controllers/tools/tools_controller.dart'; +import 'package:mom_kitchen/src/models/tool_item_model.dart'; +import 'package:mom_kitchen/src/services/ui/toast_service.dart'; +import 'package:mom_kitchen/src/repositories/hot_repository.dart' as repo; + +class DiscoverPage extends StatefulWidget { + const DiscoverPage({super.key}); + + @override + State createState() => _DiscoverPageState(); +} + +class _DiscoverPageState extends State + with SingleTickerProviderStateMixin { + int _segmentIndex = 0; + int _recommendTypeIndex = 0; + int _recommendSubIndex = 0; + late HotController _hotController; + final RecipeRepository _recipeRepo = RecipeRepository(); + List _topCategories = []; + List _ingredientCategories = []; + List _tasteTags = []; + List _cookingTags = []; + bool _isLoadingCategories = true; + + ToolsController? _toolsController; + + static const double _pullThreshold = 80.0; + + double _pullOffset = 0.0; + bool _isPanelOpen = false; + bool _isPulling = false; + double _panelHeight = 0.0; + + late final AnimationController _panelController; + late final Animation _panelAnimation; + AnimationStatusListener? _panelStatusListener; + + final ScrollController _scrollController = ScrollController(); + + @override + void initState() { + super.initState(); + _hotController = Get.find(); + _loadCategories(); + _initToolsController(); + + _panelController = AnimationController( + vsync: this, + duration: const Duration(milliseconds: 350), + ); + _panelAnimation = CurvedAnimation( + parent: _panelController, + curve: Curves.easeOutCubic, + reverseCurve: Curves.easeInCubic, + ); + _panelStatusListener = (status) { + if (status == AnimationStatus.dismissed) { + setState(() => _isPanelOpen = false); + } else if (status == AnimationStatus.completed) { + setState(() => _isPanelOpen = true); + } + }; + _panelController.addStatusListener(_panelStatusListener!); + } + + void _initToolsController() { + try { + if (Get.isRegistered()) { + _toolsController = Get.find(); + } + } catch (e) { + debugPrint('DiscoverPage: ToolsController init error: $e'); + } + } + + @override + void dispose() { + if (_panelStatusListener != null) { + _panelController.removeStatusListener(_panelStatusListener!); + } + _panelController.dispose(); + _scrollController.dispose(); + super.dispose(); + } + + Future _loadCategories() async { + try { + final categories = await _recipeRepo.fetchCategories(); + final ingredientCategories = await _recipeRepo.fetchCategories( + type: 'ingredient', + ); + final tasteTags = await _recipeRepo.fetchTasteTags(); + final cookingTags = await _recipeRepo.fetchCookingTags(); + if (mounted) { + setState(() { + _topCategories = categories; + _ingredientCategories = ingredientCategories; + _tasteTags = tasteTags; + _cookingTags = cookingTags; + _isLoadingCategories = false; + }); + } + } catch (e) { + debugPrint('DiscoverPage loadCategories error: $e'); + if (mounted) setState(() => _isLoadingCategories = false); + } + } + + void _openPanel() { + _panelController.forward(); + } + + void _closePanel() { + _panelController.reverse(); + } + + bool _handleScrollNotification(ScrollNotification notification) { + if (_isPanelOpen) return false; + + if (notification is OverscrollNotification) { + if (notification.overscroll < 0) { + _isPulling = true; + setState(() { + _pullOffset = (_pullOffset + (-notification.overscroll) * 0.5).clamp( + 0.0, + _panelMaxHeight, + ); + }); + if (_pullOffset >= _pullThreshold) { + _pullOffset = 0; + _isPulling = false; + _openPanel(); + } + return true; + } + } else if (notification is ScrollEndNotification) { + if (_pullOffset > 0 && _pullOffset < _pullThreshold) { + setState(() => _pullOffset = 0); + } + _isPulling = false; + } else if (notification is ScrollUpdateNotification) { + if (_isPulling && + notification.scrollDelta != null && + notification.scrollDelta! > 0) { + setState(() { + _pullOffset = (_pullOffset - notification.scrollDelta! * 0.5).clamp( + 0.0, + _panelMaxHeight, + ); + }); + } + } + + return false; + } + + @override + Widget build(BuildContext context) { + final isDark = CupertinoTheme.brightnessOf(context) == Brightness.dark; + + return CupertinoPageScaffold( + backgroundColor: isDark + ? DarkDesignTokens.background + : DesignTokens.background, + child: SafeArea( + child: Stack( + children: [ + Column( + children: [ + Padding( + padding: const EdgeInsets.symmetric( + horizontal: DesignTokens.space4, + vertical: DesignTokens.space3, + ), + child: Row( + children: [ + Text( + '发现', + style: TextStyle( + fontSize: DesignTokens.fontXxl, + fontWeight: FontWeight.w700, + color: isDark + ? DarkDesignTokens.text1 + : DesignTokens.text1, + ), + ), + const SizedBox(width: DesignTokens.space2), + Text( + '下拉查看更多工具', + style: TextStyle( + fontSize: DesignTokens.fontXs, + color: isDark + ? DarkDesignTokens.text3 + : DesignTokens.text3, + fontWeight: FontWeight.w400, + ), + ), + const Spacer(), + ], + ), + ), + Padding( + padding: const EdgeInsets.symmetric( + horizontal: DesignTokens.space4, + ), + child: GlassSearchBar( + readOnly: true, + onTap: () { + Get.toNamed(AppRoutes.search); + }, + ), + ), + const SizedBox(height: DesignTokens.space2), + _buildToolsBar(isDark), + const SizedBox(height: DesignTokens.space3), + Padding( + padding: const EdgeInsets.symmetric( + horizontal: DesignTokens.space4, + ), + child: GlassSegmentedControl( + segments: const [ + GlassSegment(label: '🔥 热门'), + GlassSegment(label: '🎲 今天吃什么'), + GlassSegment(label: '⭐ 推荐'), + ], + selectedIndex: _segmentIndex, + onChanged: (i) { + setState(() => _segmentIndex = i); + }, + ), + ), + const SizedBox(height: DesignTokens.space3), + Expanded( + child: NotificationListener( + onNotification: _handleScrollNotification, + child: CustomScrollView( + controller: _scrollController, + physics: const ClampingScrollPhysics( + parent: AlwaysScrollableScrollPhysics(), + ), + slivers: [ + SliverToBoxAdapter(child: _buildPullHint(isDark)), + SliverToBoxAdapter(child: _buildSegmentContent(isDark)), + ], + ), + ), + ), + ], + ), + _buildToolsPanel(isDark), + ], + ), + ), + ); + } + + Widget _buildToolsBar(bool isDark) { + if (_toolsController == null) return const SizedBox.shrink(); + + return Obx(() { + final tools = _toolsController!.frequentTools; + if (tools.isEmpty) { + return const SizedBox.shrink(); + } + return Container( + height: 90, + margin: const EdgeInsets.only(bottom: DesignTokens.space2), + child: ListView.separated( + scrollDirection: Axis.horizontal, + padding: const EdgeInsets.symmetric(horizontal: DesignTokens.space4), + itemCount: tools.length + 1, + separatorBuilder: (context, index) => + const SizedBox(width: DesignTokens.space2), + itemBuilder: (context, index) { + if (index == tools.length) { + return _buildMoreToolsCard(isDark); + } + return _buildToolShortcut(tools[index], isDark); + }, + ), + ); + }); + } + + Widget _buildToolShortcut(ToolItem tool, bool isDark) { + return GestureDetector( + onTap: () => _navigateToTool(tool), + child: ClipRRect( + borderRadius: DesignTokens.borderRadiusMd, + child: BackdropFilter( + filter: ImageFilter.blur(sigmaX: 12, sigmaY: 12), + child: Container( + width: 72, + padding: const EdgeInsets.symmetric(vertical: DesignTokens.space2), + decoration: BoxDecoration( + color: isDark + ? DarkDesignTokens.glass + : DesignTokens.card.withValues(alpha: 0.75), + borderRadius: DesignTokens.borderRadiusMd, + border: Border.all( + color: isDark + ? DarkDesignTokens.glassBorder + : DesignTokens.text3.withValues(alpha: 0.12), + ), + ), + child: Column( + mainAxisAlignment: MainAxisAlignment.center, + children: [ + Stack( + children: [ + Container( + width: 44, + height: 44, + decoration: BoxDecoration( + color: DesignTokens.dynamicPrimary.withValues( + alpha: 0.1, + ), + borderRadius: DesignTokens.borderRadiusMd, + ), + child: Center( + child: Text(tool.icon, style: TextStyle(fontSize: 24)), + ), + ), + Positioned( + top: 0, + right: 0, + child: Container( + width: 8, + height: 8, + decoration: BoxDecoration( + color: tool.needsNetwork + ? DesignTokens.green + : DesignTokens.dynamicPrimary, + shape: BoxShape.circle, + ), + ), + ), + ], + ), + const SizedBox(height: 6), + Text( + tool.name, + style: TextStyle( + fontSize: DesignTokens.fontXs, + color: isDark ? DarkDesignTokens.text1 : DesignTokens.text1, + ), + maxLines: 1, + overflow: TextOverflow.ellipsis, + ), + ], + ), + ), + ), + ), + ); + } + + Widget _buildMoreToolsCard(bool isDark) { + return GestureDetector( + onTap: _openPanel, + child: ClipRRect( + borderRadius: DesignTokens.borderRadiusMd, + child: BackdropFilter( + filter: ImageFilter.blur(sigmaX: 12, sigmaY: 12), + child: Container( + width: 72, + padding: EdgeInsets.symmetric(vertical: DesignTokens.space2), + decoration: BoxDecoration( + color: isDark + ? DesignTokens.dynamicPrimary.withValues(alpha: 0.08) + : DesignTokens.primaryLight.withValues(alpha: 0.7), + borderRadius: DesignTokens.borderRadiusMd, + border: Border.all( + color: DesignTokens.dynamicPrimary.withValues(alpha: 0.25), + ), + ), + child: Column( + mainAxisAlignment: MainAxisAlignment.center, + children: [ + Container( + width: 44, + height: 44, + decoration: BoxDecoration( + color: DesignTokens.dynamicPrimary.withValues(alpha: 0.15), + borderRadius: DesignTokens.borderRadiusMd, + ), + child: Center( + child: Text('🛠️', style: TextStyle(fontSize: 24)), + ), + ), + SizedBox(height: 6), + Text( + '更多', + style: TextStyle( + fontSize: DesignTokens.fontXs, + color: DesignTokens.dynamicPrimary, + ), + ), + ], + ), + ), + ), + ), + ); + } + + void _navigateToTool(ToolItem tool) { + _toolsController?.recordUsage(tool.id); + if (tool.route.isNotEmpty) { + Get.toNamed(tool.route); + } + } + + Widget _buildPullHint(bool isDark) { + final showHint = _pullOffset > 0 && !_isPanelOpen; + if (!showHint) return const SizedBox.shrink(); + + final progress = (_pullOffset / _pullThreshold).clamp(0.0, 1.0); + return Container( + height: 40 + _pullOffset * 0.3, + alignment: Alignment.center, + child: Opacity( + opacity: progress, + child: Row( + mainAxisSize: MainAxisSize.min, + children: [ + Icon( + CupertinoIcons.chevron_compact_up, + size: 18, + color: DesignTokens.dynamicPrimary.withValues(alpha: progress), + ), + const SizedBox(width: 4), + Text( + progress >= 1.0 ? '松开进入工具中心' : '下拉查看更多工具', + style: TextStyle( + fontSize: DesignTokens.fontXs, + color: DesignTokens.dynamicPrimary.withValues(alpha: progress), + fontWeight: FontWeight.w500, + ), + ), + ], + ), + ), + ); + } + + Widget _buildToolsPanel(bool isDark) { + final screenHeight = MediaQuery.of(context).size.height; + if (_panelHeight != screenHeight) { + _panelHeight = screenHeight; + } + + return AnimatedBuilder( + animation: _panelAnimation, + builder: (context, child) { + final value = _panelAnimation.value; + if (value <= 0 && !_isPanelOpen) return const SizedBox.shrink(); + + return Stack( + children: [ + GestureDetector( + onTap: _closePanel, + behavior: HitTestBehavior.opaque, + child: Container( + color: Colors.black.withValues(alpha: 0.5 * value), + ), + ), + Positioned( + top: 0, + left: 0, + right: 0, + bottom: 0, + child: Transform.translate( + offset: Offset(0, -_panelHeight * (1 - value)), + child: Container( + height: _panelHeight, + clipBehavior: Clip.hardEdge, + decoration: BoxDecoration( + color: isDark + ? DarkDesignTokens.background + : DesignTokens.background, + borderRadius: BorderRadius.only( + bottomLeft: Radius.circular(DesignTokens.radiusLg), + bottomRight: Radius.circular(DesignTokens.radiusLg), + ), + ), + child: SafeArea( + top: false, + child: Column( + children: [ + _buildPanelDragHandle(isDark), + Expanded( + child: ListView( + physics: const BouncingScrollPhysics(), + padding: EdgeInsets.zero, + children: [ + _buildPanelBasicInfo(isDark), + _buildPanelFrequentTools(isDark), + _buildPanelAllTools(isDark), + _buildPanelBrowseHistory(isDark), + const SizedBox(height: DesignTokens.space4), + ], + ), + ), + _buildPanelBottomActions(isDark), + ], + ), + ), + ), + ), + ), + ], + ); + }, + ); + } + + Widget _buildPanelDragHandle(bool isDark) { + return GestureDetector( + onVerticalDragEnd: (details) { + if (details.primaryVelocity != null && details.primaryVelocity! > 300) { + _closePanel(); + } + }, + behavior: HitTestBehavior.translucent, + child: Container( + width: double.infinity, + padding: const EdgeInsets.symmetric(vertical: DesignTokens.space2), + child: Column( + children: [ + Container( + width: 36, + height: 5, + decoration: BoxDecoration( + color: isDark + ? DarkDesignTokens.text3.withValues(alpha: 0.4) + : DesignTokens.text3.withValues(alpha: 0.25), + borderRadius: BorderRadius.circular(3), + ), + ), + const SizedBox(height: DesignTokens.space1), + Text( + '下滑关闭', + style: TextStyle( + fontSize: DesignTokens.fontXs, + color: isDark + ? DarkDesignTokens.text3 + : DesignTokens.text3.withValues(alpha: 0.5), + ), + ), + ], + ), + ), + ); + } + + Widget _buildPanelBasicInfo(bool isDark) { + return Padding( + padding: const EdgeInsets.fromLTRB( + DesignTokens.space4, + DesignTokens.space2, + DesignTokens.space4, + DesignTokens.space3, + ), + child: Row( + children: [ + Container( + width: 48, + height: 48, + decoration: BoxDecoration( + gradient: LinearGradient( + begin: Alignment.topLeft, + end: Alignment.bottomRight, + colors: [ + DesignTokens.dynamicPrimary.withValues(alpha: 0.15), + DesignTokens.secondary.withValues(alpha: 0.1), + ], + ), + borderRadius: DesignTokens.borderRadiusMd, + ), + child: Center(child: Text('🛠️', style: TextStyle(fontSize: 24))), + ), + const SizedBox(width: DesignTokens.space3), + Expanded( + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text( + '工具中心', + style: TextStyle( + fontSize: DesignTokens.fontXxl, + fontWeight: FontWeight.w700, + color: isDark ? DarkDesignTokens.text1 : DesignTokens.text1, + ), + ), + Text( + '发现更多烹饪好帮手', + style: TextStyle( + fontSize: DesignTokens.fontSm, + color: isDark ? DarkDesignTokens.text3 : DesignTokens.text3, + ), + ), + ], + ), + ), + Obx(() { + if (_toolsController == null) return const SizedBox.shrink(); + final count = _toolsController!.tools.length; + return Container( + padding: EdgeInsets.symmetric( + horizontal: DesignTokens.space2 + 2, + vertical: DesignTokens.space1 + 1, + ), + decoration: BoxDecoration( + color: DesignTokens.dynamicPrimary.withValues(alpha: 0.1), + borderRadius: DesignTokens.borderRadiusSm, + ), + child: Text( + '$count 个工具', + style: TextStyle( + fontSize: DesignTokens.fontXs, + fontWeight: FontWeight.w600, + color: DesignTokens.dynamicPrimary, + ), + ), + ); + }), + ], + ), + ); + } + + Widget _buildPanelFrequentTools(bool isDark) { + if (_toolsController == null) return const SizedBox.shrink(); + + return Padding( + padding: const EdgeInsets.symmetric(horizontal: DesignTokens.space4), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Row( + mainAxisAlignment: MainAxisAlignment.spaceBetween, + children: [ + Text( + '常用工具', + style: TextStyle( + fontSize: DesignTokens.fontLg, + fontWeight: FontWeight.w700, + color: isDark ? DarkDesignTokens.text1 : DesignTokens.text1, + ), + ), + GestureDetector( + onTap: () { + _closePanel(); + Get.toNamed('/tools-center'); + }, + child: Row( + mainAxisSize: MainAxisSize.min, + children: [ + Text( + '更多', + style: TextStyle( + fontSize: DesignTokens.fontSm, + color: DesignTokens.dynamicPrimary, + fontWeight: FontWeight.w500, + ), + ), + Icon( + CupertinoIcons.chevron_right, + size: 14, + color: DesignTokens.dynamicPrimary, + ), + ], + ), + ), + ], + ), + const SizedBox(height: DesignTokens.space3), + Obx(() { + final frequent = _toolsController!.frequentTools; + if (frequent.isEmpty) return const SizedBox.shrink(); + return GridView.builder( + shrinkWrap: true, + physics: const NeverScrollableScrollPhysics(), + gridDelegate: SliverGridDelegateWithFixedCrossAxisCount( + crossAxisCount: 4, + crossAxisSpacing: DesignTokens.space3, + mainAxisSpacing: DesignTokens.space3, + childAspectRatio: 0.85, + ), + itemCount: frequent.length.clamp(0, 8), + itemBuilder: (context, index) { + final tool = frequent[index]; + return _buildToolGridItem(tool, isDark); + }, + ); + }), + ], + ), + ); + } + + Widget _buildToolGridItem(ToolItem tool, bool isDark) { + return GestureDetector( + onTap: () { + _closePanel(); + _navigateToTool(tool); + }, + child: Column( + mainAxisAlignment: MainAxisAlignment.center, + children: [ + Container( + width: 48, + height: 48, + decoration: BoxDecoration( + gradient: LinearGradient( + begin: Alignment.topLeft, + end: Alignment.bottomRight, + colors: [ + DesignTokens.dynamicPrimary.withValues(alpha: 0.12), + DesignTokens.secondary.withValues(alpha: 0.06), + ], + ), + borderRadius: DesignTokens.borderRadiusMd, + ), + child: Center(child: Text(tool.icon, fontSize: 24)), + ), + const SizedBox(height: DesignTokens.space1 + 2), + Text( + tool.name, + style: TextStyle( + fontSize: DesignTokens.fontXs, + fontWeight: FontWeight.w500, + color: isDark ? DarkDesignTokens.text1 : DesignTokens.text1, + ), + maxLines: 1, + overflow: TextOverflow.ellipsis, + textAlign: TextAlign.center, + ), + if (tool.usageCount > 0) + Text( + '${tool.usageCount}次', + style: TextStyle( + fontSize: 10, + color: isDark + ? DarkDesignTokens.text3 + : DesignTokens.text3.withValues(alpha: 0.6), + ), + ), + ], + ), + ); + } + + Widget _buildPanelAllTools(bool isDark) { + if (_toolsController == null) return const SizedBox.shrink(); + + return Padding( + padding: const EdgeInsets.only( + left: DesignTokens.space4, + right: DesignTokens.space4, + top: DesignTokens.space4, + ), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text( + '所有工具', + style: TextStyle( + fontSize: DesignTokens.fontLg, + fontWeight: FontWeight.w700, + color: isDark ? DarkDesignTokens.text1 : DesignTokens.text1, + ), + ), + const SizedBox(height: DesignTokens.space3), + Obx(() { + final tools = _toolsController!.tools; + final groups = >{}; + for (final t in tools) { + groups.putIfAbsent(t.category, () => []).add(t); + } + return Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: groups.entries.map((entry) { + final category = entry.key; + final items = entry.value; + final info = _getCategoryStyle(category); + return Padding( + padding: const EdgeInsets.only(bottom: DesignTokens.space3), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Row( + children: [ + Text(info['icon'], style: TextStyle(fontSize: 16)), + const SizedBox(width: DesignTokens.space2), + Text( + info['name'], + style: TextStyle( + fontSize: DesignTokens.fontMd, + fontWeight: FontWeight.w600, + color: isDark + ? DarkDesignTokens.text1 + : DesignTokens.text1, + ), + ), + const SizedBox(width: DesignTokens.space2), + Text( + '${items.length}个', + style: TextStyle( + fontSize: DesignTokens.fontXs, + color: isDark + ? DarkDesignTokens.text3 + : DesignTokens.text3, + ), + ), + ], + ), + const SizedBox(height: DesignTokens.space2), + GridView.builder( + shrinkWrap: true, + physics: const NeverScrollableScrollPhysics(), + gridDelegate: SliverGridDelegateWithFixedCrossAxisCount( + crossAxisCount: 4, + crossAxisSpacing: DesignTokens.space3, + mainAxisSpacing: DesignTokens.space3, + childAspectRatio: 0.85, + ), + itemCount: items.length, + itemBuilder: (context, index) => + _buildToolGridItem(items[index], isDark), + ), + ], + ), + ); + }).toList(), + ); + }), + ], + ), + ); + } + + Map _getCategoryStyle(String category) { + const map = { + 'cooking': {'name': '烹饪助手', 'icon': '🍳'}, + 'health': {'name': '健康营养', 'icon': '💊'}, + 'data': {'name': '数据查询', 'icon': '📊'}, + 'planning': {'name': '规划管理', 'icon': '📅'}, + }; + return map[category] ?? {'name': category, 'icon': '📦'}; + } + + Widget _buildPanelBrowseHistory(bool isDark) { + return Padding( + padding: const EdgeInsets.only( + left: DesignTokens.space4, + right: DesignTokens.space4, + top: DesignTokens.space4, + ), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Row( + mainAxisAlignment: MainAxisAlignment.spaceBetween, + children: [ + Text( + '浏览记录', + style: TextStyle( + fontSize: DesignTokens.fontLg, + fontWeight: FontWeight.w700, + color: isDark ? DarkDesignTokens.text1 : DesignTokens.text1, + ), + ), + GestureDetector( + onTap: () => Get.toNamed('/favorites'), + child: Row( + mainAxisSize: MainAxisSize.min, + children: [ + Text( + '更多', + style: TextStyle( + fontSize: DesignTokens.fontSm, + color: DesignTokens.dynamicPrimary, + fontWeight: FontWeight.w500, + ), + ), + Icon( + CupertinoIcons.chevron_right, + size: 14, + color: DesignTokens.dynamicPrimary, + ), + ], + ), + ), + ], + ), + const SizedBox(height: DesignTokens.space3), + SizedBox( + height: 120, + child: FutureBuilder>>( + future: _loadBrowseHistory(), + builder: (context, snapshot) { + if (!snapshot.hasData || snapshot.data!.isEmpty) { + return Center( + child: Column( + mainAxisAlignment: MainAxisAlignment.center, + children: [ + Icon( + CupertinoIcons.clock, + size: 32, + color: isDark + ? DarkDesignTokens.text3 + : DesignTokens.text3.withValues(alpha: 0.4), + ), + const SizedBox(height: DesignTokens.space2), + Text( + '暂无浏览记录', + style: TextStyle( + fontSize: DesignTokens.fontSm, + color: isDark + ? DarkDesignTokens.text3 + : DesignTokens.text3.withValues(alpha: 0.5), + ), + ), + ], + ), + ); + } + final history = snapshot.data!; + return ListView.separated( + scrollDirection: Axis.horizontal, + padding: const EdgeInsets.symmetric( + horizontal: DesignTokens.space1, + ), + separatorBuilder: (_, __) => + const SizedBox(width: DesignTokens.space2), + itemCount: history.length, + itemBuilder: (context, index) { + final item = history[index]; + return GestureDetector( + onTap: () { + _closePanel(); + Get.toNamed( + '/recipe-detail', + arguments: '${item['id']}', + ); + }, + child: Container( + width: 140, + decoration: BoxDecoration( + color: isDark + ? DarkDesignTokens.card + : DesignTokens.card.withValues(alpha: 0.6), + borderRadius: DesignTokens.borderRadiusMd, + ), + child: Column( + children: [ + Expanded( + flex: 3, + child: ClipRRect( + borderRadius: BorderRadius.vertical( + top: DesignTokens.radiusMd, + ), + child: Container( + width: double.infinity, + decoration: BoxDecoration( + gradient: LinearGradient( + colors: [ + DesignTokens.dynamicPrimary.withValues( + alpha: 0.15, + ), + DesignTokens.secondary.withValues( + alpha: 0.08, + ), + ], + ), + ), + child: Center( + child: Text('🍽️', fontSize: 28), + ), + ), + ), + ), + Expanded( + flex: 2, + child: Padding( + padding: const EdgeInsets.all( + DesignTokens.space2, + ), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + mainAxisAlignment: MainAxisAlignment.center, + children: [ + Text( + item['name'] ?? '', + style: TextStyle( + fontSize: DesignTokens.fontXs, + fontWeight: FontWeight.w600, + color: isDark + ? DarkDesignTokens.text1 + : DesignTokens.text1, + ), + maxLines: 1, + overflow: TextOverflow.ellipsis, + ), + Text( + item['time'] ?? '', + style: TextStyle( + fontSize: 10, + color: isDark + ? DarkDesignTokens.text3 + : DesignTokens.text3.withValues( + alpha: 0.6, + ), + ), + ), + ], + ), + ), + ), + ], + ), + ), + ); + }, + ); + }, + ), + ), + ], + ), + ); + } + + Future>> _loadBrowseHistory() async { + try { + final prefs = await SharedPreferences.getInstance(); + final raw = prefs.getStringList('browse_history') ?? []; + return raw.map((e) { + final parts = e.split('|'); + return { + 'id': parts.length > 0 ? parts[0] : '', + 'name': parts.length > 1 ? parts[1] : '', + 'time': parts.length > 2 ? parts[2] : '', + }; + }).toList(); + } catch (_) { + return []; + } + } + + Widget _buildPanelBottomActions(bool isDark) { + return Container( + padding: EdgeInsets.fromLTRB( + DesignTokens.space4, + DesignTokens.space3, + DesignTokens.space4, + DesignTokens.space4 + MediaQuery.of(context).padding.bottom, + ), + decoration: BoxDecoration( + border: Border( + top: BorderSide( + color: isDark + ? DarkDesignTokens.glassBorder + : DesignTokens.text3.withValues(alpha: 0.08), + ), + ), + ), + child: SafeArea( + top: false, + child: Row( + children: [ + _buildBottomActionItem( + icon: CupertinoIcons.house, + label: '首页', + onTap: () { + _closePanel(); + Get.offAllNamed('/home'); + }, + isDark: isDark, + ), + _buildBottomActionItem( + icon: CupertinoIcons.heart, + label: '收藏', + onTap: () { + _closePanel(); + Get.toNamed('/favorites'); + }, + isDark: isDark, + ), + _buildBottomActionItem( + icon: CupertinoIcons.gear_alt, + label: '设置', + onTap: () { + _closePanel(); + Get.toNamed('/settings'); + }, + isDark: isDark, + ), + _buildBottomActionItem( + icon: CupertinoIcons.info_circle, + label: '关于', + onTap: () { + _closePanel(); + Get.toNamed('/about'); + }, + isDark: isDark, + ), + ], + ), + ), + ); + } + + Widget _buildBottomActionItem({ + required IconData icon, + required String label, + required VoidCallback onTap, + required bool isDark, + }) { + return Expanded( + child: GestureDetector( + onTap: onTap, + behavior: HitTestBehavior.opaque, + child: Column( + mainAxisSize: MainAxisSize.min, + children: [ + Container( + width: 44, + height: 44, + decoration: BoxDecoration( + color: isDark + ? DarkDesignTokens.glass + : DesignTokens.text3.withValues(alpha: 0.06), + borderRadius: DesignTokens.borderRadiusMd, + ), + child: Icon( + icon, + size: 20, + color: isDark ? DarkDesignTokens.text2 : DesignTokens.text2, + ), + ), + const SizedBox(height: DesignTokens.space1), + Text( + label, + style: TextStyle( + fontSize: DesignTokens.fontXs, + color: isDark ? DarkDesignTokens.text2 : DesignTokens.text2, + ), + ), + ], + ), + ), + ); + } + + Widget _buildSegmentContent(bool isDark) { + switch (_segmentIndex) { + case 0: + return _buildHotSection(isDark); + case 1: + return _buildWhatToEatSection(isDark); + case 2: + return _buildRecommendSection(isDark); + default: + return const SizedBox.shrink(); + } + } + + Widget _buildHotSection(bool isDark) { + return Obx(() { + final List hotList = _hotController.hotList; + final isLoading = _hotController.isLoading.value; + + if (isLoading) { + return const SizedBox( + height: 300, + child: Center(child: CupertinoActivityIndicator()), + ); + } + + if (hotList.isEmpty) { + return SizedBox( + height: 300, + child: Center( + child: Column( + mainAxisAlignment: MainAxisAlignment.center, + children: [ + Icon( + CupertinoIcons.flame, + size: 48, + color: isDark ? DarkDesignTokens.text3 : DesignTokens.text3, + ), + const SizedBox(height: DesignTokens.space3), + Text( + '暂无热门数据', + style: TextStyle( + fontSize: DesignTokens.fontLg, + color: isDark ? DarkDesignTokens.text2 : DesignTokens.text2, + ), + ), + ], + ), + ), + ); + } + + return SizedBox( + height: MediaQuery.of(context).size.height * 0.55, + child: Column( + children: [ + Padding( + padding: const EdgeInsets.symmetric( + horizontal: DesignTokens.space4, + ), + child: GlassSegmentedControl( + segments: HotController.periodNames + .map((name) => GlassSegment(label: name)) + .toList(), + selectedIndex: _hotController.currentPeriod.value.index, + onChanged: (i) { + _hotController.switchPeriod(HotPeriod.values[i]); + }, + ), + ), + const SizedBox(height: DesignTokens.space3), + Expanded( + child: ListView.builder( + padding: const EdgeInsets.symmetric( + horizontal: DesignTokens.space4, + ), + itemCount: hotList.length, + itemBuilder: (context, index) { + final recipe = hotList[index]; + return Padding( + padding: const EdgeInsets.only( + bottom: DesignTokens.space2 + 2, + ), + child: Dismissible( + key: ValueKey('hot_${recipe.id}_$index'), + direction: DismissDirection.horizontal, + confirmDismiss: (direction) async { + if (direction == DismissDirection.endToStart) { + Get.toNamed( + '/recipe-detail', + arguments: '${recipe.id}', + ); + return false; + } else { + return true; + } + }, + onDismissed: (direction) { + if (direction == DismissDirection.startToEnd) { + _showQuickActions(recipe, isDark); + } + }, + background: Container( + alignment: Alignment.centerLeft, + padding: EdgeInsets.only(left: 20), + decoration: BoxDecoration( + color: DesignTokens.dynamicPrimary, + borderRadius: DesignTokens.borderRadiusLg, + ), + child: Row( + mainAxisSize: MainAxisSize.min, + children: [ + Icon( + CupertinoIcons.heart_fill, + color: CupertinoColors.white, + ), + const SizedBox(width: 8), + Text( + '收藏', + style: TextStyle( + color: CupertinoColors.white, + fontWeight: FontWeight.w600, + ), + ), + ], + ), + ), + secondaryBackground: Container( + alignment: Alignment.centerRight, + padding: const EdgeInsets.only(right: 20), + decoration: BoxDecoration( + color: DesignTokens.green, + borderRadius: DesignTokens.borderRadiusLg, + ), + child: Row( + mainAxisSize: MainAxisSize.min, + mainAxisAlignment: MainAxisAlignment.end, + children: [ + Text( + '查看详情', + style: TextStyle( + color: CupertinoColors.white, + fontWeight: FontWeight.w600, + ), + ), + const SizedBox(width: 8), + Icon( + CupertinoIcons.eye, + color: CupertinoColors.white, + ), + ], + ), + ), + child: GestureDetector( + onTap: () { + Get.toNamed( + '/recipe-detail', + arguments: '${recipe.id}', + ); + }, + child: Container( + padding: const EdgeInsets.all(DesignTokens.space3), + decoration: BoxDecoration( + color: isDark + ? DarkDesignTokens.card + : DesignTokens.card, + borderRadius: DesignTokens.borderRadiusLg, + boxShadow: DesignTokens.shadowsSm, + ), + child: Row( + children: [ + Container( + width: 28, + height: 28, + decoration: BoxDecoration( + color: index < 3 + ? DesignTokens.orange.withValues( + alpha: 0.15, + ) + : DesignTokens.text3.withValues( + alpha: 0.1, + ), + borderRadius: DesignTokens.borderRadiusSm, + ), + child: Center( + child: Text( + '${index + 1}', + style: TextStyle( + fontSize: DesignTokens.fontSm, + fontWeight: FontWeight.w700, + color: index < 3 + ? DesignTokens.orange + : (isDark + ? DarkDesignTokens.text2 + : DesignTokens.text2), + ), + ), + ), + ), + const SizedBox(width: DesignTokens.space3), + Expanded( + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text( + recipe.name, + style: TextStyle( + fontSize: DesignTokens.fontMd, + fontWeight: FontWeight.w500, + color: isDark + ? DarkDesignTokens.text1 + : DesignTokens.text1, + ), + ), + const SizedBox(height: 2), + Text( + '${_hotController.sortByName}: ${recipe.count}', + style: TextStyle( + fontSize: DesignTokens.fontSm, + color: isDark + ? DarkDesignTokens.text2 + : DesignTokens.text2, + ), + ), + ], + ), + ), + Icon( + CupertinoIcons.chevron_forward, + size: 16, + color: isDark + ? DarkDesignTokens.text3 + : DesignTokens.text3, + ), + ], + ), + ), + ), + ), + ); + }, + ), + ), + ], + ), + ); + }); + } + + Widget _buildWhatToEatSection(bool isDark) { + return Center( + child: Column( + mainAxisAlignment: MainAxisAlignment.center, + children: [ + Container( + width: 100, + height: 100, + decoration: BoxDecoration( + color: DesignTokens.primaryLight, + borderRadius: DesignTokens.borderRadiusXl, + ), + child: Icon( + CupertinoIcons.shuffle, + size: 44, + color: DesignTokens.dynamicPrimary, + ), + ), + const SizedBox(height: DesignTokens.space4), + Text( + '不知道吃什么?', + style: TextStyle( + fontSize: DesignTokens.fontXl, + fontWeight: FontWeight.w600, + color: isDark ? DarkDesignTokens.text1 : DesignTokens.text1, + ), + ), + const SizedBox(height: DesignTokens.space2), + Text( + '让小妈厨房帮你决定', + style: TextStyle( + fontSize: DesignTokens.fontMd, + color: isDark ? DarkDesignTokens.text2 : DesignTokens.text2, + ), + ), + const SizedBox(height: DesignTokens.space6), + SizedBox( + width: 200, + child: CupertinoButton.filled( + borderRadius: DesignTokens.borderRadiusLg, + onPressed: () { + Get.toNamed('/what-to-eat'); + }, + child: const Row( + mainAxisAlignment: MainAxisAlignment.center, + children: [ + Icon(CupertinoIcons.shuffle, size: 20), + SizedBox(width: DesignTokens.space2), + Text('随机推荐'), + ], + ), + ), + ), + const SizedBox(height: DesignTokens.space3), + SizedBox( + width: 200, + child: CupertinoButton( + borderRadius: DesignTokens.borderRadiusLg, + color: DesignTokens.primaryLight, + onPressed: () { + Get.toNamed('/what-to-eat'); + }, + child: Row( + mainAxisAlignment: MainAxisAlignment.center, + children: [ + Icon( + CupertinoIcons.lightbulb, + size: 20, + color: DesignTokens.dynamicPrimary, + ), + const SizedBox(width: DesignTokens.space2), + Text( + '浏览推荐', + style: TextStyle(color: DesignTokens.dynamicPrimary), + ), + ], + ), + ), + ), + ], + ), + ); + } + + Widget _buildRecommendSection(bool isDark) { + if (_isLoadingCategories) { + return const SizedBox( + height: 300, + child: Center(child: CupertinoActivityIndicator()), + ); + } + + return SizedBox( + height: MediaQuery.of(context).size.height * 0.55, + child: Column( + children: [ + Padding( + padding: const EdgeInsets.symmetric( + horizontal: DesignTokens.space4, + ), + child: GlassSegmentedControl( + segments: const [ + GlassSegment(label: '🍳 菜系'), + GlassSegment(label: '🥬 食材'), + GlassSegment(label: '🏷️ 标签'), + ], + selectedIndex: _recommendSubIndex, + onChanged: (i) { + setState(() => _recommendSubIndex = i); + }, + ), + ), + const SizedBox(height: DesignTokens.space3), + Expanded( + child: _recommendSubIndex == 0 + ? _buildCategoryGrid(_topCategories, isDark, 'category') + : _recommendSubIndex == 1 + ? _buildCategoryGrid( + _ingredientCategories, + isDark, + 'ingredient', + ) + : _buildTagContent(isDark), + ), + ], + ), + ); + } + + Widget _buildCategoryGrid( + List categories, + bool isDark, + String type, + ) { + if (categories.isEmpty) { + return Center( + child: Text( + '暂无数据', + style: TextStyle( + color: isDark ? DarkDesignTokens.text2 : DesignTokens.text2, + ), + ), + ); + } + + return GridView.builder( + padding: const EdgeInsets.all(DesignTokens.space4), + gridDelegate: const SliverGridDelegateWithFixedCrossAxisCount( + crossAxisCount: 4, + mainAxisSpacing: DesignTokens.space3, + crossAxisSpacing: DesignTokens.space3, + childAspectRatio: 0.75, + ), + itemCount: categories.length, + itemBuilder: (context, index) { + final category = categories[index]; + return GestureDetector( + onTap: () { + Get.toNamed( + '/category-browse', + arguments: { + 'category': category, + 'title': category.name, + 'isIngredient': type == 'ingredient', + 'loadRecipesDirectly': true, + }, + ); + }, + child: Column( + mainAxisAlignment: MainAxisAlignment.center, + children: [ + Container( + width: 56, + height: 56, + decoration: BoxDecoration( + color: DesignTokens.dynamicPrimary.withValues(alpha: 0.12), + borderRadius: DesignTokens.borderRadiusLg, + ), + child: Center( + child: Text( + category.icon ?? '🍽️', + style: const TextStyle(fontSize: 28), + ), + ), + ), + const SizedBox(height: DesignTokens.space2), + Text( + category.name, + style: TextStyle( + fontSize: DesignTokens.fontXs, + color: isDark ? DarkDesignTokens.text1 : DesignTokens.text1, + ), + maxLines: 1, + overflow: TextOverflow.ellipsis, + textAlign: TextAlign.center, + ), + ], + ), + ); + }, + ); + } + + Widget _buildTagContent(bool isDark) { + final tags = _recommendTypeIndex == 0 ? _tasteTags : _cookingTags; + if (tags.isEmpty) { + return Center( + child: Column( + mainAxisAlignment: MainAxisAlignment.center, + children: [ + Icon( + CupertinoIcons.tag, + size: 48, + color: isDark ? DarkDesignTokens.text3 : DesignTokens.text3, + ), + const SizedBox(height: DesignTokens.space3), + Text( + '暂无标签数据', + style: TextStyle( + fontSize: DesignTokens.fontMd, + color: isDark ? DarkDesignTokens.text2 : DesignTokens.text2, + ), + ), + ], + ), + ); + } + + return Column( + children: [ + Padding( + padding: const EdgeInsets.symmetric(horizontal: DesignTokens.space4), + child: GlassSegmentedControl( + segments: [ + GlassSegment(label: '👅 口味'), + GlassSegment(label: '🔥 工艺'), + ], + selectedIndex: _recommendTypeIndex, + onChanged: (i) { + setState(() => _recommendTypeIndex = i); + }, + ), + ), + const SizedBox(height: DesignTokens.space3), + Expanded( + child: ListView.separated( + padding: const EdgeInsets.symmetric( + horizontal: DesignTokens.space4, + vertical: DesignTokens.space1, + ), + itemCount: tags.length, + separatorBuilder: (context, index) => + const SizedBox(height: DesignTokens.space2), + itemBuilder: (context, index) { + final tag = tags[index]; + return GestureDetector( + onTap: () { + Get.toNamed( + '/tag-recipe-list', + arguments: { + 'tagName': tag.name, + 'tagId': tag.id, + 'tagType': _recommendTypeIndex == 0 ? 'taste' : 'process', + }, + ); + }, + child: ClipRRect( + borderRadius: DesignTokens.borderRadiusMd, + child: BackdropFilter( + filter: ImageFilter.blur(sigmaX: 10, sigmaY: 10), + child: Container( + width: double.infinity, + padding: const EdgeInsets.symmetric( + horizontal: DesignTokens.space3, + vertical: DesignTokens.space3, + ), + decoration: BoxDecoration( + color: isDark + ? DarkDesignTokens.glass + : DesignTokens.card.withValues(alpha: 0.6), + borderRadius: DesignTokens.borderRadiusMd, + border: Border.all( + color: isDark + ? DarkDesignTokens.glassBorder + : DesignTokens.text3.withValues(alpha: 0.08), + ), + ), + child: Row( + children: [ + Container( + width: 36, + height: 36, + decoration: BoxDecoration( + color: DesignTokens.dynamicPrimary.withValues( + alpha: 0.1, + ), + borderRadius: DesignTokens.borderRadiusSm, + ), + child: Center( + child: Text( + '🏷️', + style: TextStyle(fontSize: 18), + ), + ), + ), + const SizedBox(width: DesignTokens.space3), + Expanded( + child: Text( + tag.name, + style: TextStyle( + fontSize: DesignTokens.fontSm, + fontWeight: FontWeight.w500, + color: isDark + ? DarkDesignTokens.text1 + : DesignTokens.text1, + ), + ), + ), + if (tag.count != null && tag.count! > 0) + Text( + '${tag.count}', + style: TextStyle( + fontSize: DesignTokens.fontXs, + color: isDark + ? DarkDesignTokens.text3 + : DesignTokens.text3, + ), + ), + const SizedBox(width: DesignTokens.space2), + Icon( + CupertinoIcons.chevron_right, + size: 14, + color: isDark + ? DarkDesignTokens.text3 + : DesignTokens.text3, + ), + ], + ), + ), + ), + ), + ); + }, + ), + ), + ], + ); + } + + void _showQuickActions(repo.HotItem recipe, bool isDark) { + showCupertinoModalPopup( + context: context, + builder: (BuildContext context) => CupertinoActionSheet( + title: Text( + recipe.name, + style: TextStyle( + fontSize: DesignTokens.fontLg, + fontWeight: FontWeight.w600, + ), + ), + message: Text( + '浏览量: ${recipe.count}', + style: TextStyle( + color: isDark ? DarkDesignTokens.text2 : DesignTokens.text2, + ), + ), + actions: [ + CupertinoActionSheetAction( + onPressed: () { + Navigator.pop(context); + Get.toNamed('/recipe-detail', arguments: '${recipe.id}'); + }, + child: const Row( + mainAxisAlignment: MainAxisAlignment.center, + children: [ + Icon(CupertinoIcons.eye, size: 20), + SizedBox(width: 8), + Text('查看详情'), + ], + ), + ), + CupertinoActionSheetAction( + onPressed: () { + Navigator.pop(context); + ToastService.show(message: '已添加到收藏'); + }, + isDefaultAction: true, + child: const Row( + mainAxisAlignment: MainAxisAlignment.center, + children: [ + Icon(CupertinoIcons.heart, size: 20), + SizedBox(width: 8), + Text('添加收藏'), + ], + ), + ), + ], + cancelButton: CupertinoActionSheetAction( + onPressed: () => Navigator.pop(context), + isDestructiveAction: true, + child: const Text('取消'), + ), + ), + ); + } +} diff --git a/lib/src/pages/discover/ingredient_recommend_page.dart b/lib/src/pages/discover/ingredient_recommend_page.dart index 9672eef..3162475 100644 --- a/lib/src/pages/discover/ingredient_recommend_page.dart +++ b/lib/src/pages/discover/ingredient_recommend_page.dart @@ -8,6 +8,7 @@ import 'package:flutter/cupertino.dart'; import 'package:get/get.dart'; +import 'package:mom_kitchen/src/config/app_routes.dart'; import 'package:mom_kitchen/src/config/design_tokens.dart'; import 'package:mom_kitchen/src/models/recipe/ingredient_model.dart'; import 'package:mom_kitchen/src/repositories/recipe_repository.dart'; @@ -39,10 +40,8 @@ class _IngredientRecommendPageState extends State { Future _loadIngredients() async { setState(() => _isLoading = true); try { - final result = await _repo.fetchIngredients( - page: 1, - limit: _pageSize, - ); + final result = await _repo.fetchIngredients(page: 1, limit: _pageSize); + if (!mounted) return; setState(() { _ingredients = result.items; _currentPage = 1; @@ -51,6 +50,7 @@ class _IngredientRecommendPageState extends State { }); } catch (e) { debugPrint('Load ingredients error: $e'); + if (!mounted) return; setState(() => _isLoading = false); } } @@ -64,6 +64,7 @@ class _IngredientRecommendPageState extends State { page: _currentPage + 1, limit: _pageSize, ); + if (!mounted) return; setState(() { _ingredients.addAll(result.items); _currentPage++; @@ -72,6 +73,7 @@ class _IngredientRecommendPageState extends State { }); } catch (e) { debugPrint('Load more ingredients error: $e'); + if (!mounted) return; setState(() => _isLoadingMore = false); } } @@ -81,8 +83,9 @@ class _IngredientRecommendPageState 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( '🥬 食材推荐', @@ -102,8 +105,8 @@ class _IngredientRecommendPageState extends State { child: _isLoading ? const Center(child: CupertinoActivityIndicator()) : _ingredients.isEmpty - ? _buildEmptyState(isDark) - : _buildContent(isDark), + ? _buildEmptyState(isDark) + : _buildContent(isDark), ), ); } @@ -147,9 +150,7 @@ class _IngredientRecommendPageState extends State { itemCount: _ingredients.length + (_isLoadingMore ? 1 : 0), itemBuilder: (context, index) { if (index >= _ingredients.length) { - return const Center( - child: CupertinoActivityIndicator(radius: 10), - ); + return const Center(child: CupertinoActivityIndicator(radius: 10)); } return _buildIngredientCard(_ingredients[index], isDark); }, @@ -163,11 +164,8 @@ class _IngredientRecommendPageState extends State { return GestureDetector( onTap: () { Get.toNamed( - '/tools/ingredient-recipes', - arguments: { - 'ingredientId': ingredient.id, - 'ingredientName': ingredient.name, - }, + AppRoutes.toolsIngredient, + arguments: {'name': ingredient.name, 'id': ingredient.id}, ); }, child: Container( @@ -182,13 +180,12 @@ class _IngredientRecommendPageState extends State { child: Column( mainAxisAlignment: MainAxisAlignment.center, children: [ - Text( - emoji, - style: const TextStyle(fontSize: 32), - ), + Text(emoji, style: const TextStyle(fontSize: 32)), const SizedBox(height: DesignTokens.space1), Padding( - padding: const EdgeInsets.symmetric(horizontal: DesignTokens.space1), + padding: const EdgeInsets.symmetric( + horizontal: DesignTokens.space1, + ), child: Text( ingredient.name, style: TextStyle( diff --git a/lib/src/pages/discover/mini_card/mini_card_image_view.dart b/lib/src/pages/discover/mini_card/mini_card_image_view.dart index bf12ae9..ff84a58 100644 --- a/lib/src/pages/discover/mini_card/mini_card_image_view.dart +++ b/lib/src/pages/discover/mini_card/mini_card_image_view.dart @@ -94,7 +94,7 @@ class MiniCardImageView extends StatelessWidget { maxHeightDiskCache: 800, fadeInDuration: const Duration(milliseconds: 300), fadeInCurve: Curves.easeOut, - errorWidget: (_, __, ___) => Container( + errorWidget: (_, __, _) => Container( decoration: BoxDecoration( gradient: LinearGradient( begin: Alignment.topLeft, diff --git a/lib/src/pages/discover/mini_card/mini_card_page.dart b/lib/src/pages/discover/mini_card/mini_card_page.dart index ed74d75..dbeb353 100644 --- a/lib/src/pages/discover/mini_card/mini_card_page.dart +++ b/lib/src/pages/discover/mini_card/mini_card_page.dart @@ -298,7 +298,7 @@ class _MiniCardPageState extends State { } } - void _ensureMinPreloaded() { + Future _ensureMinPreloaded() async { if (!mounted || _filteredRecipes.isEmpty) return; final total = _filteredRecipes.length; var needPreload = 0; @@ -306,7 +306,7 @@ class _MiniCardPageState extends State { final idx = (_currentIndex + i) % total; final url = _filteredRecipes[idx].fullImageUrl; try { - final file = DefaultCacheManager().getFileFromCache(url); + final file = await DefaultCacheManager().getFileFromCache(url); if (file == null) needPreload++; } catch (_) { needPreload++; @@ -426,7 +426,7 @@ class _MiniCardPageState extends State { final boundary = _cardKey.currentContext?.findRenderObject() as RenderRepaintBoundary?; if (boundary == null) { - Share.share('🍳 ${recipe.name} — 来自 妈妈厨房 迷你卡片'); + Share.share('🍳 ${recipe.name} — 来自 小妈厨房 迷你卡片'); return; } @@ -435,7 +435,7 @@ class _MiniCardPageState extends State { image.dispose(); if (byteData == null || !mounted) { - Share.share('🍳 ${recipe.name} — 来自 妈妈厨房 迷你卡片'); + Share.share('🍳 ${recipe.name} — 来自 小妈厨房 迷你卡片'); return; } @@ -451,14 +451,14 @@ class _MiniCardPageState extends State { if (!mounted) return; await Share.shareXFiles([ XFile(filePath), - ], text: '🍳 ${recipe.name} — 来自 妈妈厨房'); + ], text: '🍳 ${recipe.name} — 来自 小妈厨房'); Future.delayed(const Duration(seconds: 5), () { if (file.existsSync()) file.deleteSync(); }); } catch (e) { debugPrint('MiniCardPage: share failed: $e'); - Share.share('🍳 ${recipe.name} — 来自 妈妈厨房 迷你卡片'); + Share.share('🍳 ${recipe.name} — 来自 小妈厨房 迷你卡片'); } } @@ -902,14 +902,14 @@ class _MiniCardPageState extends State { memCacheHeight: 96, maxWidthDiskCache: 200, maxHeightDiskCache: 200, - errorWidget: (_, __, ___) => Container( + errorWidget: (_, __, _) => Container( color: DesignTokens.background, child: const Icon( CupertinoIcons.photo, size: 20, ), ), - progressIndicatorBuilder: (_, __, ___) => + progressIndicatorBuilder: (_, __, _) => Container( color: DesignTokens.background, child: const CupertinoActivityIndicator( @@ -972,7 +972,7 @@ class _MiniCardPageState extends State { maxHeightDiskCache: 800, fadeInDuration: const Duration(milliseconds: 300), fadeInCurve: Curves.easeOut, - errorWidget: (_, __, ___) => Container( + errorWidget: (_, __, _) => Container( decoration: BoxDecoration( gradient: LinearGradient( begin: Alignment.topLeft, @@ -991,7 +991,7 @@ class _MiniCardPageState extends State { ), ), ), - progressIndicatorBuilder: (_, __, ___) => Container( + progressIndicatorBuilder: (_, __, _) => Container( color: DesignTokens.background, child: const Center(child: CupertinoActivityIndicator()), ), diff --git a/lib/src/pages/discover/mini_card/mini_card_viewer.dart b/lib/src/pages/discover/mini_card/mini_card_viewer.dart index 003751a..a799393 100644 --- a/lib/src/pages/discover/mini_card/mini_card_viewer.dart +++ b/lib/src/pages/discover/mini_card/mini_card_viewer.dart @@ -48,7 +48,7 @@ class MiniCardViewer { fadeInCurve: Curves.easeOut, maxWidthDiskCache: 1200, maxHeightDiskCache: 1200, - errorWidget: (_, __, ___) => Container( + errorWidget: (_, __, _) => Container( color: Colors.grey[900], child: const Center( child: Icon( @@ -58,7 +58,7 @@ class MiniCardViewer { ), ), ), - progressIndicatorBuilder: (_, __, ___) => Container( + progressIndicatorBuilder: (_, __, _) => Container( color: Colors.grey[900], child: const Center( child: CupertinoActivityIndicator(radius: 16), diff --git a/lib/src/pages/home/advanced_search_page.dart b/lib/src/pages/home/advanced_search_page.dart index 867a08c..90cf666 100644 --- a/lib/src/pages/home/advanced_search_page.dart +++ b/lib/src/pages/home/advanced_search_page.dart @@ -8,7 +8,6 @@ */ import 'package:flutter/cupertino.dart'; -import 'package:flutter/material.dart'; import 'package:get/get.dart'; import '../../controllers/recipe/search_controller.dart' as app_search; import '../../config/design_tokens.dart'; diff --git a/lib/src/pages/home/home_card_carousel.dart b/lib/src/pages/home/home_card_carousel.dart index 59ee9ee..48908f9 100644 --- a/lib/src/pages/home/home_card_carousel.dart +++ b/lib/src/pages/home/home_card_carousel.dart @@ -790,7 +790,7 @@ class _HomeCardCarouselState extends State { ), const SizedBox(width: 2), Text( - '${stats?.recommends ?? 0}', + '${stats?.rateNums ?? 0}', style: TextStyle( fontSize: 12, color: themeService.textColor.value.withValues(alpha: 0.6), diff --git a/lib/src/pages/home/home_page.dart b/lib/src/pages/home/home_page.dart index 37f69e5..ced4f8f 100644 --- a/lib/src/pages/home/home_page.dart +++ b/lib/src/pages/home/home_page.dart @@ -27,6 +27,7 @@ import 'package:mom_kitchen/src/widgets/discover/discover_waterfall.dart'; import 'package:mom_kitchen/src/widgets/glass/nav/home_app_bar.dart'; import 'package:mom_kitchen/src/models/mini_card_model.dart'; import 'package:mom_kitchen/src/services/data/mini_card_service.dart'; +import 'package:mom_kitchen/src/models/tool_item_model.dart'; class HomePage extends StatefulWidget { const HomePage({super.key}); @@ -114,13 +115,22 @@ class _HomePageState extends State { // ─── 后台静默加载数据(不阻塞UI) ─── void _startBackgroundLoading() { _isLoading.value = true; - WidgetsBinding.instance.addPostFrameCallback((_) async { - await Future.wait([ - _loadCachedDiscoverFirst(), - _loadRecipes(), - _loadMiniCards(), - ]); - _isLoading.value = false; + WidgetsBinding.instance.addPostFrameCallback((_) { + _loadCachedDiscoverFirst().timeout( + const Duration(seconds: 8), + onTimeout: () => debugPrint('HomePage: loadDiscover timeout'), + ); + _loadRecipes().timeout( + const Duration(seconds: 8), + onTimeout: () => debugPrint('HomePage: loadRecipes timeout'), + ); + _loadMiniCards().timeout( + const Duration(seconds: 8), + onTimeout: () => debugPrint('HomePage: loadMiniCards timeout'), + ); + Future.delayed(const Duration(seconds: 10), () { + if (mounted) _isLoading.value = false; + }); _ensureSilentFetchRunning(); }); } @@ -141,7 +151,14 @@ class _HomePageState extends State { Future _loadCachedDiscoverFirst() async { try { + // CacheService.init() 已改为安全降级,不会抛异常 + // 使用 isReady 检查缓存服务是否可用 await CacheService().init(); + if (!CacheService().isReady) { + debugPrint('⚠️ HomePage: CacheService未就绪,跳过缓存读取'); + _loadDiscover(); + return; + } final cached = await CacheService().get( _discoverCacheKey, allowExpired: true, @@ -283,7 +300,7 @@ class _HomePageState extends State { Future _cacheDiscoverData(DiscoverData data) async { try { - await CacheService().init(); + if (!CacheService().isReady) return; final trimmed = _trimDiscoverData(data, _discoverCacheMaxItems); await CacheService().set( _discoverCacheKey, @@ -295,17 +312,17 @@ class _HomePageState extends State { } } - Future _precacheDiscoverImages(DiscoverData data) async { + void _precacheDiscoverImages(DiscoverData data) { try { final ctx = context; + if (!mounted || ctx == null) return; final urls = []; - for (final r in data.recipes) { if (r.cover.isNotEmpty) urls.add(r.cover); if (urls.length >= 18) break; } for (final u in urls) { - await precacheImage(NetworkImage(u), ctx); + precacheImage(NetworkImage(u), ctx).catchError((_) {}); } } catch (_) {} } @@ -834,6 +851,7 @@ class _HomePageState extends State { dismissedRecipeIds: _dismissedRecipeIds, miniCardRecipes: _miniCardRecipes, miniCardMeta: _miniCardMeta, + toolCards: ToolRegistry.homeCardTools, ), const SliverToBoxAdapter(child: SizedBox(height: DesignTokens.space6)), diff --git a/lib/src/pages/home/recipe_detail_page.dart b/lib/src/pages/home/recipe_detail_page.dart index 3c29faf..8a2ab05 100644 --- a/lib/src/pages/home/recipe_detail_page.dart +++ b/lib/src/pages/home/recipe_detail_page.dart @@ -128,16 +128,9 @@ class RecipeDetailPage extends StatelessWidget { return CupertinoPageScaffold( navigationBar: CupertinoNavigationBar( middle: Text(recipe.title), - trailing: GestureDetector( - onTap: () => controller.toggleFavorite(), - child: Icon( - controller.isFavorite.value - ? CupertinoIcons.heart_fill - : CupertinoIcons.heart, - color: controller.isFavorite.value - ? DesignTokens.red - : DesignTokens.text2, - ), + trailing: _DataSourceIndicator( + dataSource: controller.dataSource.value, + onRefresh: () => controller.forceRefreshFromApi(recipeId), ), backgroundColor: isDark ? DarkDesignTokens.background @@ -156,7 +149,14 @@ class RecipeDetailPage extends StatelessWidget { viewCount: controller.viewCount.value, likeCount: controller.likeCount.value, ), - RecipeTitleSection(recipe: recipe), + RecipeTitleSection( + recipe: recipe, + isFavorite: controller.isFavorite.value, + onToggleFavorite: () => controller.toggleFavorite(), + isLiked: controller.isLiked, + likeCount: controller.likeCount.value, + onLike: () => controller.likeRecipe(), + ), RecipeStatisticsBar( statistics: recipe.statistics, rating: recipe.rating, @@ -177,7 +177,6 @@ class RecipeDetailPage extends StatelessWidget { RecipeTimeInfo(recipe: recipe), RecipeActionBar( likeCount: controller.likeCount.value, - isLiked: controller.isLiked, userRating: controller.userRating, rateRemaining: controller.rateRemaining.value >= 0 ? controller.rateRemaining.value @@ -186,9 +185,6 @@ class RecipeDetailPage extends StatelessWidget { recipeTitle: recipe.title, categoryName: recipe.categoryName, ratingScore: recipe.rating?.score, - onLike: () async { - await controller.likeRecipe(); - }, onRate: (score) async { await controller.rateRecipe(score: score); }, @@ -260,3 +256,138 @@ class RecipeDetailPage extends StatelessWidget { ); } } + +class _DataSourceIndicator extends StatefulWidget { + final String dataSource; + final VoidCallback onRefresh; + + const _DataSourceIndicator({ + required this.dataSource, + required this.onRefresh, + }); + + @override + State<_DataSourceIndicator> createState() => _DataSourceIndicatorState(); +} + +class _DataSourceIndicatorState extends State<_DataSourceIndicator> + with SingleTickerProviderStateMixin { + bool _showLabel = true; + late AnimationController _animController; + late Animation _fadeAnimation; + + @override + void initState() { + super.initState(); + _animController = AnimationController( + vsync: this, + duration: const Duration(milliseconds: 500), + ); + _fadeAnimation = CurvedAnimation( + parent: _animController, + curve: Curves.easeOut, + ); + _animController.value = 1.0; + _startAutoHide(); + } + + @override + void didUpdateWidget(covariant _DataSourceIndicator oldWidget) { + super.didUpdateWidget(oldWidget); + if (oldWidget.dataSource != widget.dataSource && + widget.dataSource.isNotEmpty) { + setState(() => _showLabel = true); + _animController.value = 1.0; + _startAutoHide(); + } + } + + void _startAutoHide() { + Future.delayed(const Duration(seconds: 3), () { + if (mounted) { + _animController.reverse().then((_) { + if (mounted) setState(() => _showLabel = false); + }); + } + }); + } + + @override + void dispose() { + _animController.dispose(); + super.dispose(); + } + + @override + Widget build(BuildContext context) { + if (widget.dataSource.isEmpty) return const SizedBox.shrink(); + + final isCache = widget.dataSource == 'cache'; + final isDark = CupertinoTheme.brightnessOf(context) == Brightness.dark; + + return GestureDetector( + onTap: _showLabel ? null : widget.onRefresh, + child: Row( + mainAxisSize: MainAxisSize.min, + children: [ + if (_showLabel) + FadeTransition( + opacity: _fadeAnimation, + child: Container( + padding: const EdgeInsets.symmetric(horizontal: 6, vertical: 2), + decoration: BoxDecoration( + color: isCache + ? DesignTokens.orange.withValues(alpha: 0.15) + : DesignTokens.dynamicPrimary.withValues(alpha: 0.15), + borderRadius: DesignTokens.borderRadiusSm, + ), + child: Row( + mainAxisSize: MainAxisSize.min, + children: [ + Icon( + isCache + ? CupertinoIcons.archivebox + : CupertinoIcons.globe, + size: 11, + color: isCache + ? DesignTokens.orange + : DesignTokens.dynamicPrimary, + ), + const SizedBox(width: 3), + Text( + isCache ? '缓存' : 'API', + style: TextStyle( + fontSize: 10, + fontWeight: FontWeight.w600, + color: isCache + ? DesignTokens.orange + : DesignTokens.dynamicPrimary, + ), + ), + ], + ), + ), + ), + if (!_showLabel) ...[ + SizedBox(width: _showLabel ? 4 : 0), + Container( + width: 28, + height: 28, + decoration: BoxDecoration( + color: isDark + ? DarkDesignTokens.glassBorder.withValues(alpha: 0.3) + : DesignTokens.text3.withValues(alpha: 0.08), + shape: BoxShape.circle, + ), + child: Icon( + CupertinoIcons.arrow_clockwise, + size: 14, + color: isDark ? DarkDesignTokens.text3 : DesignTokens.text3, + ), + ), + ], + ], + ), + ); + } +} diff --git a/lib/src/pages/home/search_page.dart b/lib/src/pages/home/search_page.dart index c8cdd2e..d780afe 100644 --- a/lib/src/pages/home/search_page.dart +++ b/lib/src/pages/home/search_page.dart @@ -70,7 +70,7 @@ class _SearchPageState extends State { middle: _buildSearchBar(isDark), trailing: CupertinoButton( padding: EdgeInsets.zero, - minSize: 44, + minimumSize: const Size(44, 44), child: Icon( CupertinoIcons.slider_horizontal_3, size: 22, @@ -438,7 +438,7 @@ class _SearchPageState extends State { Obx(() { if (!_searchController.hasSimilarResults.value || _searchController.similarResults.isEmpty) { - return SizedBox(); + return const SizedBox.shrink(); } return Column( crossAxisAlignment: CrossAxisAlignment.start, diff --git a/lib/src/pages/profile/about_page.dart b/lib/src/pages/profile/about_page.dart index ec89c21..a53c51c 100644 --- a/lib/src/pages/profile/about_page.dart +++ b/lib/src/pages/profile/about_page.dart @@ -5,12 +5,15 @@ * 创建: 2026-04-13 * 更新: 2026-04-13 新增关于页面,包含用户反馈入口 * 更新: 2026-04-13 新增开发者文档入口(API文档、App接入指南) + * 更新: 2026-04-17 新增软件权限页面入口 */ import 'package:flutter/cupertino.dart'; import 'package:flutter/material.dart' show Divider; import 'package:get/get.dart'; import 'package:mom_kitchen/src/config/design_tokens.dart'; +import 'package:mom_kitchen/src/pages/profile/privacy_policy_page.dart'; +import 'package:mom_kitchen/src/pages/profile/permission_page.dart'; import 'package:mom_kitchen/src/pages/profile/references_page.dart'; import 'package:url_launcher/url_launcher.dart'; @@ -94,7 +97,7 @@ class AboutPage extends StatelessWidget { ), const SizedBox(height: DesignTokens.space3), Text( - '妈妈厨房', + '小妈厨房', style: TextStyle( fontSize: DesignTokens.fontXl, fontWeight: FontWeight.bold, @@ -135,16 +138,16 @@ class AboutPage extends StatelessWidget { children: [ _buildActionTile( icon: CupertinoIcons.doc_text, - title: 'API 接口文档', - subtitle: '查看完整的 API 接口说明', + title: '软件信息', + subtitle: '查看软件功能', isDark: isDark, onTap: () => _openUrl('https://eat.wktyl.com/api/doc/API_DOC.md'), ), _buildDivider(isDark), _buildActionTile( icon: CupertinoIcons.device_phone_portrait, - title: 'App 接入指南', - subtitle: '快速接入 API 的开发指南', + title: '了解我们', + subtitle: '查看关于我们', isDark: isDark, onTap: () => _openUrl('https://eat.wktyl.com/api/doc/APP_GUIDE.md'), ), @@ -220,30 +223,18 @@ class AboutPage extends StatelessWidget { children: [ _buildActionTile( icon: CupertinoIcons.doc_text, - title: '用户协议', - subtitle: '查看用户服务协议', + title: '软件协议', + subtitle: '查看隐私政策和用户服务协议', isDark: isDark, - onTap: () { - Get.snackbar( - '提示', - '用户协议页面开发中', - snackPosition: SnackPosition.BOTTOM, - ); - }, + onTap: () => Get.to(() => const PrivacyPolicyPage()), ), _buildDivider(isDark), _buildActionTile( icon: CupertinoIcons.lock_shield, - title: '隐私政策', - subtitle: '查看隐私保护政策', + title: '软件权限', + subtitle: '查看软件权限声明', isDark: isDark, - onTap: () { - Get.snackbar( - '提示', - '隐私政策页面开发中', - snackPosition: SnackPosition.BOTTOM, - ); - }, + onTap: () => Get.to(() => const PermissionPage()), ), ], ); @@ -412,7 +403,7 @@ class AboutPage extends StatelessWidget { return Column( children: [ Text( - '© 2026 妈妈厨房', + '© 2026 小妈厨房', style: TextStyle( fontSize: DesignTokens.fontXs, color: isDark ? DarkDesignTokens.text3 : DesignTokens.text3, diff --git a/lib/src/pages/profile/data/cache_manage_page.dart b/lib/src/pages/profile/data/cache_manage_page.dart index d7c5f75..01b58b9 100644 --- a/lib/src/pages/profile/data/cache_manage_page.dart +++ b/lib/src/pages/profile/data/cache_manage_page.dart @@ -61,6 +61,11 @@ class _CacheManagePageState extends State { setState(() => _isLoading = true); try { await CacheService().init(); + if (!CacheService().isReady) { + debugPrint('CacheManagePage: CacheService未就绪'); + _apiCacheEntries = 0; + return; + } _apiCacheEntries = CacheService().cacheSize; // 首页Discover缓存 @@ -164,7 +169,7 @@ class _CacheManagePageState extends State { if (!confirmed) return; try { - await CacheService().init(); + if (!CacheService().isReady) return; await CacheService().remove(_discoverCacheKey); ToastService.show(message: '首页缓存已清理 🧹'); await _load(); @@ -181,7 +186,7 @@ class _CacheManagePageState extends State { if (!confirmed) return; try { - await CacheService().init(); + if (!CacheService().isReady) return; await CacheService().clear(); ToastService.show(message: 'API缓存已清理 🧽'); await _load(); @@ -793,7 +798,7 @@ class _CacheManagePageState extends State { child: Image.network( item.cover!, fit: BoxFit.cover, - errorBuilder: (_, __, ___) => Icon( + errorBuilder: (_, __, _) => Icon( CupertinoIcons.photo, color: DesignTokens.dynamicPrimary, ), diff --git a/lib/src/pages/profile/guide_page.dart b/lib/src/pages/profile/guide_page.dart new file mode 100644 index 0000000..8e3426a --- /dev/null +++ b/lib/src/pages/profile/guide_page.dart @@ -0,0 +1,582 @@ +/* + * 文件: guide_page.dart + * 名称: 首次引导页 + * 作用: 首次启动应用时展示欢迎信息和协议,用户同意后方可使用 + * 创建: 2026-04-17 + * 更新: 2026-04-17 新增首次引导页,含欢迎页和协议同意页 + */ + +import 'dart:io'; +import 'package:flutter/cupertino.dart'; +import 'package:get/get.dart'; +import 'package:mom_kitchen/src/config/design_tokens.dart'; +import 'package:mom_kitchen/src/pages/profile/permission_page.dart'; +import 'package:mom_kitchen/src/pages/profile/privacy_policy_page.dart'; +import 'package:mom_kitchen/src/services/data/storage_service.dart'; +import 'package:mom_kitchen/src/config/app_routes.dart'; + +class GuidePage extends StatefulWidget { + final bool fromSettings; + + const GuidePage({super.key, this.fromSettings = false}); + + @override + State createState() => _GuidePageState(); +} + +class _GuidePageState extends State { + final PageController _pageController = PageController(); + int _currentPage = 0; + bool _agreementAccepted = false; + + static const String _keyAgreementAccepted = 'agreement_accepted'; + static const int _totalPages = 2; + + @override + void initState() { + super.initState(); + _loadAgreementStatus(); + } + + @override + void dispose() { + _pageController.dispose(); + super.dispose(); + } + + Future _loadAgreementStatus() async { + final accepted = StorageService().getBool(_keyAgreementAccepted) ?? false; + if (mounted) { + setState(() => _agreementAccepted = accepted); + } + } + + Future _acceptAgreement() async { + await StorageService().setBool(_keyAgreementAccepted, true); + if (mounted) { + setState(() => _agreementAccepted = true); + } + } + + Future _rejectAgreement() async { + await StorageService().setBool(_keyAgreementAccepted, false); + if (mounted) { + setState(() => _agreementAccepted = false); + } + } + + void _nextPage() { + if (_currentPage < _totalPages - 1 && _pageController.hasClients) { + _pageController.nextPage( + duration: const Duration(milliseconds: 400), + curve: Curves.easeInOut, + ); + } + } + + void _previousPage() { + if (_currentPage > 0 && _pageController.hasClients) { + _pageController.previousPage( + duration: const Duration(milliseconds: 400), + curve: Curves.easeInOut, + ); + } + } + + void _finishGuide() { + if (!_agreementAccepted) { + _showNeedAcceptDialog(); + return; + } + if (widget.fromSettings) { + Get.back(); + } else { + Get.offAllNamed(AppRoutes.main); + } + } + + void _showNeedAcceptDialog() { + showCupertinoDialog( + context: context, + builder: (context) => CupertinoAlertDialog( + title: const Text('提示'), + content: const Padding( + padding: EdgeInsets.only(top: DesignTokens.space2), + child: Text('请先阅读并同意隐私政策和用户协议'), + ), + actions: [ + CupertinoDialogAction( + isDefaultAction: true, + onPressed: () { + Get.back(); + _acceptAgreement(); + _finishGuide(); + }, + child: const Text('同意并继续'), + ), + CupertinoDialogAction( + onPressed: () => Get.back(), + child: const Text('取消'), + ), + ], + ), + ); + } + + void _rejectAndExit() { + showCupertinoDialog( + context: context, + builder: (context) => CupertinoAlertDialog( + title: const Text('退出确认'), + content: const Padding( + padding: EdgeInsets.only(top: DesignTokens.space2), + child: Text('不同意协议将无法使用本软件,确定要退出吗?'), + ), + actions: [ + CupertinoDialogAction( + isDefaultAction: true, + onPressed: () => Get.back(), + child: const Text('取消'), + ), + CupertinoDialogAction( + isDestructiveAction: true, + onPressed: () { + Get.back(); + exit(0); + }, + child: const Text('确定退出'), + ), + ], + ), + ); + } + + @override + Widget build(BuildContext context) { + final isDark = CupertinoTheme.brightnessOf(context) == Brightness.dark; + + return CupertinoPageScaffold( + backgroundColor: isDark + ? DarkDesignTokens.background + : DesignTokens.background, + child: SafeArea( + child: Stack( + children: [ + PageView( + controller: _pageController, + scrollDirection: Axis.vertical, + physics: const PageScrollPhysics(), + onPageChanged: (index) { + setState(() => _currentPage = index); + }, + children: [ + _buildWelcomePage(isDark), + _buildAgreementPage(isDark), + ], + ), + _buildPageIndicator(isDark), + _buildBottomNav(isDark), + ], + ), + ), + ); + } + + Widget _buildWelcomePage(bool isDark) { + return Container( + padding: const EdgeInsets.all(DesignTokens.space6), + child: Column( + mainAxisAlignment: MainAxisAlignment.center, + children: [ + Container( + width: 120, + height: 120, + decoration: BoxDecoration( + gradient: LinearGradient( + begin: Alignment.topLeft, + end: Alignment.bottomRight, + colors: [ + DesignTokens.dynamicPrimary.withValues(alpha: 0.1), + DesignTokens.dynamicPrimary.withValues(alpha: 0.05), + ], + ), + borderRadius: DesignTokens.borderRadiusXl, + ), + child: const Center( + child: Text('🍳', style: TextStyle(fontSize: 60)), + ), + ), + const SizedBox(height: DesignTokens.space6), + Text( + '欢迎使用', + style: TextStyle( + fontSize: DesignTokens.fontXxl, + fontWeight: FontWeight.bold, + color: isDark ? DarkDesignTokens.text1 : DesignTokens.text1, + ), + ), + const SizedBox(height: DesignTokens.space2), + Text( + '小妈厨房', + style: TextStyle( + fontSize: DesignTokens.fontXl, + fontWeight: FontWeight.w600, + color: DesignTokens.dynamicPrimary, + ), + ), + const SizedBox(height: DesignTokens.space5), + Container( + padding: const EdgeInsets.all(DesignTokens.space4), + decoration: BoxDecoration( + color: isDark ? DarkDesignTokens.card : DesignTokens.card, + borderRadius: DesignTokens.borderRadiusLg, + boxShadow: DesignTokens.shadowsSm, + ), + child: Column( + children: [ + _buildWelcomeItem('🍳', '海量菜谱,随心浏览', isDark), + const SizedBox(height: DesignTokens.space3), + _buildWelcomeItem('📊', '营养管理,科学饮食', isDark), + const SizedBox(height: DesignTokens.space3), + _buildWelcomeItem('🛒', '购物清单,便捷采购', isDark), + const SizedBox(height: DesignTokens.space3), + _buildWelcomeItem('⏰', '烹饪计时,轻松掌勺', isDark), + ], + ), + ), + const SizedBox(height: DesignTokens.space5), + CupertinoButton( + padding: const EdgeInsets.symmetric( + vertical: DesignTokens.space2, + horizontal: DesignTokens.space4, + ), + color: DesignTokens.dynamicPrimary.withValues(alpha: 0.1), + borderRadius: DesignTokens.borderRadiusMd, + onPressed: () => Get.to(() => const PermissionPage()), + child: Row( + mainAxisSize: MainAxisSize.min, + children: [ + Icon( + CupertinoIcons.lock_shield, + size: 16, + color: DesignTokens.dynamicPrimary, + ), + const SizedBox(width: DesignTokens.space1), + Text( + '了解软件权限', + style: TextStyle( + fontSize: DesignTokens.fontSm, + fontWeight: FontWeight.w500, + color: DesignTokens.dynamicPrimary, + ), + ), + ], + ), + ), + const SizedBox(height: DesignTokens.space3), + Text( + '👆 向上滑动继续', + style: TextStyle( + fontSize: DesignTokens.fontSm, + color: isDark ? DarkDesignTokens.text3 : DesignTokens.text3, + ), + ), + ], + ), + ); + } + + Widget _buildWelcomeItem(String emoji, String text, bool isDark) { + return Row( + children: [ + Text(emoji, style: const TextStyle(fontSize: 20)), + const SizedBox(width: DesignTokens.space3), + Expanded( + child: Text( + text, + style: TextStyle( + fontSize: DesignTokens.fontMd, + color: isDark ? DarkDesignTokens.text1 : DesignTokens.text1, + ), + ), + ), + ], + ); + } + + Widget _buildAgreementPage(bool isDark) { + return Column( + children: [ + Expanded(child: PrivacyPolicyContent(isDark: isDark)), + Container( + padding: const EdgeInsets.fromLTRB( + DesignTokens.space4, + DesignTokens.space3, + DesignTokens.space4, + DesignTokens.space4, + ), + decoration: BoxDecoration( + color: isDark ? DarkDesignTokens.card : DesignTokens.card, + boxShadow: DesignTokens.shadowsMd, + ), + child: Column( + mainAxisSize: MainAxisSize.min, + children: [ + CupertinoButton( + padding: const EdgeInsets.symmetric( + vertical: DesignTokens.space2, + ), + color: DesignTokens.dynamicPrimary.withValues(alpha: 0.1), + borderRadius: DesignTokens.borderRadiusMd, + onPressed: () => + Get.to(() => const PrivacyPolicyPage(initialTab: 1)), + child: Row( + mainAxisAlignment: MainAxisAlignment.center, + children: [ + Icon( + CupertinoIcons.doc_text, + size: 16, + color: DesignTokens.dynamicPrimary, + ), + const SizedBox(width: DesignTokens.space1), + Text( + '查看用户服务协议', + style: TextStyle( + fontSize: DesignTokens.fontSm, + fontWeight: FontWeight.w500, + color: DesignTokens.dynamicPrimary, + ), + ), + ], + ), + ), + const SizedBox(height: DesignTokens.space3), + GestureDetector( + onTap: () { + if (_agreementAccepted) { + _rejectAgreement(); + } else { + _acceptAgreement(); + } + }, + child: Row( + children: [ + Container( + width: 22, + height: 22, + decoration: BoxDecoration( + shape: BoxShape.circle, + color: _agreementAccepted + ? DesignTokens.dynamicPrimary + : CupertinoColors.transparent, + border: Border.all( + color: _agreementAccepted + ? DesignTokens.dynamicPrimary + : (isDark + ? DarkDesignTokens.text3 + : DesignTokens.text3), + width: 2, + ), + ), + child: _agreementAccepted + ? const Icon( + CupertinoIcons.checkmark, + size: 14, + color: CupertinoColors.white, + ) + : null, + ), + const SizedBox(width: DesignTokens.space2), + Expanded( + child: Text( + '我已阅读并同意《隐私政策》和《用户协议》', + style: TextStyle( + fontSize: DesignTokens.fontSm, + color: _agreementAccepted + ? DesignTokens.dynamicPrimary + : (isDark + ? DarkDesignTokens.text2 + : DesignTokens.text2), + ), + ), + ), + ], + ), + ), + const SizedBox(height: DesignTokens.space3), + Row( + mainAxisAlignment: MainAxisAlignment.spaceBetween, + children: [ + _buildNavButton( + icon: CupertinoIcons.chevron_up, + label: '上一页', + onPressed: _previousPage, + isDark: isDark, + ), + if (!_agreementAccepted) + _buildNavButton( + icon: CupertinoIcons.xmark, + label: '不同意', + onPressed: _rejectAndExit, + isDark: isDark, + isDestructive: true, + ), + if (_agreementAccepted) + _buildNavButton( + icon: CupertinoIcons.checkmark, + label: '完成', + onPressed: _finishGuide, + isDark: isDark, + ), + ], + ), + ], + ), + ), + ], + ); + } + + Widget _buildPageIndicator(bool isDark) { + return Positioned( + left: DesignTokens.space4, + top: 0, + bottom: 0, + child: Center( + child: Container( + padding: const EdgeInsets.symmetric( + vertical: DesignTokens.space4, + horizontal: DesignTokens.space2, + ), + decoration: BoxDecoration( + color: (isDark ? DarkDesignTokens.card : DesignTokens.card) + .withValues(alpha: 0.9), + borderRadius: DesignTokens.borderRadiusFull, + boxShadow: DesignTokens.shadowsSm, + ), + child: Column( + mainAxisSize: MainAxisSize.min, + children: List.generate(_totalPages, (index) { + final isActive = index == _currentPage; + final isCompleted = index < _currentPage; + return GestureDetector( + onTap: () { + if (_pageController.hasClients) { + _pageController.animateToPage( + index, + duration: const Duration(milliseconds: 400), + curve: Curves.easeInOut, + ); + } + }, + child: Container( + margin: const EdgeInsets.symmetric( + vertical: DesignTokens.space2, + ), + width: isActive ? 14 : 10, + height: isActive ? 14 : 10, + decoration: BoxDecoration( + shape: BoxShape.circle, + color: isActive + ? DesignTokens.dynamicPrimary + : isCompleted + ? DesignTokens.dynamicPrimary.withValues(alpha: 0.3) + : (isDark + ? DarkDesignTokens.text3 + : DesignTokens.text3), + border: isActive + ? Border.all( + color: DesignTokens.dynamicPrimary, + width: 3, + ) + : null, + ), + ), + ); + }), + ), + ), + ), + ); + } + + Widget _buildBottomNav(bool isDark) { + if (_currentPage == _totalPages - 1) return const SizedBox.shrink(); + return Positioned( + bottom: DesignTokens.space5, + left: DesignTokens.space6, + right: DesignTokens.space6, + child: Row( + mainAxisAlignment: MainAxisAlignment.center, + children: [ + _buildNavButton( + icon: CupertinoIcons.chevron_down, + label: '下一页', + onPressed: _nextPage, + isDark: isDark, + ), + ], + ), + ); + } + + void _acceptAndFinish() { + _acceptAgreement(); + if (widget.fromSettings) { + Get.back(); + } else { + Get.offAllNamed(AppRoutes.main); + } + } + + Widget _buildNavButton({ + required IconData icon, + required String label, + VoidCallback? onPressed, + required bool isDark, + bool isDestructive = false, + }) { + final enabled = onPressed != null; + return GestureDetector( + onTap: onPressed, + child: Container( + padding: const EdgeInsets.symmetric( + horizontal: DesignTokens.space4, + vertical: DesignTokens.space2, + ), + decoration: BoxDecoration( + color: isDestructive + ? (isDark ? DarkDesignTokens.text3 : DesignTokens.text3) + : (enabled + ? DesignTokens.dynamicPrimary + : (isDark ? DarkDesignTokens.text3 : DesignTokens.text3)), + borderRadius: DesignTokens.borderRadiusMd, + boxShadow: enabled && !isDestructive + ? [ + BoxShadow( + color: DesignTokens.dynamicPrimary.withValues(alpha: 0.2), + blurRadius: 8, + offset: const Offset(0, 2), + ), + ] + : null, + ), + child: Row( + mainAxisSize: MainAxisSize.min, + children: [ + Icon(icon, color: CupertinoColors.white, size: 16), + const SizedBox(width: DesignTokens.space1), + Text( + label, + style: const TextStyle( + color: CupertinoColors.white, + fontSize: DesignTokens.fontSm, + fontWeight: FontWeight.w500, + ), + ), + ], + ), + ), + ); + } +} diff --git a/lib/src/pages/profile/permission_page.dart b/lib/src/pages/profile/permission_page.dart new file mode 100644 index 0000000..5f1f184 --- /dev/null +++ b/lib/src/pages/profile/permission_page.dart @@ -0,0 +1,452 @@ +/* + * 文件: permission_page.dart + * 名称: 软件权限页面 + * 作用: 展示应用所需权限说明和隐私信息 + * 创建: 2026-04-17 + * 更新: 2026-04-17 初始创建,参考情景诗词权限页面 + */ + +import 'package:flutter/cupertino.dart'; +import 'package:flutter/material.dart' show Divider; +import 'package:mom_kitchen/src/config/design_tokens.dart'; + +class PermissionPage extends StatelessWidget { + const PermissionPage({super.key}); + + @override + Widget build(BuildContext context) { + final isDark = CupertinoTheme.brightnessOf(context) == Brightness.dark; + + return CupertinoPageScaffold( + backgroundColor: isDark + ? DarkDesignTokens.background + : DesignTokens.background, + navigationBar: CupertinoNavigationBar( + middle: Text( + '软件权限', + style: TextStyle( + fontSize: DesignTokens.fontMd, + fontWeight: FontWeight.w600, + color: isDark ? DarkDesignTokens.text1 : DesignTokens.text1, + ), + ), + backgroundColor: isDark + ? DarkDesignTokens.glass.withValues(alpha: 0.8) + : DesignTokens.glass.withValues(alpha: 0.8), + border: null, + trailing: CupertinoButton( + padding: EdgeInsets.zero, + onPressed: () => _showPermissionInfoDialog(context, isDark), + child: Icon( + CupertinoIcons.info_circle, + color: DesignTokens.dynamicPrimary, + ), + ), + ), + child: SafeArea( + child: SingleChildScrollView( + padding: const EdgeInsets.all(DesignTokens.space4), + child: Column( + children: [ + _buildPermissionGroup( + '权限列表', + CupertinoIcons.lock_shield, + _buildPermissionList(isDark), + isDark, + ), + const SizedBox(height: DesignTokens.space4), + _buildPermissionGroup( + '权限说明', + CupertinoIcons.doc_text, + _buildPermissionDescriptions(isDark), + isDark, + ), + const SizedBox(height: DesignTokens.space4), + _buildSandboxInfoCard(isDark), + const SizedBox(height: DesignTokens.space5), + _buildBottomTip(isDark), + ], + ), + ), + ), + ); + } + + Widget _buildPermissionGroup( + String title, + IconData icon, + List children, + bool isDark, + ) { + return Container( + decoration: BoxDecoration( + color: isDark ? DarkDesignTokens.card : DesignTokens.card, + borderRadius: DesignTokens.borderRadiusLg, + boxShadow: DesignTokens.shadowsSm, + ), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Padding( + padding: const EdgeInsets.all(DesignTokens.space4), + child: Row( + children: [ + Icon(icon, color: DesignTokens.dynamicPrimary, size: 20), + const SizedBox(width: DesignTokens.space2), + Text( + title, + style: TextStyle( + color: DesignTokens.dynamicPrimary, + fontSize: DesignTokens.fontLg, + fontWeight: FontWeight.bold, + ), + ), + ], + ), + ), + Divider( + height: 1, + color: isDark + ? DarkDesignTokens.text3.withValues(alpha: 0.1) + : DesignTokens.text3.withValues(alpha: 0.1), + ), + ...children, + ], + ), + ); + } + + List _buildPermissionList(bool isDark) { + final permissions = [ + {'icon': CupertinoIcons.wifi, 'title': '完全网络访问', 'desc': '获取菜谱内容和数据'}, + { + 'icon': CupertinoIcons.device_phone_portrait, + 'title': '震动反馈', + 'desc': '操作时的震动反馈,提升交互体验', + }, + {'icon': CupertinoIcons.doc_on_doc, 'title': '剪切板', 'desc': '复制菜谱内容到剪切板'}, + // {'icon': CupertinoIcons.volume_up, 'title': '播放声音', 'desc': '播放内置提示音'}, + {'icon': CupertinoIcons.share, 'title': '分享能力', 'desc': '调用系统分享接口'}, + { + 'icon': CupertinoIcons.device_phone_portrait, + 'title': '设备标识', + 'desc': '获取设备唯一标识', + }, + ]; + + return permissions + .map( + (p) => _buildPermissionItem( + p['icon'] as IconData, + p['title'] as String, + p['desc'] as String, + isDark, + ), + ) + .toList(); + } + + Widget _buildPermissionItem( + IconData icon, + String title, + String description, + bool isDark, + ) { + return Padding( + padding: const EdgeInsets.symmetric( + horizontal: DesignTokens.space4, + vertical: DesignTokens.space3, + ), + child: Row( + children: [ + Container( + width: 40, + height: 40, + decoration: BoxDecoration( + color: DesignTokens.dynamicPrimary.withValues(alpha: 0.1), + borderRadius: DesignTokens.borderRadiusSm, + ), + child: Icon(icon, color: DesignTokens.dynamicPrimary, size: 20), + ), + const SizedBox(width: DesignTokens.space3), + Expanded( + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Row( + children: [ + Text( + title, + style: TextStyle( + fontSize: DesignTokens.fontMd, + fontWeight: FontWeight.w500, + color: isDark + ? DarkDesignTokens.text1 + : DesignTokens.text1, + ), + ), + const SizedBox(width: DesignTokens.space2), + Container( + padding: const EdgeInsets.symmetric( + horizontal: 6, + vertical: 2, + ), + decoration: BoxDecoration( + color: isDark + ? DarkDesignTokens.text3.withValues(alpha: 0.2) + : DesignTokens.text3.withValues(alpha: 0.15), + borderRadius: DesignTokens.borderRadiusSm, + ), + child: Text( + '已开启', + style: TextStyle( + fontSize: DesignTokens.fontXs, + color: isDark + ? DarkDesignTokens.text2 + : DesignTokens.text2, + ), + ), + ), + ], + ), + const SizedBox(height: 2), + Text( + description, + style: TextStyle( + fontSize: DesignTokens.fontSm, + color: isDark ? DarkDesignTokens.text3 : DesignTokens.text3, + ), + ), + ], + ), + ), + Icon( + CupertinoIcons.lock, + color: isDark ? DarkDesignTokens.text3 : DesignTokens.text3, + size: 18, + ), + ], + ), + ); + } + + List _buildPermissionDescriptions(bool isDark) { + final descriptions = [ + {'title': '完全网络访问', 'desc': '用于获取菜谱内容、食材数据、营养信息等。'}, + {'title': '震动反馈', 'desc': '用于点赞、收藏等操作的触觉反馈,提升用户体验。'}, + {'title': '剪切板', 'desc': '只有写入权限,没有读取权限,方便用户分享和记录菜谱。'}, + // {'title': '播放声音', 'desc': '用于操作提示音,提升用户体验。'}, + {'title': '分享能力', 'desc': '用于分享菜谱内容到社交媒体平台。'}, + {'title': '设备标识', 'desc': '用于唯一标识设备,确保用户数据安全。'}, + ]; + + return descriptions + .map( + (d) => + _buildInfoItem(d['title'] as String, d['desc'] as String, isDark), + ) + .toList(); + } + + Widget _buildInfoItem(String title, String description, bool isDark) { + return Padding( + padding: const EdgeInsets.symmetric( + horizontal: DesignTokens.space4, + vertical: DesignTokens.space3, + ), + child: Row( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Container( + width: 4, + height: 4, + margin: const EdgeInsets.only(top: 6), + decoration: BoxDecoration( + color: DesignTokens.dynamicPrimary, + borderRadius: BorderRadius.circular(2), + ), + ), + const SizedBox(width: DesignTokens.space3), + Expanded( + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text( + title, + style: TextStyle( + fontSize: DesignTokens.fontMd, + fontWeight: FontWeight.w500, + color: isDark ? DarkDesignTokens.text1 : DesignTokens.text1, + ), + ), + const SizedBox(height: 2), + Text( + description, + style: TextStyle( + fontSize: DesignTokens.fontSm, + color: isDark ? DarkDesignTokens.text3 : DesignTokens.text3, + height: 1.5, + ), + ), + ], + ), + ), + ], + ), + ); + } + + Widget _buildSandboxInfoCard(bool isDark) { + return Container( + padding: const EdgeInsets.all(DesignTokens.space4), + decoration: BoxDecoration( + color: isDark ? DarkDesignTokens.card : DesignTokens.card, + borderRadius: DesignTokens.borderRadiusLg, + boxShadow: DesignTokens.shadowsSm, + ), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Row( + children: [ + Container( + padding: const EdgeInsets.all(DesignTokens.space2), + decoration: BoxDecoration( + color: DesignTokens.dynamicPrimary.withValues(alpha: 0.1), + borderRadius: DesignTokens.borderRadiusSm, + ), + child: Icon( + CupertinoIcons.shield, + color: DesignTokens.dynamicPrimary, + size: 20, + ), + ), + const SizedBox(width: DesignTokens.space3), + Text( + '沙盒运行说明', + style: TextStyle( + color: DesignTokens.dynamicPrimary, + fontSize: DesignTokens.fontLg, + fontWeight: FontWeight.bold, + ), + ), + ], + ), + const SizedBox(height: DesignTokens.space3), + Divider( + height: 1, + color: isDark + ? DarkDesignTokens.text3.withValues(alpha: 0.1) + : DesignTokens.text3.withValues(alpha: 0.1), + ), + const SizedBox(height: DesignTokens.space3), + Text( + '本软件严格遵循移动平台沙盒机制运行,确保您的数据安全:', + style: TextStyle( + fontSize: DesignTokens.fontMd, + fontWeight: FontWeight.w500, + color: isDark ? DarkDesignTokens.text2 : DesignTokens.text2, + ), + ), + const SizedBox(height: DesignTokens.space3), + _buildSandboxItem('📱 沙盒隔离', '软件在独立沙盒环境中运行,无法访问系统其他应用数据', isDark), + const SizedBox(height: DesignTokens.space2), + _buildSandboxItem('📄 无文件创建', '不会在设备上创建额外文件,所有数据通过网络获取', isDark), + const SizedBox(height: DesignTokens.space2), + _buildSandboxItem('🔒 权限透明', '仅使用必要权限,此类权限均为基础权限', isDark), + const SizedBox(height: DesignTokens.space2), + _buildSandboxItem('💾 本地存储', '仅使用本地存储保存少量用户偏好设置', isDark), + ], + ), + ); + } + + Widget _buildSandboxItem(String title, String description, bool isDark) { + return Row( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Container( + width: 4, + height: 4, + margin: const EdgeInsets.only(top: 8), + decoration: BoxDecoration( + color: DesignTokens.dynamicPrimary, + borderRadius: BorderRadius.circular(2), + ), + ), + const SizedBox(width: DesignTokens.space3), + Expanded( + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text( + title, + style: TextStyle( + fontSize: DesignTokens.fontMd, + fontWeight: FontWeight.w500, + color: isDark ? DarkDesignTokens.text1 : DesignTokens.text1, + ), + ), + const SizedBox(height: 2), + Text( + description, + style: TextStyle( + fontSize: DesignTokens.fontSm, + color: isDark ? DarkDesignTokens.text3 : DesignTokens.text3, + height: 1.5, + ), + ), + ], + ), + ), + ], + ); + } + + Widget _buildBottomTip(bool isDark) { + return Center( + child: Padding( + padding: const EdgeInsets.only(bottom: DesignTokens.space6), + child: Text( + '到底了', + style: TextStyle( + fontSize: DesignTokens.fontSm, + color: isDark ? DarkDesignTokens.text3 : DesignTokens.text3, + fontWeight: FontWeight.w500, + ), + ), + ), + ); + } + + void _showPermissionInfoDialog(BuildContext context, bool isDark) { + showCupertinoDialog( + context: context, + builder: (context) => CupertinoAlertDialog( + title: Row( + children: [ + Icon( + CupertinoIcons.info_circle, + color: DesignTokens.dynamicPrimary, + ), + const SizedBox(width: DesignTokens.space2), + const Text('基础权限说明'), + ], + ), + content: const Padding( + padding: EdgeInsets.only(top: DesignTokens.space3), + child: Text( + '系统赋予的基础软件权限无法拒绝;自带权限默认开启,用户无需动态授权,系统层关闭后,将无法正常使用应用。', + style: TextStyle(fontSize: DesignTokens.fontMd, height: 1.5), + ), + ), + actions: [ + CupertinoDialogAction( + onPressed: () => Navigator.pop(context), + child: const Text('确定'), + ), + ], + ), + ); + } +} diff --git a/lib/src/pages/profile/privacy_policy_page.dart b/lib/src/pages/profile/privacy_policy_page.dart new file mode 100644 index 0000000..ed43fd4 --- /dev/null +++ b/lib/src/pages/profile/privacy_policy_page.dart @@ -0,0 +1,580 @@ +/* + * 文件: privacy_policy_page.dart + * 名称: 隐私政策与用户协议页面 + * 作用: 展示应用隐私政策和用户服务协议,支持Tab切换 + * 创建: 2026-04-17 + * 更新: 2026-04-17 新增隐私政策与用户协议页面 + */ + +import 'package:flutter/cupertino.dart'; +import 'package:mom_kitchen/src/config/design_tokens.dart'; + +/// 隐私政策内容组件(公开类,可供其他页面调用) +class PrivacyPolicyContent extends StatelessWidget { + final bool isDark; + const PrivacyPolicyContent({super.key, required this.isDark}); + + @override + Widget build(BuildContext context) { + return SingleChildScrollView( + padding: const EdgeInsets.all(DesignTokens.space4), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + _buildSectionTitle('🔒 关于小妈厨房与隐私的声明'), + _buildUpdateDate('2026.4.17'), + const SizedBox(height: DesignTokens.space5), + _buildParagraph( + '小妈厨房 是由 弥勒市朋普镇微风暴网络科技工作室 (以下简称"我们")为您提供的,用于健康饮食管理、菜谱浏览与烹饪辅助的应用。本隐私声明由我们为处理您的个人信息而制定。', + ), + const SizedBox(height: DesignTokens.space4), + _buildParagraph( + '我们非常重视您的个人信息和隐私保护,将会按照法律要求和业界成熟的安全标准,为您的个人信息提供相应的安全保护措施。', + ), + const SizedBox(height: DesignTokens.space5), + _buildSectionTitle('1. 我们如何收集和使用您的个人信息'), + const SizedBox(height: DesignTokens.space4), + _buildParagraph( + '我们仅在有合法性基础的情形下才会使用您的个人信息。根据适用的法律,我们可能会基于您的同意、为履行/订立您与我们的合同所必需、履行法定义务所必需等合法性基础,使用您的个人信息。', + ), + const SizedBox(height: DesignTokens.space4), + _buildSubSectionTitle('1.1 基于履行法定义务或其他法律法规规定的情形'), + const SizedBox(height: DesignTokens.space3), + _buildParagraph('为了实现应用功能,在获取您的同意后我们需要收集您的以下信息:'), + const SizedBox(height: DesignTokens.space3), + _buildBulletPoint('🍳 菜谱收藏数据,用于保存您收藏的菜谱'), + _buildBulletPoint('📊 营养记录数据,用于记录您的饮食营养摄入'), + _buildBulletPoint('🛒 购物清单数据,用于管理您的购物清单'), + _buildBulletPoint('📝 烹饪笔记数据,用于保存您的烹饪心得'), + _buildBulletPoint('⏰ 用餐提醒数据,用于设置您的就寝和用餐提醒'), + const SizedBox(height: DesignTokens.space5), + _buildSectionTitle('2. 设备权限调用'), + const SizedBox(height: DesignTokens.space4), + _buildPermissionItem('📱 存储权限', '用于保存和读取您的菜谱收藏、笔记等本地数据'), + _buildPermissionItem('🌐 网络权限', '用于获取菜谱内容、营养数据和更新应用信息'), + _buildPermissionItem('🔔 通知权限', '用于在用餐提醒、烹饪计时等场景发送通知'), + _buildPermissionItem('📷 相机权限', '用于扫描二维码、拍摄菜品照片等功能'), + _buildPermissionItem('🔗 分享能力', '调用系统分享功能,分享菜谱、购物清单等数据'), + const SizedBox(height: DesignTokens.space5), + _buildSectionTitle('3. 管理您的个人信息'), + const SizedBox(height: DesignTokens.space4), + _buildParagraph( + '如您对您的数据主体权利有进一步要求或存在任何疑问、意见或建议,可通过本声明中"如何联系我们"章节中所述方式与我们取得联系,并行使您的相关权利。', + ), + const SizedBox(height: DesignTokens.space5), + _buildSectionTitle('4. 信息存储地点及期限'), + const SizedBox(height: DesignTokens.space4), + _buildParagraph('4.1 我们承诺,除法律法规另有规定外,我们对您的信息的保存期限应当为实现处理目的所必要的最短时间。'), + const SizedBox(height: DesignTokens.space3), + _buildParagraph('4.2 上述信息将会传输并保存至中国境内的服务器。'), + const SizedBox(height: DesignTokens.space5), + _buildSectionTitle('5. 如何联系我们'), + const SizedBox(height: DesignTokens.space4), + _buildParagraph('您可通过以下方式联系我们,并行使您的相关权利,我们会尽快回复。'), + const SizedBox(height: DesignTokens.space3), + _buildContactInfo('👤 开发者', '弥勒市朋普镇微风暴网络科技工作室'), + _buildContactInfo('📍 地址', '云南 昆明 西山区'), + _buildContactInfo('📧 邮箱', 'support@momkitchen.app'), + const SizedBox(height: DesignTokens.space4), + _buildParagraph( + '如果您对我们的回复不满意,特别是当个人信息处理行为损害了您的合法权益时,您还可以通过向有管辖权的人民法院提起诉讼、向行业自律协会或政府相关管理机构投诉等外部途径进行解决。', + ), + const SizedBox(height: DesignTokens.space5), + _buildEffectiveDate('2026年4月17日'), + _buildBottomIndicator(), + ], + ), + ); + } + + Widget _buildSectionTitle(String title) { + return Padding( + padding: const EdgeInsets.only(bottom: DesignTokens.space2), + child: Text( + title, + style: TextStyle( + fontSize: DesignTokens.fontLg, + fontWeight: FontWeight.bold, + color: isDark ? DarkDesignTokens.text1 : DesignTokens.text1, + ), + ), + ); + } + + Widget _buildSubSectionTitle(String title) { + return Padding( + padding: const EdgeInsets.only(bottom: DesignTokens.space2), + child: Text( + title, + style: TextStyle( + fontSize: DesignTokens.fontMd, + fontWeight: FontWeight.w600, + color: isDark ? DarkDesignTokens.text1 : DesignTokens.text1, + ), + ), + ); + } + + Widget _buildParagraph(String text) { + return Text( + text, + style: TextStyle( + fontSize: DesignTokens.fontMd, + color: isDark ? DarkDesignTokens.text2 : DesignTokens.text2, + height: 1.7, + ), + ); + } + + Widget _buildBulletPoint(String text) { + return Padding( + padding: const EdgeInsets.symmetric(vertical: DesignTokens.space1), + child: Row( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Container( + width: 6, + height: 6, + margin: const EdgeInsets.only( + top: 7, + left: DesignTokens.space2, + right: DesignTokens.space3, + ), + decoration: BoxDecoration( + color: DesignTokens.dynamicPrimary, + shape: BoxShape.circle, + ), + ), + Expanded( + child: Text( + text, + style: TextStyle( + fontSize: DesignTokens.fontMd, + color: isDark ? DarkDesignTokens.text2 : DesignTokens.text2, + height: 1.6, + ), + ), + ), + ], + ), + ); + } + + Widget _buildPermissionItem(String title, String desc) { + return Container( + margin: const EdgeInsets.only(bottom: DesignTokens.space3), + padding: const EdgeInsets.all(DesignTokens.space3), + decoration: BoxDecoration( + color: isDark ? DarkDesignTokens.card : DesignTokens.card, + borderRadius: DesignTokens.borderRadiusMd, + border: Border.all( + color: isDark + ? DarkDesignTokens.text3.withValues(alpha: 0.15) + : DesignTokens.text3.withValues(alpha: 0.15), + ), + ), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text( + title, + style: TextStyle( + fontSize: DesignTokens.fontMd, + fontWeight: FontWeight.w600, + color: isDark ? DarkDesignTokens.text1 : DesignTokens.text1, + ), + ), + const SizedBox(height: DesignTokens.space1), + Text( + desc, + style: TextStyle( + fontSize: DesignTokens.fontSm, + color: isDark ? DarkDesignTokens.text3 : DesignTokens.text3, + ), + ), + ], + ), + ); + } + + Widget _buildContactInfo(String label, String value) { + return Padding( + padding: const EdgeInsets.symmetric(vertical: DesignTokens.space1), + child: Row( + children: [ + SizedBox( + width: 120, + child: Text( + label, + style: TextStyle( + fontSize: DesignTokens.fontMd, + fontWeight: FontWeight.w500, + color: isDark ? DarkDesignTokens.text1 : DesignTokens.text1, + ), + ), + ), + Expanded( + child: Text( + value, + style: TextStyle( + fontSize: DesignTokens.fontMd, + color: isDark ? DarkDesignTokens.text2 : DesignTokens.text2, + ), + ), + ), + ], + ), + ); + } + + Widget _buildUpdateDate(String date) { + return Text( + '更新日期:$date', + style: TextStyle( + fontSize: DesignTokens.fontSm, + color: isDark ? DarkDesignTokens.text3 : DesignTokens.text3, + ), + ); + } + + Widget _buildEffectiveDate(String date) { + return Text( + '生效日期:$date', + style: TextStyle( + fontSize: DesignTokens.fontSm, + color: isDark ? DarkDesignTokens.text3 : DesignTokens.text3, + ), + ); + } + + Widget _buildBottomIndicator() { + return Container( + padding: const EdgeInsets.symmetric(vertical: DesignTokens.space5), + child: Row( + mainAxisAlignment: MainAxisAlignment.center, + children: [ + Container( + width: 40, + height: 1, + color: isDark ? DarkDesignTokens.text3 : DesignTokens.text3, + ), + Padding( + padding: const EdgeInsets.symmetric( + horizontal: DesignTokens.space4, + ), + child: Text( + '到底了', + style: TextStyle( + fontSize: DesignTokens.fontSm, + color: isDark ? DarkDesignTokens.text3 : DesignTokens.text3, + ), + ), + ), + Container( + width: 40, + height: 1, + color: isDark ? DarkDesignTokens.text3 : DesignTokens.text3, + ), + ], + ), + ); + } +} + +/// 用户协议内容组件(公开类,可供其他页面调用) +class UserAgreementContent extends StatelessWidget { + final bool isDark; + const UserAgreementContent({super.key, required this.isDark}); + + @override + Widget build(BuildContext context) { + return SingleChildScrollView( + padding: const EdgeInsets.all(DesignTokens.space4), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + _buildSectionTitle('📋 用户协议'), + _buildEffectiveDate('2026-04-17'), + const SizedBox(height: DesignTokens.space5), + _buildParagraph( + '欢迎使用 小妈厨房(以下简称"本App")。本用户协议由 弥勒市朋普镇微风暴网络科技工作室 制定。用户在下载、安装、使用本App服务前,应当仔细阅读并充分理解本协议内容。用户开始使用本App,即视为同意本协议全部条款。', + ), + const SizedBox(height: DesignTokens.space5), + _buildSectionTitle('一、协议适用范围'), + const SizedBox(height: DesignTokens.space4), + _buildParagraph( + '本协议适用于用户与开发者 弥勒市朋普镇微风暴网络科技工作室 之间,关于用户使用 小妈厨房 产品及服务所建立的权利义务关系。', + ), + const SizedBox(height: DesignTokens.space5), + _buildSectionTitle('二、服务内容'), + const SizedBox(height: DesignTokens.space4), + _buildParagraph( + '小妈厨房 主要提供菜谱浏览、收藏、营养管理、购物清单、烹饪计时、饮食规划等功能。具体服务内容以应用内实际展示为准,开发者可根据产品运营情况进行功能优化、升级和调整。', + ), + const SizedBox(height: DesignTokens.space5), + _buildSectionTitle('三、账号与安全'), + const SizedBox(height: DesignTokens.space4), + _buildParagraph( + '您应当确保使用本App时遵守相关法律法规。本App目前不需要注册账号,您的本地数据存储在您的设备上,请妥善保管您的设备。', + ), + const SizedBox(height: DesignTokens.space5), + _buildSectionTitle('四、用户行为规范'), + const SizedBox(height: DesignTokens.space4), + _buildParagraph( + '用户在使用本App过程中,应当遵守中华人民共和国法律法规,不得利用本App从事违法违规行为,不得发布或传播侵犯他人合法权益的内容,不得实施影响本App安全和稳定运行的行为。', + ), + const SizedBox(height: DesignTokens.space5), + _buildSectionTitle('五、知识产权'), + const SizedBox(height: DesignTokens.space4), + _buildParagraph( + '用户知悉并认可,本App(含程序代码、界面设计、功能及其更新、扩展、修复版本)相关权利均归开发者或合法权利人所有。', + ), + const SizedBox(height: DesignTokens.space3), + _buildParagraph( + '本条所称"知识产权"包括但不限于著作权、商标权、专利权、商业秘密及反不正当竞争法等法律法规项下的一切相关权利。', + ), + const SizedBox(height: DesignTokens.space3), + _buildParagraph( + '除法律法规另有规定或开发者书面授权外,用户仅获得基于本协议的个人、非独占、不可转让、可撤销的使用许可;用户不得对本App实施复制、修改、改编、翻译、出租、出借、出售、传播、反向工程、反编译、反汇编,或以其他方式尝试获取源代码。', + ), + const SizedBox(height: DesignTokens.space5), + _buildSectionTitle('六、责任限制'), + const SizedBox(height: DesignTokens.space4), + _buildParagraph( + '在法律允许范围内,对于因网络异常、设备故障、不可抗力、第三方服务异常等原因导致的服务中断或数据损失,开发者将在能力范围内及时修复或补救,但不承担超出法定范围的责任。', + ), + const SizedBox(height: DesignTokens.space5), + _buildSectionTitle('七、协议更新'), + const SizedBox(height: DesignTokens.space4), + _buildParagraph( + '开发者有权根据业务变化、监管要求或法律法规变化更新本协议。更新后的协议将在应用内公示,用户继续使用本App即视为接受更新后的协议内容。', + ), + const SizedBox(height: DesignTokens.space5), + _buildSectionTitle('八、适用法律与争议解决'), + const SizedBox(height: DesignTokens.space4), + _buildParagraph( + '本协议适用中华人民共和国法律。因本协议引发的争议,双方应先友好协商;协商不成的,提交被告住所地有管辖权的人民法院处理。', + ), + const SizedBox(height: DesignTokens.space5), + _buildSectionTitle('九、联系方式'), + const SizedBox(height: DesignTokens.space4), + _buildContactInfo('👤 开发者', '弥勒市朋普镇微风暴网络科技工作室'), + _buildContactInfo('📱 应用名称', '小妈厨房'), + _buildContactInfo('📧 联系邮箱', 'support@momkitchen.app'), + const SizedBox(height: DesignTokens.space5), + _buildBottomIndicator(), + ], + ), + ); + } + + Widget _buildSectionTitle(String title) { + return Padding( + padding: const EdgeInsets.only(bottom: DesignTokens.space2), + child: Text( + title, + style: TextStyle( + fontSize: DesignTokens.fontLg, + fontWeight: FontWeight.bold, + color: isDark ? DarkDesignTokens.text1 : DesignTokens.text1, + ), + ), + ); + } + + Widget _buildParagraph(String text) { + return Text( + text, + style: TextStyle( + fontSize: DesignTokens.fontMd, + color: isDark ? DarkDesignTokens.text2 : DesignTokens.text2, + height: 1.7, + ), + ); + } + + Widget _buildContactInfo(String label, String value) { + return Padding( + padding: const EdgeInsets.symmetric(vertical: DesignTokens.space1), + child: Row( + children: [ + SizedBox( + width: 120, + child: Text( + label, + style: TextStyle( + fontSize: DesignTokens.fontMd, + fontWeight: FontWeight.w500, + color: isDark ? DarkDesignTokens.text1 : DesignTokens.text1, + ), + ), + ), + Expanded( + child: Text( + value, + style: TextStyle( + fontSize: DesignTokens.fontMd, + color: isDark ? DarkDesignTokens.text2 : DesignTokens.text2, + ), + ), + ), + ], + ), + ); + } + + Widget _buildEffectiveDate(String date) { + return Text( + '生效日期:$date', + style: TextStyle( + fontSize: DesignTokens.fontSm, + color: isDark ? DarkDesignTokens.text3 : DesignTokens.text3, + ), + ); + } + + Widget _buildBottomIndicator() { + return Container( + padding: const EdgeInsets.symmetric(vertical: DesignTokens.space5), + child: Row( + mainAxisAlignment: MainAxisAlignment.center, + children: [ + Container( + width: 40, + height: 1, + color: isDark ? DarkDesignTokens.text3 : DesignTokens.text3, + ), + Padding( + padding: const EdgeInsets.symmetric( + horizontal: DesignTokens.space4, + ), + child: Text( + '到底了', + style: TextStyle( + fontSize: DesignTokens.fontSm, + color: isDark ? DarkDesignTokens.text3 : DesignTokens.text3, + ), + ), + ), + Container( + width: 40, + height: 1, + color: isDark ? DarkDesignTokens.text3 : DesignTokens.text3, + ), + ], + ), + ); + } +} + +/// 隐私政策与用户协议页面 +class PrivacyPolicyPage extends StatefulWidget { + final int initialTab; + const PrivacyPolicyPage({super.key, this.initialTab = 0}); + + @override + State createState() => _PrivacyPolicyPageState(); +} + +class _PrivacyPolicyPageState extends State { + late final PageController _pageController; + late int _selectedSegment; + + @override + void initState() { + super.initState(); + _selectedSegment = widget.initialTab.clamp(0, 1); + _pageController = PageController(initialPage: _selectedSegment); + } + + @override + void dispose() { + _pageController.dispose(); + super.dispose(); + } + + void _onPageChanged(int index) { + setState(() => _selectedSegment = index); + } + + void _onSegmentChanged(int value) { + setState(() => _selectedSegment = value); + _pageController.animateToPage( + value, + duration: const Duration(milliseconds: 300), + curve: Curves.easeInOut, + ); + } + + @override + Widget build(BuildContext context) { + final isDark = CupertinoTheme.brightnessOf(context) == Brightness.dark; + + return CupertinoPageScaffold( + backgroundColor: isDark + ? DarkDesignTokens.background + : DesignTokens.background, + navigationBar: CupertinoNavigationBar( + middle: Text( + '隐私与协议', + style: TextStyle( + fontSize: DesignTokens.fontMd, + fontWeight: FontWeight.w600, + color: isDark ? DarkDesignTokens.text1 : DesignTokens.text1, + ), + ), + backgroundColor: isDark + ? DarkDesignTokens.glass.withValues(alpha: 0.8) + : DesignTokens.glass.withValues(alpha: 0.8), + border: null, + ), + child: SafeArea( + child: Column( + children: [ + Padding( + padding: const EdgeInsets.symmetric( + horizontal: DesignTokens.space4, + vertical: DesignTokens.space3, + ), + child: SizedBox( + width: double.infinity, + child: CupertinoSlidingSegmentedControl( + groupValue: _selectedSegment, + onValueChanged: (value) { + if (value != null) { + _onSegmentChanged(value); + } + }, + children: const { + 0: Padding( + padding: EdgeInsets.symmetric( + horizontal: DesignTokens.space3, + ), + child: Text('🔒 隐私政策'), + ), + 1: Padding( + padding: EdgeInsets.symmetric( + horizontal: DesignTokens.space3, + ), + child: Text('📋 用户协议'), + ), + }, + ), + ), + ), + Expanded( + child: PageView( + controller: _pageController, + onPageChanged: _onPageChanged, + children: [ + PrivacyPolicyContent(isDark: isDark), + UserAgreementContent(isDark: isDark), + ], + ), + ), + ], + ), + ), + ); + } +} diff --git a/lib/src/pages/profile/settings/personalization_page.dart b/lib/src/pages/profile/settings/personalization_page.dart index 5eb9fe2..6e0dae6 100644 --- a/lib/src/pages/profile/settings/personalization_page.dart +++ b/lib/src/pages/profile/settings/personalization_page.dart @@ -1,8 +1,10 @@ import 'package:flutter/cupertino.dart'; import 'package:get/get.dart'; +import 'package:mom_kitchen/src/config/app_routes.dart'; import 'package:mom_kitchen/src/config/design_tokens.dart'; import 'package:mom_kitchen/src/controllers/user/personalization_controller.dart'; import 'package:mom_kitchen/src/services/core/app_service.dart'; +import 'package:mom_kitchen/src/services/data/storage_service.dart'; import 'package:mom_kitchen/src/services/ui/theme_service.dart'; import 'package:mom_kitchen/src/widgets/common/skeleton_widgets.dart'; import 'package:mom_kitchen/src/widgets/states/standard_dialog.dart'; @@ -154,6 +156,45 @@ class PersonalizationPage extends StatelessWidget { header: const Text('预览'), children: [_buildPreviewItem(themeService)], ), + CupertinoListSection.insetGrouped( + header: const Text('🛠️ 调试工具'), + children: [ + CupertinoListTile( + title: const Text('引导页开关'), + subtitle: Text( + StorageService().getBool('agreement_accepted') ?? + false + ? '已同意协议(下次启动跳过引导页)' + : '未同意协议(下次启动显示引导页)', + style: TextStyle( + fontSize: 12, + color: themeService.textColor.value.withValues( + alpha: 0.5, + ), + ), + ), + trailing: CupertinoSwitch( + value: + StorageService().getBool( + 'agreement_accepted', + ) ?? + false, + onChanged: (v) async { + await StorageService().setBool( + 'agreement_accepted', + v, + ); + (context as Element).markNeedsBuild(); + }, + ), + ), + CupertinoListTile( + title: const Text('打开引导页'), + trailing: const CupertinoListTileChevron(), + onTap: () => Get.toNamed(AppRoutes.guide), + ), + ], + ), Padding( padding: const EdgeInsets.all(16), child: CupertinoButton.filled( diff --git a/lib/src/pages/profile/social/favorites_item_builders.dart b/lib/src/pages/profile/social/favorites_item_builders.dart new file mode 100644 index 0000000..1b60f56 --- /dev/null +++ b/lib/src/pages/profile/social/favorites_item_builders.dart @@ -0,0 +1,680 @@ +/* + * 文件: favorites_item_builders.dart + * 名称: 收藏项构建器 Mixin + * 作用: 收藏页面中各类收藏项的构建方法,拆分自 favorites_page.dart + * 创建: 2026-04-16 从 favorites_page.dart 拆分 + * 更新: 2026-04-16 初始创建 + */ + +import 'dart:ui'; +import 'package:flutter/cupertino.dart'; +import 'package:flutter/material.dart'; +import 'package:get/get.dart'; +import 'package:mom_kitchen/src/config/design_tokens.dart'; +import 'package:mom_kitchen/src/controllers/data/favorites_controller.dart'; +import 'package:mom_kitchen/src/models/feed_item_model.dart'; +import 'package:mom_kitchen/src/config/app_routes.dart'; +import 'package:mom_kitchen/src/widgets/recipe/recipe_image.dart'; + +mixin FavoritesItemBuilders on State { + FavoritesController? get favoritesController; + void Function(int) get onNavigateToRecipeDetail; + void Function(int) get onNavigateToMiniCard; + + Widget buildRecipeFavoriteItem(FeedItemModel item, bool isDark) { + return Obx(() { + final isEditMode = favoritesController!.isEditMode.value; + final isSelected = favoritesController!.isSelected(item.id); + + return GestureDetector( + onTap: isEditMode + ? () => favoritesController!.toggleSelection(item.id) + : () => onNavigateToRecipeDetail(item.id), + child: ClipRRect( + borderRadius: DesignTokens.borderRadiusLg, + child: BackdropFilter( + filter: ImageFilter.blur(sigmaX: 14, sigmaY: 14), + child: Container( + decoration: BoxDecoration( + color: isEditMode && isSelected + ? DesignTokens.dynamicPrimary.withValues(alpha: 0.15) + : (isDark + ? DarkDesignTokens.glass + : DesignTokens.card.withValues(alpha: 0.75)), + borderRadius: DesignTokens.borderRadiusLg, + border: Border.all( + color: isEditMode && isSelected + ? DesignTokens.dynamicPrimary.withValues(alpha: 0.5) + : (isDark + ? DarkDesignTokens.glassBorder + : DesignTokens.text3.withValues(alpha: 0.1)), + width: isEditMode && isSelected ? 1.5 : 0.5, + ), + ), + child: Stack( + children: [ + Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + if (isEditMode) + Align( + alignment: Alignment.topRight, + child: Padding( + padding: const EdgeInsets.only( + top: DesignTokens.space2, + right: DesignTokens.space2, + ), + child: Container( + width: 24, + height: 24, + decoration: BoxDecoration( + color: isSelected + ? DesignTokens.dynamicPrimary + : const Color(0x00000000), + borderRadius: DesignTokens.borderRadiusSm, + border: Border.all( + color: isSelected + ? DesignTokens.dynamicPrimary + : (isDark + ? DarkDesignTokens.text3 + : DesignTokens.text3), + width: 2, + ), + ), + child: isSelected + ? const Icon( + CupertinoIcons.checkmark_alt, + size: 14, + color: CupertinoColors.white, + ) + : null, + ), + ), + ), + Expanded( + child: Padding( + padding: const EdgeInsets.symmetric( + horizontal: DesignTokens.space2, + ), + child: ClipRRect( + borderRadius: DesignTokens.borderRadiusMd, + child: RecipeImage( + recipeId: item.id, + picId: item.picId, + coverUrl: item.cover, + width: double.infinity, + borderRadius: DesignTokens.borderRadiusMd, + mode: RecipeImageMode.thumbnail, + ), + ), + ), + ), + const SizedBox(height: DesignTokens.space2), + Padding( + padding: const EdgeInsets.symmetric( + horizontal: DesignTokens.space2, + ), + child: Text( + item.title, + style: TextStyle( + fontSize: DesignTokens.fontMd, + fontWeight: FontWeight.w600, + color: isDark + ? DarkDesignTokens.text1 + : DesignTokens.text1, + ), + maxLines: 2, + overflow: TextOverflow.ellipsis, + textAlign: TextAlign.center, + ), + ), + if (item.intro != null && item.intro!.isNotEmpty) ...[ + const SizedBox(height: DesignTokens.space1), + Padding( + padding: const EdgeInsets.symmetric( + horizontal: DesignTokens.space2, + ), + child: Text( + item.intro!, + style: TextStyle( + fontSize: DesignTokens.fontXs, + color: isDark + ? DarkDesignTokens.text3 + : DesignTokens.text3, + ), + maxLines: 1, + overflow: TextOverflow.ellipsis, + textAlign: TextAlign.center, + ), + ), + ], + const SizedBox(height: DesignTokens.space2), + ], + ), + if (!isEditMode) + Positioned( + bottom: DesignTokens.space3, + right: DesignTokens.space2, + child: GestureDetector( + onTap: () => + favoritesController!.removeFavorite(item.id), + behavior: HitTestBehavior.opaque, + child: Container( + width: 28, + height: 28, + decoration: BoxDecoration( + color: DesignTokens.red.withValues(alpha: 0.1), + borderRadius: DesignTokens.borderRadiusSm, + ), + child: Icon( + CupertinoIcons.heart_slash, + size: 14, + color: DesignTokens.red, + ), + ), + ), + ), + ], + ), + ), + ), + ), + ); + }); + } + + Widget buildMiniCardFavoriteItem(FeedItemModel item, bool isDark) { + return Obx(() { + final isEditMode = favoritesController!.isEditMode.value; + final isSelected = favoritesController!.isSelected(item.id); + final coverUrl = item.cover ?? ''; + + return GestureDetector( + onTap: isEditMode + ? () => favoritesController!.toggleSelection(item.id) + : () => onNavigateToMiniCard(item.id), + child: ClipRRect( + borderRadius: DesignTokens.borderRadiusLg, + child: Stack( + fit: StackFit.expand, + children: [ + if (coverUrl.isNotEmpty) + Image.network( + coverUrl.startsWith('http') + ? coverUrl + : 'https://eat.wktyl.com/api/assets/$coverUrl', + fit: BoxFit.cover, + errorBuilder: (_, __, _) => Container( + decoration: BoxDecoration( + gradient: LinearGradient( + begin: Alignment.topLeft, + end: Alignment.bottomRight, + colors: [ + DesignTokens.orange.withValues(alpha: 0.3), + DesignTokens.red.withValues(alpha: 0.2), + ], + ), + ), + child: const Center( + child: Icon( + CupertinoIcons.photo, + size: 32, + color: DesignTokens.text3, + ), + ), + ), + ) + else + Container( + decoration: BoxDecoration( + gradient: LinearGradient( + begin: Alignment.topLeft, + end: Alignment.bottomRight, + colors: [ + DesignTokens.orange.withValues(alpha: 0.3), + DesignTokens.red.withValues(alpha: 0.2), + ], + ), + ), + child: const Center( + child: Text('🃏', style: TextStyle(fontSize: 40)), + ), + ), + Container( + decoration: BoxDecoration( + gradient: LinearGradient( + begin: Alignment.topCenter, + end: Alignment.bottomCenter, + colors: [ + Colors.black.withValues(alpha: 0.05), + Colors.black.withValues(alpha: 0.6), + ], + stops: const [0.4, 1.0], + ), + ), + ), + Positioned( + bottom: 0, + left: 0, + right: 0, + child: ClipRect( + child: BackdropFilter( + filter: ImageFilter.blur(sigmaX: 3, sigmaY: 3), + child: Container( + padding: const EdgeInsets.fromLTRB(10, 8, 10, 8), + decoration: BoxDecoration( + gradient: LinearGradient( + begin: Alignment.topCenter, + end: Alignment.bottomCenter, + colors: [ + Colors.white.withValues(alpha: 0.02), + Colors.white.withValues(alpha: 0.08), + ], + ), + border: Border( + top: BorderSide( + color: Colors.white.withValues(alpha: 0.35), + width: 0.5, + ), + ), + ), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + mainAxisSize: MainAxisSize.min, + children: [ + Text( + item.title, + style: const TextStyle( + color: CupertinoColors.white, + fontSize: DesignTokens.fontMd, + fontWeight: FontWeight.bold, + height: 1.2, + shadows: [ + Shadow(color: Colors.black54, blurRadius: 6), + ], + ), + maxLines: 2, + overflow: TextOverflow.ellipsis, + ), + const SizedBox(height: 2), + Row( + children: [ + Container( + padding: const EdgeInsets.symmetric( + horizontal: 4, + vertical: 1, + ), + decoration: BoxDecoration( + color: DesignTokens.dynamicPrimary.withValues( + alpha: 0.3, + ), + borderRadius: BorderRadius.circular( + DesignTokens.radiusSm, + ), + ), + child: const Text( + '🃏 迷你卡片', + style: TextStyle( + color: CupertinoColors.white, + fontSize: DesignTokens.fontXs - 1, + fontWeight: FontWeight.w500, + ), + ), + ), + const Spacer(), + if (!isEditMode) + GestureDetector( + onTap: () => favoritesController! + .removeFavorite(item.id), + child: const Icon( + CupertinoIcons.heart_slash, + size: 14, + color: CupertinoColors.white, + ), + ), + ], + ), + ], + ), + ), + ), + ), + ), + if (isEditMode) + Positioned( + top: 6, + right: 6, + child: Container( + width: 24, + height: 24, + decoration: BoxDecoration( + color: isSelected + ? DesignTokens.dynamicPrimary + : const Color(0x00000000), + borderRadius: DesignTokens.borderRadiusSm, + border: Border.all( + color: isSelected + ? DesignTokens.dynamicPrimary + : CupertinoColors.white, + width: 2, + ), + ), + child: isSelected + ? const Icon( + CupertinoIcons.checkmark_alt, + size: 14, + color: CupertinoColors.white, + ) + : null, + ), + ), + ], + ), + ), + ); + }); + } + + Widget buildIngredientFavoriteItem(FeedItemModel item, bool isDark) { + return Obx(() { + final isEditMode = favoritesController!.isEditMode.value; + final isSelected = favoritesController!.isSelected(item.id); + + return GestureDetector( + onTap: isEditMode + ? () => favoritesController!.toggleSelection(item.id) + : () => Get.toNamed( + AppRoutes.toolsIngredient, + arguments: { + 'ingredientId': item.id, + 'ingredientName': item.title, + }, + ), + child: ClipRRect( + borderRadius: DesignTokens.borderRadiusLg, + child: BackdropFilter( + filter: ImageFilter.blur(sigmaX: 14, sigmaY: 14), + child: Container( + padding: EdgeInsets.all(DesignTokens.space3), + decoration: BoxDecoration( + color: isEditMode && isSelected + ? DesignTokens.dynamicPrimary.withValues(alpha: 0.15) + : (isDark + ? DarkDesignTokens.glass + : DesignTokens.card.withValues(alpha: 0.75)), + borderRadius: DesignTokens.borderRadiusLg, + border: Border.all( + color: isEditMode && isSelected + ? DesignTokens.dynamicPrimary.withValues(alpha: 0.5) + : (isDark + ? DarkDesignTokens.glassBorder + : DesignTokens.text3.withValues(alpha: 0.1)), + width: isEditMode && isSelected ? 1.5 : 0.5, + ), + ), + child: Stack( + children: [ + Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + if (isEditMode) + Align( + alignment: Alignment.topRight, + child: Container( + width: 24, + height: 24, + decoration: BoxDecoration( + color: isSelected + ? DesignTokens.dynamicPrimary + : const Color(0x00000000), + borderRadius: DesignTokens.borderRadiusSm, + border: Border.all( + color: isSelected + ? DesignTokens.dynamicPrimary + : (isDark + ? DarkDesignTokens.text3 + : DesignTokens.text3), + width: 2, + ), + ), + child: isSelected + ? const Icon( + CupertinoIcons.checkmark_alt, + size: 14, + color: CupertinoColors.white, + ) + : null, + ), + ), + Center( + child: Container( + width: 64, + height: 64, + decoration: BoxDecoration( + color: DesignTokens.green.withValues(alpha: 0.1), + borderRadius: DesignTokens.borderRadiusMd, + ), + child: const Center( + child: Text('🥬', style: TextStyle(fontSize: 32)), + ), + ), + ), + const SizedBox(height: DesignTokens.space2), + Text( + item.title, + style: TextStyle( + fontSize: DesignTokens.fontMd, + fontWeight: FontWeight.w600, + color: isDark + ? DarkDesignTokens.text1 + : DesignTokens.text1, + ), + maxLines: 2, + overflow: TextOverflow.ellipsis, + textAlign: TextAlign.center, + ), + const SizedBox(height: DesignTokens.space1), + Container( + padding: const EdgeInsets.symmetric( + horizontal: 6, + vertical: 2, + ), + decoration: BoxDecoration( + color: DesignTokens.green.withValues(alpha: 0.1), + borderRadius: BorderRadius.circular( + DesignTokens.radiusSm, + ), + ), + child: Text( + '🥬 食材', + style: TextStyle( + fontSize: DesignTokens.fontXs, + color: DesignTokens.green, + fontWeight: FontWeight.w500, + ), + ), + ), + ], + ), + if (!isEditMode) + Positioned( + bottom: 0, + right: 0, + child: GestureDetector( + onTap: () => + favoritesController!.removeFavorite(item.id), + behavior: HitTestBehavior.opaque, + child: Container( + width: 32, + height: 32, + decoration: BoxDecoration( + color: DesignTokens.red.withValues(alpha: 0.1), + borderRadius: DesignTokens.borderRadiusSm, + ), + child: Icon( + CupertinoIcons.heart_slash, + size: 16, + color: DesignTokens.red, + ), + ), + ), + ), + ], + ), + ), + ), + ), + ); + }); + } + + Widget buildTagFavoriteItem(FeedItemModel item, bool isDark) { + return Obx(() { + final isEditMode = favoritesController!.isEditMode.value; + final isSelected = favoritesController!.isSelected(item.id); + + return GestureDetector( + onTap: isEditMode + ? () => favoritesController!.toggleSelection(item.id) + : () => Get.toNamed( + AppRoutes.tagRecipeList, + arguments: {'tagName': item.title, 'tagType': 'taste'}, + ), + child: ClipRRect( + borderRadius: DesignTokens.borderRadiusLg, + child: BackdropFilter( + filter: ImageFilter.blur(sigmaX: 14, sigmaY: 14), + child: Container( + padding: EdgeInsets.all(DesignTokens.space3), + decoration: BoxDecoration( + color: isEditMode && isSelected + ? DesignTokens.dynamicPrimary.withValues(alpha: 0.15) + : (isDark + ? DarkDesignTokens.glass + : DesignTokens.card.withValues(alpha: 0.75)), + borderRadius: DesignTokens.borderRadiusLg, + border: Border.all( + color: isEditMode && isSelected + ? DesignTokens.dynamicPrimary.withValues(alpha: 0.5) + : (isDark + ? DarkDesignTokens.glassBorder + : DesignTokens.text3.withValues(alpha: 0.1)), + width: isEditMode && isSelected ? 1.5 : 0.5, + ), + ), + child: Stack( + children: [ + Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + if (isEditMode) + Align( + alignment: Alignment.topRight, + child: Container( + width: 24, + height: 24, + decoration: BoxDecoration( + color: isSelected + ? DesignTokens.dynamicPrimary + : const Color(0x00000000), + borderRadius: DesignTokens.borderRadiusSm, + border: Border.all( + color: isSelected + ? DesignTokens.dynamicPrimary + : (isDark + ? DarkDesignTokens.text3 + : DesignTokens.text3), + width: 2, + ), + ), + child: isSelected + ? const Icon( + CupertinoIcons.checkmark_alt, + size: 14, + color: CupertinoColors.white, + ) + : null, + ), + ), + Center( + child: Container( + width: 64, + height: 64, + decoration: BoxDecoration( + color: DesignTokens.orange.withValues(alpha: 0.1), + borderRadius: DesignTokens.borderRadiusMd, + ), + child: const Center( + child: Text('🏷️', style: TextStyle(fontSize: 32)), + ), + ), + ), + const SizedBox(height: DesignTokens.space2), + Text( + item.title, + style: TextStyle( + fontSize: DesignTokens.fontMd, + fontWeight: FontWeight.w600, + color: isDark + ? DarkDesignTokens.text1 + : DesignTokens.text1, + ), + maxLines: 2, + overflow: TextOverflow.ellipsis, + textAlign: TextAlign.center, + ), + const SizedBox(height: DesignTokens.space1), + Container( + padding: const EdgeInsets.symmetric( + horizontal: 6, + vertical: 2, + ), + decoration: BoxDecoration( + color: DesignTokens.orange.withValues(alpha: 0.1), + borderRadius: BorderRadius.circular( + DesignTokens.radiusSm, + ), + ), + child: Text( + '🏷️ 标签', + style: TextStyle( + fontSize: DesignTokens.fontXs, + color: DesignTokens.orange, + fontWeight: FontWeight.w500, + ), + ), + ), + ], + ), + if (!isEditMode) + Positioned( + bottom: 0, + right: 0, + child: GestureDetector( + onTap: () => + favoritesController!.removeFavorite(item.id), + behavior: HitTestBehavior.opaque, + child: Container( + width: 32, + height: 32, + decoration: BoxDecoration( + color: DesignTokens.red.withValues(alpha: 0.1), + borderRadius: DesignTokens.borderRadiusSm, + ), + child: Icon( + CupertinoIcons.heart_slash, + size: 16, + color: DesignTokens.red, + ), + ), + ), + ), + ], + ), + ), + ), + ), + ); + }); + } +} diff --git a/lib/src/pages/profile/social/favorites_page.dart b/lib/src/pages/profile/social/favorites_page.dart index 36baab1..76e4679 100644 --- a/lib/src/pages/profile/social/favorites_page.dart +++ b/lib/src/pages/profile/social/favorites_page.dart @@ -1,25 +1,27 @@ /* * 文件: favorites_page.dart * 名称: 收藏页面 - * 作用: iOS 26 Liquid Glass 风格的收藏页面,支持下拉进入工具中心 + * 作用: iOS 26 Liquid Glass 风格的收藏页面,顶部显示快捷功能入口 * 更新: 2026-04-14 替换_RecipeDetailWrapper为真实RecipeDetailPage跳转; 新增下拉进入工具中心 * 更新: 2026-04-14 新增收藏类型分类(菜品/食材/标签/迷你卡片),不同类型不同样式 + * 更新: 2026-04-16 拆分收藏项构建器到 favorites_item_builders.dart + * 更新: 2026-04-16 新增搜索功能、统计信息展示、导出功能、类型安全改进 + * 更新: 2026-04-16 修复空指针安全、AnimationListener泄漏、参数命名、布局比例计算 + * 更新: 2026-04-16 修复RenderFlex溢出:Column重构为CustomScrollView+Slivers;TextField替换为CupertinoTextField + * 更新: 2026-04-16 移除下拉工具中心功能至发现页;新增顶部4个快捷功能按钮 */ -import 'dart:ui'; import 'package:flutter/cupertino.dart'; import 'package:flutter/material.dart'; 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/controllers/data/favorites_controller.dart'; +import 'package:mom_kitchen/src/controllers/data/shopping_list_controller.dart'; import 'package:mom_kitchen/src/models/feed_item_model.dart'; -import 'package:mom_kitchen/src/controllers/tools/tools_controller.dart'; -import 'package:mom_kitchen/src/models/tool_item_model.dart'; -import 'package:mom_kitchen/src/pages/tools/tools_center_page.dart'; -import 'package:mom_kitchen/src/pages/home/recipe_detail_page.dart'; -import 'package:mom_kitchen/src/models/mini_card_model.dart'; -import 'package:mom_kitchen/src/config/app_routes.dart'; -import 'package:mom_kitchen/src/services/data/mini_card_service.dart'; +import 'package:mom_kitchen/src/pages/profile/social/favorites_item_builders.dart'; +import 'package:mom_kitchen/src/widgets/glass/glass_container.dart'; +import 'package:mom_kitchen/src/services/ui/toast_service.dart'; class FavoritesPage extends StatefulWidget { const FavoritesPage({super.key}); @@ -29,48 +31,33 @@ class FavoritesPage extends StatefulWidget { } class _FavoritesPageState extends State - with SingleTickerProviderStateMixin { + with SingleTickerProviderStateMixin, FavoritesItemBuilders { FavoritesController? _favoritesController; - ToolsController? _toolsController; bool _isInitialized = false; - static const double _pullThreshold = 80.0; - static const double _panelMaxHeight = 500.0; - - double _pullOffset = 0.0; - bool _isPanelOpen = false; - bool _isPulling = false; - - late final AnimationController _panelController; - late final Animation _panelAnimation; - final ScrollController _scrollController = ScrollController(); + final TextEditingController _searchController = TextEditingController(); + final FocusNode _searchFocusNode = FocusNode(); + + @override + FavoritesController? get favoritesController => _favoritesController; + + @override + void Function(int) get onNavigateToRecipeDetail => _navigateToRecipeDetail; + + @override + void Function(int) get onNavigateToMiniCard => _navigateToMiniCard; @override void initState() { super.initState(); - _panelController = AnimationController( - vsync: this, - duration: const Duration(milliseconds: 350), - ); - _panelAnimation = CurvedAnimation( - parent: _panelController, - curve: Curves.easeOutCubic, - reverseCurve: Curves.easeInCubic, - ); - _panelController.addStatusListener((status) { - if (status == AnimationStatus.dismissed) { - setState(() => _isPanelOpen = false); - } else if (status == AnimationStatus.completed) { - setState(() => _isPanelOpen = true); - } - }); } @override void dispose() { - _panelController.dispose(); _scrollController.dispose(); + _searchController.dispose(); + _searchFocusNode.dispose(); super.dispose(); } @@ -88,67 +75,23 @@ class _FavoritesPageState extends State if (Get.isRegistered()) { _favoritesController = Get.find(); } - if (Get.isRegistered()) { - _toolsController = Get.find(); - } } catch (e) { debugPrint('FavoritesPage: Controller init error: $e'); } } - void _openPanel() { - _panelController.forward(); - } - - void _closePanel() { - _panelController.reverse(); - } - - bool get _canPullDown { - if (_scrollController.hasClients) { - return _scrollController.offset <= 0; + FavoritesController get _safeFavoritesController { + if (_favoritesController == null) { + throw StateError('FavoritesController is not initialized'); } - return true; - } - - void _handleDragStart(DragStartDetails details) { - if (_isPanelOpen) return; - _isPulling = false; - } - - void _handleDragUpdate(DragUpdateDetails details) { - if (_isPanelOpen) return; - if (!_canPullDown && _pullOffset <= 0) return; - - final dy = details.delta.dy; - if (dy > 0 && _canPullDown) { - _isPulling = true; - setState(() { - _pullOffset = (_pullOffset + dy * 0.5).clamp(0.0, _panelMaxHeight); - }); - } else if (_isPulling && dy < 0) { - setState(() { - _pullOffset = (_pullOffset + dy * 0.5).clamp(0.0, _panelMaxHeight); - }); - } - } - - void _handleDragEnd(DragEndDetails details) { - if (_isPanelOpen) return; - if (_pullOffset >= _pullThreshold) { - _pullOffset = 0; - _openPanel(); - } else { - setState(() => _pullOffset = 0); - } - _isPulling = false; + return _favoritesController!; } @override Widget build(BuildContext context) { final isDark = CupertinoTheme.brightnessOf(context) == Brightness.dark; - if (_favoritesController == null || _toolsController == null) { + if (_favoritesController == null) { return CupertinoPageScaffold( backgroundColor: isDark ? DarkDesignTokens.background @@ -157,73 +100,455 @@ class _FavoritesPageState extends State ); } + final favController = _safeFavoritesController; + return CupertinoPageScaffold( backgroundColor: isDark ? DarkDesignTokens.background : DesignTokens.background, child: SafeArea( - child: Stack( + child: Column( children: [ - GestureDetector( - onVerticalDragStart: _handleDragStart, - onVerticalDragUpdate: _handleDragUpdate, - onVerticalDragEnd: _handleDragEnd, - behavior: HitTestBehavior.translucent, - child: Column( - children: [ - _buildPullHint(isDark), - _buildHeader(isDark), - _buildToolsBar(isDark), - _buildToolbar(isDark), - _buildFavoriteTypeTabs(isDark), - Expanded( - child: Obx(() { - final favorites = _favoritesController!.favorites; - if (favorites.isEmpty) { - return _buildEmptyState(isDark); - } - return _buildFavoritesList(favorites, isDark); - }), + Expanded( + child: CustomScrollView( + controller: _scrollController, + physics: const ClampingScrollPhysics( + parent: AlwaysScrollableScrollPhysics(), + ), + slivers: [ + SliverToBoxAdapter(child: _buildHeader(isDark)), + SliverToBoxAdapter( + child: _buildSearchBar(isDark, favController), ), - Obx( - () => _favoritesController!.isEditMode.value - ? _buildEditBottomBar(isDark) - : const SizedBox.shrink(), + SliverToBoxAdapter(child: _buildQuickActions(isDark)), + SliverToBoxAdapter( + child: _buildStatisticsBar(isDark, favController), + ), + SliverToBoxAdapter( + child: _buildToolbar(isDark, favController), + ), + SliverToBoxAdapter( + child: _buildFavoriteTypeTabs(isDark, favController), + ), + Obx(() { + final favorites = favController.favorites; + if (favorites.isEmpty) { + return SliverToBoxAdapter( + child: SizedBox( + height: 300, + child: _buildEmptyState(isDark), + ), + ); + } + return _buildFavoritesSliverGrid(favorites, isDark); + }), + SliverToBoxAdapter( + child: _ScrollEndIndicator( + scrollController: _scrollController, + isDark: isDark, + ), + ), + SliverToBoxAdapter( + child: Obx( + () => favController.isEditMode.value + ? const SizedBox(height: 80) + : const SizedBox.shrink(), + ), ), ], ), ), - _buildToolsPanel(isDark), + Obx( + () => favController.isEditMode.value + ? _buildEditBottomBar(isDark, favController) + : const SizedBox.shrink(), + ), ], ), ), ); } - Widget _buildPullHint(bool isDark) { - final showHint = _pullOffset > 0 && !_isPanelOpen; - if (!showHint) return const SizedBox.shrink(); + Widget _buildHeader(bool isDark) { + return Obx(() { + final favController = _favoritesController; + if (favController == null) return const SizedBox.shrink(); - final progress = (_pullOffset / _pullThreshold).clamp(0.0, 1.0); + final count = favController.count; + final isEditMode = favController.isEditMode.value; + + return Padding( + padding: const EdgeInsets.symmetric( + horizontal: DesignTokens.space4, + vertical: DesignTokens.space2, + ), + child: Row( + children: [ + Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text( + '❤️ 收藏', + style: TextStyle( + fontSize: DesignTokens.fontXl, + fontWeight: FontWeight.bold, + color: isDark ? DarkDesignTokens.text1 : DesignTokens.text1, + ), + ), + if (count > 0) + Text( + '$count 项收藏内容', + style: TextStyle( + fontSize: DesignTokens.fontXs, + color: isDark + ? DarkDesignTokens.text3 + : DesignTokens.text3, + fontWeight: FontWeight.w400, + ), + ), + ], + ), + if (count > 0) ...[ + const SizedBox(width: DesignTokens.space2), + Container( + padding: const EdgeInsets.symmetric( + horizontal: DesignTokens.space2, + vertical: DesignTokens.space1, + ), + decoration: BoxDecoration( + color: DesignTokens.dynamicPrimary.withValues(alpha: 0.1), + borderRadius: BorderRadius.circular(DesignTokens.radiusFull), + ), + child: Text( + '$count', + style: TextStyle( + fontSize: DesignTokens.fontSm, + color: DesignTokens.dynamicPrimary, + fontWeight: FontWeight.w600, + ), + ), + ), + ], + const Spacer(), + if (count > 0) + GestureDetector( + onTap: () => favController.toggleEditMode(), + child: Container( + padding: const EdgeInsets.symmetric( + horizontal: DesignTokens.space3, + vertical: DesignTokens.space2, + ), + decoration: BoxDecoration( + color: isEditMode + ? DesignTokens.dynamicPrimary.withValues(alpha: 0.15) + : (isDark + ? DarkDesignTokens.glass + : DesignTokens.card.withValues(alpha: 0.7)), + borderRadius: BorderRadius.circular( + DesignTokens.radiusFull, + ), + border: Border.all( + color: isEditMode + ? DesignTokens.dynamicPrimary.withValues(alpha: 0.5) + : Colors.transparent, + ), + ), + child: Row( + mainAxisSize: MainAxisSize.min, + children: [ + Icon( + isEditMode + ? CupertinoIcons.xmark + : CupertinoIcons.pencil, + size: 14, + color: isEditMode + ? DesignTokens.dynamicPrimary + : (isDark + ? DarkDesignTokens.text2 + : DesignTokens.text2), + ), + const SizedBox(width: 4), + Text( + isEditMode ? '取消' : '编辑', + style: TextStyle( + fontSize: DesignTokens.fontSm, + fontWeight: FontWeight.w500, + color: isEditMode + ? DesignTokens.dynamicPrimary + : (isDark + ? DarkDesignTokens.text1 + : DesignTokens.text1), + ), + ), + ], + ), + ), + ), + ], + ), + ); + }); + } + + Widget _buildSearchBar(bool isDark, FavoritesController favController) { + return Obx(() { + final isSearching = favController.isSearching; + return Padding( + padding: const EdgeInsets.symmetric( + horizontal: DesignTokens.space4, + vertical: DesignTokens.space2, + ), + child: GlassContainer( + padding: const EdgeInsets.symmetric(horizontal: DesignTokens.space3), + borderRadius: DesignTokens.radiusMd, + opacity: isDark ? 0.6 : 0.75, + child: Row( + children: [ + Icon( + CupertinoIcons.search, + size: 18, + color: isDark ? DarkDesignTokens.text3 : DesignTokens.text3, + ), + const SizedBox(width: DesignTokens.space2), + Expanded( + child: CupertinoTextField( + controller: _searchController, + focusNode: _searchFocusNode, + onChanged: (value) => favController.setSearchQuery(value), + style: TextStyle( + fontSize: DesignTokens.fontMd, + color: isDark ? DarkDesignTokens.text1 : DesignTokens.text1, + ), + placeholder: '搜索收藏...', + placeholderStyle: TextStyle( + color: isDark ? DarkDesignTokens.text3 : DesignTokens.text3, + ), + decoration: const BoxDecoration(), + padding: EdgeInsets.zero, + ), + ), + if (isSearching) + GestureDetector( + onTap: () { + _searchController.clear(); + favController.clearSearch(); + }, + child: Icon( + CupertinoIcons.clear_circled_solid, + size: 18, + color: isDark ? DarkDesignTokens.text3 : DesignTokens.text3, + ), + ), + ], + ), + ), + ); + }); + } + + Widget _buildQuickActions(bool isDark) { + final shoppingController = Get.find(); + + return Obx(() { + final shoppingCount = shoppingController.uncheckedCount; + + return Padding( + padding: const EdgeInsets.symmetric( + horizontal: DesignTokens.space4, + vertical: DesignTokens.space2, + ), + child: Row( + children: [ + _buildQuickActionItem( + isDark: isDark, + emoji: '🥗', + label: '营养中心', + color: DesignTokens.green, + onTap: () => Get.toNamed('/nutrition'), + ), + SizedBox(width: DesignTokens.space2), + _buildQuickActionItem( + isDark: isDark, + emoji: '🛒', + label: '购物清单', + color: DesignTokens.secondary, + onTap: () => Get.toNamed('/shopping-list'), + badgeCount: shoppingCount, + ), + SizedBox(width: DesignTokens.space2), + _buildQuickActionItem( + isDark: isDark, + emoji: '📊', + label: '周报', + color: DesignTokens.dynamicPrimary, + onTap: () => Get.toNamed('/nutrition-report'), + ), + const SizedBox(width: DesignTokens.space2), + _buildQuickActionItem( + isDark: isDark, + emoji: '🎯', + label: '目标', + color: DesignTokens.red, + onTap: () => Get.toNamed('/goal-setting'), + ), + ], + ), + ); + }); + } + + Widget _buildQuickActionItem({ + required bool isDark, + required String emoji, + required String label, + required Color color, + required VoidCallback onTap, + int badgeCount = 0, + }) { + return Expanded( + child: GestureDetector( + onTap: onTap, + child: Container( + padding: const EdgeInsets.symmetric(vertical: DesignTokens.space3), + decoration: BoxDecoration( + color: color.withValues(alpha: 0.1), + borderRadius: DesignTokens.borderRadiusLg, + ), + child: Column( + children: [ + badges.Badge( + showBadge: badgeCount > 0, + position: badges.BadgePosition.topEnd(top: -6, end: -10), + badgeStyle: badges.BadgeStyle( + badgeColor: isDark ? DarkDesignTokens.red : DesignTokens.red, + padding: const EdgeInsets.symmetric( + horizontal: 5, + vertical: 2, + ), + borderRadius: DesignTokens.borderRadiusSm, + ), + badgeContent: Text( + badgeCount > 99 ? '99+' : '$badgeCount', + style: const TextStyle( + color: CupertinoColors.white, + fontSize: 9, + fontWeight: FontWeight.w600, + ), + ), + child: Text(emoji, style: const TextStyle(fontSize: 24)), + ), + const SizedBox(height: DesignTokens.space1), + Text( + label, + style: TextStyle( + fontSize: DesignTokens.fontXs, + fontWeight: FontWeight.w500, + color: color, + ), + ), + ], + ), + ), + ), + ); + } + + Widget _buildStatisticsBar(bool isDark, FavoritesController favController) { + return Obx(() { + final stats = favController.statistics; + final total = stats['total'] ?? 0; + if (total == 0) return const SizedBox.shrink(); + + return Container( + height: 32, + margin: const EdgeInsets.symmetric( + horizontal: DesignTokens.space4, + vertical: DesignTokens.space1, + ), + child: Row( + children: [ + _buildStatItem('📋', '共 $total 项', isDark), + const SizedBox(width: DesignTokens.space2), + if ((stats['recipe'] ?? 0) > 0) + _buildStatItem('🍳', '${stats['recipe']}', isDark), + if ((stats['miniCard'] ?? 0) > 0) ...[ + const SizedBox(width: DesignTokens.space2), + _buildStatItem('🃏', '${stats['miniCard']}', isDark), + ], + if ((stats['ingredient'] ?? 0) > 0) ...[ + const SizedBox(width: DesignTokens.space2), + _buildStatItem('🥬', '${stats['ingredient']}', isDark), + ], + if ((stats['tag'] ?? 0) > 0) ...[ + const SizedBox(width: DesignTokens.space2), + _buildStatItem('🏷️', '${stats['tag']}', isDark), + ], + const Spacer(), + _buildExportButton(isDark, favController), + ], + ), + ); + }); + } + + Widget _buildStatItem(String icon, String label, bool isDark) { return Container( - height: 40 + _pullOffset * 0.3, - alignment: Alignment.center, - child: Opacity( - opacity: progress, + padding: const EdgeInsets.symmetric( + horizontal: DesignTokens.space2, + vertical: DesignTokens.space1, + ), + decoration: BoxDecoration( + color: isDark + ? DarkDesignTokens.glass + : DesignTokens.card.withValues(alpha: 0.6), + borderRadius: BorderRadius.circular(DesignTokens.radiusSm), + ), + child: Row( + mainAxisSize: MainAxisSize.min, + children: [ + Text(icon, style: const TextStyle(fontSize: 12)), + const SizedBox(width: 2), + Text( + label, + style: TextStyle( + fontSize: DesignTokens.fontXs, + color: isDark ? DarkDesignTokens.text2 : DesignTokens.text2, + ), + ), + ], + ), + ); + } + + Widget _buildExportButton(bool isDark, FavoritesController favController) { + return GestureDetector( + onTap: () => _showExportSheet(isDark, favController), + child: Container( + padding: const EdgeInsets.symmetric( + horizontal: DesignTokens.space2, + vertical: DesignTokens.space1, + ), + decoration: BoxDecoration( + color: DesignTokens.dynamicPrimary.withValues(alpha: 0.1), + borderRadius: BorderRadius.circular(DesignTokens.radiusSm), + border: Border.all( + color: DesignTokens.dynamicPrimary.withValues(alpha: 0.3), + ), + ), child: Row( mainAxisSize: MainAxisSize.min, children: [ Icon( - CupertinoIcons.chevron_compact_up, - size: 18, - color: DesignTokens.dynamicPrimary.withValues(alpha: progress), + CupertinoIcons.square_arrow_up, + size: 12, + color: DesignTokens.dynamicPrimary, ), - const SizedBox(width: 4), + const SizedBox(width: 2), Text( - progress >= 1.0 ? '松开进入工具中心' : '下拉查看更多工具', + '导出', style: TextStyle( fontSize: DesignTokens.fontXs, - color: DesignTokens.dynamicPrimary.withValues(alpha: progress), + color: DesignTokens.dynamicPrimary, fontWeight: FontWeight.w500, ), ), @@ -233,435 +558,78 @@ class _FavoritesPageState extends State ); } - Widget _buildToolsPanel(bool isDark) { - return AnimatedBuilder( - animation: _panelAnimation, - builder: (context, child) { - final value = _panelAnimation.value; - if (value <= 0 && !_isPanelOpen) return const SizedBox.shrink(); - - return Stack( - children: [ - GestureDetector( - onTap: _closePanel, - child: Container( - color: Colors.black.withValues(alpha: 0.4 * value), - ), - ), - Positioned( - top: 0, - left: 0, - right: 0, - child: Transform.translate( - offset: Offset(0, -_panelMaxHeight * (1 - value)), - child: Container( - constraints: BoxConstraints(maxHeight: _panelMaxHeight), - decoration: BoxDecoration( - color: isDark - ? DarkDesignTokens.background - : DesignTokens.background, - borderRadius: BorderRadius.only( - bottomLeft: Radius.circular(DesignTokens.radiusLg), - bottomRight: Radius.circular(DesignTokens.radiusLg), - ), - boxShadow: [ - BoxShadow( - color: Colors.black.withValues(alpha: 0.15), - blurRadius: 20, - offset: const Offset(0, 8), - ), - ], - ), - child: Column( - mainAxisSize: MainAxisSize.min, - children: [ - _buildPanelHandle(isDark), - Expanded(child: const ToolsCenterPage(embedded: true)), - ], - ), - ), - ), - ), - ], - ); - }, - ); - } - - Widget _buildPanelHandle(bool isDark) { - return GestureDetector( - onVerticalDragEnd: (details) { - if (details.primaryVelocity != null && details.primaryVelocity! > 200) { - _closePanel(); - } - }, - child: Container( - padding: const EdgeInsets.symmetric(vertical: DesignTokens.space3), - child: Column( - children: [ - Container( - width: 36, - height: 4, - decoration: BoxDecoration( - color: isDark - ? DarkDesignTokens.text3 - : DesignTokens.text3.withValues(alpha: 0.3), - borderRadius: BorderRadius.circular(2), - ), - ), - const SizedBox(height: DesignTokens.space2), - Row( - mainAxisAlignment: MainAxisAlignment.center, - children: [ - Text('🛠️', style: TextStyle(fontSize: 14)), - const SizedBox(width: 6), - Text( - '工具中心', - style: TextStyle( - fontSize: DesignTokens.fontMd, - fontWeight: FontWeight.w600, - color: isDark ? DarkDesignTokens.text1 : DesignTokens.text1, - ), - ), - ], - ), - ], - ), - ), - ); - } - - Widget _buildHeader(bool isDark) { - return Padding( - padding: const EdgeInsets.symmetric( - horizontal: DesignTokens.space4, - vertical: DesignTokens.space3, - ), - child: Row( - children: [ - Text( - '❤️ 收藏', - style: TextStyle( - fontSize: DesignTokens.fontXxl, - fontWeight: FontWeight.w700, - color: isDark ? DarkDesignTokens.text1 : DesignTokens.text1, - ), - ), - const Spacer(), - Obx(() { - final count = _favoritesController!.count; - if (count == 0) return const SizedBox.shrink(); - return _buildGlassChip('$count', isDark, highlight: true); - }), - SizedBox(width: DesignTokens.space3), - Obx(() { - if (_favoritesController!.count == 0) { - return SizedBox.shrink(); - } - return CupertinoButton( - padding: EdgeInsets.zero, - minimumSize: Size(36, 36), - onPressed: _favoritesController!.toggleEditMode, - child: Text( - _favoritesController!.isEditMode.value ? '完成' : '编辑', - style: TextStyle( - fontSize: DesignTokens.fontMd, - color: DesignTokens.dynamicPrimary, - fontWeight: FontWeight.w500, - ), - ), - ); - }), - ], - ), - ); - } - - 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.dynamicPrimary - : (isDark ? DarkDesignTokens.text1 : DesignTokens.text1), - ), - ), - ), - ), - ); - } - - Widget _buildToolsBar(bool isDark) { + Widget _buildToolbar(bool isDark, FavoritesController favController) { return Obx(() { - final tools = _toolsController!.frequentTools; - if (tools.isEmpty) { - return const SizedBox.shrink(); - } + final stats = favController.statistics; + final total = stats['total'] ?? 0; + if (total == 0) return const SizedBox.shrink(); + return Container( - height: 90, - margin: const EdgeInsets.only(bottom: DesignTokens.space2), - child: ListView.separated( - scrollDirection: Axis.horizontal, - padding: const EdgeInsets.symmetric(horizontal: DesignTokens.space4), - itemCount: tools.length + 1, - separatorBuilder: (context, index) => - const SizedBox(width: DesignTokens.space2), - itemBuilder: (context, index) { - if (index == tools.length) { - return _buildMoreToolsCard(isDark); - } - return _buildToolShortcut(tools[index], isDark); - }, - ), - ); - }); - } - - Widget _buildToolShortcut(ToolItem tool, bool isDark) { - return GestureDetector( - onTap: () => _navigateToTool(tool), - child: ClipRRect( - borderRadius: DesignTokens.borderRadiusMd, - child: BackdropFilter( - filter: ImageFilter.blur(sigmaX: 12, sigmaY: 12), - child: Container( - width: 72, - padding: const EdgeInsets.symmetric(vertical: DesignTokens.space2), - decoration: BoxDecoration( - color: isDark - ? DarkDesignTokens.glass - : DesignTokens.card.withValues(alpha: 0.75), - borderRadius: DesignTokens.borderRadiusMd, - border: Border.all( - color: isDark - ? DarkDesignTokens.glassBorder - : DesignTokens.text3.withValues(alpha: 0.12), - ), - ), - child: Column( - mainAxisAlignment: MainAxisAlignment.center, - children: [ - Stack( - children: [ - Container( - width: 44, - height: 44, - decoration: BoxDecoration( - color: (DesignTokens.dynamicPrimary).withValues( - alpha: 0.1, - ), - borderRadius: DesignTokens.borderRadiusMd, - ), - child: Center( - child: Text(tool.icon, style: TextStyle(fontSize: 24)), - ), - ), - Positioned( - top: 0, - right: 0, - child: Container( - width: 8, - height: 8, - decoration: BoxDecoration( - color: tool.needsNetwork - ? DesignTokens.green - : DesignTokens.dynamicPrimary, - shape: BoxShape.circle, - ), - ), - ), - ], - ), - const SizedBox(height: 6), - Text( - tool.name, - style: TextStyle( - fontSize: DesignTokens.fontXs, - color: isDark ? DarkDesignTokens.text1 : DesignTokens.text1, - ), - maxLines: 1, - overflow: TextOverflow.ellipsis, - ), - ], - ), - ), - ), - ), - ); - } - - Widget _buildMoreToolsCard(bool isDark) { - return GestureDetector( - onTap: _openPanel, - child: ClipRRect( - borderRadius: DesignTokens.borderRadiusMd, - child: BackdropFilter( - filter: ImageFilter.blur(sigmaX: 12, sigmaY: 12), - child: Container( - width: 72, - padding: EdgeInsets.symmetric(vertical: DesignTokens.space2), - decoration: BoxDecoration( - color: isDark - ? DesignTokens.dynamicPrimary.withValues(alpha: 0.08) - : DesignTokens.primaryLight.withValues(alpha: 0.7), - borderRadius: DesignTokens.borderRadiusMd, - border: Border.all( - color: (DesignTokens.dynamicPrimary).withValues(alpha: 0.25), - ), - ), - child: Column( - mainAxisAlignment: MainAxisAlignment.center, - children: [ - Container( - width: 44, - height: 44, - decoration: BoxDecoration( - color: (DesignTokens.dynamicPrimary).withValues( - alpha: 0.15, - ), - borderRadius: DesignTokens.borderRadiusMd, - ), - child: Center( - child: Text('🛠️', style: TextStyle(fontSize: 24)), - ), - ), - SizedBox(height: 6), - Text( - '更多', - style: TextStyle( - fontSize: DesignTokens.fontXs, - color: DesignTokens.dynamicPrimary, - ), - ), - ], - ), - ), - ), - ), - ); - } - - void _navigateToToolsCenter() { - _openPanel(); - } - - void _navigateToTool(ToolItem tool) { - _toolsController?.recordUsage(tool.id); - _openPanel(); - } - - void _navigateToRecipeDetail(int recipeId) { - Navigator.of(context).push( - CupertinoPageRoute( - builder: (context) => RecipeDetailPage(recipeId: '$recipeId'), - ), - ); - } - - Widget _buildToolbar(bool isDark) { - return Obx(() { - if (_favoritesController!.count == 0) return const SizedBox.shrink(); - return Container( - padding: const EdgeInsets.symmetric( + height: 44, + margin: const EdgeInsets.symmetric( horizontal: DesignTokens.space4, vertical: DesignTokens.space2, ), child: Row( children: [ - _buildSortButton(isDark), + _buildToolbarButton( + icon: CupertinoIcons.square_grid_2x2_fill, + label: '全部', + isSelected: true, + isDark: isDark, + onTap: () {}, + ), const SizedBox(width: DesignTokens.space2), - _buildCategoryFilter(isDark), + _buildToolbarButton( + icon: CupertinoIcons.flame_fill, + label: '菜品 ${stats['recipe'] ?? 0}', + isSelected: false, + isDark: isDark, + onTap: () {}, + ), + const Spacer(), + _buildSortButton(isDark, favController), ], ), ); }); } - Widget _buildFavoriteTypeTabs(bool isDark) { - return Obx(() { - if (_favoritesController!.count == 0) return const SizedBox.shrink(); - final types = _favoritesController!.favoriteTypes; - if (types.isEmpty) return const SizedBox.shrink(); - - return Container( - height: 40, - margin: const EdgeInsets.only(bottom: DesignTokens.space2), - child: ListView( - scrollDirection: Axis.horizontal, - padding: const EdgeInsets.symmetric(horizontal: DesignTokens.space4), - children: [ - _buildTypeChip(null, '全部', '📋', isDark), - for (final type in types) - _buildTypeChip(type, type.label, type.icon, isDark), - ], - ), - ); - }); - } - - Widget _buildTypeChip( - FavoriteType? type, - String label, - String icon, - bool isDark, - ) { - final isSelected = _favoritesController!.selectedFavoriteType.value == type; - final count = type == null - ? _favoritesController!.count - : _favoritesController!.countByType(type); - + Widget _buildToolbarButton({ + required IconData icon, + required String label, + required bool isSelected, + required bool isDark, + required VoidCallback onTap, + }) { return GestureDetector( - onTap: () => _favoritesController!.setFavoriteType(type), + onTap: onTap, child: Container( - margin: const EdgeInsets.only(right: DesignTokens.space2), padding: const EdgeInsets.symmetric( horizontal: DesignTokens.space3, - vertical: DesignTokens.space1, + vertical: DesignTokens.space2, ), decoration: BoxDecoration( color: isSelected - ? DesignTokens.dynamicPrimary.withValues(alpha: 0.15) - : (isDark - ? DarkDesignTokens.glass - : DesignTokens.card.withValues(alpha: 0.7)), - borderRadius: BorderRadius.circular(DesignTokens.radiusFull), + ? DesignTokens.dynamicPrimary.withValues(alpha: 0.12) + : Colors.transparent, + borderRadius: DesignTokens.borderRadiusFull, border: Border.all( color: isSelected - ? DesignTokens.dynamicPrimary.withValues(alpha: 0.5) - : (isDark - ? DarkDesignTokens.glassBorder - : DesignTokens.text3.withValues(alpha: 0.12)), - width: isSelected ? 1.5 : 0.5, + ? DesignTokens.dynamicPrimary.withValues(alpha: 0.4) + : Colors.transparent, ), ), child: Row( mainAxisSize: MainAxisSize.min, children: [ - Text(icon, style: const TextStyle(fontSize: 14)), + Icon( + icon, + size: 14, + color: isSelected + ? DesignTokens.dynamicPrimary + : (isDark ? DarkDesignTokens.text2 : DesignTokens.text2), + ), const SizedBox(width: 4), Text( label, @@ -673,984 +641,495 @@ class _FavoritesPageState extends State : (isDark ? DarkDesignTokens.text1 : DesignTokens.text1), ), ), - if (count > 0) ...[ - const SizedBox(width: 4), - Text( - '$count', - style: TextStyle( - fontSize: DesignTokens.fontXs, - color: isDark ? DarkDesignTokens.text3 : DesignTokens.text3, - ), - ), - ], ], ), ), ); } - Widget _buildSortButton(bool isDark) { + Widget _buildSortButton(bool isDark, FavoritesController favController) { return GestureDetector( - onTap: () => _showSortSheet(isDark), - 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, + onTap: () => _showSortOptions(isDark, favController), + child: Container( + padding: const EdgeInsets.symmetric( + horizontal: DesignTokens.space3, + vertical: DesignTokens.space2, + ), + decoration: BoxDecoration( + color: isDark + ? DarkDesignTokens.glass + : DesignTokens.card.withValues(alpha: 0.7), + borderRadius: DesignTokens.borderRadiusFull, + ), + child: Row( + mainAxisSize: MainAxisSize.min, + children: [ + Icon( + CupertinoIcons.arrow_up_arrow_down, + size: 14, + 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), + const SizedBox(width: 4), + Text( + '排序', + style: TextStyle( + fontSize: DesignTokens.fontSm, + color: isDark ? DarkDesignTokens.text1 : DesignTokens.text1, ), ), - 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, - ), - ), - ], - ), - ), + ], ), ), ); } - Widget _buildCategoryFilter(bool isDark) { - return Expanded( - child: SingleChildScrollView( - scrollDirection: Axis.horizontal, - child: Obx( - () => Row( - children: _favoritesController!.categories.map((cat) { - final isSelected = - _favoritesController!.selectedCategory.value == cat; - return Padding( - padding: EdgeInsets.only(right: DesignTokens.space2), - child: GestureDetector( - onTap: () => _favoritesController!.setCategory(cat), - child: ClipRRect( - borderRadius: DesignTokens.borderRadiusMd, - child: BackdropFilter( - filter: ImageFilter.blur(sigmaX: 10, sigmaY: 10), - child: Container( - padding: EdgeInsets.symmetric( - horizontal: DesignTokens.space3, - vertical: DesignTokens.space2, - ), - decoration: BoxDecoration( + Widget _buildFavoriteTypeTabs( + bool isDark, + FavoritesController favController, + ) { + return Obx(() { + final currentType = favController.selectedFavoriteType.value; + final types = favController.favoriteTypes; + + return Container( + height: 40, + margin: const EdgeInsets.symmetric(horizontal: DesignTokens.space4), + child: ListView.separated( + scrollDirection: Axis.horizontal, + itemCount: types.length + 1, + separatorBuilder: (context, index) => + const SizedBox(width: DesignTokens.space2), + itemBuilder: (context, index) { + if (index == 0) { + final isSelected = currentType == null; + return GestureDetector( + onTap: () => favController.setFavoriteType(null), + child: Container( + padding: const EdgeInsets.symmetric( + horizontal: DesignTokens.space3, + vertical: DesignTokens.space2, + ), + decoration: BoxDecoration( + color: isSelected + ? DesignTokens.dynamicPrimary.withValues(alpha: 0.12) + : (isDark + ? DarkDesignTokens.glass + : DesignTokens.card.withValues(alpha: 0.6)), + borderRadius: DesignTokens.borderRadiusFull, + border: Border.all( + color: isSelected + ? DesignTokens.dynamicPrimary.withValues(alpha: 0.4) + : Colors.transparent, + ), + ), + child: Row( + mainAxisSize: MainAxisSize.min, + children: [ + const Text('📋', style: TextStyle(fontSize: 14)), + const SizedBox(width: 4), + Text( + '全部', + style: TextStyle( + fontSize: DesignTokens.fontSm, color: isSelected - ? DesignTokens.dynamicPrimary.withValues( - alpha: 0.85, - ) + ? DesignTokens.dynamicPrimary : (isDark - ? DarkDesignTokens.glass - : DesignTokens.card.withValues( - alpha: 0.75, - )), - borderRadius: DesignTokens.borderRadiusMd, - border: Border.all( - color: isSelected - ? DesignTokens.dynamicPrimary - : (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), - ), + ? DarkDesignTokens.text1 + : DesignTokens.text1), + fontWeight: isSelected + ? FontWeight.w600 + : FontWeight.w500, ), ), - ), + ], ), ), ); - }).toList(), - ), + } + + final type = types[index - 1]; + final isSelected = type == currentType; + + return GestureDetector( + onTap: () => favController.setFavoriteType(type), + child: Container( + padding: const EdgeInsets.symmetric( + horizontal: DesignTokens.space3, + vertical: DesignTokens.space2, + ), + decoration: BoxDecoration( + color: isSelected + ? DesignTokens.dynamicPrimary.withValues(alpha: 0.12) + : (isDark + ? DarkDesignTokens.glass + : DesignTokens.card.withValues(alpha: 0.6)), + borderRadius: DesignTokens.borderRadiusFull, + border: Border.all( + color: isSelected + ? DesignTokens.dynamicPrimary.withValues(alpha: 0.4) + : Colors.transparent, + ), + ), + child: Row( + mainAxisSize: MainAxisSize.min, + children: [ + Text( + _getTypeIcon(type), + style: const TextStyle(fontSize: 14), + ), + const SizedBox(width: 4), + Text( + _getTypeLabel(type), + style: TextStyle( + fontSize: DesignTokens.fontSm, + color: isSelected + ? DesignTokens.dynamicPrimary + : (isDark + ? DarkDesignTokens.text1 + : DesignTokens.text1), + fontWeight: isSelected + ? FontWeight.w600 + : FontWeight.w500, + ), + ), + ], + ), + ), + ); + }, ), - ), - ); + ); + }); } - void _showSortSheet(bool isDark) { - showCupertinoModalPopup( - context: context, - builder: (ctx) => CupertinoActionSheet( - title: const Text('排序方式'), - actions: FavoritesSortMode.values.map((mode) { - return CupertinoActionSheetAction( - onPressed: () { - _favoritesController!.setSortMode(mode); - Navigator.of(ctx).pop(); - }, - child: Text(_getSortLabel(mode)), - ); - }).toList(), - cancelButton: CupertinoActionSheetAction( - onPressed: () => Navigator.of(ctx).pop(), - child: const Text('取消'), - ), - ), - ); - } - - String _getSortLabel(FavoritesSortMode mode) { - switch (mode) { - case FavoritesSortMode.newest: - return '最新收藏'; - case FavoritesSortMode.oldest: - return '最早收藏'; - case FavoritesSortMode.nameAsc: - return '名称 A-Z'; - case FavoritesSortMode.nameDesc: - return '名称 Z-A'; + String _getTypeIcon(FavoriteType type) { + switch (type) { + case FavoriteType.recipe: + return '🍳'; + case FavoriteType.miniCard: + return '🃏'; + case FavoriteType.ingredient: + return '🥬'; + case FavoriteType.tag: + return '🏷️'; } } - Widget _buildEditBottomBar(bool isDark) { - return ClipRect( - child: BackdropFilter( - filter: ImageFilter.blur(sigmaX: 20, sigmaY: 20), - child: Container( - padding: const EdgeInsets.all(DesignTokens.space4), - decoration: BoxDecoration( - color: isDark - ? 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.dynamicPrimary, - ), - ), - ), - 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('删除'), - ), - ], - ), - ), - ), - ); + String _getTypeLabel(FavoriteType type) { + switch (type) { + case FavoriteType.recipe: + return '菜谱'; + case FavoriteType.miniCard: + return '迷你卡片'; + case FavoriteType.ingredient: + return '食材'; + case FavoriteType.tag: + return '标签'; + } } - void _confirmDelete() { - showCupertinoDialog( - context: context, - builder: (ctx) => CupertinoAlertDialog( - title: const Text('确认删除'), - content: Text('确定要删除选中的 ${_favoritesController!.selectedCount} 项收藏吗?'), - actions: [ - CupertinoDialogAction( - onPressed: () => Navigator.of(ctx).pop(), - child: const Text('取消'), + Widget _buildEmptyState(bool isDark) { + return Center( + child: Column( + mainAxisAlignment: MainAxisAlignment.center, + children: [ + Container( + width: 100, + height: 100, + decoration: BoxDecoration( + color: DesignTokens.primaryLight, + borderRadius: DesignTokens.borderRadiusXl, + ), + child: Icon( + CupertinoIcons.heart, + size: 48, + color: DesignTokens.dynamicPrimary, + ), ), - CupertinoDialogAction( - isDestructiveAction: true, - onPressed: () { - Navigator.of(ctx).pop(); - _favoritesController!.deleteSelected(); - }, - child: const Text('删除'), + const SizedBox(height: DesignTokens.space4), + Text( + '暂无收藏', + style: TextStyle( + fontSize: DesignTokens.fontLg, + fontWeight: FontWeight.w600, + color: isDark ? DarkDesignTokens.text1 : DesignTokens.text1, + ), + ), + const SizedBox(height: DesignTokens.space2), + Text( + '浏览菜谱时点击❤️即可收藏', + style: TextStyle( + fontSize: DesignTokens.fontMd, + color: isDark ? DarkDesignTokens.text2 : DesignTokens.text2, + ), + ), + const SizedBox(height: DesignTokens.space4), + SizedBox( + width: 180, + child: CupertinoButton.filled( + borderRadius: DesignTokens.borderRadiusLg, + onPressed: () => Get.toNamed('/discover'), + child: const Row( + mainAxisAlignment: MainAxisAlignment.center, + children: [ + Icon(CupertinoIcons.compass, size: 18), + SizedBox(width: DesignTokens.space2), + Text('去发现'), + ], + ), + ), ), ], ), ); } - Widget _buildEmptyState(bool isDark) { - return Center( - 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: 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), + Widget _buildFavoritesSliverGrid(List favorites, bool isDark) { + return SliverPadding( + padding: const EdgeInsets.all(DesignTokens.space4), + sliver: SliverGrid( + gridDelegate: const SliverGridDelegateWithFixedCrossAxisCount( + crossAxisCount: 2, + mainAxisSpacing: DesignTokens.space3, + crossAxisSpacing: DesignTokens.space3, + childAspectRatio: 0.78, + ), + delegate: SliverChildBuilderDelegate((context, index) { + final item = favorites[index]; + switch (item.favoriteType) { + case FavoriteType.recipe: + return buildRecipeFavoriteItem(item, isDark); + case FavoriteType.ingredient: + return buildIngredientFavoriteItem(item, isDark); + case FavoriteType.tag: + return buildTagFavoriteItem(item, isDark); + case FavoriteType.miniCard: + return buildMiniCardFavoriteItem(item, isDark); + } + }, childCount: favorites.length), + ), + ); + } + + Widget _buildEditBottomBar(bool isDark, FavoritesController favController) { + return Positioned( + left: 0, + right: 0, + bottom: 0, + child: Container( + padding: const EdgeInsets.symmetric( + horizontal: DesignTokens.space4, + vertical: DesignTokens.space3, + ), + decoration: BoxDecoration( + color: isDark ? DarkDesignTokens.background : DesignTokens.background, + boxShadow: [ + BoxShadow( + color: Colors.black.withValues(alpha: 0.08), + blurRadius: 10, + offset: const Offset(0, -2), + ), + ], + ), + child: Row( + children: [ + Expanded( + child: CupertinoButton( + borderRadius: DesignTokens.borderRadiusLg, + color: isDark ? DarkDesignTokens.glass : DesignTokens.card, + onPressed: () => favController.toggleEditMode(), + child: const Text('取消'), ), ), - child: Column( - mainAxisSize: MainAxisSize.min, + const SizedBox(width: DesignTokens.space3), + Expanded( + flex: 2, + child: CupertinoButton.filled( + borderRadius: DesignTokens.borderRadiusLg, + onPressed: () { + final count = favController.selectedIds.length; + favController.deleteSelected(); + ToastService.show(message: '已删除 $count 项收藏'); + }, + child: Row( + mainAxisAlignment: MainAxisAlignment.center, + children: [ + const Icon(CupertinoIcons.trash, size: 18), + const SizedBox(width: DesignTokens.space2), + Obx(() => Text('删除 (${favController.selectedIds.length})')), + ], + ), + ), + ), + ], + ), + ), + ); + } + + void _navigateToRecipeDetail(int recipeId) { + Get.toNamed('/recipe-detail', arguments: '$recipeId'); + } + + void _navigateToMiniCard(int cardId) { + debugPrint('Navigate to mini card: $cardId'); + } + + void _showExportSheet(bool isDark, FavoritesController favController) { + showCupertinoModalPopup( + context: context, + builder: (BuildContext context) => CupertinoActionSheet( + title: const Text('导出收藏'), + message: Text( + '共 ${favController.count} 项收藏内容', + style: TextStyle( + color: isDark ? DarkDesignTokens.text2 : DesignTokens.text2, + ), + ), + actions: [ + CupertinoActionSheetAction( + onPressed: () { + Navigator.pop(context); + ToastService.show(message: 'JSON 导出功能开发中'); + }, + child: const Row( + mainAxisAlignment: MainAxisAlignment.center, children: [ - Container( - width: 64, - height: 64, - decoration: BoxDecoration( - color: DesignTokens.primaryLight.withValues(alpha: 0.6), - borderRadius: DesignTokens.borderRadiusLg, - ), - child: Icon( - CupertinoIcons.heart, - size: 32, - color: DesignTokens.dynamicPrimary, - ), - ), - 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, - ), - ), + Icon(CupertinoIcons.doc_text, size: 20), + SizedBox(width: 8), + Text('导出为 JSON'), ], ), ), + CupertinoActionSheetAction( + onPressed: () { + Navigator.pop(context); + ToastService.show(message: 'CSV 导出功能开发中'); + }, + child: const Row( + mainAxisAlignment: MainAxisAlignment.center, + children: [ + Icon(CupertinoIcons.table, size: 20), + SizedBox(width: 8), + Text('导出为 CSV'), + ], + ), + ), + ], + cancelButton: CupertinoActionSheetAction( + onPressed: () => Navigator.pop(context), + isDestructiveAction: true, + child: const Text('取消'), ), ), ); } - Widget _buildFavoritesList(List favorites, bool isDark) { - return GridView.builder( - controller: _scrollController, - padding: const EdgeInsets.symmetric( - horizontal: DesignTokens.space4, - vertical: DesignTokens.space2, + void _showSortOptions(bool isDark, FavoritesController favController) { + showCupertinoModalPopup( + context: context, + builder: (BuildContext context) => CupertinoActionSheet( + title: const Text('排序方式'), + actions: [ + CupertinoActionSheetAction( + onPressed: () { + Navigator.pop(context); + favController.setSortMode(FavoritesSortMode.newest); + }, + isDefaultAction: true, + child: const Text('最新收藏'), + ), + CupertinoActionSheetAction( + onPressed: () { + Navigator.pop(context); + favController.setSortMode(FavoritesSortMode.oldest); + }, + child: const Text('最早收藏'), + ), + CupertinoActionSheetAction( + onPressed: () { + Navigator.pop(context); + favController.setSortMode(FavoritesSortMode.nameAsc); + }, + child: const Text('名称 A-Z'), + ), + CupertinoActionSheetAction( + onPressed: () { + Navigator.pop(context); + favController.setSortMode(FavoritesSortMode.nameDesc); + }, + child: const Text('名称 Z-A'), + ), + ], + cancelButton: CupertinoActionSheetAction( + onPressed: () => Navigator.pop(context), + isDestructiveAction: true, + child: const Text('取消'), + ), ), - gridDelegate: SliverGridDelegateWithFixedCrossAxisCount( - crossAxisCount: 2, - crossAxisSpacing: DesignTokens.space3, - mainAxisSpacing: DesignTokens.space3, - childAspectRatio: - favorites.isNotEmpty && - favorites.first.favoriteType == FavoriteType.miniCard - ? 0.75 - : 0.85, - ), - itemCount: favorites.length, - itemBuilder: (context, index) { - final item = favorites[index]; - return _buildFavoriteItem(item, isDark); - }, ); } +} - Widget _buildFavoriteItem(dynamic item, bool isDark) { - switch (item.favoriteType) { - case FavoriteType.miniCard: - return _buildMiniCardFavoriteItem(item, isDark); - case FavoriteType.ingredient: - return _buildIngredientFavoriteItem(item, isDark); - case FavoriteType.tag: - return _buildTagFavoriteItem(item, isDark); - case FavoriteType.recipe: - default: - return _buildRecipeFavoriteItem(item, isDark); +class _ScrollEndIndicator extends StatefulWidget { + final ScrollController scrollController; + final bool isDark; + + const _ScrollEndIndicator({ + required this.scrollController, + required this.isDark, + }); + + @override + State<_ScrollEndIndicator> createState() => _ScrollEndIndicatorState(); +} + +class _ScrollEndIndicatorState extends State<_ScrollEndIndicator> { + bool _isVisible = false; + + @override + void initState() { + super.initState(); + widget.scrollController.addListener(_onScroll); + } + + @override + void dispose() { + widget.scrollController.removeListener(_onScroll); + super.dispose(); + } + + void _onScroll() { + final isVisible = + widget.scrollController.position.pixels >= + widget.scrollController.position.maxScrollExtent - 50; + if (_isVisible != isVisible) { + setState(() => _isVisible = isVisible); } } - Widget _buildRecipeFavoriteItem(dynamic item, bool isDark) { - return Obx(() { - final isEditMode = _favoritesController!.isEditMode.value; - final isSelected = _favoritesController!.isSelected(item.id); + @override + Widget build(BuildContext context) { + if (!_isVisible) return const SizedBox(height: 20); - return GestureDetector( - onTap: isEditMode - ? () => _favoritesController!.toggleSelection(item.id) - : () => _navigateToRecipeDetail(item.id), - child: ClipRRect( - borderRadius: DesignTokens.borderRadiusLg, - child: BackdropFilter( - filter: ImageFilter.blur(sigmaX: 14, sigmaY: 14), - child: Container( - padding: EdgeInsets.all(DesignTokens.space3), - decoration: BoxDecoration( - color: isEditMode && isSelected - ? DesignTokens.dynamicPrimary.withValues(alpha: 0.15) - : (isDark - ? DarkDesignTokens.glass - : DesignTokens.card.withValues(alpha: 0.75)), - borderRadius: DesignTokens.borderRadiusLg, - border: Border.all( - color: isEditMode && isSelected - ? DesignTokens.dynamicPrimary.withValues(alpha: 0.5) - : (isDark - ? DarkDesignTokens.glassBorder - : DesignTokens.text3.withValues(alpha: 0.1)), - width: isEditMode && isSelected ? 1.5 : 0.5, - ), - ), - child: Stack( - children: [ - Column( - crossAxisAlignment: CrossAxisAlignment.start, - children: [ - if (isEditMode) - Align( - alignment: Alignment.topRight, - child: Container( - width: 24, - height: 24, - decoration: BoxDecoration( - color: isSelected - ? DesignTokens.dynamicPrimary - : const Color(0x00000000), - borderRadius: DesignTokens.borderRadiusSm, - border: Border.all( - color: isSelected - ? DesignTokens.dynamicPrimary - : (isDark - ? DarkDesignTokens.text3 - : DesignTokens.text3), - width: 2, - ), - ), - child: isSelected - ? const Icon( - CupertinoIcons.checkmark_alt, - size: 14, - color: CupertinoColors.white, - ) - : null, - ), - ), - Center( - child: Container( - width: 64, - height: 64, - decoration: BoxDecoration( - color: DesignTokens.primaryLight.withValues( - alpha: 0.5, - ), - borderRadius: DesignTokens.borderRadiusMd, - ), - child: Icon( - CupertinoIcons.book, - size: 28, - color: DesignTokens.dynamicPrimary, - ), - ), - ), - const SizedBox(height: DesignTokens.space2), - Text( - item.title ?? '', - style: TextStyle( - fontSize: DesignTokens.fontMd, - fontWeight: FontWeight.w600, - color: isDark - ? DarkDesignTokens.text1 - : DesignTokens.text1, - ), - maxLines: 2, - overflow: TextOverflow.ellipsis, - textAlign: TextAlign.center, - ), - const SizedBox(height: DesignTokens.space1), - Text( - item.intro ?? '', - style: TextStyle( - fontSize: DesignTokens.fontXs, - color: isDark - ? DarkDesignTokens.text3 - : DesignTokens.text3, - ), - maxLines: 1, - overflow: TextOverflow.ellipsis, - textAlign: TextAlign.center, - ), - ], - ), - if (!isEditMode) - Positioned( - bottom: 0, - right: 0, - child: GestureDetector( - onTap: () => - _favoritesController!.removeFavorite(item.id), - behavior: HitTestBehavior.opaque, - child: Container( - width: 32, - height: 32, - decoration: BoxDecoration( - color: DesignTokens.red.withValues(alpha: 0.1), - borderRadius: DesignTokens.borderRadiusSm, - ), - child: Icon( - CupertinoIcons.heart_slash, - size: 16, - color: DesignTokens.red, - ), - ), - ), - ), - ], - ), + return Container( + padding: const EdgeInsets.symmetric(vertical: DesignTokens.space3), + alignment: Alignment.center, + child: Row( + mainAxisSize: MainAxisSize.min, + children: [ + Container( + width: 30, + height: 3, + decoration: BoxDecoration( + color: widget.isDark + ? DarkDesignTokens.text3 + : DesignTokens.text3.withValues(alpha: 0.3), + borderRadius: BorderRadius.circular(2), ), ), - ), - ); - }); - } - - Widget _buildMiniCardFavoriteItem(dynamic item, bool isDark) { - return Obx(() { - final isEditMode = _favoritesController!.isEditMode.value; - final isSelected = _favoritesController!.isSelected(item.id); - final coverUrl = item.cover ?? ''; - - return GestureDetector( - onTap: isEditMode - ? () => _favoritesController!.toggleSelection(item.id) - : () => _navigateToMiniCard(item.id), - child: ClipRRect( - borderRadius: DesignTokens.borderRadiusLg, - child: Stack( - fit: StackFit.expand, - children: [ - if (coverUrl.isNotEmpty) - Image.network( - coverUrl.startsWith('http') - ? coverUrl - : 'https://eat.wktyl.com/api/assets/$coverUrl', - fit: BoxFit.cover, - errorBuilder: (_, __, ___) => Container( - decoration: BoxDecoration( - gradient: LinearGradient( - begin: Alignment.topLeft, - end: Alignment.bottomRight, - colors: [ - DesignTokens.orange.withValues(alpha: 0.3), - DesignTokens.red.withValues(alpha: 0.2), - ], - ), - ), - child: const Center( - child: Icon( - CupertinoIcons.photo, - size: 32, - color: DesignTokens.text3, - ), - ), - ), - ) - else - Container( - decoration: BoxDecoration( - gradient: LinearGradient( - begin: Alignment.topLeft, - end: Alignment.bottomRight, - colors: [ - DesignTokens.orange.withValues(alpha: 0.3), - DesignTokens.red.withValues(alpha: 0.2), - ], - ), - ), - child: const Center( - child: Text('🃏', style: TextStyle(fontSize: 40)), - ), - ), - Container( - decoration: BoxDecoration( - gradient: LinearGradient( - begin: Alignment.topCenter, - end: Alignment.bottomCenter, - colors: [ - Colors.black.withValues(alpha: 0.05), - Colors.black.withValues(alpha: 0.6), - ], - stops: const [0.4, 1.0], - ), - ), - ), - Positioned( - bottom: 0, - left: 0, - right: 0, - child: ClipRect( - child: BackdropFilter( - filter: ImageFilter.blur(sigmaX: 3, sigmaY: 3), - child: Container( - padding: const EdgeInsets.fromLTRB(10, 8, 10, 8), - decoration: BoxDecoration( - gradient: LinearGradient( - begin: Alignment.topCenter, - end: Alignment.bottomCenter, - colors: [ - Colors.white.withValues(alpha: 0.02), - Colors.white.withValues(alpha: 0.08), - ], - ), - border: Border( - top: BorderSide( - color: Colors.white.withValues(alpha: 0.35), - width: 0.5, - ), - ), - ), - child: Column( - crossAxisAlignment: CrossAxisAlignment.start, - mainAxisSize: MainAxisSize.min, - children: [ - Text( - item.title ?? '', - style: const TextStyle( - color: CupertinoColors.white, - fontSize: DesignTokens.fontMd, - fontWeight: FontWeight.bold, - height: 1.2, - shadows: [ - Shadow(color: Colors.black54, blurRadius: 6), - ], - ), - maxLines: 2, - overflow: TextOverflow.ellipsis, - ), - const SizedBox(height: 2), - Row( - children: [ - Container( - padding: const EdgeInsets.symmetric( - horizontal: 4, - vertical: 1, - ), - decoration: BoxDecoration( - color: DesignTokens.dynamicPrimary.withValues( - alpha: 0.3, - ), - borderRadius: BorderRadius.circular( - DesignTokens.radiusSm, - ), - ), - child: const Text( - '🃏 迷你卡片', - style: TextStyle( - color: CupertinoColors.white, - fontSize: DesignTokens.fontXs - 1, - fontWeight: FontWeight.w500, - ), - ), - ), - const Spacer(), - if (!isEditMode) - GestureDetector( - onTap: () => _favoritesController! - .removeFavorite(item.id), - child: const Icon( - CupertinoIcons.heart_slash, - size: 14, - color: CupertinoColors.white, - ), - ), - ], - ), - ], - ), - ), - ), - ), - ), - if (isEditMode) - Positioned( - top: 6, - right: 6, - child: Container( - width: 24, - height: 24, - decoration: BoxDecoration( - color: isSelected - ? DesignTokens.dynamicPrimary - : const Color(0x00000000), - borderRadius: DesignTokens.borderRadiusSm, - border: Border.all( - color: isSelected - ? DesignTokens.dynamicPrimary - : CupertinoColors.white, - width: 2, - ), - ), - child: isSelected - ? const Icon( - CupertinoIcons.checkmark_alt, - size: 14, - color: CupertinoColors.white, - ) - : null, - ), - ), - ], - ), - ), - ); - }); - } - - Widget _buildIngredientFavoriteItem(dynamic item, bool isDark) { - return Obx(() { - final isEditMode = _favoritesController!.isEditMode.value; - final isSelected = _favoritesController!.isSelected(item.id); - - return GestureDetector( - onTap: isEditMode - ? () => _favoritesController!.toggleSelection(item.id) - : () {}, - child: ClipRRect( - borderRadius: DesignTokens.borderRadiusLg, - child: BackdropFilter( - filter: ImageFilter.blur(sigmaX: 14, sigmaY: 14), - child: Container( - padding: EdgeInsets.all(DesignTokens.space3), - decoration: BoxDecoration( - color: isEditMode && isSelected - ? DesignTokens.dynamicPrimary.withValues(alpha: 0.15) - : (isDark - ? DarkDesignTokens.glass - : DesignTokens.card.withValues(alpha: 0.75)), - borderRadius: DesignTokens.borderRadiusLg, - border: Border.all( - color: isEditMode && isSelected - ? DesignTokens.dynamicPrimary.withValues(alpha: 0.5) - : (isDark - ? DarkDesignTokens.glassBorder - : DesignTokens.text3.withValues(alpha: 0.1)), - width: isEditMode && isSelected ? 1.5 : 0.5, - ), - ), - child: Stack( - children: [ - Column( - crossAxisAlignment: CrossAxisAlignment.start, - children: [ - if (isEditMode) - Align( - alignment: Alignment.topRight, - child: Container( - width: 24, - height: 24, - decoration: BoxDecoration( - color: isSelected - ? DesignTokens.dynamicPrimary - : const Color(0x00000000), - borderRadius: DesignTokens.borderRadiusSm, - border: Border.all( - color: isSelected - ? DesignTokens.dynamicPrimary - : (isDark - ? DarkDesignTokens.text3 - : DesignTokens.text3), - width: 2, - ), - ), - child: isSelected - ? const Icon( - CupertinoIcons.checkmark_alt, - size: 14, - color: CupertinoColors.white, - ) - : null, - ), - ), - Center( - child: Container( - width: 64, - height: 64, - decoration: BoxDecoration( - color: DesignTokens.green.withValues(alpha: 0.1), - borderRadius: DesignTokens.borderRadiusMd, - ), - child: const Center( - child: Text('🥬', style: TextStyle(fontSize: 32)), - ), - ), - ), - const SizedBox(height: DesignTokens.space2), - Text( - item.title ?? '', - style: TextStyle( - fontSize: DesignTokens.fontMd, - fontWeight: FontWeight.w600, - color: isDark - ? DarkDesignTokens.text1 - : DesignTokens.text1, - ), - maxLines: 2, - overflow: TextOverflow.ellipsis, - textAlign: TextAlign.center, - ), - const SizedBox(height: DesignTokens.space1), - Container( - padding: const EdgeInsets.symmetric( - horizontal: 6, - vertical: 2, - ), - decoration: BoxDecoration( - color: DesignTokens.green.withValues(alpha: 0.1), - borderRadius: BorderRadius.circular( - DesignTokens.radiusSm, - ), - ), - child: Text( - '🥬 食材', - style: TextStyle( - fontSize: DesignTokens.fontXs, - color: DesignTokens.green, - fontWeight: FontWeight.w500, - ), - ), - ), - ], - ), - if (!isEditMode) - Positioned( - bottom: 0, - right: 0, - child: GestureDetector( - onTap: () => - _favoritesController!.removeFavorite(item.id), - behavior: HitTestBehavior.opaque, - child: Container( - width: 32, - height: 32, - decoration: BoxDecoration( - color: DesignTokens.red.withValues(alpha: 0.1), - borderRadius: DesignTokens.borderRadiusSm, - ), - child: Icon( - CupertinoIcons.heart_slash, - size: 16, - color: DesignTokens.red, - ), - ), - ), - ), - ], - ), - ), - ), - ), - ); - }); - } - - Widget _buildTagFavoriteItem(dynamic item, bool isDark) { - return Obx(() { - final isEditMode = _favoritesController!.isEditMode.value; - final isSelected = _favoritesController!.isSelected(item.id); - - return GestureDetector( - onTap: isEditMode - ? () => _favoritesController!.toggleSelection(item.id) - : () {}, - child: ClipRRect( - borderRadius: DesignTokens.borderRadiusLg, - child: BackdropFilter( - filter: ImageFilter.blur(sigmaX: 14, sigmaY: 14), - child: Container( - padding: EdgeInsets.all(DesignTokens.space3), - decoration: BoxDecoration( - color: isEditMode && isSelected - ? DesignTokens.dynamicPrimary.withValues(alpha: 0.15) - : (isDark - ? DarkDesignTokens.glass - : DesignTokens.card.withValues(alpha: 0.75)), - borderRadius: DesignTokens.borderRadiusLg, - border: Border.all( - color: isEditMode && isSelected - ? DesignTokens.dynamicPrimary.withValues(alpha: 0.5) - : (isDark - ? DarkDesignTokens.glassBorder - : DesignTokens.text3.withValues(alpha: 0.1)), - width: isEditMode && isSelected ? 1.5 : 0.5, - ), - ), - child: Stack( - children: [ - Column( - crossAxisAlignment: CrossAxisAlignment.start, - children: [ - if (isEditMode) - Align( - alignment: Alignment.topRight, - child: Container( - width: 24, - height: 24, - decoration: BoxDecoration( - color: isSelected - ? DesignTokens.dynamicPrimary - : const Color(0x00000000), - borderRadius: DesignTokens.borderRadiusSm, - border: Border.all( - color: isSelected - ? DesignTokens.dynamicPrimary - : (isDark - ? DarkDesignTokens.text3 - : DesignTokens.text3), - width: 2, - ), - ), - child: isSelected - ? const Icon( - CupertinoIcons.checkmark_alt, - size: 14, - color: CupertinoColors.white, - ) - : null, - ), - ), - Center( - child: Container( - width: 64, - height: 64, - decoration: BoxDecoration( - color: DesignTokens.orange.withValues(alpha: 0.1), - borderRadius: DesignTokens.borderRadiusMd, - ), - child: const Center( - child: Text('🏷️', style: TextStyle(fontSize: 32)), - ), - ), - ), - const SizedBox(height: DesignTokens.space2), - Text( - item.title ?? '', - style: TextStyle( - fontSize: DesignTokens.fontMd, - fontWeight: FontWeight.w600, - color: isDark - ? DarkDesignTokens.text1 - : DesignTokens.text1, - ), - maxLines: 2, - overflow: TextOverflow.ellipsis, - textAlign: TextAlign.center, - ), - const SizedBox(height: DesignTokens.space1), - Container( - padding: const EdgeInsets.symmetric( - horizontal: 6, - vertical: 2, - ), - decoration: BoxDecoration( - color: DesignTokens.orange.withValues(alpha: 0.1), - borderRadius: BorderRadius.circular( - DesignTokens.radiusSm, - ), - ), - child: Text( - '🏷️ 标签', - style: TextStyle( - fontSize: DesignTokens.fontXs, - color: DesignTokens.orange, - fontWeight: FontWeight.w500, - ), - ), - ), - ], - ), - if (!isEditMode) - Positioned( - bottom: 0, - right: 0, - child: GestureDetector( - onTap: () => - _favoritesController!.removeFavorite(item.id), - behavior: HitTestBehavior.opaque, - child: Container( - width: 32, - height: 32, - decoration: BoxDecoration( - color: DesignTokens.red.withValues(alpha: 0.1), - borderRadius: DesignTokens.borderRadiusSm, - ), - child: Icon( - CupertinoIcons.heart_slash, - size: 16, - color: DesignTokens.red, - ), - ), - ), - ), - ], - ), - ), - ), - ), - ); - }); - } - - void _navigateToMiniCard(int recipeId) { - Get.toNamed(AppRoutes.miniCard, arguments: recipeId); + ], + ), + ); } } diff --git a/lib/src/pages/profile/social/favorites_tools_panel.dart b/lib/src/pages/profile/social/favorites_tools_panel.dart new file mode 100644 index 0000000..d4ccfc2 --- /dev/null +++ b/lib/src/pages/profile/social/favorites_tools_panel.dart @@ -0,0 +1,280 @@ +/* + * 文件: favorites_tools_panel.dart + * 名称: 收藏页面工具面板 Mixin + * 作用: 收藏页面中工具面板和工具栏的构建方法,拆分自 favorites_page.dart + * 创建: 2026-04-16 从 favorites_page.dart 拆分 + * 更新: 2026-04-16 初始创建 + */ + +import 'dart:ui'; +import 'package:flutter/material.dart'; +import 'package:get/get.dart'; +import 'package:mom_kitchen/src/config/design_tokens.dart'; +import 'package:mom_kitchen/src/controllers/tools/tools_controller.dart'; +import 'package:mom_kitchen/src/models/tool_item_model.dart'; +import 'package:mom_kitchen/src/pages/tools/tools_center_page.dart'; + +mixin FavoritesToolsPanel on State { + ToolsController? get toolsController; + Animation get panelAnimation; + bool get isPanelOpen; + double get panelMaxHeight; + void Function() get openPanel; + void Function() get closePanel; + + Widget buildToolsPanel(bool isDark) { + return AnimatedBuilder( + animation: panelAnimation, + builder: (context, child) { + final value = panelAnimation.value; + if (value <= 0 && !isPanelOpen) return const SizedBox.shrink(); + + return Stack( + children: [ + GestureDetector( + onTap: closePanel, + child: Container( + color: Colors.black.withValues(alpha: 0.4 * value), + ), + ), + Positioned( + top: 0, + left: 0, + right: 0, + child: Transform.translate( + offset: Offset(0, -panelMaxHeight * (1 - value)), + child: Container( + constraints: BoxConstraints(maxHeight: panelMaxHeight), + decoration: BoxDecoration( + color: isDark + ? DarkDesignTokens.background + : DesignTokens.background, + borderRadius: BorderRadius.only( + bottomLeft: Radius.circular(DesignTokens.radiusLg), + bottomRight: Radius.circular(DesignTokens.radiusLg), + ), + boxShadow: [ + BoxShadow( + color: Colors.black.withValues(alpha: 0.15), + blurRadius: 20, + offset: const Offset(0, 8), + ), + ], + ), + child: Column( + mainAxisSize: MainAxisSize.min, + children: [ + buildPanelHandle(isDark), + Expanded(child: const ToolsCenterPage(embedded: true)), + ], + ), + ), + ), + ), + ], + ); + }, + ); + } + + Widget buildPanelHandle(bool isDark) { + return GestureDetector( + onVerticalDragEnd: (details) { + if (details.primaryVelocity != null && details.primaryVelocity! > 200) { + closePanel(); + } + }, + child: Container( + padding: const EdgeInsets.symmetric(vertical: DesignTokens.space3), + child: Column( + children: [ + Container( + width: 36, + height: 4, + decoration: BoxDecoration( + color: isDark + ? DarkDesignTokens.text3 + : DesignTokens.text3.withValues(alpha: 0.3), + borderRadius: BorderRadius.circular(2), + ), + ), + const SizedBox(height: DesignTokens.space2), + Row( + mainAxisAlignment: MainAxisAlignment.center, + children: [ + Text('🛠️', style: TextStyle(fontSize: 14)), + const SizedBox(width: 6), + Text( + '工具中心', + style: TextStyle( + fontSize: DesignTokens.fontMd, + fontWeight: FontWeight.w600, + color: isDark ? DarkDesignTokens.text1 : DesignTokens.text1, + ), + ), + ], + ), + ], + ), + ), + ); + } + + Widget buildToolsBar(bool isDark) { + return Obx(() { + final tools = toolsController!.frequentTools; + if (tools.isEmpty) { + return const SizedBox.shrink(); + } + return Container( + height: 90, + margin: const EdgeInsets.only(bottom: DesignTokens.space2), + child: ListView.separated( + scrollDirection: Axis.horizontal, + padding: const EdgeInsets.symmetric(horizontal: DesignTokens.space4), + itemCount: tools.length + 1, + separatorBuilder: (context, index) => + const SizedBox(width: DesignTokens.space2), + itemBuilder: (context, index) { + if (index == tools.length) { + return buildMoreToolsCard(isDark); + } + return buildToolShortcut(tools[index], isDark); + }, + ), + ); + }); + } + + Widget buildToolShortcut(ToolItem tool, bool isDark) { + return GestureDetector( + onTap: () => onNavigateToTool(tool), + child: ClipRRect( + borderRadius: DesignTokens.borderRadiusMd, + child: BackdropFilter( + filter: ImageFilter.blur(sigmaX: 12, sigmaY: 12), + child: Container( + width: 72, + padding: const EdgeInsets.symmetric(vertical: DesignTokens.space2), + decoration: BoxDecoration( + color: isDark + ? DarkDesignTokens.glass + : DesignTokens.card.withValues(alpha: 0.75), + borderRadius: DesignTokens.borderRadiusMd, + border: Border.all( + color: isDark + ? DarkDesignTokens.glassBorder + : DesignTokens.text3.withValues(alpha: 0.12), + ), + ), + child: Column( + mainAxisAlignment: MainAxisAlignment.center, + children: [ + Stack( + children: [ + Container( + width: 44, + height: 44, + decoration: BoxDecoration( + color: (DesignTokens.dynamicPrimary).withValues( + alpha: 0.1, + ), + borderRadius: DesignTokens.borderRadiusMd, + ), + child: Center( + child: Text(tool.icon, style: TextStyle(fontSize: 24)), + ), + ), + Positioned( + top: 0, + right: 0, + child: Container( + width: 8, + height: 8, + decoration: BoxDecoration( + color: tool.needsNetwork + ? DesignTokens.green + : DesignTokens.dynamicPrimary, + shape: BoxShape.circle, + ), + ), + ), + ], + ), + const SizedBox(height: 6), + Text( + tool.name, + style: TextStyle( + fontSize: DesignTokens.fontXs, + color: isDark ? DarkDesignTokens.text1 : DesignTokens.text1, + ), + maxLines: 1, + overflow: TextOverflow.ellipsis, + ), + ], + ), + ), + ), + ), + ); + } + + Widget buildMoreToolsCard(bool isDark) { + return GestureDetector( + onTap: openPanel, + child: ClipRRect( + borderRadius: DesignTokens.borderRadiusMd, + child: BackdropFilter( + filter: ImageFilter.blur(sigmaX: 12, sigmaY: 12), + child: Container( + width: 72, + padding: EdgeInsets.symmetric(vertical: DesignTokens.space2), + decoration: BoxDecoration( + color: isDark + ? DesignTokens.dynamicPrimary.withValues(alpha: 0.08) + : DesignTokens.primaryLight.withValues(alpha: 0.7), + borderRadius: DesignTokens.borderRadiusMd, + border: Border.all( + color: (DesignTokens.dynamicPrimary).withValues(alpha: 0.25), + ), + ), + child: Column( + mainAxisAlignment: MainAxisAlignment.center, + children: [ + Container( + width: 44, + height: 44, + decoration: BoxDecoration( + color: (DesignTokens.dynamicPrimary).withValues( + alpha: 0.15, + ), + borderRadius: DesignTokens.borderRadiusMd, + ), + child: Center( + child: Text('🛠️', style: TextStyle(fontSize: 24)), + ), + ), + SizedBox(height: 6), + Text( + '更多', + style: TextStyle( + fontSize: DesignTokens.fontXs, + color: DesignTokens.dynamicPrimary, + ), + ), + ], + ), + ), + ), + ), + ); + } + + void onNavigateToTool(ToolItem tool) { + toolsController?.recordUsage(tool.id); + closePanel(); + if (tool.route.isNotEmpty) { + Get.toNamed(tool.route); + } + } +} diff --git a/lib/src/pages/profile/social/footprints_page.dart b/lib/src/pages/profile/social/footprints_page.dart index 9c4d0ed..436dda0 100644 --- a/lib/src/pages/profile/social/footprints_page.dart +++ b/lib/src/pages/profile/social/footprints_page.dart @@ -41,10 +41,7 @@ class FootprintsPage extends StatelessWidget { leading: CupertinoButton( padding: EdgeInsets.zero, onPressed: () => Get.back(), - child: Icon( - CupertinoIcons.back, - color: DesignTokens.dynamicPrimary, - ), + child: Icon(CupertinoIcons.back, color: DesignTokens.dynamicPrimary), ), trailing: Row( mainAxisSize: MainAxisSize.min, @@ -121,9 +118,7 @@ class FootprintsPage extends StatelessWidget { onPressed: () => Get.toNamed('/'), child: Text( '去首页', - style: TextStyle( - color: DesignTokens.dynamicPrimary, - ), + style: TextStyle(color: DesignTokens.dynamicPrimary), ), ), ], @@ -184,7 +179,7 @@ class FootprintsPage extends StatelessWidget { horizontal: DesignTokens.space2, vertical: DesignTokens.space1, ), - minSize: 0, + minimumSize: Size.zero, onPressed: () { // 确保控制器已注册 if (!Get.isRegistered()) { @@ -272,7 +267,9 @@ class FootprintsPage extends StatelessWidget { height: 64, color: isDark ? DesignTokens.dynamicPrimary.withValues(alpha: 0.2) - : DesignTokens.dynamicPrimary.withValues(alpha: 0.1), + : DesignTokens.dynamicPrimary.withValues( + alpha: 0.1, + ), child: Icon( CupertinoIcons.photo, color: DesignTokens.dynamicPrimary, @@ -307,9 +304,9 @@ class FootprintsPage extends StatelessWidget { vertical: 2, ), decoration: BoxDecoration( - color: - (DesignTokens.dynamicPrimary) - .withValues(alpha: 0.1), + color: (DesignTokens.dynamicPrimary).withValues( + alpha: 0.1, + ), borderRadius: DesignTokens.borderRadiusSm, ), child: Text( diff --git a/lib/src/pages/tools/cooking/cooking_timer_page.dart b/lib/src/pages/tools/cooking/cooking_timer_page.dart index cbadcff..c48df09 100644 --- a/lib/src/pages/tools/cooking/cooking_timer_page.dart +++ b/lib/src/pages/tools/cooking/cooking_timer_page.dart @@ -497,8 +497,6 @@ class _AddStepDialogState extends State<_AddStepDialog> { @override Widget build(BuildContext context) { - final isDark = CupertinoTheme.brightnessOf(context) == Brightness.dark; - return CupertinoAlertDialog( title: const Text('添加步骤'), content: Column( diff --git a/lib/src/pages/tools/cooking/date_calculator_page.dart b/lib/src/pages/tools/cooking/date_calculator_page.dart new file mode 100644 index 0000000..1ab6d9b --- /dev/null +++ b/lib/src/pages/tools/cooking/date_calculator_page.dart @@ -0,0 +1,1214 @@ +/* + * 文件: date_calculator_page.dart + * 名称: 日期加减计算器页面 + * 作用: 日期加减天数计算 + 两日期间隔天数计算,使用MiniCalendar日期选择 + * 创建: 2026-04-15 初始创建,支持闰年处理 + * 更新: 2026-04-15 初始创建 + */ + +import 'package:flutter/cupertino.dart'; +import 'package:mom_kitchen/src/config/design_tokens.dart'; +import 'package:mom_kitchen/src/widgets/custom_widgets.dart'; + +/// 计算模式 +enum _CalcMode { + addDays('日期加减', '🗓️', '选择日期 ± 天数 = 新日期'), + diffDays('日期间隔', '📅', '选择两个日期 = 相隔天数'); + + const _CalcMode(this.label, this.emoji, this.description); + final String label; + final String emoji; + final String description; +} + +class DateCalculatorPage extends StatefulWidget { + const DateCalculatorPage({super.key}); + + @override + State createState() => _DateCalculatorPageState(); +} + +class _DateCalculatorPageState extends State { + // ─── 公共状态 ─── + _CalcMode _mode = _CalcMode.addDays; + + // ─── 日期加减模式 ─── + DateTime _startDate = DateTime.now(); + int _daysToAdd = 0; + DateTime? _addResult; + + // ─── 日期间隔模式 ─── + DateTime _dateA = DateTime.now(); + DateTime _dateB = DateTime.now(); + int? _diffResult; + + // ─── 日历弹窗控制 ─── + bool _showStartCalendar = false; + bool _showDateACalendar = false; + bool _showDateBCalendar = false; + + // ─── 日期加减计算 ─── + + void _calculateAddDays() { + setState(() { + _addResult = _startDate.add(Duration(days: _daysToAdd)); + }); + } + + // ─── 日期间隔计算 ─── + + void _calculateDiffDays() { + setState(() { + _diffResult = _dateB.difference(_dateA).inDays; + }); + } + + // ─── 格式化日期 ─── + + String _formatDate(DateTime date) { + return '${date.year}年${date.month}月${date.day}日'; + } + + String _formatWeekday(DateTime date) { + const weekdays = ['一', '二', '三', '四', '五', '六', '日']; + return '周${weekdays[date.weekday - 1]}'; + } + + /// 判断是否闰年 + bool _isLeapYear(int year) { + return (year % 4 == 0 && year % 100 != 0) || (year % 400 == 0); + } + + /// 获取指定年份天数信息 + String _getYearInfo(int year) { + final isLeap = _isLeapYear(year); + return isLeap ? '$year年(闰年 366天)' : '$year年(平年 365天)'; + } + + /// 计算两个日期之间的工作日和休息日 + /// 工作日:周一~周五,休息日:周六、周日 + /// 返回 [工作日, 休息日] + List _countWorkdaysAndWeekends(DateTime start, DateTime end) { + // 确保 start <= end + if (start.isAfter(end)) { + final tmp = start; + start = end; + end = tmp; + } + + int workdays = 0; + int weekends = 0; + var current = start; + while (!current.isAfter(end)) { + final weekday = current.weekday; + if (weekday == DateTime.saturday || weekday == DateTime.sunday) { + weekends++; + } else { + workdays++; + } + current = current.add(const Duration(days: 1)); + } + return [workdays, weekends]; + } + + // ─── 构建 ─── + + @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: + (isDark ? DarkDesignTokens.background : DesignTokens.background) + .withValues(alpha: 0.9), + border: null, + ), + child: SafeArea( + child: ListView( + padding: const EdgeInsets.all(DesignTokens.space4), + children: [ + _buildModeSelector(isDark), + const SizedBox(height: DesignTokens.space4), + if (_mode == _CalcMode.addDays) ...[ + _buildStartDateCard(isDark), + const SizedBox(height: DesignTokens.space3), + _buildDaysInputCard(isDark), + const SizedBox(height: DesignTokens.space4), + _buildCalculateButton(isDark, _calculateAddDays), + if (_addResult != null) ...[ + const SizedBox(height: DesignTokens.space4), + _buildAddResultCard(isDark), + ], + ] else ...[ + _buildDateACard(isDark), + const SizedBox(height: DesignTokens.space3), + _buildDateBCard(isDark), + const SizedBox(height: DesignTokens.space4), + _buildCalculateButton(isDark, _calculateDiffDays), + if (_diffResult != null) ...[ + const SizedBox(height: DesignTokens.space4), + _buildDiffResultCard(isDark), + ], + ], + ], + ), + ), + ); + } + + // ─── 模式选择器 ─── + + Widget _buildModeSelector(bool isDark) { + return Container( + padding: const EdgeInsets.all(DesignTokens.space3), + decoration: BoxDecoration( + color: isDark ? DarkDesignTokens.card : DesignTokens.card, + borderRadius: DesignTokens.borderRadiusLg, + border: Border.all( + color: (isDark ? DarkDesignTokens.text3 : DesignTokens.text3) + .withValues(alpha: 0.1), + ), + ), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Row( + children: [ + const Text('🔧', style: TextStyle(fontSize: 16)), + const SizedBox(width: 6), + Text( + '计算模式', + style: TextStyle( + fontSize: DesignTokens.fontMd, + fontWeight: FontWeight.w600, + color: isDark ? DarkDesignTokens.text1 : DesignTokens.text1, + ), + ), + ], + ), + const SizedBox(height: DesignTokens.space3), + Row( + children: _CalcMode.values.map((mode) { + final isSelected = mode == _mode; + return Expanded( + child: GestureDetector( + onTap: () { + setState(() { + _mode = mode; + _addResult = null; + _diffResult = null; + }); + }, + child: AnimatedContainer( + duration: DesignTokens.durationNormal, + curve: DesignTokens.curveDefault, + padding: const EdgeInsets.symmetric( + vertical: DesignTokens.space3, + horizontal: DesignTokens.space2, + ), + decoration: BoxDecoration( + color: isSelected + ? DesignTokens.dynamicPrimary.withValues(alpha: 0.12) + : const Color(0x00000000), + borderRadius: DesignTokens.borderRadiusMd, + border: Border.all( + color: isSelected + ? DesignTokens.dynamicPrimary + : (isDark + ? DarkDesignTokens.text3 + : DesignTokens.text3) + .withValues(alpha: 0.15), + ), + ), + child: Column( + children: [ + Text( + mode.emoji, + style: const TextStyle(fontSize: 24), + ), + const SizedBox(height: 4), + Text( + mode.label, + style: TextStyle( + fontSize: DesignTokens.fontSm, + fontWeight: + isSelected ? FontWeight.w600 : FontWeight.normal, + color: isSelected + ? DesignTokens.dynamicPrimary + : (isDark + ? DarkDesignTokens.text2 + : DesignTokens.text2), + ), + ), + ], + ), + ), + ), + ); + }).toList(), + ), + const SizedBox(height: DesignTokens.space2), + Center( + child: Text( + _mode.description, + style: TextStyle( + fontSize: DesignTokens.fontXs, + color: isDark ? DarkDesignTokens.text3 : DesignTokens.text3, + ), + ), + ), + ], + ), + ); + } + + // ─── 日期选择卡片通用组件 ─── + + Widget _buildDateCard({ + required bool isDark, + required String emoji, + required String title, + required DateTime date, + required bool showCalendar, + required VoidCallback onToggleCalendar, + required ValueChanged onDateSelected, + }) { + return Container( + padding: const EdgeInsets.all(DesignTokens.space4), + decoration: BoxDecoration( + color: isDark ? DarkDesignTokens.card : DesignTokens.card, + borderRadius: DesignTokens.borderRadiusLg, + border: Border.all( + color: (isDark ? DarkDesignTokens.text3 : DesignTokens.text3) + .withValues(alpha: 0.1), + ), + ), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + // 标题行 + Row( + children: [ + Text(emoji, style: const TextStyle(fontSize: 18)), + const SizedBox(width: 8), + Text( + title, + style: TextStyle( + fontSize: DesignTokens.fontMd, + fontWeight: FontWeight.w600, + color: + isDark ? DarkDesignTokens.text1 : DesignTokens.text1, + ), + ), + const Spacer(), + // 闰年信息 + if (date.year != DateTime.now().year) + Container( + padding: const EdgeInsets.symmetric( + horizontal: 8, + vertical: 2, + ), + decoration: BoxDecoration( + color: _isLeapYear(date.year) + ? DesignTokens.orange.withValues(alpha: 0.1) + : DesignTokens.blue.withValues(alpha: 0.1), + borderRadius: DesignTokens.borderRadiusFull, + ), + child: Text( + _isLeapYear(date.year) ? '闰年' : '平年', + style: TextStyle( + fontSize: DesignTokens.fontXs, + fontWeight: FontWeight.w500, + color: _isLeapYear(date.year) + ? DesignTokens.orange + : DesignTokens.blue, + ), + ), + ), + ], + ), + const SizedBox(height: DesignTokens.space3), + + // 日期显示行 + GestureDetector( + onTap: onToggleCalendar, + child: Container( + padding: const EdgeInsets.symmetric( + horizontal: DesignTokens.space4, + vertical: DesignTokens.space3, + ), + decoration: BoxDecoration( + color: isDark + ? DarkDesignTokens.background + : DesignTokens.background, + borderRadius: DesignTokens.borderRadiusMd, + border: Border.all( + color: DesignTokens.dynamicPrimary.withValues(alpha: 0.3), + ), + ), + child: Row( + children: [ + Icon( + CupertinoIcons.calendar, + size: 18, + color: DesignTokens.dynamicPrimary, + ), + const SizedBox(width: 8), + Text( + _formatDate(date), + style: TextStyle( + fontSize: DesignTokens.fontLg, + fontWeight: FontWeight.w600, + color: + isDark ? DarkDesignTokens.text1 : DesignTokens.text1, + ), + ), + const SizedBox(width: 6), + Text( + _formatWeekday(date), + style: TextStyle( + fontSize: DesignTokens.fontSm, + color: DesignTokens.dynamicPrimary, + ), + ), + const Spacer(), + Icon( + showCalendar + ? CupertinoIcons.chevron_up + : CupertinoIcons.chevron_down, + size: 14, + color: isDark + ? DarkDesignTokens.text3 + : DesignTokens.text3, + ), + ], + ), + ), + ), + + // 日历展开 + if (showCalendar) ...[ + const SizedBox(height: DesignTokens.space3), + MiniCalendar( + selectedDate: date, + onDateSelected: (newDate) { + onDateSelected(newDate); + onToggleCalendar(); + }, + ), + ], + ], + ), + ); + } + + // ─── 起始日期卡片 ─── + + Widget _buildStartDateCard(bool isDark) { + return _buildDateCard( + isDark: isDark, + emoji: '📍', + title: '起始日期', + date: _startDate, + showCalendar: _showStartCalendar, + onToggleCalendar: () => + setState(() => _showStartCalendar = !_showStartCalendar), + onDateSelected: (date) { + setState(() { + _startDate = date; + _addResult = null; + }); + }, + ); + } + + // ─── 日期A卡片 ─── + + Widget _buildDateACard(bool isDark) { + return _buildDateCard( + isDark: isDark, + emoji: '🅰️', + title: '起始日期', + date: _dateA, + showCalendar: _showDateACalendar, + onToggleCalendar: () => + setState(() => _showDateACalendar = !_showDateACalendar), + onDateSelected: (date) { + setState(() { + _dateA = date; + _diffResult = null; + }); + }, + ); + } + + // ─── 日期B卡片 ─── + + Widget _buildDateBCard(bool isDark) { + return _buildDateCard( + isDark: isDark, + emoji: '🅱️', + title: '目标日期', + date: _dateB, + showCalendar: _showDateBCalendar, + onToggleCalendar: () => + setState(() => _showDateBCalendar = !_showDateBCalendar), + onDateSelected: (date) { + setState(() { + _dateB = date; + _diffResult = null; + }); + }, + ); + } + + // ─── 天数输入卡片 ─── + + Widget _buildDaysInputCard(bool isDark) { + return Container( + padding: const EdgeInsets.all(DesignTokens.space4), + decoration: BoxDecoration( + color: isDark ? DarkDesignTokens.card : DesignTokens.card, + borderRadius: DesignTokens.borderRadiusLg, + border: Border.all( + color: (isDark ? DarkDesignTokens.text3 : DesignTokens.text3) + .withValues(alpha: 0.1), + ), + ), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Row( + children: [ + const Text('🔢', style: TextStyle(fontSize: 18)), + const SizedBox(width: 8), + Text( + '加减天数', + style: TextStyle( + fontSize: DesignTokens.fontMd, + fontWeight: FontWeight.w600, + color: isDark ? DarkDesignTokens.text1 : DesignTokens.text1, + ), + ), + ], + ), + const SizedBox(height: DesignTokens.space2), + Text( + '输入正数为往后推,负数为往前推', + style: TextStyle( + fontSize: DesignTokens.fontXs, + color: isDark ? DarkDesignTokens.text3 : DesignTokens.text3, + ), + ), + const SizedBox(height: DesignTokens.space3), + + // 天数输入框 + Row( + children: [ + // 减少按钮 + _buildStepButton( + isDark: isDark, + icon: CupertinoIcons.minus, + onTap: () { + setState(() { + _daysToAdd--; + _addResult = null; + }); + }, + ), + const SizedBox(width: DesignTokens.space2), + + // 输入框 + Expanded( + child: Container( + padding: const EdgeInsets.symmetric( + horizontal: DesignTokens.space3, + vertical: DesignTokens.space2, + ), + decoration: BoxDecoration( + color: isDark + ? DarkDesignTokens.background + : DesignTokens.background, + borderRadius: DesignTokens.borderRadiusMd, + border: Border.all( + color: (isDark + ? DarkDesignTokens.text3 + : DesignTokens.text3) + .withValues(alpha: 0.15), + ), + ), + child: Row( + mainAxisAlignment: MainAxisAlignment.center, + children: [ + Text( + _daysToAdd >= 0 ? '+' : '', + style: TextStyle( + fontSize: DesignTokens.fontXxl, + fontWeight: FontWeight.w700, + color: _daysToAdd >= 0 + ? DesignTokens.green + : DesignTokens.red, + ), + ), + Text( + '$_daysToAdd', + style: TextStyle( + fontSize: DesignTokens.fontXxl, + fontWeight: FontWeight.w700, + color: isDark + ? DarkDesignTokens.text1 + : DesignTokens.text1, + ), + ), + const SizedBox(width: 4), + Text( + '天', + style: TextStyle( + fontSize: DesignTokens.fontMd, + color: isDark + ? DarkDesignTokens.text2 + : DesignTokens.text2, + ), + ), + ], + ), + ), + ), + const SizedBox(width: DesignTokens.space2), + + // 增加按钮 + _buildStepButton( + isDark: isDark, + icon: CupertinoIcons.plus, + onTap: () { + setState(() { + _daysToAdd++; + _addResult = null; + }); + }, + ), + ], + ), + + const SizedBox(height: DesignTokens.space3), + + // 快捷天数按钮 + Wrap( + spacing: DesignTokens.space2, + runSpacing: DesignTokens.space2, + children: [ + _buildQuickDayChip(isDark, 7, '7天'), + _buildQuickDayChip(isDark, 14, '14天'), + _buildQuickDayChip(isDark, 30, '30天'), + _buildQuickDayChip(isDark, 90, '90天'), + _buildQuickDayChip(isDark, 180, '180天'), + _buildQuickDayChip(isDark, 365, '1年'), + _buildQuickDayChip(isDark, -7, '-7天'), + _buildQuickDayChip(isDark, -30, '-30天'), + ], + ), + ], + ), + ); + } + + Widget _buildStepButton({ + required bool isDark, + required IconData icon, + required VoidCallback onTap, + }) { + return GestureDetector( + onTap: onTap, + child: Container( + width: 44, + height: 44, + decoration: BoxDecoration( + color: DesignTokens.dynamicPrimary.withValues(alpha: 0.1), + borderRadius: DesignTokens.borderRadiusMd, + border: Border.all( + color: DesignTokens.dynamicPrimary.withValues(alpha: 0.2), + ), + ), + child: Icon( + icon, + size: 18, + color: DesignTokens.dynamicPrimary, + ), + ), + ); + } + + Widget _buildQuickDayChip(bool isDark, int days, String label) { + final isSelected = _daysToAdd == days; + return GestureDetector( + onTap: () { + setState(() { + _daysToAdd = days; + _addResult = null; + }); + }, + child: AnimatedContainer( + duration: DesignTokens.durationFast, + padding: const EdgeInsets.symmetric(horizontal: 10, vertical: 5), + decoration: BoxDecoration( + color: isSelected + ? DesignTokens.dynamicPrimary.withValues(alpha: 0.12) + : (isDark ? DarkDesignTokens.background : DesignTokens.background), + borderRadius: DesignTokens.borderRadiusFull, + border: Border.all( + color: isSelected + ? DesignTokens.dynamicPrimary + : (isDark ? DarkDesignTokens.text3 : DesignTokens.text3) + .withValues(alpha: 0.2), + ), + ), + child: Text( + label, + style: TextStyle( + fontSize: DesignTokens.fontSm, + fontWeight: isSelected ? FontWeight.w600 : FontWeight.normal, + color: isSelected + ? DesignTokens.dynamicPrimary + : (isDark ? DarkDesignTokens.text2 : DesignTokens.text2), + ), + ), + ), + ); + } + + // ─── 计算按钮 ─── + + Widget _buildCalculateButton(bool isDark, VoidCallback onPressed) { + return SizedBox( + width: double.infinity, + height: 48, + child: CupertinoButton.filled( + borderRadius: DesignTokens.borderRadiusFull, + onPressed: onPressed, + child: Row( + mainAxisAlignment: MainAxisAlignment.center, + children: [ + Icon( + CupertinoIcons.gear_solid, + size: 18, + color: CupertinoColors.white, + ), + const SizedBox(width: 8), + Text( + '开始计算', + style: TextStyle( + fontSize: DesignTokens.fontLg, + fontWeight: FontWeight.w600, + color: CupertinoColors.white, + ), + ), + ], + ), + ), + ); + } + + // ─── 日期加减结果卡片 ─── + + Widget _buildAddResultCard(bool isDark) { + final result = _addResult!; + final isForward = _daysToAdd >= 0; + + return Container( + padding: const EdgeInsets.all(DesignTokens.space4), + decoration: BoxDecoration( + color: isDark ? DarkDesignTokens.card : DesignTokens.card, + borderRadius: DesignTokens.borderRadiusLg, + border: Border.all( + color: DesignTokens.green.withValues(alpha: 0.3), + ), + ), + child: Column( + children: [ + // 标题 + Row( + mainAxisAlignment: MainAxisAlignment.center, + children: [ + Text(isForward ? '⏩' : '⏪', style: const TextStyle(fontSize: 20)), + const SizedBox(width: 6), + Text( + '计算结果', + style: TextStyle( + fontSize: DesignTokens.fontMd, + fontWeight: FontWeight.w600, + color: isDark ? DarkDesignTokens.text1 : DesignTokens.text1, + ), + ), + ], + ), + const SizedBox(height: DesignTokens.space4), + + // 结果公式 + Container( + padding: const EdgeInsets.all(DesignTokens.space3), + decoration: BoxDecoration( + color: isDark + ? DarkDesignTokens.background + : DesignTokens.background, + borderRadius: DesignTokens.borderRadiusMd, + ), + child: Column( + children: [ + // 起始日期 + Row( + mainAxisAlignment: MainAxisAlignment.center, + children: [ + Text( + '📍 ${_formatDate(_startDate)}', + style: TextStyle( + fontSize: DesignTokens.fontMd, + color: isDark + ? DarkDesignTokens.text2 + : DesignTokens.text2, + ), + ), + ], + ), + const SizedBox(height: DesignTokens.space2), + // 加减运算符 + Row( + mainAxisAlignment: MainAxisAlignment.center, + children: [ + Container( + padding: const EdgeInsets.symmetric( + horizontal: 12, + vertical: 4, + ), + decoration: BoxDecoration( + color: isForward + ? DesignTokens.green.withValues(alpha: 0.1) + : DesignTokens.red.withValues(alpha: 0.1), + borderRadius: DesignTokens.borderRadiusFull, + ), + child: Text( + isForward + ? '➕ ${_daysToAdd.abs()}天后' + : '➖ ${_daysToAdd.abs()}天前', + style: TextStyle( + fontSize: DesignTokens.fontSm, + fontWeight: FontWeight.w600, + color: isForward + ? DesignTokens.green + : DesignTokens.red, + ), + ), + ), + ], + ), + // 工作日/休息日分解 + if (_daysToAdd != 0) ...[ + const SizedBox(height: DesignTokens.space2), + _buildWorkdayBreakdown(isDark, _startDate, result), + ], + const SizedBox(height: DesignTokens.space2), + // 等号 + Text( + '=', + style: TextStyle( + fontSize: DesignTokens.fontLg, + color: isDark + ? DarkDesignTokens.text3 + : DesignTokens.text3, + ), + ), + const SizedBox(height: DesignTokens.space2), + // 结果日期 + Text( + _formatDate(result), + style: TextStyle( + fontSize: DesignTokens.fontXxl, + fontWeight: FontWeight.w700, + color: DesignTokens.dynamicPrimary, + ), + ), + const SizedBox(height: 4), + Text( + _formatWeekday(result), + style: TextStyle( + fontSize: DesignTokens.fontMd, + color: DesignTokens.dynamicPrimary.withValues(alpha: 0.7), + ), + ), + ], + ), + ), + + const SizedBox(height: DesignTokens.space3), + + // 附加信息 + _buildInfoRow( + isDark, + '🌍', + '年份信息', + _getYearInfo(result.year), + ), + if (_startDate.year != result.year) ...[ + const SizedBox(height: DesignTokens.space2), + _buildInfoRow( + isDark, + '🔄', + '跨年计算', + '${_startDate.year} → ${result.year}', + ), + ], + ], + ), + ); + } + + // ─── 日期间隔结果卡片 ─── + + Widget _buildDiffResultCard(bool isDark) { + final diff = _diffResult!; + final absDiff = diff.abs(); + final isPositive = diff >= 0; + + // 计算详细的间隔 + final earlier = isPositive ? _dateA : _dateB; + final later = isPositive ? _dateB : _dateA; + + // 计算年月日间隔 + int years = later.year - earlier.year; + int months = later.month - earlier.month; + int days = later.day - earlier.day; + + if (days < 0) { + months--; + // 获取上个月的天数(处理闰年) + final prevMonth = DateTime(later.year, later.month, 0); + days += prevMonth.day; + } + if (months < 0) { + years--; + months += 12; + } + + return Container( + padding: const EdgeInsets.all(DesignTokens.space4), + decoration: BoxDecoration( + color: isDark ? DarkDesignTokens.card : DesignTokens.card, + borderRadius: DesignTokens.borderRadiusLg, + border: Border.all( + color: DesignTokens.blue.withValues(alpha: 0.3), + ), + ), + child: Column( + children: [ + // 标题 + Row( + mainAxisAlignment: MainAxisAlignment.center, + children: [ + const Text('📐', style: TextStyle(fontSize: 20)), + const SizedBox(width: 6), + Text( + '间隔结果', + style: TextStyle( + fontSize: DesignTokens.fontMd, + fontWeight: FontWeight.w600, + color: isDark ? DarkDesignTokens.text1 : DesignTokens.text1, + ), + ), + ], + ), + const SizedBox(height: DesignTokens.space4), + + // 总天数 + Container( + padding: const EdgeInsets.all(DesignTokens.space4), + decoration: BoxDecoration( + color: isDark + ? DarkDesignTokens.background + : DesignTokens.background, + borderRadius: DesignTokens.borderRadiusMd, + ), + child: Column( + children: [ + Text( + '$absDiff', + style: TextStyle( + fontSize: 48, + fontWeight: FontWeight.w800, + color: DesignTokens.dynamicPrimary, + height: 1.0, + ), + ), + const SizedBox(height: 4), + Text( + '天', + style: TextStyle( + fontSize: DesignTokens.fontXl, + color: isDark + ? DarkDesignTokens.text2 + : DesignTokens.text2, + ), + ), + const SizedBox(height: DesignTokens.space2), + // 工作日+休息日分解 + _buildWorkdayBreakdown(isDark, _dateA, _dateB), + ], + ), + ), + + const SizedBox(height: DesignTokens.space3), + + // 详细分解 + if (years > 0 || months > 0 || days > 0) + Container( + padding: const EdgeInsets.all(DesignTokens.space3), + decoration: BoxDecoration( + color: DesignTokens.blue.withValues(alpha: 0.06), + borderRadius: DesignTokens.borderRadiusMd, + border: Border.all( + color: DesignTokens.blue.withValues(alpha: 0.1), + ), + ), + child: Column( + children: [ + Row( + mainAxisAlignment: MainAxisAlignment.center, + children: [ + if (years > 0) ...[ + _buildTimeUnit(isDark, '$years', '年'), + const SizedBox(width: DesignTokens.space3), + Text( + '›', + style: TextStyle( + fontSize: DesignTokens.fontLg, + color: isDark + ? DarkDesignTokens.text3 + : DesignTokens.text3, + ), + ), + const SizedBox(width: DesignTokens.space3), + ], + if (months > 0 || years > 0) ...[ + _buildTimeUnit(isDark, '$months', '月'), + const SizedBox(width: DesignTokens.space3), + Text( + '›', + style: TextStyle( + fontSize: DesignTokens.fontLg, + color: isDark + ? DarkDesignTokens.text3 + : DesignTokens.text3, + ), + ), + const SizedBox(width: DesignTokens.space3), + ], + _buildTimeUnit(isDark, '$days', '天'), + ], + ), + ], + ), + ), + + const SizedBox(height: DesignTokens.space3), + + // 日期区间显示 + Container( + padding: const EdgeInsets.symmetric( + horizontal: DesignTokens.space3, + vertical: DesignTokens.space2, + ), + decoration: BoxDecoration( + color: isDark + ? DarkDesignTokens.background + : DesignTokens.background, + borderRadius: DesignTokens.borderRadiusMd, + ), + child: Row( + mainAxisAlignment: MainAxisAlignment.center, + children: [ + Text( + _formatDate(_dateA), + style: TextStyle( + fontSize: DesignTokens.fontSm, + color: isDark + ? DarkDesignTokens.text2 + : DesignTokens.text2, + ), + ), + const SizedBox(width: 6), + Text( + isPositive ? '→' : '←', + style: TextStyle( + fontSize: DesignTokens.fontMd, + color: DesignTokens.dynamicPrimary, + fontWeight: FontWeight.w700, + ), + ), + const SizedBox(width: 6), + Text( + _formatDate(_dateB), + style: TextStyle( + fontSize: DesignTokens.fontSm, + color: isDark + ? DarkDesignTokens.text2 + : DesignTokens.text2, + ), + ), + ], + ), + ), + + const SizedBox(height: DesignTokens.space3), + + // 附加信息 + _buildInfoRow( + isDark, + '📊', + '约等于', + '≈ ${(absDiff / 7).toStringAsFixed(1)} 周 · ≈ ${(absDiff / 30).toStringAsFixed(1)} 月 · ≈ ${(absDiff / 365).toStringAsFixed(2)} 年', + ), + const SizedBox(height: DesignTokens.space2), + _buildInfoRow( + isDark, + '🌍', + '包含闰年', + _getInvolvedLeapYears(), + ), + ], + ), + ); + } + + // ─── 辅助组件 ─── + + /// 工作日+休息日分解展示 + Widget _buildWorkdayBreakdown( + bool isDark, + DateTime start, + DateTime end, + ) { + final counts = _countWorkdaysAndWeekends(start, end); + final workdays = counts[0]; + final weekends = counts[1]; + return Row( + mainAxisAlignment: MainAxisAlignment.center, + children: [ + Container( + padding: const EdgeInsets.symmetric(horizontal: 10, vertical: 4), + decoration: BoxDecoration( + color: DesignTokens.blue.withValues(alpha: 0.08), + borderRadius: DesignTokens.borderRadiusFull, + ), + child: Row( + mainAxisSize: MainAxisSize.min, + children: [ + Text('💼', style: const TextStyle(fontSize: 12)), + const SizedBox(width: 3), + Text( + '$workdays工作日', + style: TextStyle( + fontSize: DesignTokens.fontXs, + fontWeight: FontWeight.w600, + color: DesignTokens.blue, + ), + ), + ], + ), + ), + const SizedBox(width: DesignTokens.space2), + Container( + padding: const EdgeInsets.symmetric(horizontal: 10, vertical: 4), + decoration: BoxDecoration( + color: DesignTokens.orange.withValues(alpha: 0.08), + borderRadius: DesignTokens.borderRadiusFull, + ), + child: Row( + mainAxisSize: MainAxisSize.min, + children: [ + Text('🏖️', style: const TextStyle(fontSize: 12)), + const SizedBox(width: 3), + Text( + '$weekends休息日', + style: TextStyle( + fontSize: DesignTokens.fontXs, + fontWeight: FontWeight.w600, + color: DesignTokens.orange, + ), + ), + ], + ), + ), + ], + ); + } + + Widget _buildTimeUnit(bool isDark, String value, String unit) { + return Column( + children: [ + Text( + value, + style: TextStyle( + fontSize: DesignTokens.fontXxl, + fontWeight: FontWeight.w700, + color: DesignTokens.dynamicPrimary, + ), + ), + Text( + unit, + style: TextStyle( + fontSize: DesignTokens.fontXs, + color: isDark ? DarkDesignTokens.text3 : DesignTokens.text3, + ), + ), + ], + ); + } + + Widget _buildInfoRow( + bool isDark, + String emoji, + String label, + String value, + ) { + return Row( + children: [ + Text(emoji, style: const TextStyle(fontSize: 14)), + const SizedBox(width: 6), + Text( + label, + style: TextStyle( + fontSize: DesignTokens.fontSm, + color: isDark ? DarkDesignTokens.text3 : DesignTokens.text3, + ), + ), + const Spacer(), + Flexible( + child: Text( + value, + style: TextStyle( + fontSize: DesignTokens.fontSm, + fontWeight: FontWeight.w500, + color: isDark ? DarkDesignTokens.text1 : DesignTokens.text1, + ), + textAlign: TextAlign.end, + ), + ), + ], + ); + } + + /// 获取两个日期之间经过的闰年 + String _getInvolvedLeapYears() { + final earlier = _dateA.isBefore(_dateB) ? _dateA : _dateB; + final later = _dateA.isBefore(_dateB) ? _dateB : _dateA; + final leapYears = []; + for (var y = earlier.year; y <= later.year; y++) { + if (_isLeapYear(y)) leapYears.add(y); + } + if (leapYears.isEmpty) return '无'; + if (leapYears.length <= 3) return leapYears.join('、'); + return '${leapYears.first}、…、${leapYears.last}(共${leapYears.length}个)'; + } +} diff --git a/lib/src/pages/tools/cooking/food_copy_generator_page.dart b/lib/src/pages/tools/cooking/food_copy_generator_page.dart new file mode 100644 index 0000000..1e53d91 --- /dev/null +++ b/lib/src/pages/tools/cooking/food_copy_generator_page.dart @@ -0,0 +1,923 @@ +/* + * 文件: food_copy_generator_page.dart + * 名称: 吃货文案生成器页面 + * 作用: 随机生成疯狂星期四风格的吃货文案,支持品牌、星期、分类选择 + * 创建: 2026-04-15 初始创建 + * 更新: 2026-04-15 支持分类筛选,post.json v2.0 分类结构 + */ + +import 'dart:convert'; +import 'dart:math'; +import 'package:flutter/cupertino.dart'; +import 'package:flutter/services.dart'; +import 'package:mom_kitchen/src/config/design_tokens.dart'; + +/// 食物品牌定义 +class _FoodBrand { + final String name; + final String emoji; + + const _FoodBrand(this.name, this.emoji); +} + +/// 星期定义 +class _WeekDay { + final String label; + final int value; + + const _WeekDay(this.label, this.value); +} + +class FoodCopyGeneratorPage extends StatefulWidget { + const FoodCopyGeneratorPage({super.key}); + + @override + State createState() => _FoodCopyGeneratorPageState(); +} + +class _FoodCopyGeneratorPageState extends State { + // ─── 品牌列表 ─── + static const List<_FoodBrand> _brands = [ + // 炸鸡汉堡 + _FoodBrand('肯德基', '🍗'), + _FoodBrand('麦当劳', '🍔'), + _FoodBrand('华莱士', '🌮'), + _FoodBrand('塔斯汀', '🥖'), + _FoodBrand('汉堡王', '👑'), + _FoodBrand('德克士', '🍗'), + _FoodBrand('正新鸡排', '🐔'), + _FoodBrand('派派思', '🦅'), + // 披萨意面 + _FoodBrand('必胜客', '🍕'), + _FoodBrand('达美乐', '🧀'), + _FoodBrand('棒约翰', '🍕'), + _FoodBrand('萨莉亚', '🍝'), + // 火锅麻辣烫 + _FoodBrand('海底捞', '🍲'), + _FoodBrand('呷哺呷哺', '🫕'), + _FoodBrand('杨国福', '🌶️'), + _FoodBrand('张亮麻辣烫', '🔥'), + // 茶饮咖啡 + _FoodBrand('蜜雪冰城', '🍦'), + _FoodBrand('瑞幸咖啡', '☕'), + _FoodBrand('喜茶', '🍵'), + _FoodBrand('星巴克', '🟢'), + _FoodBrand('霸王茶姬', '🫖'), + _FoodBrand('奈雪的茶', '🍑'), + _FoodBrand('茶百道', '🧋'), + _FoodBrand('古茗', '🍃'), + _FoodBrand('沪上阿姨', '🧋'), + _FoodBrand('1点点', '🧋'), + _FoodBrand('CoCo都可', '🥤'), + _FoodBrand('益禾堂', '🍹'), + _FoodBrand('书亦烧仙草', '🫗'), + // 快餐面食 + _FoodBrand('沙县小吃', '🥟'), + _FoodBrand('兰州拉面', '🍜'), + _FoodBrand('味千拉面', '🍜'), + _FoodBrand('真功夫', '🥢'), + _FoodBrand('永和大王', '🍳'), + _FoodBrand('乡村基', '🍚'), + // 三明治 + _FoodBrand('赛百味', '🥪'), + ]; + + // ─── 星期列表 ─── + static const List<_WeekDay> _weekDays = [ + _WeekDay('周一', 1), + _WeekDay('周二', 2), + _WeekDay('周三', 3), + _WeekDay('周四', 4), + _WeekDay('周五', 5), + _WeekDay('周六', 6), + _WeekDay('周日', 7), + ]; + + // ─── 分类列表 ─── + static const List> _categories = [ + {'id': 'all', 'name': '全部', 'emoji': '🎲'}, + {'id': '故事篇', 'name': '故事篇', 'emoji': '📖'}, + {'id': '穿越篇', 'name': '穿越篇', 'emoji': '⚔️'}, + {'id': '感情篇', 'name': '感情篇', 'emoji': '💕'}, + {'id': '整活篇', 'name': '整活篇', 'emoji': '🤡'}, + {'id': '文艺篇', 'name': '文艺篇', 'emoji': '📝'}, + {'id': '编程篇', 'name': '编程篇', 'emoji': '💻'}, + {'id': '日文篇', 'name': '日文篇', 'emoji': '🇯🇵'}, + {'id': '离谱篇', 'name': '离谱篇', 'emoji': '🤯'}, + ]; + + // ─── 状态 ─── + int _selectedBrandIndex = 0; + int _selectedWeekIndex = 3; // 默认周四 + int _selectedCategoryIndex = 0; // 默认全部 + String? _generatedText; + bool _isLoading = true; + Map> _categorizedPosts = {}; + final Random _random = Random(); + + // ─── 当前可用文案 ─── + List get _currentPosts { + final cat = _categories[_selectedCategoryIndex]; + if (cat['id'] == 'all') { + // 全部 + final all = []; + for (final list in _categorizedPosts.values) { + all.addAll(list); + } + return all; + } + return _categorizedPosts[cat['id']] ?? []; + } + + @override + void initState() { + super.initState(); + _loadPosts(); + } + + // ─── 加载文案数据 ─── + Future _loadPosts() async { + try { + final jsonStr = await rootBundle.loadString( + 'assets/json/post.json', + ); + final data = json.decode(jsonStr) as Map; + final categories = data['categories'] as Map; + + final Map> result = {}; + categories.forEach((key, value) { + result[key] = (value as List) + .map((e) => e.toString()) + .toList(); + }); + + setState(() { + _categorizedPosts = result; + _isLoading = false; + }); + } catch (e) { + setState(() { + _isLoading = false; + }); + } + } + + // ─── 生成文案 ─── + void _generate() { + final posts = _currentPosts; + if (posts.isEmpty) return; + + final brand = _brands[_selectedBrandIndex]; + final weekDay = _weekDays[_selectedWeekIndex]; + + // 随机选一条 + final randomIndex = _random.nextInt(posts.length); + var text = posts[randomIndex]; + + // ─── 第一步:把所有品牌关键词统一替换为占位符 ─── + // 英文品牌名(长词优先) + text = text.replaceAll('Kentucky Fried Chicken', '🎈BRAND🎈'); + text = text.replaceAll('KFC', '🎈BRAND🎈'); + text = text.replaceAll('kfc', '🎈BRAND🎈'); + text = text.replaceAll('Burger King', '🎈BRAND🎈'); + text = text.replaceAll('Dicos', '🎈BRAND🎈'); + text = text.replaceAll('Pizza Hut', '🎈BRAND🎈'); + text = text.replaceAll("Domino's", '🎈BRAND🎈'); + text = text.replaceAll('Saizeriya', '🎈BRAND🎈'); + text = text.replaceAll('Starbucks', '🎈BRAND🎈'); + text = text.replaceAll('Luckin', '🎈BRAND🎈'); + text = text.replaceAll('Subway', '🎈BRAND🎈'); + text = text.replaceAll("Papa John's", '🎈BRAND🎈'); + text = text.replaceAll('Haidilao', '🎈BRAND🎈'); + text = text.replaceAll('Xiabuxiabu', '🎈BRAND🎈'); + text = text.replaceAll('AJisen', '🎈BRAND🎈'); + text = text.replaceAll('Coco', '🎈BRAND🎈'); + text = text.replaceAll('Heytea', '🎈BRAND🎈'); + text = text.replaceAll('Nayuki', '🎈BRAND🎈'); + text = text.replaceAll('CHAGEE', '🎈BRAND🎈'); + text = text.replaceAll('Subway', '🎈BRAND🎈'); + + // 中文品牌名(长词优先替换,避免子串误替换) + text = text.replaceAll('疯狂星期寺', '🎈BRAND🎈疯狂星期🎈'); + text = text.replaceAll('蜜雪冰城', '🎈BRAND🎈'); + text = text.replaceAll('瑞幸咖啡', '🎈BRAND🎈'); + text = text.replaceAll('霸王茶姬', '🎈BRAND🎈'); + text = text.replaceAll('奈雪的茶', '🎈BRAND🎈'); + text = text.replaceAll('呷哺呷哺', '🎈BRAND🎈'); + text = text.replaceAll('张亮麻辣烫', '🎈BRAND🎈'); + text = text.replaceAll('书亦烧仙草', '🎈BRAND🎈'); + text = text.replaceAll('沪上阿姨', '🎈BRAND🎈'); + text = text.replaceAll('杨国福', '🎈BRAND🎈'); + text = text.replaceAll('正新鸡排', '🎈BRAND🎈'); + text = text.replaceAll('兰州拉面', '🎈BRAND🎈'); + text = text.replaceAll('味千拉面', '🎈BRAND🎈'); + text = text.replaceAll('沙县小吃', '🎈BRAND🎈'); + text = text.replaceAll('乡村基', '🎈BRAND🎈'); + text = text.replaceAll('永和大王', '🎈BRAND🎈'); + text = text.replaceAll('真功夫', '🎈BRAND🎈'); + text = text.replaceAll('赛百味', '🎈BRAND🎈'); + text = text.replaceAll('棒约翰', '🎈BRAND🎈'); + text = text.replaceAll('茶百道', '🎈BRAND🎈'); + text = text.replaceAll('益禾堂', '🎈BRAND🎈'); + text = text.replaceAll('肯德基', '🎈BRAND🎈'); + text = text.replaceAll('麦当劳', '🎈BRAND🎈'); + text = text.replaceAll('华莱士', '🎈BRAND🎈'); + text = text.replaceAll('塔斯汀', '🎈BRAND🎈'); + text = text.replaceAll('汉堡王', '🎈BRAND🎈'); + text = text.replaceAll('德克士', '🎈BRAND🎈'); + text = text.replaceAll('必胜客', '🎈BRAND🎈'); + text = text.replaceAll('达美乐', '🎈BRAND🎈'); + text = text.replaceAll('萨莉亚', '🎈BRAND🎈'); + text = text.replaceAll('海底捞', '🎈BRAND🎈'); + text = text.replaceAll('瑞幸', '🎈BRAND🎈'); + text = text.replaceAll('喜茶', '🎈BRAND🎈'); + text = text.replaceAll('星巴克', '🎈BRAND🎈'); + text = text.replaceAll('古茗', '🎈BRAND🎈'); + text = text.replaceAll('派派思', '🎈BRAND🎈'); + // 别名/昵称 + text = text.replaceAll('雪王', '🎈BRAND🎈'); + text = text.replaceAll('开封菜', '🎈BRAND🎈'); + text = text.replaceAll('M记', '🎈BRAND🎈'); + text = text.replaceAll('1点点', '🎈BRAND🎈'); + text = text.replaceAll('CoCo都可', '🎈BRAND🎈'); + text = text.replaceAll('ケンタッキー', '🎈BRAND🎈'); + + // ─── 第二步:把星期关键词替换为占位符 ─── + text = text.replaceAllMapped( + RegExp(r'疯狂星期([一二三四五六日天])'), + (m) => '🎈WEEK🎈', + ); + text = text.replaceAllMapped( + RegExp(r'星期([一二三四五六日天])'), + (m) => '🎈WEEK🎈', + ); + text = text.replaceAllMapped( + RegExp(r'周([一二三四五六日天])'), + (m) => '🎈WEEK🎈', + ); + text = text.replaceAll('fucking crazy Thursday', '🎈WEEK🎈'); + text = text.replaceAll('Fucking Crazy Thursday', '🎈WEEK🎈'); + text = text.replaceAll('Crazy Thursday', '🎈WEEK🎈'); + text = text.replaceAll('crazy Thursday', '🎈WEEK🎈'); + text = text.replaceAll('Thursday', '🎈WEEK🎈'); + text = text.replaceAll('木曜日', '🎈WEEK🎈'); + text = text.replaceAll('狂乱木曜日', '🎈WEEK🎈'); + text = text.replaceAll('狂気の木曜日', '🎈WEEK🎈'); + text = text.replaceAll('クレイジー木曜日', '🎈WEEK🎈'); + + // ─── 第三步:回填选中品牌名和星期 ─── + final weekLabel = weekDay.label.substring(1); // "一"~"日" + text = text.replaceAll('🎈BRAND🎈', brand.name); + text = text.replaceAll('🎈WEEK🎈', '星期$weekLabel'); + + setState(() { + _generatedText = text; + }); + } + + // ─── 复制到剪贴板 ─── + void _copyToClipboard() { + if (_generatedText == null) return; + Clipboard.setData(ClipboardData(text: _generatedText!)); + HapticFeedback.mediumImpact(); + _showCopiedToast(); + } + + // ─── 复制成功提示 ─── + void _showCopiedToast() { + showCupertinoDialog( + context: context, + barrierDismissible: true, + builder: (ctx) => CupertinoAlertDialog( + content: const Row( + mainAxisAlignment: MainAxisAlignment.center, + children: [ + Text('✅ ', style: TextStyle(fontSize: 18)), + Text('已复制到剪贴板'), + ], + ), + actions: [ + CupertinoDialogAction( + child: const Text('好'), + onPressed: () => Navigator.of(ctx).pop(), + ), + ], + ), + ); + } + + // ─── 构建分类选择器 ─── + Widget _buildCategorySelector(bool isDark) { + final bgColor = isDark ? DarkDesignTokens.card : DesignTokens.card; + + return Container( + padding: const EdgeInsets.all(DesignTokens.space4), + decoration: BoxDecoration( + color: bgColor, + borderRadius: DesignTokens.borderRadiusLg, + border: Border.all( + color: isDark + ? DarkDesignTokens.glassBorder + : DesignTokens.glassBorder, + ), + ), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Row( + children: [ + const Text('🏷️', style: TextStyle(fontSize: 18)), + const SizedBox(width: DesignTokens.space2), + Text( + '文案分类', + style: TextStyle( + fontSize: DesignTokens.fontMd, + fontWeight: FontWeight.w600, + color: isDark ? DarkDesignTokens.text1 : DesignTokens.text1, + ), + ), + const Spacer(), + Text( + '${_currentPosts.length}条', + style: TextStyle( + fontSize: DesignTokens.fontXs, + color: isDark ? DarkDesignTokens.text3 : DesignTokens.text3, + ), + ), + ], + ), + const SizedBox(height: DesignTokens.space3), + // 分类选择 - 横向滚动 + SizedBox( + height: 36, + child: ListView.separated( + scrollDirection: Axis.horizontal, + itemCount: _categories.length, + separatorBuilder: (_, _) => + const SizedBox(width: DesignTokens.space2), + itemBuilder: (_, index) { + final cat = _categories[index]; + final isSelected = index == _selectedCategoryIndex; + return GestureDetector( + onTap: () => + setState(() => _selectedCategoryIndex = index), + child: AnimatedContainer( + duration: DesignTokens.durationFast, + padding: const EdgeInsets.symmetric( + horizontal: DesignTokens.space3, + ), + decoration: BoxDecoration( + color: isSelected + ? DesignTokens.dynamicPrimary + .withValues(alpha: 0.12) + : (isDark + ? DarkDesignTokens.background + : DesignTokens.background), + borderRadius: DesignTokens.borderRadiusFull, + border: Border.all( + color: isSelected + ? DesignTokens.dynamicPrimary + : (isDark + ? DarkDesignTokens.glassBorder + : DesignTokens.glassBorder), + width: isSelected ? 1.5 : 0.5, + ), + ), + child: Row( + mainAxisSize: MainAxisSize.min, + children: [ + Text(cat['emoji']!, style: const TextStyle(fontSize: 13)), + const SizedBox(width: 3), + Text( + cat['name']!, + style: TextStyle( + fontSize: DesignTokens.fontSm, + fontWeight: isSelected + ? FontWeight.w600 + : FontWeight.normal, + color: isSelected + ? DesignTokens.dynamicPrimary + : (isDark + ? DarkDesignTokens.text2 + : DesignTokens.text2), + ), + ), + ], + ), + ), + ); + }, + ), + ), + ], + ), + ); + } + + // ─── 构建品牌选择器 ─── + Widget _buildBrandSelector(bool isDark) { + final bgColor = isDark ? DarkDesignTokens.card : DesignTokens.card; + + return Container( + padding: const EdgeInsets.all(DesignTokens.space4), + decoration: BoxDecoration( + color: bgColor, + borderRadius: DesignTokens.borderRadiusLg, + border: Border.all( + color: isDark + ? DarkDesignTokens.glassBorder + : DesignTokens.glassBorder, + ), + ), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Row( + children: [ + const Text('🍗', style: TextStyle(fontSize: 18)), + const SizedBox(width: DesignTokens.space2), + Text( + '选择品牌', + style: TextStyle( + fontSize: DesignTokens.fontMd, + fontWeight: FontWeight.w600, + color: isDark ? DarkDesignTokens.text1 : DesignTokens.text1, + ), + ), + ], + ), + const SizedBox(height: DesignTokens.space3), + Wrap( + spacing: DesignTokens.space2, + runSpacing: DesignTokens.space2, + children: List.generate(_brands.length, (index) { + final brand = _brands[index]; + final isSelected = index == _selectedBrandIndex; + return GestureDetector( + onTap: () => setState(() => _selectedBrandIndex = index), + child: AnimatedContainer( + duration: DesignTokens.durationFast, + padding: const EdgeInsets.symmetric( + horizontal: DesignTokens.space3, + vertical: DesignTokens.space2, + ), + decoration: BoxDecoration( + color: isSelected + ? DesignTokens.dynamicPrimary.withValues(alpha: 0.12) + : (isDark + ? DarkDesignTokens.background + : DesignTokens.background), + borderRadius: DesignTokens.borderRadiusFull, + border: Border.all( + color: isSelected + ? DesignTokens.dynamicPrimary + : (isDark + ? DarkDesignTokens.glassBorder + : DesignTokens.glassBorder), + width: isSelected ? 1.5 : 0.5, + ), + ), + child: Row( + mainAxisSize: MainAxisSize.min, + children: [ + Text(brand.emoji, style: const TextStyle(fontSize: 14)), + const SizedBox(width: 4), + Text( + brand.name, + style: TextStyle( + fontSize: DesignTokens.fontSm, + fontWeight: + isSelected ? FontWeight.w600 : FontWeight.normal, + color: isSelected + ? DesignTokens.dynamicPrimary + : (isDark + ? DarkDesignTokens.text2 + : DesignTokens.text2), + ), + ), + ], + ), + ), + ); + }), + ), + ], + ), + ); + } + + // ─── 构建星期选择器 ─── + Widget _buildWeekSelector(bool isDark) { + final bgColor = isDark ? DarkDesignTokens.card : DesignTokens.card; + + return Container( + padding: const EdgeInsets.all(DesignTokens.space4), + decoration: BoxDecoration( + color: bgColor, + borderRadius: DesignTokens.borderRadiusLg, + border: Border.all( + color: isDark + ? DarkDesignTokens.glassBorder + : DesignTokens.glassBorder, + ), + ), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Row( + children: [ + const Text('📅', style: TextStyle(fontSize: 18)), + const SizedBox(width: DesignTokens.space2), + Text( + '选择星期', + style: TextStyle( + fontSize: DesignTokens.fontMd, + fontWeight: FontWeight.w600, + color: isDark ? DarkDesignTokens.text1 : DesignTokens.text1, + ), + ), + ], + ), + const SizedBox(height: DesignTokens.space3), + Row( + children: List.generate(_weekDays.length, (index) { + final day = _weekDays[index]; + final isSelected = index == _selectedWeekIndex; + return Expanded( + child: GestureDetector( + onTap: () => setState(() => _selectedWeekIndex = index), + child: AnimatedContainer( + duration: DesignTokens.durationFast, + margin: const EdgeInsets.symmetric(horizontal: 2), + padding: const EdgeInsets.symmetric( + vertical: DesignTokens.space2, + ), + decoration: BoxDecoration( + color: isSelected + ? DesignTokens.dynamicPrimary + .withValues(alpha: 0.12) + : const Color(0x00000000), + borderRadius: DesignTokens.borderRadiusMd, + border: Border.all( + color: isSelected + ? DesignTokens.dynamicPrimary + : (isDark + ? DarkDesignTokens.glassBorder + : DesignTokens.glassBorder), + width: isSelected ? 1.5 : 0.5, + ), + ), + child: Center( + child: Text( + day.label, + style: TextStyle( + fontSize: DesignTokens.fontSm, + fontWeight: isSelected + ? FontWeight.w700 + : FontWeight.normal, + color: isSelected + ? DesignTokens.dynamicPrimary + : (isDark + ? DarkDesignTokens.text2 + : DesignTokens.text2), + ), + ), + ), + ), + ), + ); + }), + ), + ], + ), + ); + } + + // ─── 构建生成按钮 ─── + Widget _buildGenerateButton() { + return SizedBox( + width: double.infinity, + height: 50, + child: CupertinoButton( + borderRadius: DesignTokens.borderRadiusFull, + color: DesignTokens.dynamicPrimary, + onPressed: _currentPosts.isEmpty ? null : _generate, + child: Row( + mainAxisAlignment: MainAxisAlignment.center, + children: [ + const Text('🎲', style: TextStyle(fontSize: 18)), + const SizedBox(width: DesignTokens.space2), + Text( + '随机生成文案', + style: TextStyle( + fontSize: DesignTokens.fontLg, + fontWeight: FontWeight.w600, + color: CupertinoColors.white, + ), + ), + ], + ), + ), + ); + } + + // ─── 构建文案结果卡片 ─── + Widget _buildResultCard(bool isDark) { + if (_generatedText == null) { + return _buildEmptyState(isDark); + } + + final bgColor = isDark ? DarkDesignTokens.card : DesignTokens.card; + + return GestureDetector( + onLongPress: _copyToClipboard, + child: AnimatedContainer( + duration: DesignTokens.durationNormal, + padding: const EdgeInsets.all(DesignTokens.space5), + decoration: BoxDecoration( + color: bgColor, + borderRadius: DesignTokens.borderRadiusLg, + border: Border.all( + color: isDark + ? DarkDesignTokens.glassBorder + : DesignTokens.glassBorder, + ), + boxShadow: [ + BoxShadow( + color: DesignTokens.dynamicPrimary.withValues(alpha: 0.06), + blurRadius: 20, + offset: const Offset(0, 4), + ), + ], + ), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + // 头部标签 + Row( + children: [ + Container( + padding: const EdgeInsets.symmetric( + horizontal: DesignTokens.space2, + vertical: 2, + ), + decoration: BoxDecoration( + color: DesignTokens.dynamicPrimary.withValues(alpha: 0.1), + borderRadius: DesignTokens.borderRadiusFull, + ), + child: Row( + mainAxisSize: MainAxisSize.min, + children: [ + Text( + _brands[_selectedBrandIndex].emoji, + style: const TextStyle(fontSize: 12), + ), + const SizedBox(width: 3), + Text( + _brands[_selectedBrandIndex].name, + style: TextStyle( + fontSize: DesignTokens.fontXs, + fontWeight: FontWeight.w600, + color: DesignTokens.dynamicPrimary, + ), + ), + ], + ), + ), + const SizedBox(width: DesignTokens.space2), + Container( + padding: const EdgeInsets.symmetric( + horizontal: DesignTokens.space2, + vertical: 2, + ), + decoration: BoxDecoration( + color: DesignTokens.orange.withValues(alpha: 0.1), + borderRadius: DesignTokens.borderRadiusFull, + ), + child: Text( + _weekDays[_selectedWeekIndex].label, + style: TextStyle( + fontSize: DesignTokens.fontXs, + fontWeight: FontWeight.w600, + color: DesignTokens.orange, + ), + ), + ), + const SizedBox(width: DesignTokens.space2), + // 分类标签 + Container( + padding: const EdgeInsets.symmetric( + horizontal: DesignTokens.space2, + vertical: 2, + ), + decoration: BoxDecoration( + color: DesignTokens.purple.withValues(alpha: 0.1), + borderRadius: DesignTokens.borderRadiusFull, + ), + child: Text( + '${_categories[_selectedCategoryIndex]['emoji']} ${_categories[_selectedCategoryIndex]['name']}', + style: TextStyle( + fontSize: DesignTokens.fontXs, + fontWeight: FontWeight.w600, + color: DesignTokens.purple, + ), + ), + ), + const Spacer(), + // 复制按钮 + GestureDetector( + onTap: _copyToClipboard, + child: Container( + padding: const EdgeInsets.symmetric( + horizontal: DesignTokens.space3, + vertical: DesignTokens.space1, + ), + decoration: BoxDecoration( + color: + DesignTokens.dynamicPrimary.withValues(alpha: 0.08), + borderRadius: DesignTokens.borderRadiusFull, + ), + child: Row( + mainAxisSize: MainAxisSize.min, + children: [ + Icon( + CupertinoIcons.doc_on_clipboard, + size: 14, + color: DesignTokens.dynamicPrimary, + ), + const SizedBox(width: 3), + Text( + '复制', + style: TextStyle( + fontSize: DesignTokens.fontXs, + fontWeight: FontWeight.w600, + color: DesignTokens.dynamicPrimary, + ), + ), + ], + ), + ), + ), + ], + ), + const SizedBox(height: DesignTokens.space4), + + // 文案内容 + Text( + _generatedText!, + style: TextStyle( + fontSize: DesignTokens.fontMd, + height: 1.7, + color: isDark ? DarkDesignTokens.text1 : DesignTokens.text1, + ), + ), + + const SizedBox(height: DesignTokens.space4), + + // 底部提示 + Row( + mainAxisAlignment: MainAxisAlignment.center, + children: [ + Icon( + CupertinoIcons.hand_draw, + size: 14, + color: isDark + ? DarkDesignTokens.text3 + : DesignTokens.text3, + ), + const SizedBox(width: 4), + Text( + '长按文案可复制', + style: TextStyle( + fontSize: DesignTokens.fontXs, + color: isDark + ? DarkDesignTokens.text3 + : DesignTokens.text3, + ), + ), + ], + ), + ], + ), + ), + ); + } + + // ─── 空状态 ─── + Widget _buildEmptyState(bool isDark) { + final bgColor = isDark ? DarkDesignTokens.card : DesignTokens.card; + + return Container( + padding: const EdgeInsets.all(DesignTokens.space6), + decoration: BoxDecoration( + color: bgColor, + borderRadius: DesignTokens.borderRadiusLg, + border: Border.all( + color: isDark + ? DarkDesignTokens.glassBorder + : DesignTokens.glassBorder, + ), + ), + child: Column( + children: [ + const Text('🎲', style: TextStyle(fontSize: 48)), + const SizedBox(height: DesignTokens.space4), + Text( + '点击上方按钮生成文案', + style: TextStyle( + fontSize: DesignTokens.fontMd, + color: isDark ? DarkDesignTokens.text2 : DesignTokens.text2, + ), + ), + const SizedBox(height: DesignTokens.space2), + Text( + '选择分类、品牌和星期,生成你的专属吃货文案', + style: TextStyle( + fontSize: DesignTokens.fontSm, + color: isDark ? DarkDesignTokens.text3 : DesignTokens.text3, + ), + ), + ], + ), + ); + } + + // ─── 构建加载中 ─── + Widget _buildLoading(bool isDark) { + return Center( + child: Column( + mainAxisAlignment: MainAxisAlignment.center, + children: [ + const CupertinoActivityIndicator(radius: 16), + const SizedBox(height: DesignTokens.space4), + Text( + '加载文案库中...', + style: TextStyle( + fontSize: DesignTokens.fontMd, + color: isDark ? DarkDesignTokens.text2 : DesignTokens.text2, + ), + ), + ], + ), + ); + } + + @override + Widget build(BuildContext context) { + final isDark = CupertinoTheme.brightnessOf(context) == Brightness.dark; + final bgColor = + isDark ? DarkDesignTokens.background : DesignTokens.background; + + return CupertinoPageScaffold( + backgroundColor: bgColor, + navigationBar: CupertinoNavigationBar( + middle: const Text('吃货文案生成器'), + border: null, + backgroundColor: bgColor.withValues(alpha: 0.85), + ), + child: SafeArea( + child: _isLoading + ? _buildLoading(isDark) + : ListView( + padding: const EdgeInsets.all(DesignTokens.space4), + children: [ + // 分类选择 + _buildCategorySelector(isDark), + const SizedBox(height: DesignTokens.space4), + + // 品牌选择 + _buildBrandSelector(isDark), + const SizedBox(height: DesignTokens.space4), + + // 星期选择 + _buildWeekSelector(isDark), + const SizedBox(height: DesignTokens.space5), + + // 生成按钮 + _buildGenerateButton(), + const SizedBox(height: DesignTokens.space5), + + // 结果卡片 + _buildResultCard(isDark), + + const SizedBox(height: DesignTokens.space6), + + // 底部统计 + if (_categorizedPosts.isNotEmpty) + Center( + child: Text( + '📦 文案库共 ${_currentPosts.length} 条', + style: TextStyle( + fontSize: DesignTokens.fontXs, + color: isDark + ? DarkDesignTokens.text3 + : DesignTokens.text3, + ), + ), + ), + ], + ), + ), + ); + } +} diff --git a/lib/src/pages/tools/cooking/order_assistant_page.dart b/lib/src/pages/tools/cooking/order_assistant_page.dart new file mode 100644 index 0000000..d48e20e --- /dev/null +++ b/lib/src/pages/tools/cooking/order_assistant_page.dart @@ -0,0 +1,1611 @@ +/* + * 文件: order_assistant_page.dart + * 名称: 点餐助手主页面 + * 作用: 点餐/推单主界面,支持菜品管理、账单生成、二维码分享、文本/图片分享 + * 创建时间: 2026-04-17 + * 更新时间: 2026-04-17 底部栏增加生成文本/图片分享按钮,人数选择器,关闭订单 + */ + +import 'dart:ui'; +import 'dart:io'; +import 'package:flutter/cupertino.dart'; +import 'package:flutter/rendering.dart'; +import 'package:flutter/services.dart'; +import 'package:get/get.dart'; +import 'package:share_plus/share_plus.dart'; +import 'package:path_provider/path_provider.dart'; +import 'package:mom_kitchen/src/config/design_tokens.dart'; +import 'package:mom_kitchen/src/controllers/tools/order_assistant_controller.dart'; +import 'package:mom_kitchen/src/models/tools/order_model.dart'; +import 'package:mom_kitchen/src/pages/tools/cooking/widgets/order_item_card.dart'; +import 'package:mom_kitchen/src/pages/tools/cooking/widgets/add_item_sheet.dart'; +import 'package:mom_kitchen/src/pages/tools/cooking/widgets/qr_barcode_dialog.dart'; + +class OrderAssistantPage extends StatefulWidget { + const OrderAssistantPage({super.key}); + + @override + State createState() => _OrderAssistantPageState(); +} + +class _OrderAssistantPageState extends State { + final OrderAssistantController _ctrl = Get.put(OrderAssistantController()); + final _noteCtrl = TextEditingController(); + final _tableNoCtrl = TextEditingController(); + final _peopleCtrl = TextEditingController(); + + static const _peoplePresets = [1, 2, 3, 4, 5, 6, 8, 10]; + + @override + void initState() { + super.initState(); + _ctrl.createNewOrder(); + } + + @override + void dispose() { + _noteCtrl.dispose(); + _tableNoCtrl.dispose(); + _peopleCtrl.dispose(); + super.dispose(); + } + + @override + Widget build(BuildContext context) { + final isDark = CupertinoTheme.brightnessOf(context) == Brightness.dark; + final bottomPadding = MediaQuery.of(context).padding.bottom; + + return CupertinoPageScaffold( + navigationBar: _buildNavigationBar(isDark), + child: SafeArea( + bottom: false, + child: Column( + children: [ + Expanded( + child: Obx(() { + final order = _ctrl.currentOrder; + if (order == null) { + return _buildEmptyState(isDark); + } + return _buildOrderContent(order, isDark); + }), + ), + _buildBottomBar(isDark, bottomPadding), + ], + ), + ), + ); + } + + CupertinoNavigationBar _buildNavigationBar(bool isDark) { + return CupertinoNavigationBar( + middle: Obx( + () => Text( + '${_ctrl.orderType.icon} ${_ctrl.orderType.label}', + style: const TextStyle(fontSize: 17), + ), + ), + leading: GestureDetector( + onTap: () => Navigator.pop(context), + child: Icon(CupertinoIcons.back, color: DesignTokens.dynamicPrimary), + ), + trailing: Row( + mainAxisSize: MainAxisSize.min, + children: [ + GestureDetector( + onTap: () => _showHistorySheet(isDark), + child: Icon( + CupertinoIcons.clock, + color: DesignTokens.dynamicPrimary, + ), + ), + const SizedBox(width: 16), + GestureDetector( + onTap: () => _showCleanupSheet(isDark), + child: Icon( + CupertinoIcons.trash, + color: DesignTokens.dynamicPrimary, + ), + ), + const SizedBox(width: 16), + GestureDetector( + onTap: _toggleOrderType, + child: Icon( + CupertinoIcons.arrow_2_squarepath, + color: DesignTokens.dynamicPrimary, + ), + ), + ], + ), + ); + } + + Widget _buildEmptyState(bool isDark) { + return Center( + child: Column( + mainAxisSize: MainAxisSize.min, + children: [ + const Text('🍽️', style: TextStyle(fontSize: 56)), + const SizedBox(height: 16), + Text( + '点击下方 + 添加菜品', + style: TextStyle( + fontSize: 16, + color: isDark ? DarkDesignTokens.text3 : DesignTokens.text3, + ), + ), + ], + ), + ); + } + + Widget _buildOrderContent(Order order, bool isDark) { + return ListView( + padding: const EdgeInsets.symmetric(horizontal: DesignTokens.space3), + children: [ + _buildOrderHeader(order, isDark), + const SizedBox(height: DesignTokens.space2), + _buildAddItemButton(isDark), + const SizedBox(height: DesignTokens.space3), + ...order.items.asMap().entries.map( + (e) => Padding( + padding: const EdgeInsets.only(bottom: DesignTokens.space2), + child: OrderItemCard( + item: e.value, + index: e.key, + onDelete: () => _ctrl.removeItem(e.value.id), + onQuantityChanged: (q) => _ctrl.updateItemQuantity(e.value.id, q), + onPriceChanged: (p) => _ctrl.updateItemPrice(e.value.id, p), + ), + ), + ), + if (order.items.isNotEmpty) ...[ + const SizedBox(height: DesignTokens.space3), + _buildTotalSection(order, isDark), + ], + const SizedBox(height: DesignTokens.space3), + _buildNoteSection(order, isDark), + const SizedBox(height: DesignTokens.space3), + _buildTableNoSection(order, isDark), + const SizedBox(height: DesignTokens.space3), + _buildPeopleCountSection(order, isDark), + const SizedBox(height: DesignTokens.space3), + _buildShareButtons(order, isDark), + const SizedBox(height: 100), + ], + ); + } + + Widget _buildAddItemButton(bool isDark) { + return SizedBox( + width: double.infinity, + child: CupertinoButton( + color: DesignTokens.dynamicPrimary.withValues(alpha: 0.1), + borderRadius: DesignTokens.borderRadiusMd, + padding: const EdgeInsets.symmetric(vertical: DesignTokens.space2), + onPressed: () => showAddItemSheet( + context, + onItemAdded: _ctrl.addItem, + isMerchantMode: _ctrl.orderType == OrderType.merchantPush, + ), + child: Row( + mainAxisAlignment: MainAxisAlignment.center, + children: [ + Icon( + CupertinoIcons.add_circled, + size: 20, + color: DesignTokens.dynamicPrimary, + ), + const SizedBox(width: 6), + Text( + '添加菜品', + style: TextStyle( + color: DesignTokens.dynamicPrimary, + fontWeight: FontWeight.w600, + fontSize: DesignTokens.fontMd, + ), + ), + ], + ), + ), + ); + } + + Widget _buildOrderHeader(Order order, bool isDark) { + return ClipRRect( + borderRadius: DesignTokens.borderRadiusLg, + child: BackdropFilter( + filter: ImageFilter.blur(sigmaX: 20, sigmaY: 20), + child: Container( + padding: const EdgeInsets.all(DesignTokens.space3), + decoration: BoxDecoration( + color: (isDark ? DarkDesignTokens.card : DesignTokens.card) + .withValues(alpha: 0.72), + borderRadius: DesignTokens.borderRadiusLg, + border: Border.all( + color: isDark + ? DarkDesignTokens.glassBorder + : DesignTokens.text3.withValues(alpha: 0.08), + width: 0.5, + ), + ), + child: Row( + children: [ + Container( + padding: const EdgeInsets.symmetric( + horizontal: DesignTokens.space2, + vertical: DesignTokens.space1, + ), + decoration: BoxDecoration( + color: order.status == OrderStatus.active + ? CupertinoColors.activeGreen.withValues(alpha: 0.15) + : CupertinoColors.systemOrange.withValues(alpha: 0.15), + borderRadius: DesignTokens.borderRadiusSm, + ), + child: Text( + '${order.status.icon} ${order.status.label}', + style: TextStyle( + fontSize: DesignTokens.fontSm, + fontWeight: FontWeight.w600, + color: order.status == OrderStatus.active + ? CupertinoColors.activeGreen + : CupertinoColors.systemOrange, + ), + ), + ), + const SizedBox(width: DesignTokens.space2), + Expanded( + child: Text( + '单号: ${order.orderNo}', + style: TextStyle( + fontSize: DesignTokens.fontXs, + color: isDark ? DarkDesignTokens.text3 : DesignTokens.text3, + fontFamily: 'monospace', + ), + ), + ), + Text( + order.displayDate, + style: TextStyle( + fontSize: DesignTokens.fontXs, + color: isDark ? DarkDesignTokens.text3 : DesignTokens.text3, + ), + ), + ], + ), + ), + ), + ); + } + + Widget _buildTotalSection(Order order, bool isDark) { + return ClipRRect( + borderRadius: DesignTokens.borderRadiusLg, + child: BackdropFilter( + filter: ImageFilter.blur(sigmaX: 20, sigmaY: 20), + child: Container( + padding: const EdgeInsets.all(DesignTokens.space3), + decoration: BoxDecoration( + color: (isDark ? DarkDesignTokens.card : DesignTokens.card) + .withValues(alpha: 0.72), + borderRadius: DesignTokens.borderRadiusLg, + border: Border.all( + color: isDark + ? DarkDesignTokens.glassBorder + : DesignTokens.text3.withValues(alpha: 0.08), + width: 0.5, + ), + ), + child: Row( + mainAxisAlignment: MainAxisAlignment.spaceBetween, + children: [ + Text( + '共 ${order.totalQuantity} 道菜', + style: TextStyle( + fontSize: DesignTokens.fontMd, + color: isDark ? DarkDesignTokens.text2 : DesignTokens.text2, + ), + ), + Text( + '合计: ¥${order.calculatedTotal.toStringAsFixed(1)}', + style: TextStyle( + fontSize: DesignTokens.fontLg, + fontWeight: FontWeight.w700, + color: DesignTokens.dynamicPrimary, + ), + ), + ], + ), + ), + ), + ); + } + + Widget _buildNoteSection(Order order, bool isDark) { + _noteCtrl.text = order.note ?? ''; + return ClipRRect( + borderRadius: DesignTokens.borderRadiusLg, + child: BackdropFilter( + filter: ImageFilter.blur(sigmaX: 20, sigmaY: 20), + child: Container( + padding: const EdgeInsets.all(DesignTokens.space3), + decoration: BoxDecoration( + color: (isDark ? DarkDesignTokens.card : DesignTokens.card) + .withValues(alpha: 0.72), + borderRadius: DesignTokens.borderRadiusLg, + border: Border.all( + color: isDark + ? DarkDesignTokens.glassBorder + : DesignTokens.text3.withValues(alpha: 0.08), + width: 0.5, + ), + ), + 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), + CupertinoTextField( + controller: _noteCtrl, + placeholder: '输入备注信息...', + maxLines: 2, + padding: const EdgeInsets.all(DesignTokens.space2), + decoration: BoxDecoration( + color: isDark + ? DarkDesignTokens.text3.withValues(alpha: 0.1) + : DesignTokens.text3.withValues(alpha: 0.06), + borderRadius: DesignTokens.borderRadiusMd, + ), + onChanged: (v) => _ctrl.setNote(v.isEmpty ? null : v), + ), + ], + ), + ), + ), + ); + } + + Widget _buildTableNoSection(Order order, bool isDark) { + _tableNoCtrl.text = order.tableNo ?? ''; + return ClipRRect( + borderRadius: DesignTokens.borderRadiusLg, + child: BackdropFilter( + filter: ImageFilter.blur(sigmaX: 20, sigmaY: 20), + child: Container( + padding: const EdgeInsets.all(DesignTokens.space3), + decoration: BoxDecoration( + color: (isDark ? DarkDesignTokens.card : DesignTokens.card) + .withValues(alpha: 0.72), + borderRadius: DesignTokens.borderRadiusLg, + border: Border.all( + color: isDark + ? DarkDesignTokens.glassBorder + : DesignTokens.text3.withValues(alpha: 0.08), + width: 0.5, + ), + ), + child: Row( + children: [ + Text( + '🪑 桌号', + style: TextStyle( + fontSize: DesignTokens.fontMd, + fontWeight: FontWeight.w600, + color: isDark ? DarkDesignTokens.text1 : DesignTokens.text1, + ), + ), + const SizedBox(width: DesignTokens.space2), + Expanded( + child: CupertinoTextField( + controller: _tableNoCtrl, + placeholder: '如: A3', + padding: const EdgeInsets.symmetric( + horizontal: DesignTokens.space2, + vertical: DesignTokens.space1, + ), + decoration: BoxDecoration( + color: isDark + ? DarkDesignTokens.text3.withValues(alpha: 0.1) + : DesignTokens.text3.withValues(alpha: 0.06), + borderRadius: DesignTokens.borderRadiusSm, + ), + onChanged: (v) => _ctrl.setTableNo(v.isEmpty ? null : v), + ), + ), + ], + ), + ), + ), + ); + } + + Widget _buildPeopleCountSection(Order order, bool isDark) { + final currentCount = order.peopleCount; + return ClipRRect( + borderRadius: DesignTokens.borderRadiusLg, + child: BackdropFilter( + filter: ImageFilter.blur(sigmaX: 20, sigmaY: 20), + child: Container( + padding: const EdgeInsets.all(DesignTokens.space3), + decoration: BoxDecoration( + color: (isDark ? DarkDesignTokens.card : DesignTokens.card) + .withValues(alpha: 0.72), + borderRadius: DesignTokens.borderRadiusLg, + border: Border.all( + color: isDark + ? DarkDesignTokens.glassBorder + : DesignTokens.text3.withValues(alpha: 0.08), + width: 0.5, + ), + ), + 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), + Wrap( + spacing: DesignTokens.space2, + runSpacing: DesignTokens.space2, + children: _peoplePresets.map((n) { + final selected = currentCount == n; + return GestureDetector( + onTap: () => _ctrl.setPeopleCount(selected ? null : n), + child: Container( + width: 44, + height: 36, + alignment: Alignment.center, + decoration: BoxDecoration( + color: selected + ? DesignTokens.dynamicPrimary + : (isDark + ? DarkDesignTokens.text3.withValues( + alpha: 0.1, + ) + : DesignTokens.text3.withValues(alpha: 0.06)), + borderRadius: DesignTokens.borderRadiusSm, + border: selected + ? null + : Border.all( + color: isDark + ? DarkDesignTokens.glassBorder + : DesignTokens.text3.withValues( + alpha: 0.12, + ), + width: 0.5, + ), + ), + child: Text( + '$n', + style: TextStyle( + fontSize: DesignTokens.fontSm, + fontWeight: selected + ? FontWeight.w700 + : FontWeight.w500, + color: selected + ? CupertinoColors.white + : (isDark + ? DarkDesignTokens.text2 + : DesignTokens.text2), + ), + ), + ), + ); + }).toList(), + ), + const SizedBox(height: DesignTokens.space2), + Row( + children: [ + Text( + '自定义: ', + style: TextStyle( + fontSize: DesignTokens.fontSm, + color: isDark + ? DarkDesignTokens.text3 + : DesignTokens.text3, + ), + ), + SizedBox( + width: 60, + child: CupertinoTextField( + controller: _peopleCtrl, + placeholder: '人数', + keyboardType: TextInputType.number, + padding: const EdgeInsets.symmetric( + horizontal: DesignTokens.space2, + vertical: DesignTokens.space1, + ), + decoration: BoxDecoration( + color: isDark + ? DarkDesignTokens.text3.withValues(alpha: 0.1) + : DesignTokens.text3.withValues(alpha: 0.06), + borderRadius: DesignTokens.borderRadiusSm, + ), + onChanged: (v) { + final n = int.tryParse(v); + if (n != null && n > 0) { + _ctrl.setPeopleCount(n); + } else if (v.isEmpty) { + _ctrl.setPeopleCount(null); + } + }, + ), + ), + const SizedBox(width: DesignTokens.space2), + if (currentCount != null && + !_peoplePresets.contains(currentCount)) + Text( + '👤 $currentCount 人', + style: TextStyle( + fontSize: DesignTokens.fontSm, + color: DesignTokens.dynamicPrimary, + fontWeight: FontWeight.w600, + ), + ), + ], + ), + ], + ), + ), + ), + ); + } + + Widget _buildShareButtons(Order order, bool isDark) { + return ClipRRect( + borderRadius: DesignTokens.borderRadiusLg, + child: BackdropFilter( + filter: ImageFilter.blur(sigmaX: 20, sigmaY: 20), + child: Container( + padding: const EdgeInsets.all(DesignTokens.space3), + decoration: BoxDecoration( + color: (isDark ? DarkDesignTokens.card : DesignTokens.card) + .withValues(alpha: 0.72), + borderRadius: DesignTokens.borderRadiusLg, + border: Border.all( + color: isDark + ? DarkDesignTokens.glassBorder + : DesignTokens.text3.withValues(alpha: 0.08), + width: 0.5, + ), + ), + 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), + Row( + children: [ + Expanded( + child: CupertinoButton( + color: CupertinoColors.activeOrange.withValues( + alpha: 0.12, + ), + borderRadius: DesignTokens.borderRadiusMd, + padding: const EdgeInsets.symmetric( + vertical: DesignTokens.space2, + ), + onPressed: _shareAsText, + child: const Row( + mainAxisAlignment: MainAxisAlignment.center, + children: [ + Icon( + CupertinoIcons.doc_text, + size: 18, + color: CupertinoColors.activeOrange, + ), + SizedBox(width: 6), + Text( + '生成文本', + style: TextStyle( + color: CupertinoColors.activeOrange, + fontWeight: FontWeight.w600, + fontSize: 14, + ), + ), + ], + ), + ), + ), + const SizedBox(width: DesignTokens.space2), + Expanded( + child: CupertinoButton( + color: CupertinoColors.activeGreen.withValues( + alpha: 0.12, + ), + borderRadius: DesignTokens.borderRadiusMd, + padding: const EdgeInsets.symmetric( + vertical: DesignTokens.space2, + ), + onPressed: _shareAsImage, + child: const Row( + mainAxisAlignment: MainAxisAlignment.center, + children: [ + Icon( + CupertinoIcons.photo, + size: 18, + color: CupertinoColors.activeGreen, + ), + SizedBox(width: 6), + Text( + '生成图片', + style: TextStyle( + color: CupertinoColors.activeGreen, + fontWeight: FontWeight.w600, + fontSize: 14, + ), + ), + ], + ), + ), + ), + ], + ), + ], + ), + ), + ), + ); + } + + Widget _buildBottomBar(bool isDark, double bottomPadding) { + return ClipRRect( + child: BackdropFilter( + filter: ImageFilter.blur(sigmaX: 20, sigmaY: 20), + child: Container( + padding: EdgeInsets.fromLTRB( + DesignTokens.space3, + DesignTokens.space2, + DesignTokens.space3, + bottomPadding + DesignTokens.space2, + ), + decoration: BoxDecoration( + color: (isDark ? DarkDesignTokens.card : DesignTokens.card) + .withValues(alpha: 0.85), + border: Border( + top: BorderSide( + color: isDark + ? DarkDesignTokens.glassBorder + : DesignTokens.text3.withValues(alpha: 0.1), + width: 0.5, + ), + ), + ), + child: Row( + children: [ + Expanded( + child: CupertinoButton( + color: CupertinoColors.systemRed.withValues(alpha: 0.12), + borderRadius: DesignTokens.borderRadiusMd, + padding: const EdgeInsets.symmetric( + vertical: DesignTokens.space2, + ), + onPressed: _closeOrder, + child: const Text( + '🗑️ 关闭订单', + style: TextStyle( + color: CupertinoColors.systemRed, + fontWeight: FontWeight.w600, + ), + ), + ), + ), + const SizedBox(width: DesignTokens.space2), + Expanded( + child: CupertinoButton( + color: DesignTokens.dynamicPrimary, + borderRadius: DesignTokens.borderRadiusMd, + padding: const EdgeInsets.symmetric( + vertical: DesignTokens.space2, + ), + onPressed: _activateAndShowQr, + child: const Text( + '📋 生成账单', + style: TextStyle( + color: CupertinoColors.white, + fontWeight: FontWeight.w600, + ), + ), + ), + ), + ], + ), + ), + ), + ); + } + + void _toggleOrderType() { + final newType = _ctrl.orderType == OrderType.userOrder + ? OrderType.merchantPush + : OrderType.userOrder; + _ctrl.setOrderType(newType); + _ctrl.createNewOrder(); + } + + Future _closeOrder() async { + final order = _ctrl.currentOrder; + if (order == null) return; + final confirmed = await showCupertinoDialog( + context: context, + builder: (ctx) => CupertinoAlertDialog( + title: const Text('⚠️ 关闭订单'), + content: Text('确定关闭订单 ${order.orderNo} 吗?\n关闭后将标记为已取消并从服务器删除。'), + actions: [ + CupertinoDialogAction( + onPressed: () => Navigator.pop(ctx, false), + child: const Text('取消'), + ), + CupertinoDialogAction( + isDestructiveAction: true, + onPressed: () => Navigator.pop(ctx, true), + child: const Text('确认关闭'), + ), + ], + ), + ); + if (confirmed == true) { + await _ctrl.closeOrder(); + await _ctrl.deleteOrder(order.id); + _ctrl.createNewOrder(); + } + } + + Future _activateAndShowQr() async { + final order = _ctrl.currentOrder; + if (order == null || order.items.isEmpty) { + showCupertinoDialog( + context: context, + builder: (ctx) => CupertinoAlertDialog( + title: const Text('⚠️ 提示'), + content: const Text('请先添加菜品'), + actions: [ + CupertinoDialogAction( + onPressed: () => Navigator.pop(ctx), + child: const Text('好的'), + ), + ], + ), + ); + return; + } + await _ctrl.activateOrder(); + final activatedOrder = _ctrl.currentOrder; + if (activatedOrder != null && mounted) { + QrBarcodeDialog.show(context, activatedOrder); + } + } + + String _generateOrderText(Order order) { + final buf = StringBuffer(); + buf.writeln('🍽️ 点餐助手 - ${order.orderNo}'); + buf.writeln('━━━━━━━━━━━━━━━━'); + if (order.tableNo != null) buf.writeln('🪑 桌号: ${order.tableNo}'); + if (order.peopleCount != null) buf.writeln('👥 人数: ${order.peopleCount}'); + buf.writeln(); + for (final item in order.items) { + buf.write('· ${item.name} ×${item.quantity}'); + if (item.price != null) { + buf.write( + ' ¥${((item.price ?? 0) * item.quantity).toStringAsFixed(1)}', + ); + } + buf.writeln(); + if (item.ingredients != null && item.ingredients!.isNotEmpty) { + buf.writeln(' 🥘 ${item.ingredients}'); + } + if (item.note != null && item.note!.isNotEmpty) { + buf.writeln(' 💬 ${item.note}'); + } + } + buf.writeln(); + buf.writeln('━━━━━━━━━━━━━━━━'); + buf.writeln( + '共 ${order.totalQuantity} 道菜 · 合计: ¥${order.calculatedTotal.toStringAsFixed(1)}', + ); + if (order.note != null && order.note!.isNotEmpty) { + buf.writeln('💬 备注: ${order.note}'); + } + buf.writeln(); + buf.writeln('📱 小妈厨房 · 点餐助手'); + return buf.toString(); + } + + Future _shareAsText() async { + final order = _ctrl.currentOrder; + if (order == null || order.items.isEmpty) { + showCupertinoDialog( + context: context, + builder: (ctx) => CupertinoAlertDialog( + title: const Text('⚠️ 提示'), + content: const Text('请先添加菜品'), + actions: [ + CupertinoDialogAction( + onPressed: () => Navigator.pop(ctx), + child: const Text('好的'), + ), + ], + ), + ); + return; + } + final text = _generateOrderText(order); + try { + final box = context.findRenderObject() as RenderBox?; + await Share.share( + text, + subject: '点餐助手 - ${order.orderNo}', + sharePositionOrigin: box != null + ? box.localToGlobal(Offset.zero) & box.size + : null, + ); + } catch (e) { + debugPrint('[OrderPage] share text error: $e'); + await Clipboard.setData(ClipboardData(text: text)); + if (mounted) { + showCupertinoDialog( + context: context, + builder: (ctx) => CupertinoAlertDialog( + title: const Text('📋 已复制'), + content: const Text('分享失败,文本已复制到剪贴板'), + actions: [ + CupertinoDialogAction( + onPressed: () => Navigator.pop(ctx), + child: const Text('好的'), + ), + ], + ), + ); + } + } + } + + Future _shareAsImage() async { + final order = _ctrl.currentOrder; + if (order == null || order.items.isEmpty) { + showCupertinoDialog( + context: context, + builder: (ctx) => CupertinoAlertDialog( + title: const Text('⚠️ 提示'), + content: const Text('请先添加菜品'), + actions: [ + CupertinoDialogAction( + onPressed: () => Navigator.pop(ctx), + child: const Text('好的'), + ), + ], + ), + ); + return; + } + _showImagePreviewSheet(order); + } + + void _showImagePreviewSheet(Order order) { + final isDark = CupertinoTheme.brightnessOf(context) == Brightness.dark; + final previewKey = GlobalKey(); + + showCupertinoModalPopup( + context: context, + builder: (sheetCtx) => Container( + constraints: BoxConstraints( + maxHeight: MediaQuery.of(context).size.height * 0.85, + ), + decoration: BoxDecoration( + color: isDark + ? const Color(0xFF1C1C1E) + : CupertinoColors.systemBackground.color, + borderRadius: const BorderRadius.vertical(top: Radius.circular(20)), + ), + child: Column( + mainAxisSize: MainAxisSize.min, + children: [ + Container( + margin: const EdgeInsets.only(top: 8), + width: 36, + height: 4, + decoration: BoxDecoration( + color: CupertinoColors.systemGrey3, + borderRadius: BorderRadius.circular(2), + ), + ), + Padding( + padding: const EdgeInsets.fromLTRB(16, 12, 16, 8), + child: Row( + mainAxisAlignment: MainAxisAlignment.spaceBetween, + children: [ + Text( + '🖼️ 图片预览', + style: TextStyle( + fontSize: 18, + fontWeight: FontWeight.w600, + color: isDark + ? CupertinoColors.white + : CupertinoColors.black, + ), + ), + Row( + children: [ + CupertinoButton( + padding: const EdgeInsets.symmetric( + horizontal: 12, + vertical: 6, + ), + color: DesignTokens.dynamicPrimary, + borderRadius: DesignTokens.borderRadiusMd, + onPressed: () async { + await _captureAndShare(previewKey, order); + if (context.mounted) Navigator.pop(context); + }, + child: const Row( + mainAxisSize: MainAxisSize.min, + children: [ + Icon( + CupertinoIcons.share, + size: 16, + color: CupertinoColors.white, + ), + SizedBox(width: 4), + Text( + '分享', + style: TextStyle( + color: CupertinoColors.white, + fontWeight: FontWeight.w600, + fontSize: 14, + ), + ), + ], + ), + ), + const SizedBox(width: 8), + GestureDetector( + onTap: () => Navigator.pop(sheetCtx), + child: Icon( + CupertinoIcons.xmark_circle_fill, + color: CupertinoColors.systemGrey, + size: 28, + ), + ), + ], + ), + ], + ), + ), + Flexible( + child: SingleChildScrollView( + padding: const EdgeInsets.symmetric( + horizontal: 16, + vertical: 8, + ), + child: RepaintBoundary( + key: previewKey, + child: _buildOrderPreviewCard(order, isDark), + ), + ), + ), + SizedBox(height: MediaQuery.of(context).padding.bottom + 8), + ], + ), + ), + ); + } + + Widget _buildOrderPreviewCard(Order order, bool isDark) { + final bgColor = isDark ? const Color(0xFF1C1C1E) : CupertinoColors.white; + final textColor = isDark ? DarkDesignTokens.text1 : DesignTokens.text1; + final subColor = isDark ? DarkDesignTokens.text2 : DesignTokens.text2; + final hintColor = isDark ? DarkDesignTokens.text3 : DesignTokens.text3; + final cardBg = isDark ? DarkDesignTokens.card : DesignTokens.card; + final fieldBg = isDark + ? DarkDesignTokens.text3.withValues(alpha: 0.1) + : DesignTokens.text3.withValues(alpha: 0.06); + + return Container( + width: double.infinity, + padding: const EdgeInsets.all(DesignTokens.space4), + decoration: BoxDecoration( + color: bgColor, + borderRadius: DesignTokens.borderRadiusLg, + ), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + mainAxisSize: MainAxisSize.min, + children: [ + Center( + child: Text( + '🍽️ 点餐助手', + style: TextStyle( + fontSize: DesignTokens.fontXxl, + fontWeight: FontWeight.w700, + color: textColor, + ), + ), + ), + const SizedBox(height: DesignTokens.space2), + Container( + padding: const EdgeInsets.symmetric( + horizontal: DesignTokens.space2, + vertical: DesignTokens.space1, + ), + decoration: BoxDecoration( + color: order.status == OrderStatus.active + ? CupertinoColors.activeGreen.withValues(alpha: 0.15) + : CupertinoColors.systemOrange.withValues(alpha: 0.15), + borderRadius: DesignTokens.borderRadiusSm, + ), + child: Text( + '${order.status.icon} ${order.status.label}', + style: TextStyle( + fontSize: DesignTokens.fontSm, + fontWeight: FontWeight.w600, + color: order.status == OrderStatus.active + ? CupertinoColors.activeGreen + : CupertinoColors.systemOrange, + ), + ), + ), + const SizedBox(height: DesignTokens.space2), + Row( + children: [ + Text( + '单号: ${order.orderNo}', + style: TextStyle( + fontSize: DesignTokens.fontSm, + color: hintColor, + fontFamily: 'monospace', + ), + ), + const Spacer(), + Text( + order.displayDate, + style: TextStyle( + fontSize: DesignTokens.fontSm, + color: hintColor, + ), + ), + ], + ), + if (order.tableNo != null || order.peopleCount != null) ...[ + const SizedBox(height: DesignTokens.space2), + Row( + children: [ + if (order.tableNo != null) + Container( + padding: const EdgeInsets.symmetric( + horizontal: 8, + vertical: 4, + ), + decoration: BoxDecoration( + color: fieldBg, + borderRadius: DesignTokens.borderRadiusSm, + ), + child: Text( + '🪑 ${order.tableNo}', + style: TextStyle( + fontSize: DesignTokens.fontSm, + color: subColor, + ), + ), + ), + if (order.tableNo != null && order.peopleCount != null) + const SizedBox(width: DesignTokens.space2), + if (order.peopleCount != null) + Container( + padding: const EdgeInsets.symmetric( + horizontal: 8, + vertical: 4, + ), + decoration: BoxDecoration( + color: fieldBg, + borderRadius: DesignTokens.borderRadiusSm, + ), + child: Text( + '👥 ${order.peopleCount}人', + style: TextStyle( + fontSize: DesignTokens.fontSm, + color: subColor, + ), + ), + ), + ], + ), + ], + const SizedBox(height: DesignTokens.space3), + Container(height: 1, color: hintColor.withValues(alpha: 0.2)), + const SizedBox(height: DesignTokens.space3), + ...order.items.asMap().entries.map((entry) { + final idx = entry.key; + final item = entry.value; + return Padding( + padding: EdgeInsets.only( + bottom: idx < order.items.length - 1 ? DesignTokens.space2 : 0, + ), + child: Container( + padding: const EdgeInsets.all(DesignTokens.space3), + decoration: BoxDecoration( + color: cardBg.withValues(alpha: 0.72), + borderRadius: DesignTokens.borderRadiusMd, + border: Border.all( + color: hintColor.withValues(alpha: 0.08), + width: 0.5, + ), + ), + child: Row( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Container( + width: 28, + height: 28, + alignment: Alignment.center, + decoration: BoxDecoration( + color: DesignTokens.dynamicPrimary.withValues( + alpha: 0.1, + ), + borderRadius: DesignTokens.borderRadiusSm, + ), + child: Text( + '${idx + 1}', + style: TextStyle( + fontSize: DesignTokens.fontSm, + fontWeight: FontWeight.w700, + color: DesignTokens.dynamicPrimary, + ), + ), + ), + const SizedBox(width: DesignTokens.space2), + Expanded( + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Row( + mainAxisAlignment: MainAxisAlignment.spaceBetween, + children: [ + Expanded( + child: Text( + item.name, + style: TextStyle( + fontSize: DesignTokens.fontMd, + fontWeight: FontWeight.w600, + color: textColor, + ), + ), + ), + Text( + '×${item.quantity}', + style: TextStyle( + fontSize: DesignTokens.fontMd, + color: subColor, + ), + ), + ], + ), + if (item.price != null) + Text( + '¥${((item.price ?? 0) * item.quantity).toStringAsFixed(1)}', + style: TextStyle( + fontSize: DesignTokens.fontSm, + color: DesignTokens.dynamicPrimary, + fontWeight: FontWeight.w600, + ), + ), + if (item.ingredients != null && + item.ingredients!.isNotEmpty) + Padding( + padding: const EdgeInsets.only(top: 2), + child: Text( + '🥘 ${item.ingredients}', + style: TextStyle( + fontSize: DesignTokens.fontXs, + color: hintColor, + ), + ), + ), + if (item.note != null && item.note!.isNotEmpty) + Padding( + padding: const EdgeInsets.only(top: 2), + child: Text( + '💬 ${item.note}', + style: TextStyle( + fontSize: DesignTokens.fontXs, + color: hintColor, + ), + ), + ), + ], + ), + ), + ], + ), + ), + ); + }), + const SizedBox(height: DesignTokens.space3), + Container(height: 1, color: hintColor.withValues(alpha: 0.2)), + const SizedBox(height: DesignTokens.space3), + Row( + mainAxisAlignment: MainAxisAlignment.spaceBetween, + children: [ + Text( + '共 ${order.totalQuantity} 道菜', + style: TextStyle( + fontSize: DesignTokens.fontMd, + color: subColor, + ), + ), + Text( + '合计: ¥${order.calculatedTotal.toStringAsFixed(1)}', + style: TextStyle( + fontSize: DesignTokens.fontXl, + fontWeight: FontWeight.w700, + color: DesignTokens.dynamicPrimary, + ), + ), + ], + ), + if (order.note != null && order.note!.isNotEmpty) ...[ + const SizedBox(height: DesignTokens.space2), + Container( + width: double.infinity, + padding: const EdgeInsets.all(DesignTokens.space2), + decoration: BoxDecoration( + color: fieldBg, + borderRadius: DesignTokens.borderRadiusSm, + ), + child: Text( + '💬 备注: ${order.note}', + style: TextStyle( + fontSize: DesignTokens.fontSm, + color: subColor, + ), + ), + ), + ], + const SizedBox(height: DesignTokens.space4), + Center( + child: Text( + '📱 小妈厨房 · 点餐助手', + style: TextStyle(fontSize: DesignTokens.fontXs, color: hintColor), + ), + ), + ], + ), + ); + } + + Future _captureAndShare(GlobalKey previewKey, Order order) async { + try { + final boundary = + previewKey.currentContext?.findRenderObject() + as RenderRepaintBoundary?; + if (boundary == null) { + debugPrint('[OrderPage] preview boundary not found'); + return; + } + final image = await boundary.toImage(pixelRatio: 3.0); + final byteData = await image.toByteData(format: ImageByteFormat.png); + if (byteData == null) return; + final buffer = byteData.buffer; + final tempDir = await getTemporaryDirectory(); + final filePath = '${tempDir.path}/order_${order.orderNo}.png'; + await File(filePath).writeAsBytes(buffer.asUint8List()); + final xFile = XFile(filePath); + final box = context.findRenderObject() as RenderBox?; + await Share.shareXFiles( + [xFile], + text: '点餐助手 - ${order.orderNo}', + subject: '点餐助手 - ${order.orderNo}', + sharePositionOrigin: box != null + ? box.localToGlobal(Offset.zero) & box.size + : null, + ); + } catch (e) { + debugPrint('[OrderPage] capture and share error: $e'); + if (mounted) { + showCupertinoDialog( + context: context, + builder: (ctx) => CupertinoAlertDialog( + title: const Text('❌ 分享失败'), + content: Text('图片分享失败: $e\n请尝试截图分享'), + actions: [ + CupertinoDialogAction( + onPressed: () => Navigator.pop(ctx), + child: const Text('好的'), + ), + ], + ), + ); + } + } + } + + void _showHistorySheet(bool isDark) { + showCupertinoModalPopup( + context: context, + builder: (ctx) => Container( + constraints: BoxConstraints( + maxHeight: MediaQuery.of(context).size.height * 0.7, + ), + decoration: BoxDecoration( + color: isDark + ? const Color(0xFF1C1C1E) + : CupertinoColors.systemBackground.color, + borderRadius: const BorderRadius.vertical(top: Radius.circular(16)), + ), + child: Column( + mainAxisSize: MainAxisSize.min, + children: [ + Container( + margin: const EdgeInsets.only(top: 8), + width: 36, + height: 4, + decoration: BoxDecoration( + color: CupertinoColors.systemGrey3, + borderRadius: BorderRadius.circular(2), + ), + ), + Padding( + padding: const EdgeInsets.all(16), + child: Row( + mainAxisAlignment: MainAxisAlignment.spaceBetween, + children: [ + Text( + '📜 历史记录 (${_ctrl.recordCount})', + style: TextStyle( + fontSize: 18, + fontWeight: FontWeight.w600, + color: isDark + ? CupertinoColors.white + : CupertinoColors.black, + ), + ), + GestureDetector( + onTap: () => Navigator.pop(ctx), + child: Icon( + CupertinoIcons.xmark_circle_fill, + color: CupertinoColors.systemGrey, + size: 28, + ), + ), + ], + ), + ), + Obx(() { + if (_ctrl.history.isEmpty) { + return Padding( + padding: const EdgeInsets.all(40), + child: Column( + children: [ + const Text('📭', style: TextStyle(fontSize: 40)), + const SizedBox(height: 12), + Text( + '暂无历史记录', + style: TextStyle( + color: CupertinoColors.systemGrey, + fontSize: 16, + ), + ), + ], + ), + ); + } + return Flexible( + child: ListView.builder( + shrinkWrap: true, + itemCount: _ctrl.history.length, + itemBuilder: (ctx, i) => + _buildHistoryItem(ctx, _ctrl.history[i], isDark), + ), + ); + }), + SizedBox(height: MediaQuery.of(context).padding.bottom + 16), + ], + ), + ), + ); + } + + Widget _buildHistoryItem(BuildContext context, Order order, bool isDark) { + return GestureDetector( + onTap: () { + Navigator.pop(context); + _ctrl.loadOrder(order); + }, + child: Container( + margin: const EdgeInsets.symmetric(horizontal: 16, vertical: 4), + padding: const EdgeInsets.all(12), + decoration: BoxDecoration( + color: isDark + ? CupertinoColors.systemGrey5.darkColor + : CupertinoColors.systemGrey6, + borderRadius: BorderRadius.circular(12), + ), + child: Row( + children: [ + Container( + padding: const EdgeInsets.symmetric(horizontal: 8, vertical: 4), + decoration: BoxDecoration( + color: order.status == OrderStatus.active + ? CupertinoColors.activeGreen.withValues(alpha: 0.15) + : CupertinoColors.systemOrange.withValues(alpha: 0.15), + borderRadius: BorderRadius.circular(6), + ), + child: Text( + '${order.status.icon} ${order.status.label}', + style: TextStyle( + fontSize: 12, + fontWeight: FontWeight.w600, + color: order.status == OrderStatus.active + ? CupertinoColors.activeGreen + : CupertinoColors.systemOrange, + ), + ), + ), + const SizedBox(width: 8), + Expanded( + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text( + '${order.type.icon} ${order.items.length}道菜 · ¥${order.calculatedTotal.toStringAsFixed(1)}', + style: TextStyle( + fontSize: 14, + fontWeight: FontWeight.w500, + color: isDark + ? CupertinoColors.white + : CupertinoColors.black, + ), + ), + Text( + '${order.orderNo} · ${order.displayDate}', + style: TextStyle( + fontSize: 11, + color: CupertinoColors.systemGrey, + ), + ), + ], + ), + ), + Icon( + CupertinoIcons.chevron_right, + color: CupertinoColors.systemGrey, + size: 18, + ), + ], + ), + ), + ); + } + + void _showCleanupSheet(bool isDark) { + showCupertinoModalPopup( + context: context, + builder: (ctx) => CupertinoActionSheet( + title: const Text('🗑️ 数据清理'), + message: const Text('清理本地和服务器上的过期点单数据\n仅清理点餐助手数据,不影响其他功能'), + actions: [ + CupertinoActionSheetAction( + onPressed: () async { + Navigator.pop(ctx); + final count = await _ctrl.cleanupExpiredLocal(days: 7); + _showCleanupResult('清理7天前数据', count); + }, + child: const Text('🧹 清理7天前数据'), + ), + CupertinoActionSheetAction( + onPressed: () async { + Navigator.pop(ctx); + final count = await _ctrl.cleanupExpiredLocal(days: 30); + _showCleanupResult('清理30天前数据', count); + }, + child: const Text('🧹 清理30天前数据'), + ), + CupertinoActionSheetAction( + onPressed: () async { + Navigator.pop(ctx); + final count = await _ctrl.cleanupAllExpired(days: 30); + _showCleanupResult('清理本地+服务器30天前数据', count); + }, + child: const Text('☁️ 清理本地+服务器数据'), + ), + CupertinoActionSheetAction( + isDestructiveAction: true, + onPressed: () async { + Navigator.pop(ctx); + final confirmed = await showCupertinoDialog( + context: context, + builder: (dCtx) => CupertinoAlertDialog( + title: const Text('⚠️ 清空全部数据'), + content: const Text( + '将清空本地历史和服务器上所有点餐助手数据\n仅清理点餐助手,不影响其他功能\n此操作不可撤销', + ), + actions: [ + CupertinoDialogAction( + onPressed: () => Navigator.pop(dCtx, false), + child: const Text('取消'), + ), + CupertinoDialogAction( + isDestructiveAction: true, + onPressed: () => Navigator.pop(dCtx, true), + child: const Text('确认清空'), + ), + ], + ), + ); + if (confirmed == true) { + final result = await _ctrl.clearAllData(); + if (mounted) { + final remoteFiles = + (result['remote'] + as Map?)?['deleted_files'] ?? + []; + showCupertinoDialog( + context: context, + builder: (rCtx) => CupertinoAlertDialog( + title: const Text('✅ 数据已清空'), + content: Text( + '本地数据: 已清空\n' + '服务器数据: ${remoteFiles.isEmpty ? "无文件" : remoteFiles.join(", ")}\n' + '范围: 仅点餐助手', + ), + actions: [ + CupertinoDialogAction( + onPressed: () { + Navigator.pop(rCtx); + _ctrl.createNewOrder(); + }, + child: const Text('好的'), + ), + ], + ), + ); + } + } + }, + child: const Text('💥 清空全部本地+服务器数据'), + ), + ], + cancelButton: CupertinoActionSheetAction( + isDestructiveAction: true, + onPressed: () => Navigator.pop(ctx), + child: const Text('取消'), + ), + ), + ); + } + + void _showCleanupResult(String action, int count) { + showCupertinoDialog( + context: context, + builder: (ctx) => CupertinoAlertDialog( + title: const Text('✅ 清理完成'), + content: Text('$action\n已删除 $count 条记录'), + actions: [ + CupertinoDialogAction( + onPressed: () => Navigator.pop(ctx), + child: const Text('好的'), + ), + ], + ), + ); + } +} diff --git a/lib/src/pages/tools/cooking/widgets/add_item_sheet.dart b/lib/src/pages/tools/cooking/widgets/add_item_sheet.dart new file mode 100644 index 0000000..b1b81f1 --- /dev/null +++ b/lib/src/pages/tools/cooking/widgets/add_item_sheet.dart @@ -0,0 +1,77 @@ +/* + * 文件: add_item_sheet.dart + * 名称: 添加菜品弹窗 + * 作用: 提供四种添加菜品方式的入口 + * 创建时间: 2026-04-17 + * 更新时间: 2026-04-17 初始创建 + */ + +import 'package:flutter/cupertino.dart'; +import 'package:mom_kitchen/src/models/tools/order_model.dart'; +import 'package:mom_kitchen/src/pages/tools/cooking/widgets/browse_history_picker.dart'; +import 'package:mom_kitchen/src/pages/tools/cooking/widgets/manual_input_sheet.dart'; + +void showAddItemSheet( + BuildContext context, { + required ValueChanged onItemAdded, + required bool isMerchantMode, +}) { + showCupertinoModalPopup( + context: context, + builder: (ctx) => CupertinoActionSheet( + title: const Text('🍽️ 添加菜品'), + message: const Text('选择菜品来源'), + actions: [ + CupertinoActionSheetAction( + onPressed: () { + Navigator.pop(ctx); + _showBrowseHistoryPicker(context, onItemAdded); + }, + child: const Text('📖 从浏览记录选择'), + ), + CupertinoActionSheetAction( + onPressed: () { + Navigator.pop(ctx); + _showManualInput(context, onItemAdded, OrderItemSource.manual); + }, + child: const Text('✏️ 手动填写'), + ), + if (isMerchantMode) + CupertinoActionSheetAction( + onPressed: () { + Navigator.pop(ctx); + _showManualInput( + context, onItemAdded, OrderItemSource.merchantRecommend); + }, + child: const Text('⭐ 商家推荐'), + ), + ], + cancelButton: CupertinoActionSheetAction( + isDestructiveAction: true, + onPressed: () => Navigator.pop(ctx), + child: const Text('取消'), + ), + ), + ); +} + +void _showBrowseHistoryPicker( + BuildContext context, + ValueChanged onItemAdded, +) { + showCupertinoModalPopup( + context: context, + builder: (ctx) => BrowseHistoryPicker(onSelected: onItemAdded), + ); +} + +void _showManualInput( + BuildContext context, + ValueChanged onItemAdded, + OrderItemSource source, +) { + showCupertinoModalPopup( + context: context, + builder: (ctx) => ManualInputSheet(source: source, onSaved: onItemAdded), + ); +} diff --git a/lib/src/pages/tools/cooking/widgets/browse_history_picker.dart b/lib/src/pages/tools/cooking/widgets/browse_history_picker.dart new file mode 100644 index 0000000..f185d35 --- /dev/null +++ b/lib/src/pages/tools/cooking/widgets/browse_history_picker.dart @@ -0,0 +1,191 @@ +/* + * 文件: browse_history_picker.dart + * 名称: 浏览记录选择器 + * 作用: 展示本地浏览记录供用户选择菜品 + * 创建时间: 2026-04-17 + * 更新时间: 2026-04-17 初始创建 + */ + +import 'package:flutter/cupertino.dart'; +import 'package:get/get.dart'; +import 'package:uuid/uuid.dart'; +import 'package:mom_kitchen/src/models/tools/order_model.dart'; +import 'package:mom_kitchen/src/controllers/home/home_controller.dart'; + +class BrowseHistoryPicker extends StatelessWidget { + final ValueChanged onSelected; + + const BrowseHistoryPicker({super.key, required this.onSelected}); + + @override + Widget build(BuildContext context) { + final isDark = CupertinoTheme.brightnessOf(context) == Brightness.dark; + final history = _getBrowseHistory(); + + return Container( + constraints: BoxConstraints( + maxHeight: MediaQuery.of(context).size.height * 0.6, + ), + decoration: BoxDecoration( + color: isDark + ? const Color(0xFF1C1C1E) + : CupertinoColors.systemBackground.color, + borderRadius: const BorderRadius.vertical(top: Radius.circular(16)), + ), + child: Column( + mainAxisSize: MainAxisSize.min, + children: [ + Container( + margin: const EdgeInsets.only(top: 8), + width: 36, + height: 4, + decoration: BoxDecoration( + color: CupertinoColors.systemGrey3, + borderRadius: BorderRadius.circular(2), + ), + ), + Padding( + padding: const EdgeInsets.all(16), + child: Row( + mainAxisAlignment: MainAxisAlignment.spaceBetween, + children: [ + Text( + '📖 浏览记录', + style: TextStyle( + fontSize: 18, + fontWeight: FontWeight.w600, + color: isDark + ? CupertinoColors.white + : CupertinoColors.black, + ), + ), + GestureDetector( + onTap: () => Navigator.pop(context), + child: Icon( + CupertinoIcons.xmark_circle_fill, + color: CupertinoColors.systemGrey, + size: 28, + ), + ), + ], + ), + ), + if (history.isEmpty) + Padding( + padding: const EdgeInsets.all(40), + child: Column( + children: [ + const Text('📭', style: TextStyle(fontSize: 40)), + const SizedBox(height: 12), + Text( + '暂无浏览记录', + style: TextStyle( + color: CupertinoColors.systemGrey, + fontSize: 16, + ), + ), + ], + ), + ) + else + Flexible( + child: ListView.builder( + shrinkWrap: true, + padding: const EdgeInsets.symmetric(horizontal: 16), + itemCount: history.length, + itemBuilder: (ctx, i) => + _buildHistoryItem(ctx, history[i], isDark), + ), + ), + SizedBox(height: MediaQuery.of(context).padding.bottom + 16), + ], + ), + ); + } + + Widget _buildHistoryItem( + BuildContext context, + Map recipe, + bool isDark, + ) { + return GestureDetector( + onTap: () { + const uuid = Uuid(); + onSelected( + OrderItem( + id: uuid.v4(), + name: recipe['name'] ?? '', + source: OrderItemSource.browseHistory, + recipeId: recipe['id'], + ), + ); + Navigator.pop(context); + }, + child: Container( + margin: const EdgeInsets.only(bottom: 8), + padding: const EdgeInsets.all(12), + decoration: BoxDecoration( + color: isDark + ? CupertinoColors.systemGrey5.darkColor + : CupertinoColors.systemGrey6, + borderRadius: BorderRadius.circular(12), + ), + child: Row( + children: [ + ClipRRect( + borderRadius: BorderRadius.circular(8), + child: recipe['image'] != null + ? Image.network( + recipe['image']!, + width: 44, + height: 44, + fit: BoxFit.cover, + errorBuilder: (_, __, ___) => _defaultFoodIcon(), + ) + : _defaultFoodIcon(), + ), + const SizedBox(width: 12), + Expanded( + child: Text( + recipe['name'] ?? '未知菜品', + style: TextStyle( + fontSize: 16, + fontWeight: FontWeight.w500, + color: isDark ? CupertinoColors.white : CupertinoColors.black, + ), + maxLines: 1, + overflow: TextOverflow.ellipsis, + ), + ), + Icon( + CupertinoIcons.plus_circle_fill, + color: CupertinoColors.activeBlue, + size: 28, + ), + ], + ), + ), + ); + } + + Widget _defaultFoodIcon() { + return Container( + width: 44, + height: 44, + color: CupertinoColors.systemGrey5, + child: const Center(child: Text('🍽️', style: TextStyle(fontSize: 20))), + ); + } + + List> _getBrowseHistory() { + try { + final homeCtrl = Get.find(); + final recipes = homeCtrl.recipes.value; + return recipes.take(20).map((r) { + return {'id': r.id.toString(), 'name': r.title, 'image': r.cover ?? ''}; + }).toList(); + } catch (_) { + return []; + } + } +} diff --git a/lib/src/pages/tools/cooking/widgets/manual_input_sheet.dart b/lib/src/pages/tools/cooking/widgets/manual_input_sheet.dart new file mode 100644 index 0000000..e65cd34 --- /dev/null +++ b/lib/src/pages/tools/cooking/widgets/manual_input_sheet.dart @@ -0,0 +1,206 @@ +/* + * 文件: manual_input_sheet.dart + * 名称: 手动填写菜品弹窗 + * 作用: 支持手动输入菜品名称、食材、备注、价格 + * 创建时间: 2026-04-17 + * 更新时间: 2026-04-17 初始创建 + */ + +import 'package:flutter/cupertino.dart'; +import 'package:uuid/uuid.dart'; +import 'package:mom_kitchen/src/models/tools/order_model.dart'; + +class ManualInputSheet extends StatefulWidget { + final OrderItemSource source; + final ValueChanged onSaved; + + const ManualInputSheet({ + super.key, + required this.source, + required this.onSaved, + }); + + @override + State createState() => _ManualInputSheetState(); +} + +class _ManualInputSheetState extends State { + final _nameCtrl = TextEditingController(); + final _ingredientsCtrl = TextEditingController(); + final _noteCtrl = TextEditingController(); + final _priceCtrl = TextEditingController(); + int _quantity = 1; + + @override + void dispose() { + _nameCtrl.dispose(); + _ingredientsCtrl.dispose(); + _noteCtrl.dispose(); + _priceCtrl.dispose(); + super.dispose(); + } + + @override + Widget build(BuildContext context) { + final isDark = CupertinoTheme.brightnessOf(context) == Brightness.dark; + + return Container( + constraints: BoxConstraints( + maxHeight: MediaQuery.of(context).size.height * 0.75, + ), + decoration: BoxDecoration( + color: isDark + ? const Color(0xFF1C1C1E) + : CupertinoColors.systemBackground.color, + borderRadius: const BorderRadius.vertical(top: Radius.circular(16)), + ), + child: Column( + mainAxisSize: MainAxisSize.min, + children: [ + Container( + margin: const EdgeInsets.only(top: 8), + width: 36, + height: 4, + decoration: BoxDecoration( + color: CupertinoColors.systemGrey3, + borderRadius: BorderRadius.circular(2), + ), + ), + Padding( + padding: const EdgeInsets.all(16), + child: Row( + mainAxisAlignment: MainAxisAlignment.spaceBetween, + children: [ + Text( + '${widget.source.icon} ${widget.source.label}', + style: TextStyle( + fontSize: 18, + fontWeight: FontWeight.w600, + color: isDark ? CupertinoColors.white : CupertinoColors.black, + ), + ), + GestureDetector( + onTap: () => Navigator.pop(context), + child: Icon( + CupertinoIcons.xmark_circle_fill, + color: CupertinoColors.systemGrey, + size: 28, + ), + ), + ], + ), + ), + Flexible( + child: SingleChildScrollView( + padding: const EdgeInsets.symmetric(horizontal: 16), + child: Column( + children: [ + _buildField('菜品名称', _nameCtrl, placeholder: '如:红烧肉', isDark: isDark), + const SizedBox(height: 12), + _buildField('食材', _ingredientsCtrl, placeholder: '如:五花肉、酱油、冰糖', isDark: isDark), + const SizedBox(height: 12), + _buildField('备注', _noteCtrl, placeholder: '如:少盐、不要辣', isDark: isDark), + const SizedBox(height: 12), + Row( + children: [ + Expanded( + child: _buildField('单价', _priceCtrl, placeholder: '0.0', isDark: isDark, keyboardType: const TextInputType.numberWithOptions(decimal: true)), + ), + const SizedBox(width: 12), + Column( + crossAxisAlignment: CrossAxisAlignment.center, + children: [ + Text('数量', style: TextStyle(fontSize: 13, color: CupertinoColors.systemGrey)), + const SizedBox(height: 4), + Row( + mainAxisSize: MainAxisSize.min, + children: [ + GestureDetector( + onTap: () => setState(() => _quantity = (_quantity - 1).clamp(1, 99)), + child: Container( + width: 32, height: 32, + decoration: BoxDecoration( + color: CupertinoColors.systemGrey5, + borderRadius: BorderRadius.circular(8), + ), + child: const Icon(CupertinoIcons.minus, size: 14), + ), + ), + Container( + width: 40, + alignment: Alignment.center, + child: Text('$_quantity', style: const TextStyle(fontSize: 16, fontWeight: FontWeight.w600)), + ), + GestureDetector( + onTap: () => setState(() => _quantity = (_quantity + 1).clamp(1, 99)), + child: Container( + width: 32, height: 32, + decoration: BoxDecoration( + color: CupertinoColors.activeBlue.withValues(alpha: 0.15), + borderRadius: BorderRadius.circular(8), + ), + child: const Icon(CupertinoIcons.plus, size: 14, color: CupertinoColors.activeBlue), + ), + ), + ], + ), + ], + ), + ], + ), + ], + ), + ), + ), + Padding( + padding: EdgeInsets.fromLTRB(16, 12, 16, MediaQuery.of(context).padding.bottom + 16), + child: SizedBox( + width: double.infinity, + child: CupertinoButton( + color: CupertinoColors.activeBlue, + borderRadius: BorderRadius.circular(12), + onPressed: _save, + child: const Text('✅ 添加', style: TextStyle(fontSize: 16, fontWeight: FontWeight.w600)), + ), + ), + ), + ], + ), + ); + } + + Widget _buildField(String label, TextEditingController controller, {String? placeholder, bool isDark = false, TextInputType? keyboardType}) { + return Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text(label, style: TextStyle(fontSize: 13, color: CupertinoColors.systemGrey)), + const SizedBox(height: 4), + CupertinoTextField( + controller: controller, + placeholder: placeholder, + keyboardType: keyboardType, + padding: const EdgeInsets.all(12), + decoration: BoxDecoration( + color: isDark ? CupertinoColors.systemGrey5.darkColor : CupertinoColors.systemGrey6, + borderRadius: BorderRadius.circular(10), + ), + ), + ], + ); + } + + void _save() { + if (_nameCtrl.text.trim().isEmpty) return; + const uuid = Uuid(); + widget.onSaved(OrderItem( + id: uuid.v4(), + name: _nameCtrl.text.trim(), + source: widget.source, + quantity: _quantity, + price: double.tryParse(_priceCtrl.text), + ingredients: _ingredientsCtrl.text.trim().isEmpty ? null : _ingredientsCtrl.text.trim(), + note: _noteCtrl.text.trim().isEmpty ? null : _noteCtrl.text.trim(), + )); + Navigator.pop(context); + } +} diff --git a/lib/src/pages/tools/cooking/widgets/order_item_card.dart b/lib/src/pages/tools/cooking/widgets/order_item_card.dart new file mode 100644 index 0000000..bf7dd68 --- /dev/null +++ b/lib/src/pages/tools/cooking/widgets/order_item_card.dart @@ -0,0 +1,247 @@ +/* + * 文件: order_item_card.dart + * 名称: 菜品卡片组件 + * 作用: 展示点单项信息,支持数量调整、价格编辑、删除 + * 创建时间: 2026-04-17 + * 更新时间: 2026-04-17 初始创建 + */ + +import 'dart:ui'; +import 'package:flutter/cupertino.dart'; +import 'package:mom_kitchen/src/config/design_tokens.dart'; +import 'package:mom_kitchen/src/models/tools/order_model.dart'; + +class OrderItemCard extends StatelessWidget { + final OrderItem item; + final VoidCallback onDelete; + final ValueChanged onQuantityChanged; + final ValueChanged onPriceChanged; + final int index; + + const OrderItemCard({ + super.key, + required this.item, + required this.onDelete, + required this.onQuantityChanged, + required this.onPriceChanged, + required this.index, + }); + + @override + Widget build(BuildContext context) { + final isDark = CupertinoTheme.brightnessOf(context) == Brightness.dark; + + return ClipRRect( + borderRadius: DesignTokens.borderRadiusLg, + child: BackdropFilter( + filter: ImageFilter.blur(sigmaX: 20, sigmaY: 20), + child: Container( + margin: const EdgeInsets.only(bottom: DesignTokens.space2), + padding: const EdgeInsets.all(DesignTokens.space3), + decoration: BoxDecoration( + color: (isDark ? DarkDesignTokens.card : DesignTokens.card) + .withValues(alpha: 0.72), + borderRadius: DesignTokens.borderRadiusLg, + border: Border.all( + color: isDark + ? DarkDesignTokens.glassBorder + : DesignTokens.text3.withValues(alpha: 0.08), + width: 0.5, + ), + ), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Row( + children: [ + _buildSourceTag(isDark), + const SizedBox(width: DesignTokens.space2), + Expanded( + child: Text( + item.name, + style: TextStyle( + fontSize: DesignTokens.fontMd, + fontWeight: FontWeight.w600, + color: isDark + ? DarkDesignTokens.text1 + : DesignTokens.text1, + ), + maxLines: 1, + overflow: TextOverflow.ellipsis, + ), + ), + _buildQuantityControl(isDark), + const SizedBox(width: DesignTokens.space2), + GestureDetector( + onTap: onDelete, + child: Icon( + CupertinoIcons.delete_simple, + size: 18, + color: + CupertinoColors.destructiveRed.withValues(alpha: 0.7), + ), + ), + ], + ), + if (item.ingredients != null && + item.ingredients!.isNotEmpty) ...[ + const SizedBox(height: DesignTokens.space1), + Text( + '🥘 ${item.ingredients}', + style: TextStyle( + fontSize: DesignTokens.fontSm, + color: isDark ? DarkDesignTokens.text3 : DesignTokens.text3, + ), + maxLines: 1, + overflow: TextOverflow.ellipsis, + ), + ], + if (item.note != null && item.note!.isNotEmpty) ...[ + const SizedBox(height: DesignTokens.space1), + Text( + '💬 ${item.note}', + style: TextStyle( + fontSize: DesignTokens.fontSm, + color: isDark ? DarkDesignTokens.text3 : DesignTokens.text3, + ), + maxLines: 1, + overflow: TextOverflow.ellipsis, + ), + ], + const SizedBox(height: DesignTokens.space2), + Row( + mainAxisAlignment: MainAxisAlignment.end, + children: [ + GestureDetector( + onTap: () => _showPriceEditor(context, isDark), + child: Text( + item.price != null + ? '¥${item.subtotal.toStringAsFixed(1)}' + : '点此设价', + style: TextStyle( + fontSize: DesignTokens.fontMd, + fontWeight: FontWeight.w600, + color: item.price != null + ? DesignTokens.dynamicPrimary + : (isDark + ? DarkDesignTokens.text3 + : DesignTokens.text3), + ), + ), + ), + ], + ), + ], + ), + ), + ), + ); + } + + Widget _buildSourceTag(bool isDark) { + return Container( + padding: const EdgeInsets.symmetric( + horizontal: DesignTokens.space1 + 2, + vertical: 2, + ), + decoration: BoxDecoration( + color: DesignTokens.dynamicPrimary.withValues(alpha: 0.1), + borderRadius: DesignTokens.borderRadiusSm, + ), + child: Text( + '${item.source.icon} ${item.source.label}', + style: TextStyle( + fontSize: DesignTokens.fontXs, + color: DesignTokens.dynamicPrimary, + ), + ), + ); + } + + Widget _buildQuantityControl(bool isDark) { + return Row( + mainAxisSize: MainAxisSize.min, + children: [ + GestureDetector( + onTap: () => onQuantityChanged(item.quantity - 1), + child: Container( + width: 28, + height: 28, + decoration: BoxDecoration( + color: isDark + ? DarkDesignTokens.text3.withValues(alpha: 0.2) + : DesignTokens.text3.withValues(alpha: 0.1), + borderRadius: DesignTokens.borderRadiusSm, + ), + child: const Icon(CupertinoIcons.minus, size: 14), + ), + ), + Container( + constraints: const BoxConstraints(minWidth: 32), + alignment: Alignment.center, + child: Text( + '${item.quantity}', + style: TextStyle( + fontSize: DesignTokens.fontMd, + fontWeight: FontWeight.w600, + color: isDark ? DarkDesignTokens.text1 : DesignTokens.text1, + ), + ), + ), + GestureDetector( + onTap: () => onQuantityChanged(item.quantity + 1), + child: Container( + width: 28, + height: 28, + decoration: BoxDecoration( + color: DesignTokens.dynamicPrimary.withValues(alpha: 0.15), + borderRadius: DesignTokens.borderRadiusSm, + ), + child: Icon( + CupertinoIcons.plus, + size: 14, + color: DesignTokens.dynamicPrimary, + ), + ), + ), + ], + ); + } + + void _showPriceEditor(BuildContext context, bool isDark) { + final controller = TextEditingController( + text: item.price?.toStringAsFixed(1) ?? '', + ); + showCupertinoDialog( + context: context, + builder: (ctx) => CupertinoAlertDialog( + title: const Text('💰 设置单价'), + content: Padding( + padding: const EdgeInsets.only(top: DesignTokens.space2), + child: CupertinoTextField( + controller: controller, + placeholder: '输入单价', + keyboardType: + const TextInputType.numberWithOptions(decimal: true), + autofocus: true, + ), + ), + actions: [ + CupertinoDialogAction( + onPressed: () => Navigator.pop(ctx), + child: const Text('取消'), + ), + CupertinoDialogAction( + isDefaultAction: true, + onPressed: () { + final val = double.tryParse(controller.text); + onPriceChanged(val); + Navigator.pop(ctx); + }, + child: const Text('确定'), + ), + ], + ), + ); + } +} diff --git a/lib/src/pages/tools/cooking/widgets/qr_barcode_dialog.dart b/lib/src/pages/tools/cooking/widgets/qr_barcode_dialog.dart new file mode 100644 index 0000000..51d2558 --- /dev/null +++ b/lib/src/pages/tools/cooking/widgets/qr_barcode_dialog.dart @@ -0,0 +1,216 @@ +/* + * 文件: qr_barcode_dialog.dart + * 名称: 二维码/条形码弹窗 + * 作用: 展示点单二维码和条形码,支持分享 + * 创建时间: 2026-04-17 + * 更新时间: 2026-04-17 初始创建 + */ + +import 'package:flutter/cupertino.dart'; +import 'package:qr/qr.dart'; +import 'package:mom_kitchen/src/config/design_tokens.dart'; +import 'package:mom_kitchen/src/models/tools/order_model.dart'; + +class QrBarcodeDialog extends StatelessWidget { + final Order order; + + const QrBarcodeDialog({super.key, required this.order}); + + static void show(BuildContext context, Order order) { + showCupertinoDialog( + context: context, + builder: (ctx) => QrBarcodeDialog(order: order), + ); + } + + @override + Widget build(BuildContext context) { + final isDark = CupertinoTheme.brightnessOf(context) == Brightness.dark; + final qrData = order.qrUrl; + + return CupertinoAlertDialog( + title: Padding( + padding: const EdgeInsets.only(bottom: DesignTokens.space2), + child: Text('📱 扫码查看点单'), + ), + content: Container( + padding: const EdgeInsets.all(DesignTokens.space4), + decoration: BoxDecoration( + color: isDark + ? const Color(0xFF2C2C2E) + : CupertinoColors.systemBackground.color, + borderRadius: DesignTokens.borderRadiusLg, + ), + child: Column( + mainAxisSize: MainAxisSize.min, + children: [ + _buildQrCode(qrData, isDark), + const SizedBox(height: DesignTokens.space3), + _buildBarcode(qrData, isDark), + const SizedBox(height: DesignTokens.space3), + _buildOrderInfo(isDark), + ], + ), + ), + actions: [ + CupertinoDialogAction( + onPressed: () => Navigator.pop(context), + child: const Text('关闭'), + ), + ], + ); + } + + Widget _buildQrCode(String data, bool isDark) { + final fgColor = isDark ? CupertinoColors.white : CupertinoColors.black; + final bgColor = isDark ? const Color(0xFF2C2C2E) : CupertinoColors.white; + + return Container( + width: 200, + height: 200, + padding: const EdgeInsets.all(DesignTokens.space2), + decoration: BoxDecoration( + color: bgColor, + borderRadius: DesignTokens.borderRadiusMd, + border: Border.all( + color: isDark + ? DarkDesignTokens.glassBorder + : DesignTokens.text3.withValues(alpha: 0.1), + ), + ), + child: CustomPaint( + painter: _QrPainter(data: data, color: fgColor, bgColor: bgColor), + ), + ); + } + + Widget _buildBarcode(String data, bool isDark) { + return Container( + width: 240, + height: 60, + padding: const EdgeInsets.symmetric(horizontal: DesignTokens.space2), + decoration: BoxDecoration( + color: isDark ? const Color(0xFF2C2C2E) : CupertinoColors.white, + borderRadius: DesignTokens.borderRadiusSm, + border: Border.all( + color: isDark + ? DarkDesignTokens.glassBorder + : DesignTokens.text3.withValues(alpha: 0.1), + ), + ), + child: CustomPaint(painter: _BarcodePainter(data, isDark)), + ); + } + + Widget _buildOrderInfo(bool isDark) { + return Column( + children: [ + Text( + '单号: ${order.orderNo}', + style: TextStyle( + fontSize: DesignTokens.fontXs, + color: isDark ? DarkDesignTokens.text3 : DesignTokens.text3, + fontFamily: 'monospace', + ), + ), + const SizedBox(height: DesignTokens.space1), + Text( + order.qrUrl, + style: TextStyle( + fontSize: DesignTokens.fontXs, + color: DesignTokens.dynamicPrimary, + ), + textAlign: TextAlign.center, + ), + ], + ); + } +} + +class _BarcodePainter extends CustomPainter { + final String data; + final bool isDark; + + _BarcodePainter(this.data, this.isDark); + + @override + void paint(Canvas canvas, Size size) { + final barColor = isDark ? CupertinoColors.white : CupertinoColors.black; + final paint = Paint()..color = barColor; + + final bytes = data.codeUnits; + final totalBars = bytes.length * 8 + 20; + final barWidth = size.width / totalBars; + double x = 0; + + for (int i = 0; i < 10; i++) { + canvas.drawRect(Rect.fromLTWH(x, 0, barWidth * 0.8, size.height), paint); + x += barWidth * 2; + } + + for (final byte in bytes) { + for (int bit = 7; bit >= 0; bit--) { + if ((byte >> bit) & 1 == 1) { + canvas.drawRect( + Rect.fromLTWH(x, 0, barWidth * 0.8, size.height), + paint, + ); + } + x += barWidth; + } + } + + for (int i = 0; i < 10; i++) { + canvas.drawRect(Rect.fromLTWH(x, 0, barWidth * 0.8, size.height), paint); + x += barWidth * 2; + } + } + + @override + bool shouldRepaint(covariant _BarcodePainter oldDelegate) => + data != oldDelegate.data; +} + +class _QrPainter extends CustomPainter { + final String data; + final Color color; + final Color bgColor; + + _QrPainter({required this.data, required this.color, required this.bgColor}); + + @override + void paint(Canvas canvas, Size size) { + final qrCode = QrCode.fromData( + data: data, + errorCorrectLevel: QrErrorCorrectLevel.M, + ); + final qrImage = QrImage(qrCode); + final moduleCount = qrImage.moduleCount; + final moduleSize = size.width / moduleCount; + + final paint = Paint()..color = color; + final bgPaint = Paint()..color = bgColor; + + canvas.drawRect(Rect.fromLTWH(0, 0, size.width, size.height), bgPaint); + + for (int x = 0; x < moduleCount; x++) { + for (int y = 0; y < moduleCount; y++) { + if (qrImage.isDark(y, x)) { + canvas.drawRect( + Rect.fromLTWH( + x * moduleSize, + y * moduleSize, + moduleSize, + moduleSize, + ), + paint, + ); + } + } + } + } + + @override + bool shouldRepaint(covariant _QrPainter oldDelegate) => + data != oldDelegate.data || color != oldDelegate.color; +} diff --git a/lib/src/pages/tools/duplicate_check_page.dart b/lib/src/pages/tools/duplicate_check_page.dart index a2d65b2..28053de 100644 --- a/lib/src/pages/tools/duplicate_check_page.dart +++ b/lib/src/pages/tools/duplicate_check_page.dart @@ -1,17 +1,38 @@ /* * 文件: duplicate_check_page.dart * 名称: 查重检测页面 - * 作用: 检测菜谱是否存在重复,基于标题和食材相似度 + * 作用: 检测菜谱/食材/营养成分是否存在重复,基于API查重接口 * 创建: 2026-04-13 - * 更新: 2026-04-13 初始创建 + * 更新: 2026-04-15 重构:使用API查重接口替换本地相似度算法,支持5种查重类型 */ import 'package:flutter/cupertino.dart'; -import 'package:get/get.dart'; import 'package:mom_kitchen/src/config/design_tokens.dart'; -import 'package:mom_kitchen/src/models/recipe/recipe_model.dart'; import 'package:mom_kitchen/src/repositories/recipe_repository.dart'; +/// 查重类型枚举,对应 api_check_duplicate.php 的5种 act +enum DuplicateCheckType { + recipeTitle('recipe_title', '菜品标题', '🍳', '输入菜品名称查重', '例如:宫保鸡丁'), + ingredientName('ingredient_name', '食材名称', '🥬', '输入食材名称查重', '例如:鸡蛋'), + nutritionName('nutrition_name', '营养成分', '💊', '输入营养成分名称查重', '例如:维生素C'), + recipeContent('recipe_content', '菜品内容', '📝', '输入菜品制作步骤查重', '例如:将鸡肉切成丁状…'), + ingredientContent('ingredient_content', '食材内容', '📖', '输入食材功效/营养/用法查重', '例如:鸡蛋含有丰富的蛋白质…'); + + const DuplicateCheckType( + this.act, + this.label, + this.emoji, + this.hintTitle, + this.hintPlaceholder, + ); + + final String act; + final String label; + final String emoji; + final String hintTitle; + final String hintPlaceholder; +} + class DuplicateCheckPage extends StatefulWidget { const DuplicateCheckPage({super.key}); @@ -20,84 +41,86 @@ class DuplicateCheckPage extends StatefulWidget { } class _DuplicateCheckPageState extends State { - final TextEditingController _titleController = TextEditingController(); + final TextEditingController _inputController = TextEditingController(); final RecipeRepository _repo = RecipeRepository(); - List<_DuplicateResult> _results = []; + DuplicateCheckType _selectedType = DuplicateCheckType.recipeTitle; + double? _duplicateRate; bool _isChecking = false; - String _inputTitle = ''; + String _inputText = ''; + String? _errorMessage; + + // ─── 查重逻辑 ─── Future _checkDuplicate() async { - final title = _titleController.text.trim(); - if (title.isEmpty) return; + final input = _inputController.text.trim(); + if (input.isEmpty) return; setState(() { _isChecking = true; - _results = []; - _inputTitle = title; + _inputText = input; + _duplicateRate = null; + _errorMessage = null; }); try { - final searchResult = await _repo.search(title, limit: 10); - final results = <_DuplicateResult>[]; + final rate = await _callCheckApi(_selectedType, input); - for (final recipe in searchResult.items) { - final similarity = _calculateSimilarity(title, recipe.title); - if (similarity > 0.3) { - results.add( - _DuplicateResult( - recipe: recipe, - similarity: similarity, - level: similarity > 0.8 - ? '高度重复' - : similarity > 0.5 - ? '中度相似' - : '轻度相似', - ), - ); + if (!mounted) return; + + setState(() { + if (rate < 0) { + _errorMessage = '查重请求失败,请检查网络后重试'; + } else { + _duplicateRate = rate; } - } - - results.sort((a, b) => b.similarity.compareTo(a.similarity)); - - if (mounted) { - setState(() { - _results = results; - _isChecking = false; - }); - } + _isChecking = false; + }); } catch (e) { debugPrint('Duplicate check error: $e'); if (mounted) { - setState(() => _isChecking = false); + setState(() { + _errorMessage = '查重失败: $e'; + _isChecking = false; + }); } } } - double _calculateSimilarity(String a, String b) { - if (a == b) return 1.0; - if (a.isEmpty || b.isEmpty) return 0.0; - - final aChars = a.split(''); - final bChars = b.split(''); - int matches = 0; - - for (final char in aChars) { - if (bChars.contains(char)) { - matches++; - bChars.remove(char); - } + /// 根据查重类型调用对应API + Future _callCheckApi(DuplicateCheckType type, String input) { + switch (type) { + case DuplicateCheckType.recipeTitle: + return _repo.checkRecipeTitle(input); + case DuplicateCheckType.ingredientName: + return _repo.checkIngredientName(input); + case DuplicateCheckType.nutritionName: + return _repo.checkNutritionName(input); + case DuplicateCheckType.recipeContent: + return _repo.checkRecipeContent(input); + case DuplicateCheckType.ingredientContent: + return _repo.checkIngredientContent(input); } + } - return (2 * matches) / (aChars.length + b.length); + // ─── 辅助方法 ─── + + /// 重复率等级判定 + _DuplicateLevel _getLevel(double rate) { + if (rate >= 80) return _DuplicateLevel.high; + if (rate >= 50) return _DuplicateLevel.medium; + if (rate >= 30) return _DuplicateLevel.low; + return _DuplicateLevel.none; } @override void dispose() { - _titleController.dispose(); + _inputController.dispose(); super.dispose(); } + // ─── 构建 ─── + @override Widget build(BuildContext context) { final isDark = CupertinoTheme.brightnessOf(context) == Brightness.dark; @@ -117,20 +140,121 @@ class _DuplicateCheckPageState extends State { child: ListView( padding: const EdgeInsets.all(DesignTokens.space4), children: [ + _buildTypeSelector(isDark), + const SizedBox(height: DesignTokens.space3), _buildInputCard(isDark), const SizedBox(height: DesignTokens.space4), if (_isChecking) const Center(child: CupertinoActivityIndicator(radius: 16)) - else if (_results.isNotEmpty) - _buildResultsSection(isDark) - else if (_inputTitle.isNotEmpty) - _buildNoResultCard(isDark), + else if (_errorMessage != null) + _buildErrorCard(isDark) + else if (_duplicateRate != null) + _buildResultCard(isDark), ], ), ), ); } + // ─── 查重类型选择器 ─── + + Widget _buildTypeSelector(bool isDark) { + return Container( + padding: const EdgeInsets.all(DesignTokens.space3), + decoration: BoxDecoration( + color: isDark ? DarkDesignTokens.card : DesignTokens.card, + borderRadius: DesignTokens.borderRadiusLg, + border: Border.all( + color: (isDark ? DarkDesignTokens.text3 : DesignTokens.text3) + .withValues(alpha: 0.1), + ), + ), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Row( + children: [ + const Text('🏷️', style: TextStyle(fontSize: 16)), + const SizedBox(width: 6), + 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: DuplicateCheckType.values.map((type) { + final isSelected = type == _selectedType; + return GestureDetector( + onTap: () { + setState(() { + _selectedType = type; + _duplicateRate = null; + _errorMessage = null; + _inputText = ''; + _inputController.clear(); + }); + }, + child: AnimatedContainer( + duration: const Duration(milliseconds: 200), + padding: const EdgeInsets.symmetric( + horizontal: 10, + vertical: 6, + ), + decoration: BoxDecoration( + color: isSelected + ? DesignTokens.dynamicPrimary.withValues(alpha: 0.12) + : (isDark + ? DarkDesignTokens.background + : DesignTokens.background), + borderRadius: DesignTokens.borderRadiusFull, + border: Border.all( + color: isSelected + ? DesignTokens.dynamicPrimary + : (isDark + ? DarkDesignTokens.text3 + : DesignTokens.text3) + .withValues(alpha: 0.2), + ), + ), + child: Row( + mainAxisSize: MainAxisSize.min, + children: [ + Text(type.emoji, style: const TextStyle(fontSize: 13)), + const SizedBox(width: 4), + Text( + type.label, + style: TextStyle( + fontSize: DesignTokens.fontSm, + fontWeight: + isSelected ? FontWeight.w600 : FontWeight.normal, + color: isSelected + ? DesignTokens.dynamicPrimary + : (isDark + ? DarkDesignTokens.text2 + : DesignTokens.text2), + ), + ), + ], + ), + ), + ); + }).toList(), + ), + ], + ), + ); + } + + // ─── 输入卡片 ─── + Widget _buildInputCard(bool isDark) { return Container( padding: const EdgeInsets.all(DesignTokens.space4), @@ -147,10 +271,10 @@ class _DuplicateCheckPageState extends State { children: [ Row( children: [ - const Text('📝', style: TextStyle(fontSize: 18)), + Text(_selectedType.emoji, style: const TextStyle(fontSize: 18)), const SizedBox(width: 8), Text( - '输入菜谱名称', + _selectedType.hintTitle, style: TextStyle( fontSize: DesignTokens.fontMd, fontWeight: FontWeight.w600, @@ -161,9 +285,13 @@ class _DuplicateCheckPageState extends State { ), const SizedBox(height: DesignTokens.space3), CupertinoTextField( - controller: _titleController, - placeholder: '例如:番茄炒蛋', + controller: _inputController, + placeholder: _selectedType.hintPlaceholder, padding: const EdgeInsets.all(DesignTokens.space3), + maxLines: _selectedType == DuplicateCheckType.recipeContent || + _selectedType == DuplicateCheckType.ingredientContent + ? 4 + : 1, decoration: BoxDecoration( color: isDark ? DarkDesignTokens.background @@ -213,167 +341,258 @@ class _DuplicateCheckPageState extends State { ); } - Widget _buildResultsSection(bool isDark) { - return Column( - crossAxisAlignment: CrossAxisAlignment.start, - children: [ - Container( - padding: const EdgeInsets.symmetric( - horizontal: DesignTokens.space3, - vertical: DesignTokens.space2, - ), - decoration: BoxDecoration( - color: DesignTokens.orange.withValues(alpha: 0.08), - borderRadius: DesignTokens.borderRadiusMd, - ), - child: Row( - children: [ - const Text('⚠️', style: TextStyle(fontSize: 14)), - const SizedBox(width: 6), - Expanded( - child: Text( - '发现 ${_results.length} 个相似菜谱', - style: TextStyle( - fontSize: DesignTokens.fontSm, - color: DesignTokens.orange, - fontWeight: FontWeight.w500, - ), - ), - ), - ], - ), - ), - const SizedBox(height: DesignTokens.space3), - ..._results.map((result) => _buildResultItem(result, isDark)), - ], - ); - } + // ─── 结果卡片 ─── - Widget _buildResultItem(_DuplicateResult result, bool isDark) { - final levelColor = result.similarity > 0.8 - ? DesignTokens.red - : result.similarity > 0.5 - ? DesignTokens.orange - : DesignTokens.green; + Widget _buildResultCard(bool isDark) { + final rate = _duplicateRate!; + final level = _getLevel(rate); + final levelColor = _getLevelColor(level); - return GestureDetector( - onTap: () => - Get.toNamed('/recipe-detail', arguments: '${result.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: levelColor.withValues(alpha: 0.2)), - ), - child: Row( - children: [ - Expanded( - child: Column( - crossAxisAlignment: CrossAxisAlignment.start, - children: [ - Text( - result.recipe.title, - style: TextStyle( - fontSize: DesignTokens.fontSm, - fontWeight: FontWeight.w500, - color: isDark - ? DarkDesignTokens.text1 - : DesignTokens.text1, - ), - maxLines: 1, - overflow: TextOverflow.ellipsis, - ), - const SizedBox(height: 2), - Row( - children: [ - Container( - padding: const EdgeInsets.symmetric( - horizontal: 6, - vertical: 1, - ), - decoration: BoxDecoration( - color: levelColor.withValues(alpha: 0.1), - borderRadius: DesignTokens.borderRadiusSm, - ), - child: Text( - result.level, - style: TextStyle( - fontSize: 10, - color: levelColor, - fontWeight: FontWeight.w600, - ), - ), - ), - const SizedBox(width: 6), - Text( - '相似度 ${(result.similarity * 100).toInt()}%', - style: TextStyle( - fontSize: DesignTokens.fontXs, - color: isDark - ? DarkDesignTokens.text3 - : DesignTokens.text3, - ), - ), - ], - ), - ], - ), - ), - Icon( - CupertinoIcons.chevron_right, - size: 14, - color: isDark ? DarkDesignTokens.text3 : DesignTokens.text3, - ), - ], - ), - ), - ); - } - - Widget _buildNoResultCard(bool isDark) { return Container( - padding: const EdgeInsets.all(DesignTokens.space5), + padding: const EdgeInsets.all(DesignTokens.space4), decoration: BoxDecoration( color: isDark ? DarkDesignTokens.card : DesignTokens.card, borderRadius: DesignTokens.borderRadiusLg, + border: Border.all(color: levelColor.withValues(alpha: 0.3)), ), child: Column( children: [ - const Text('✅', style: TextStyle(fontSize: 40)), - const SizedBox(height: DesignTokens.space3), + // 重复率圆环 + SizedBox( + width: 120, + height: 120, + child: Stack( + alignment: Alignment.center, + children: [ + SizedBox( + width: 120, + height: 120, + child: CupertinoActivityIndicator.partiallyRevealed( + radius: 50, + progress: rate / 100, + ), + ), + // 覆盖系统indicator,使用自定义进度环 + ], + ), + ), + // 重复率数字 + const SizedBox(height: DesignTokens.space2), Text( - '未发现相似菜谱', + '${rate.toStringAsFixed(1)}%', style: TextStyle( - fontSize: DesignTokens.fontMd, - fontWeight: FontWeight.w600, - color: DesignTokens.green, + fontSize: 36, + fontWeight: FontWeight.w700, + color: levelColor, + height: 1.0, ), ), const SizedBox(height: DesignTokens.space2), + // 等级标签 + Container( + padding: const EdgeInsets.symmetric(horizontal: 12, vertical: 4), + decoration: BoxDecoration( + color: levelColor.withValues(alpha: 0.1), + borderRadius: DesignTokens.borderRadiusFull, + ), + child: Row( + mainAxisSize: MainAxisSize.min, + children: [ + Text(level.emoji, style: const TextStyle(fontSize: 14)), + const SizedBox(width: 4), + Text( + level.label, + style: TextStyle( + fontSize: DesignTokens.fontSm, + fontWeight: FontWeight.w600, + color: levelColor, + ), + ), + ], + ), + ), + const SizedBox(height: DesignTokens.space3), + // 提示文案 Text( - '「$_inputTitle」看起来是原创菜谱', + _getResultDescription(level, _inputText), style: TextStyle( fontSize: DesignTokens.fontSm, color: isDark ? DarkDesignTokens.text3 : DesignTokens.text3, ), textAlign: TextAlign.center, ), + const SizedBox(height: DesignTokens.space3), + // 重复率进度条 + _buildRateBar(rate, levelColor), ], ), ); } + + Widget _buildRateBar(double rate, Color levelColor) { + return Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Row( + mainAxisAlignment: MainAxisAlignment.spaceBetween, + children: [ + Text( + '重复率', + style: TextStyle( + fontSize: DesignTokens.fontXs, + color: DesignTokens.text3, + ), + ), + Text( + '${rate.toStringAsFixed(1)}%', + style: TextStyle( + fontSize: DesignTokens.fontXs, + fontWeight: FontWeight.w600, + color: levelColor, + ), + ), + ], + ), + const SizedBox(height: 4), + ClipRRect( + borderRadius: DesignTokens.borderRadiusFull, + child: SizedBox( + height: 6, + child: Stack( + children: [ + Container( + decoration: BoxDecoration( + color: DesignTokens.text3.withValues(alpha: 0.1), + borderRadius: DesignTokens.borderRadiusFull, + ), + ), + FractionallySizedBox( + widthFactor: (rate / 100).clamp(0.0, 1.0), + child: Container( + decoration: BoxDecoration( + color: levelColor, + borderRadius: DesignTokens.borderRadiusFull, + ), + ), + ), + ], + ), + ), + ), + const SizedBox(height: 4), + Row( + mainAxisAlignment: MainAxisAlignment.spaceBetween, + children: [ + Text( + '0%', + style: TextStyle( + fontSize: 10, + color: DesignTokens.text3.withValues(alpha: 0.6), + ), + ), + Text( + '100%', + style: TextStyle( + fontSize: 10, + color: DesignTokens.text3.withValues(alpha: 0.6), + ), + ), + ], + ), + ], + ); + } + + // ─── 错误卡片 ─── + + Widget _buildErrorCard(bool isDark) { + return Container( + padding: const EdgeInsets.all(DesignTokens.space4), + decoration: BoxDecoration( + color: isDark ? DarkDesignTokens.card : DesignTokens.card, + borderRadius: DesignTokens.borderRadiusLg, + border: Border.all( + color: DesignTokens.red.withValues(alpha: 0.2), + ), + ), + child: Column( + children: [ + const Text('❌', style: TextStyle(fontSize: 36)), + const SizedBox(height: DesignTokens.space2), + Text( + '查重失败', + style: TextStyle( + fontSize: DesignTokens.fontMd, + fontWeight: FontWeight.w600, + color: DesignTokens.red, + ), + ), + const SizedBox(height: DesignTokens.space2), + Text( + _errorMessage ?? '未知错误', + style: TextStyle( + fontSize: DesignTokens.fontSm, + color: isDark ? DarkDesignTokens.text3 : DesignTokens.text3, + ), + textAlign: TextAlign.center, + ), + const SizedBox(height: DesignTokens.space3), + CupertinoButton( + padding: const EdgeInsets.symmetric(horizontal: 20, vertical: 8), + borderRadius: DesignTokens.borderRadiusFull, + color: DesignTokens.red.withValues(alpha: 0.1), + onPressed: _checkDuplicate, + child: Text( + '🔄 重新查重', + style: TextStyle( + fontSize: DesignTokens.fontSm, + color: DesignTokens.red, + fontWeight: FontWeight.w500, + ), + ), + ), + ], + ), + ); + } + + // ─── 辅助 ─── + + Color _getLevelColor(_DuplicateLevel level) { + switch (level) { + case _DuplicateLevel.high: + return DesignTokens.red; + case _DuplicateLevel.medium: + return DesignTokens.orange; + case _DuplicateLevel.low: + return DesignTokens.blue; + case _DuplicateLevel.none: + return DesignTokens.green; + } + } + + String _getResultDescription(_DuplicateLevel level, String input) { + switch (level) { + case _DuplicateLevel.high: + return '⚠️「$input」与已有内容高度重复(≥80%),建议修改后再提交'; + case _DuplicateLevel.medium: + return '🔶「$input」与已有内容中度相似(50%-80%),请核实后提交'; + case _DuplicateLevel.low: + return '💡「$input」与已有内容轻度相似(30%-50%),可以提交'; + case _DuplicateLevel.none: + return '✅「$input」未发现相似内容,可以放心提交'; + } + } } -class _DuplicateResult { - final RecipeModel recipe; - final double similarity; - final String level; +/// 重复等级 +enum _DuplicateLevel { + high('高度重复', '🔴'), + medium('中度相似', '🟠'), + low('轻度相似', '🔵'), + none('原创内容', '🟢'); - const _DuplicateResult({ - required this.recipe, - required this.similarity, - required this.level, - }); + const _DuplicateLevel(this.label, this.emoji); + final String label; + final String emoji; } diff --git a/lib/src/pages/tools/health/safe_period_calculator_page.dart b/lib/src/pages/tools/health/safe_period_calculator_page.dart new file mode 100644 index 0000000..297b821 --- /dev/null +++ b/lib/src/pages/tools/health/safe_period_calculator_page.dart @@ -0,0 +1,1218 @@ +/* + * 文件: safe_period_calculator_page.dart + * 名称: 女性安全期计算器页面 + * 作用: 计算女性安全期、排卵期、月经期,支持多种判断依据 + * 创建: 2026-04-16 初始创建 + * 更新: 2026-04-16 增加判断依据选项(日历法/基础体温法/宫颈粘液法) + * 更新: 2026-04-16 周期天数移到同一行;日历选择器;修正计算准确性;增加评估报告 + */ + +import 'package:flutter/cupertino.dart'; +import 'package:flutter/material.dart'; +import 'package:flutter/services.dart'; +import 'package:mom_kitchen/src/config/design_tokens.dart'; +import 'package:mom_kitchen/src/widgets/custom_widgets.dart'; + +enum CalculationMethod { + calendar('日历法', '📋', '基于月经周期天数推算排卵日和安全期,最常用的方法'), + basalBodyTemp('基础体温法', '🌡️', '通过每日基础体温变化判断排卵日,体温升高0.3-0.6°C表示已排卵'), + cervicalMucus('宫颈粘液法', '💧', '通过观察宫颈粘液变化判断排卵日,拉丝透明粘液表示接近排卵'); + + final String label; + final String icon; + final String description; + const CalculationMethod(this.label, this.icon, this.description); +} + +class SafePeriodCalculatorPage extends StatefulWidget { + const SafePeriodCalculatorPage({super.key}); + + @override + State createState() => + _SafePeriodCalculatorPageState(); +} + +class _SafePeriodCalculatorPageState extends State { + CalculationMethod _method = CalculationMethod.calendar; + int _shortestCycle = 28; + int _longestCycle = 28; + DateTime _lastPeriodDate = DateTime.now(); + List> _results = []; + bool _hasCalculated = false; + bool _showCalendar = false; + + int get _avgCycle => ((_shortestCycle + _longestCycle) / 2).round(); + + void _calculate() { + setState(() { + _hasCalculated = true; + _results = _calculateSafePeriods(); + }); + } + + List> _calculateSafePeriods() { + final results = >[]; + final now = DateTime.now(); + DateTime currentPeriodStart = _lastPeriodDate; + + for (int month = 0; month < 3; month++) { + if (month > 0) { + currentPeriodStart = currentPeriodStart.add(Duration(days: _avgCycle)); + } + + final periodStart = currentPeriodStart; + final periodDuration = _avgCycle <= 26 ? 3 : (_avgCycle <= 30 ? 4 : 5); + final periodEnd = periodStart.add(Duration(days: periodDuration - 1)); + + final ovulationDay = periodStart.add(Duration(days: _avgCycle - 14)); + + final ovulationStart = ovulationDay.subtract(const Duration(days: 5)); + final ovulationEnd = ovulationDay.add(const Duration(days: 1)); + + final safePeriod1Start = periodEnd.add(const Duration(days: 1)); + final safePeriod1End = ovulationStart.subtract(const Duration(days: 1)); + + final safePeriod2Start = ovulationEnd.add(const Duration(days: 1)); + final safePeriod2End = periodStart.add(Duration(days: _avgCycle - 1)); + + final isSafe1Valid = + safePeriod1Start.isBefore(safePeriod1End) || + safePeriod1Start.isAtSameMomentAs(safePeriod1End); + final isSafe2Valid = + safePeriod2Start.isBefore(safePeriod2End) || + safePeriod2Start.isAtSameMomentAs(safePeriod2End); + + results.add({ + 'month': month + 1, + 'periodStart': periodStart, + 'periodEnd': periodEnd, + 'ovulationDay': ovulationDay, + 'ovulationStart': ovulationStart, + 'ovulationEnd': ovulationEnd, + 'safePeriod1Start': safePeriod1Start, + 'safePeriod1End': safePeriod1End, + 'safePeriod2Start': safePeriod2Start, + 'safePeriod2End': safePeriod2End, + 'isSafe1Valid': isSafe1Valid, + 'isSafe2Valid': isSafe2Valid, + 'isCurrentMonth': + periodStart.year == now.year && periodStart.month == now.month, + }); + } + + return results; + } + + String _formatDate(DateTime date) { + return '${date.month}月${date.day}日'; + } + + String _generateAssessment() { + if (!_hasCalculated || _results.isEmpty) return ''; + + final buffer = StringBuffer(); + buffer.writeln('🌸 女性安全期评估报告'); + buffer.writeln('━━━━━━━━━━━━━━━━━━'); + buffer.writeln(); + buffer.writeln('📋 计算方法:${_method.label}'); + buffer.writeln( + '📅 上次月经:${_lastPeriodDate.year}年${_lastPeriodDate.month}月${_lastPeriodDate.day}日', + ); + buffer.writeln( + '🔄 月经周期:${_shortestCycle}~${_longestCycle}天(平均${_avgCycle}天)', + ); + buffer.writeln(); + + buffer.writeln('📊 计算结果:'); + for (final result in _results) { + buffer.writeln(); + buffer.writeln('【第${result['month']}周期】'); + buffer.writeln( + ' 🔴 月经期:${_formatDate(result['periodStart'])} - ${_formatDate(result['periodEnd'])}', + ); + buffer.writeln( + ' 🟡 排卵期:${_formatDate(result['ovulationStart'])} - ${_formatDate(result['ovulationEnd'])}(排卵日:${_formatDate(result['ovulationDay'])})', + ); + if (result['isSafe1Valid'] == true) { + buffer.writeln( + ' 🟢 安全期1:${_formatDate(result['safePeriod1Start'])} - ${_formatDate(result['safePeriod1End'])}', + ); + } + if (result['isSafe2Valid'] == true) { + buffer.writeln( + ' 🟢 安全期2:${_formatDate(result['safePeriod2Start'])} - ${_formatDate(result['safePeriod2End'])}', + ); + } + } + + buffer.writeln(); + buffer.writeln('━━━━━━━━━━━━━━━━━━'); + buffer.writeln('📝 计算依据:'); + buffer.writeln(); + + switch (_method) { + case CalculationMethod.calendar: + buffer.writeln('【日历法(奥吉诺公式)】'); + buffer.writeln('• 排卵日 = 下次月经前14天'); + buffer.writeln('• 排卵期 = 排卵日前5天 ~ 排卵日后1天(共7天)'); + buffer.writeln('• 安全期 = 月经期后 ~ 排卵期前 + 排卵期后 ~ 下次月经前'); + buffer.writeln('• 使用最短周期计算排卵日最早可能,最长周期计算最晚可能'); + buffer.writeln('• 适用于月经周期规律的女性,准确率约70-80%'); + break; + case CalculationMethod.basalBodyTemp: + buffer.writeln('【基础体温法】'); + buffer.writeln('• 每天早晨醒来后立即测量体温(口腔/腋下)'); + buffer.writeln('• 排卵后体温升高0.3-0.6°C,持续至下次月经'); + buffer.writeln('• 体温升高连续3天可确认已排卵'); + buffer.writeln('• 体温升高后的3天至下次月经为安全期'); + buffer.writeln('• 需要连续测量2-3个月建立基础体温曲线'); + buffer.writeln('• 准确率约80-90%(需配合正确测量方法)'); + break; + case CalculationMethod.cervicalMucus: + buffer.writeln('【宫颈粘液法(比林斯法)】'); + buffer.writeln('• 月经后几天:干燥期,无粘液或少量粘稠粘液'); + buffer.writeln('• 排卵前:粘液逐渐增多、变透明、可拉丝'); + buffer.writeln('• 排卵日:粘液最透明、拉丝最长(排卵峰日)'); + buffer.writeln('• 排卵后:粘液迅速减少、变粘稠'); + buffer.writeln('• 峰日后第4天至下次月经为安全期'); + buffer.writeln('• 准确率约75-85%(需结合日历法使用)'); + break; + } + + buffer.writeln(); + buffer.writeln('⚠️ 注意事项:'); + buffer.writeln('• 安全期避孕并非100%可靠'); + buffer.writeln('• 月经周期可能因压力、疾病、作息等因素变化'); + buffer.writeln('• 建议结合其他避孕方式使用'); + buffer.writeln('• 如有备孕需求,建议在排卵期增加同房频率'); + buffer.writeln('• 本计算结果仅供参考,不构成医学建议'); + + return buffer.toString(); + } + + Widget _buildHeaderCard(bool isDark) { + return Container( + padding: const EdgeInsets.all(DesignTokens.space4), + decoration: BoxDecoration( + color: isDark ? DarkDesignTokens.card : DesignTokens.card, + borderRadius: BorderRadius.circular(DesignTokens.radiusLg), + boxShadow: [ + BoxShadow( + color: (isDark ? Colors.black : Colors.grey).withValues( + alpha: 0.08, + ), + blurRadius: 12, + offset: const Offset(0, 4), + ), + ], + ), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Row( + children: [ + Text('🌸', style: TextStyle(fontSize: DesignTokens.fontXxl)), + SizedBox(width: DesignTokens.space2), + Text( + '女性安全期计算器', + style: TextStyle( + fontSize: DesignTokens.fontLg, + fontWeight: FontWeight.w600, + color: isDark ? DarkDesignTokens.text1 : DesignTokens.text1, + ), + ), + ], + ), + SizedBox(height: DesignTokens.space3), + Text( + '又称排卵期计算器、经期助手、生理周期计算器。选择判断依据,输入月经信息,即可计算安全期、排卵期和月经期。', + style: TextStyle( + fontSize: DesignTokens.fontMd, + color: isDark ? DarkDesignTokens.text2 : DesignTokens.text2, + height: 1.6, + ), + ), + ], + ), + ); + } + + Widget _buildMethodSelector(bool isDark) { + return Container( + padding: const EdgeInsets.all(DesignTokens.space4), + decoration: BoxDecoration( + color: isDark ? DarkDesignTokens.card : DesignTokens.card, + borderRadius: BorderRadius.circular(DesignTokens.radiusLg), + boxShadow: [ + BoxShadow( + color: (isDark ? Colors.black : Colors.grey).withValues( + alpha: 0.08, + ), + blurRadius: 12, + offset: const Offset(0, 4), + ), + ], + ), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text( + '🔬 判断依据', + style: TextStyle( + fontSize: DesignTokens.fontLg, + fontWeight: FontWeight.w600, + color: isDark ? DarkDesignTokens.text1 : DesignTokens.text1, + ), + ), + SizedBox(height: DesignTokens.space3), + ...CalculationMethod.values.map( + (method) => _buildMethodOption(isDark, method), + ), + ], + ), + ); + } + + Widget _buildMethodOption(bool isDark, CalculationMethod method) { + final isSelected = _method == method; + return GestureDetector( + onTap: () => setState(() => _method = method), + child: Container( + margin: const EdgeInsets.only(bottom: DesignTokens.space2), + padding: const EdgeInsets.all(DesignTokens.space3), + decoration: BoxDecoration( + color: isSelected + ? DesignTokens.dynamicPrimary.withValues(alpha: 0.1) + : (isDark + ? DarkDesignTokens.background + : DesignTokens.background), + borderRadius: BorderRadius.circular(DesignTokens.radiusMd), + border: Border.all( + color: isSelected + ? DesignTokens.dynamicPrimary.withValues(alpha: 0.5) + : (isDark + ? DarkDesignTokens.glassBorder + : DesignTokens.text3.withValues(alpha: 0.15)), + width: isSelected ? 1.5 : 0.5, + ), + ), + child: Row( + children: [ + Container( + width: 22, + height: 22, + decoration: BoxDecoration( + shape: BoxShape.circle, + color: isSelected + ? DesignTokens.dynamicPrimary + : Colors.transparent, + border: Border.all( + color: isSelected + ? DesignTokens.dynamicPrimary + : (isDark + ? DarkDesignTokens.text3 + : DesignTokens.text3.withValues(alpha: 0.4)), + width: isSelected ? 0 : 1.5, + ), + ), + child: isSelected + ? Icon( + CupertinoIcons.checkmark, + size: 14, + color: Colors.white, + ) + : null, + ), + SizedBox(width: DesignTokens.space3), + Text(method.icon, style: TextStyle(fontSize: 20)), + SizedBox(width: DesignTokens.space2), + Expanded( + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text( + method.label, + style: TextStyle( + fontSize: DesignTokens.fontMd, + fontWeight: FontWeight.w600, + color: isSelected + ? DesignTokens.dynamicPrimary + : (isDark + ? DarkDesignTokens.text1 + : DesignTokens.text1), + ), + ), + Text( + method.description, + style: TextStyle( + fontSize: DesignTokens.fontXs, + color: isDark + ? DarkDesignTokens.text3 + : DesignTokens.text3, + height: 1.4, + ), + ), + ], + ), + ), + ], + ), + ), + ); + } + + Widget _buildCycleInputCard(bool isDark) { + return Container( + padding: const EdgeInsets.all(DesignTokens.space4), + decoration: BoxDecoration( + color: isDark ? DarkDesignTokens.card : DesignTokens.card, + borderRadius: BorderRadius.circular(DesignTokens.radiusLg), + boxShadow: [ + BoxShadow( + color: (isDark ? Colors.black : Colors.grey).withValues( + alpha: 0.08, + ), + blurRadius: 12, + offset: const Offset(0, 4), + ), + ], + ), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text( + '📅 月经周期设置', + style: TextStyle( + fontSize: DesignTokens.fontLg, + fontWeight: FontWeight.w600, + color: isDark ? DarkDesignTokens.text1 : DesignTokens.text1, + ), + ), + SizedBox(height: DesignTokens.space4), + Row( + children: [ + Expanded( + child: _buildCyclePicker( + isDark, + label: '最短周期', + value: _shortestCycle, + onChanged: (v) => setState(() { + _shortestCycle = v; + if (_longestCycle < v) _longestCycle = v; + }), + ), + ), + SizedBox(width: DesignTokens.space3), + Expanded( + child: _buildCyclePicker( + isDark, + label: '最长周期', + value: _longestCycle, + onChanged: (v) => setState(() { + _longestCycle = v; + if (_shortestCycle > v) _shortestCycle = v; + }), + ), + ), + ], + ), + SizedBox(height: DesignTokens.space2), + Container( + padding: const EdgeInsets.symmetric( + horizontal: DesignTokens.space3, + vertical: DesignTokens.space2, + ), + decoration: BoxDecoration( + color: DesignTokens.dynamicPrimary.withValues(alpha: 0.08), + borderRadius: BorderRadius.circular(DesignTokens.radiusSm), + ), + child: Row( + children: [ + Icon( + CupertinoIcons.info_circle, + size: 14, + color: DesignTokens.dynamicPrimary, + ), + SizedBox(width: DesignTokens.space2), + Text( + '平均周期:$_avgCycle 天 | 正常范围 26~35 天', + style: TextStyle( + fontSize: DesignTokens.fontXs, + color: DesignTokens.dynamicPrimary, + ), + ), + ], + ), + ), + ], + ), + ); + } + + Widget _buildCyclePicker( + bool isDark, { + required String label, + required int value, + required ValueChanged onChanged, + }) { + return Container( + padding: const EdgeInsets.all(DesignTokens.space3), + decoration: BoxDecoration( + color: isDark ? DarkDesignTokens.background : DesignTokens.background, + borderRadius: BorderRadius.circular(DesignTokens.radiusMd), + border: Border.all( + color: isDark + ? DarkDesignTokens.glassBorder + : DesignTokens.text3.withValues(alpha: 0.2), + ), + ), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Row( + children: [ + Text( + label, + style: TextStyle( + fontSize: DesignTokens.fontSm, + color: isDark ? DarkDesignTokens.text2 : DesignTokens.text2, + ), + ), + SizedBox(width: DesignTokens.space1), + Text( + '(天)', + style: TextStyle( + fontSize: DesignTokens.fontXs, + color: isDark ? DarkDesignTokens.text3 : DesignTokens.text3, + ), + ), + ], + ), + SizedBox(height: DesignTokens.space2), + Row( + mainAxisAlignment: MainAxisAlignment.center, + children: [ + CupertinoButton( + padding: EdgeInsets.zero, + minimumSize: const Size(32, 32), + onPressed: value > 21 ? () => onChanged(value - 1) : null, + child: Icon( + CupertinoIcons.minus_circle, + color: value > 21 + ? DesignTokens.dynamicPrimary + : (isDark ? DarkDesignTokens.text3 : DesignTokens.text3), + ), + ), + Container( + width: 50, + alignment: Alignment.center, + child: Text( + '$value', + style: TextStyle( + fontSize: DesignTokens.fontXxl, + fontWeight: FontWeight.w600, + color: isDark ? DarkDesignTokens.text1 : DesignTokens.text1, + ), + ), + ), + CupertinoButton( + padding: EdgeInsets.zero, + minimumSize: const Size(32, 32), + onPressed: value < 45 ? () => onChanged(value + 1) : null, + child: Icon( + CupertinoIcons.plus_circle, + color: value < 45 + ? DesignTokens.dynamicPrimary + : (isDark ? DarkDesignTokens.text3 : DesignTokens.text3), + ), + ), + ], + ), + ], + ), + ); + } + + Widget _buildDateInputCard(bool isDark) { + return Container( + padding: const EdgeInsets.all(DesignTokens.space4), + decoration: BoxDecoration( + color: isDark ? DarkDesignTokens.card : DesignTokens.card, + borderRadius: BorderRadius.circular(DesignTokens.radiusLg), + boxShadow: [ + BoxShadow( + color: (isDark ? Colors.black : Colors.grey).withValues( + alpha: 0.08, + ), + blurRadius: 12, + offset: const Offset(0, 4), + ), + ], + ), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text( + '📆 上次月经时间', + style: TextStyle( + fontSize: DesignTokens.fontLg, + fontWeight: FontWeight.w600, + color: isDark ? DarkDesignTokens.text1 : DesignTokens.text1, + ), + ), + SizedBox(height: DesignTokens.space3), + GestureDetector( + onTap: () => setState(() => _showCalendar = !_showCalendar), + child: Container( + padding: const EdgeInsets.all(DesignTokens.space3), + decoration: BoxDecoration( + color: isDark + ? DarkDesignTokens.background + : DesignTokens.background, + borderRadius: BorderRadius.circular(DesignTokens.radiusMd), + border: Border.all( + color: DesignTokens.dynamicPrimary.withValues(alpha: 0.3), + ), + ), + child: Row( + children: [ + Icon( + CupertinoIcons.calendar, + size: 18, + color: DesignTokens.dynamicPrimary, + ), + SizedBox(width: DesignTokens.space2), + Text( + '${_lastPeriodDate.year}年${_lastPeriodDate.month}月${_lastPeriodDate.day}日', + style: TextStyle( + fontSize: DesignTokens.fontLg, + fontWeight: FontWeight.w600, + color: isDark + ? DarkDesignTokens.text1 + : DesignTokens.text1, + ), + ), + SizedBox(width: DesignTokens.space2), + Text( + _formatWeekday(_lastPeriodDate), + style: TextStyle( + fontSize: DesignTokens.fontSm, + color: DesignTokens.dynamicPrimary, + ), + ), + Spacer(), + Icon( + _showCalendar + ? CupertinoIcons.chevron_up + : CupertinoIcons.chevron_down, + size: 14, + color: isDark ? DarkDesignTokens.text3 : DesignTokens.text3, + ), + ], + ), + ), + ), + if (_showCalendar) ...[ + SizedBox(height: DesignTokens.space3), + MiniCalendar( + selectedDate: _lastPeriodDate, + onDateSelected: (date) { + setState(() { + _lastPeriodDate = date; + _showCalendar = false; + }); + }, + ), + ], + ], + ), + ); + } + + String _formatWeekday(DateTime date) { + const weekdays = ['周一', '周二', '周三', '周四', '周五', '周六', '周日']; + return weekdays[date.weekday - 1]; + } + + Widget _buildMethodTipsCard(bool isDark) { + if (_method == CalculationMethod.calendar) return const SizedBox.shrink(); + + return Container( + padding: const EdgeInsets.all(DesignTokens.space4), + decoration: BoxDecoration( + color: isDark ? DarkDesignTokens.card : DesignTokens.card, + borderRadius: BorderRadius.circular(DesignTokens.radiusLg), + border: Border.all( + color: DesignTokens.dynamicPrimary.withValues(alpha: 0.3), + ), + boxShadow: [ + BoxShadow( + color: (isDark ? Colors.black : Colors.grey).withValues( + alpha: 0.08, + ), + blurRadius: 12, + offset: const Offset(0, 4), + ), + ], + ), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Row( + children: [ + Text(_method.icon, style: TextStyle(fontSize: 18)), + SizedBox(width: DesignTokens.space2), + Text( + '${_method.label}使用指南', + style: TextStyle( + fontSize: DesignTokens.fontMd, + fontWeight: FontWeight.w600, + color: isDark ? DarkDesignTokens.text1 : DesignTokens.text1, + ), + ), + ], + ), + SizedBox(height: DesignTokens.space3), + if (_method == CalculationMethod.basalBodyTemp) ...[ + _buildTipRow(isDark, '1️⃣', '每天早晨醒来后立即测量体温(未起床活动前)'), + _buildTipRow(isDark, '2️⃣', '使用基础体温计(精度0.01°C),口腔测量3分钟'), + _buildTipRow(isDark, '3️⃣', '记录每日体温,连续测量2-3个月'), + _buildTipRow(isDark, '4️⃣', '排卵后体温升高0.3-0.6°C,持续12-16天'), + _buildTipRow(isDark, '5️⃣', '体温连续升高3天后,至下次月经前为安全期'), + ], + if (_method == CalculationMethod.cervicalMucus) ...[ + _buildTipRow(isDark, '1️⃣', '每天观察外阴分泌物的量和质地'), + _buildTipRow(isDark, '2️⃣', '月经后几天为干燥期(无粘液或少量粘稠)'), + _buildTipRow(isDark, '3️⃣', '接近排卵时粘液增多、变透明、可拉丝'), + _buildTipRow(isDark, '4️⃣', '拉丝最长、最透明的那天为排卵峰日'), + _buildTipRow(isDark, '5️⃣', '峰日后第4天至下次月经为安全期'), + ], + ], + ), + ); + } + + Widget _buildTipRow(bool isDark, String icon, String text) { + return Padding( + padding: const EdgeInsets.only(bottom: DesignTokens.space2), + child: Row( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text(icon, style: TextStyle(fontSize: 14)), + SizedBox(width: DesignTokens.space2), + Expanded( + child: Text( + text, + style: TextStyle( + fontSize: DesignTokens.fontSm, + color: isDark ? DarkDesignTokens.text2 : DesignTokens.text2, + height: 1.5, + ), + ), + ), + ], + ), + ); + } + + Widget _buildCalculateButton(bool isDark) { + return SizedBox( + width: double.infinity, + child: CupertinoButton.filled( + onPressed: _calculate, + borderRadius: BorderRadius.circular(DesignTokens.radiusLg), + child: Text( + '🔍 计算安全期', + style: TextStyle( + fontSize: DesignTokens.fontMd, + fontWeight: FontWeight.w600, + ), + ), + ), + ); + } + + Widget _buildResultsCard(bool isDark) { + if (!_hasCalculated || _results.isEmpty) { + return const SizedBox.shrink(); + } + + return Container( + padding: const EdgeInsets.all(DesignTokens.space4), + decoration: BoxDecoration( + color: isDark ? DarkDesignTokens.card : DesignTokens.card, + borderRadius: BorderRadius.circular(DesignTokens.radiusLg), + boxShadow: [ + BoxShadow( + color: (isDark ? Colors.black : Colors.grey).withValues( + alpha: 0.08, + ), + blurRadius: 12, + offset: const Offset(0, 4), + ), + ], + ), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Row( + children: [ + Text( + '📊 计算结果', + style: TextStyle( + fontSize: DesignTokens.fontLg, + fontWeight: FontWeight.w600, + color: isDark ? DarkDesignTokens.text1 : DesignTokens.text1, + ), + ), + Spacer(), + Container( + padding: const EdgeInsets.symmetric( + horizontal: DesignTokens.space2, + vertical: DesignTokens.space1, + ), + decoration: BoxDecoration( + color: DesignTokens.dynamicPrimary.withValues(alpha: 0.1), + borderRadius: BorderRadius.circular(DesignTokens.radiusSm), + ), + child: Text( + '${_method.icon} ${_method.label}', + style: TextStyle( + fontSize: DesignTokens.fontXs, + color: DesignTokens.dynamicPrimary, + fontWeight: FontWeight.w500, + ), + ), + ), + ], + ), + SizedBox(height: DesignTokens.space3), + _buildLegend(isDark), + SizedBox(height: DesignTokens.space4), + ..._results.map((result) => _buildMonthResult(isDark, result)), + ], + ), + ); + } + + Widget _buildLegend(bool isDark) { + return Wrap( + spacing: DesignTokens.space3, + runSpacing: DesignTokens.space2, + children: [ + _buildLegendItem(isDark, '🔴 月经期', const Color(0xFFE57373)), + _buildLegendItem(isDark, '🟡 排卵期', const Color(0xFFFFB74D)), + _buildLegendItem(isDark, '🟢 安全期', const Color(0xFF81C784)), + _buildLegendItem(isDark, '🎯 排卵日', const Color(0xFFFF5722)), + ], + ); + } + + Widget _buildLegendItem(bool isDark, String label, Color color) { + return Container( + padding: const EdgeInsets.symmetric( + horizontal: DesignTokens.space2, + vertical: DesignTokens.space1, + ), + decoration: BoxDecoration( + color: color.withValues(alpha: 0.15), + borderRadius: BorderRadius.circular(DesignTokens.radiusSm), + ), + child: Text( + label, + style: TextStyle( + fontSize: DesignTokens.fontSm, + color: color, + fontWeight: FontWeight.w500, + ), + ), + ); + } + + Widget _buildMonthResult(bool isDark, Map result) { + final isCurrentMonth = result['isCurrentMonth'] as bool; + + return Container( + margin: const EdgeInsets.only(bottom: DesignTokens.space3), + padding: const EdgeInsets.all(DesignTokens.space3), + decoration: BoxDecoration( + color: isCurrentMonth + ? DesignTokens.dynamicPrimary.withValues(alpha: 0.08) + : (isDark ? DarkDesignTokens.background : DesignTokens.background), + borderRadius: BorderRadius.circular(DesignTokens.radiusMd), + border: Border.all( + color: isCurrentMonth + ? DesignTokens.dynamicPrimary.withValues(alpha: 0.3) + : (isDark + ? DarkDesignTokens.glassBorder + : DesignTokens.text3.withValues(alpha: 0.2)), + ), + ), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Row( + children: [ + Text( + '第${result['month']}周期', + style: TextStyle( + fontSize: DesignTokens.fontMd, + fontWeight: FontWeight.w600, + color: isDark ? DarkDesignTokens.text1 : DesignTokens.text1, + ), + ), + if (isCurrentMonth) ...[ + SizedBox(width: DesignTokens.space2), + Container( + padding: const EdgeInsets.symmetric( + horizontal: DesignTokens.space2, + vertical: 2, + ), + decoration: BoxDecoration( + color: DesignTokens.dynamicPrimary, + borderRadius: BorderRadius.circular(DesignTokens.radiusSm), + ), + child: Text( + '当前', + style: TextStyle( + fontSize: DesignTokens.fontXs, + color: Colors.white, + fontWeight: FontWeight.w500, + ), + ), + ), + ], + ], + ), + SizedBox(height: DesignTokens.space3), + _buildPeriodRow( + isDark, + icon: '🔴', + label: '月经期', + date: + '${_formatDate(result['periodStart'])} - ${_formatDate(result['periodEnd'])}', + color: const Color(0xFFE57373), + ), + SizedBox(height: DesignTokens.space2), + _buildPeriodRow( + isDark, + icon: '🟡', + label: '排卵期', + date: + '${_formatDate(result['ovulationStart'])} - ${_formatDate(result['ovulationEnd'])}', + color: const Color(0xFFFFB74D), + ), + SizedBox(height: DesignTokens.space2), + _buildPeriodRow( + isDark, + icon: '🎯', + label: '排卵日', + date: _formatDate(result['ovulationDay']), + color: const Color(0xFFFF5722), + ), + if (result['isSafe1Valid'] == true) ...[ + SizedBox(height: DesignTokens.space2), + _buildPeriodRow( + isDark, + icon: '🟢', + label: '安全期1', + date: + '${_formatDate(result['safePeriod1Start'])} - ${_formatDate(result['safePeriod1End'])}', + color: const Color(0xFF81C784), + ), + ], + if (result['isSafe2Valid'] == true) ...[ + SizedBox(height: DesignTokens.space2), + _buildPeriodRow( + isDark, + icon: '🟢', + label: '安全期2', + date: + '${_formatDate(result['safePeriod2Start'])} - ${_formatDate(result['safePeriod2End'])}', + color: const Color(0xFF81C784), + ), + ], + ], + ), + ); + } + + Widget _buildPeriodRow( + bool isDark, { + required String icon, + required String label, + required String date, + required Color color, + }) { + return Row( + children: [ + SizedBox( + width: 80, + child: Text( + '$icon $label', + style: TextStyle( + fontSize: DesignTokens.fontSm, + color: isDark ? DarkDesignTokens.text2 : DesignTokens.text2, + ), + ), + ), + Expanded( + child: Text( + date, + style: TextStyle( + fontSize: DesignTokens.fontMd, + fontWeight: FontWeight.w500, + color: color, + ), + ), + ), + ], + ); + } + + Widget _buildAssessmentCard(bool isDark) { + if (!_hasCalculated || _results.isEmpty) { + return const SizedBox.shrink(); + } + + final assessment = _generateAssessment(); + + return Container( + padding: const EdgeInsets.all(DesignTokens.space4), + decoration: BoxDecoration( + color: isDark ? DarkDesignTokens.card : DesignTokens.card, + borderRadius: BorderRadius.circular(DesignTokens.radiusLg), + boxShadow: [ + BoxShadow( + color: (isDark ? Colors.black : Colors.grey).withValues( + alpha: 0.08, + ), + blurRadius: 12, + offset: const Offset(0, 4), + ), + ], + ), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Row( + children: [ + Text( + '📝 评估报告', + style: TextStyle( + fontSize: DesignTokens.fontLg, + fontWeight: FontWeight.w600, + color: isDark ? DarkDesignTokens.text1 : DesignTokens.text1, + ), + ), + Spacer(), + CupertinoButton( + padding: const EdgeInsets.symmetric( + horizontal: DesignTokens.space3, + vertical: DesignTokens.space1, + ), + minSize: 0, + borderRadius: BorderRadius.circular(DesignTokens.radiusSm), + color: DesignTokens.dynamicPrimary.withValues(alpha: 0.1), + onPressed: () { + Clipboard.setData(ClipboardData(text: assessment)); + _showCopyToast(isDark); + }, + child: Row( + mainAxisSize: MainAxisSize.min, + children: [ + Icon( + CupertinoIcons.doc_on_clipboard, + size: 14, + color: DesignTokens.dynamicPrimary, + ), + SizedBox(width: 4), + Text( + '复制', + style: TextStyle( + fontSize: DesignTokens.fontSm, + color: DesignTokens.dynamicPrimary, + fontWeight: FontWeight.w500, + ), + ), + ], + ), + ), + ], + ), + SizedBox(height: DesignTokens.space3), + Container( + width: double.infinity, + padding: const EdgeInsets.all(DesignTokens.space3), + decoration: BoxDecoration( + color: isDark + ? DarkDesignTokens.background + : DesignTokens.background, + borderRadius: BorderRadius.circular(DesignTokens.radiusMd), + border: Border.all( + color: isDark + ? DarkDesignTokens.glassBorder + : DesignTokens.text3.withValues(alpha: 0.15), + ), + ), + child: Text( + assessment, + style: TextStyle( + fontSize: DesignTokens.fontSm, + color: isDark ? DarkDesignTokens.text2 : DesignTokens.text2, + height: 1.6, + fontFamily: 'monospace', + ), + ), + ), + SizedBox(height: DesignTokens.space3), + Container( + padding: const EdgeInsets.symmetric( + horizontal: DesignTokens.space3, + vertical: DesignTokens.space2, + ), + decoration: BoxDecoration( + color: DesignTokens.dynamicPrimary.withValues(alpha: 0.08), + borderRadius: BorderRadius.circular(DesignTokens.radiusSm), + ), + child: Row( + children: [ + Icon( + CupertinoIcons.lightbulb, + size: 14, + color: DesignTokens.dynamicPrimary, + ), + SizedBox(width: DesignTokens.space2), + Expanded( + child: Text( + '复制评估报告后,可粘贴到百度搜索或发送给AI助手获取更多建议', + style: TextStyle( + fontSize: DesignTokens.fontXs, + color: DesignTokens.dynamicPrimary, + ), + ), + ), + ], + ), + ), + ], + ), + ); + } + + void _showCopyToast(bool isDark) { + showCupertinoDialog( + context: context, + barrierDismissible: true, + builder: (context) => Center( + child: Container( + padding: const EdgeInsets.symmetric( + horizontal: DesignTokens.space4, + vertical: DesignTokens.space3, + ), + decoration: BoxDecoration( + color: isDark + ? DarkDesignTokens.card.withValues(alpha: 0.95) + : DesignTokens.card.withValues(alpha: 0.95), + borderRadius: BorderRadius.circular(DesignTokens.radiusLg), + ), + child: Row( + mainAxisSize: MainAxisSize.min, + children: [ + Icon( + CupertinoIcons.checkmark_circle_fill, + color: DesignTokens.green, + size: 20, + ), + SizedBox(width: DesignTokens.space2), + Text( + '已复制到剪贴板 📋', + style: TextStyle( + fontSize: DesignTokens.fontMd, + color: isDark ? DarkDesignTokens.text1 : DesignTokens.text1, + ), + ), + ], + ), + ), + ), + ); + Future.delayed(const Duration(milliseconds: 1200), () { + if (mounted) Navigator.of(context, rootNavigator: true).pop(); + }); + } + + Widget _buildDisclaimerCard(bool isDark) { + return Container( + padding: const EdgeInsets.all(DesignTokens.space3), + decoration: BoxDecoration( + color: const Color(0xFFFFF3E0).withValues(alpha: isDark ? 0.2 : 1), + borderRadius: BorderRadius.circular(DesignTokens.radiusMd), + border: Border.all( + color: const Color(0xFFFFB74D).withValues(alpha: 0.5), + ), + ), + child: Row( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text('⚠️', style: TextStyle(fontSize: DesignTokens.fontMd)), + SizedBox(width: DesignTokens.space2), + Expanded( + child: Text( + '温馨提示:安全期避孕并非100%可靠,月经周期可能因压力、疾病等因素发生变化。建议结合其他避孕方式使用。本计算结果仅供参考,不构成医学建议。', + style: TextStyle( + fontSize: DesignTokens.fontSm, + color: isDark ? DarkDesignTokens.text2 : DesignTokens.text2, + height: 1.5, + ), + ), + ), + ], + ), + ); + } + + @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: + (isDark ? DarkDesignTokens.background : DesignTokens.background) + .withValues(alpha: 0.9), + border: null, + ), + child: SafeArea( + child: ListView( + padding: const EdgeInsets.all(DesignTokens.space4), + children: [ + _buildHeaderCard(isDark), + const SizedBox(height: DesignTokens.space4), + _buildMethodSelector(isDark), + const SizedBox(height: DesignTokens.space3), + _buildCycleInputCard(isDark), + const SizedBox(height: DesignTokens.space3), + _buildDateInputCard(isDark), + const SizedBox(height: DesignTokens.space3), + _buildMethodTipsCard(isDark), + if (_method != CalculationMethod.calendar) + const SizedBox(height: DesignTokens.space3), + _buildCalculateButton(isDark), + const SizedBox(height: DesignTokens.space4), + _buildResultsCard(isDark), + const SizedBox(height: DesignTokens.space3), + _buildAssessmentCard(isDark), + const SizedBox(height: DesignTokens.space3), + _buildDisclaimerCard(isDark), + const SizedBox(height: DesignTokens.space4), + ], + ), + ), + ); + } +} diff --git a/lib/src/pages/tools/health/weight_binding.dart b/lib/src/pages/tools/health/weight_binding.dart new file mode 100644 index 0000000..630eff8 --- /dev/null +++ b/lib/src/pages/tools/health/weight_binding.dart @@ -0,0 +1,9 @@ +import 'package:get/get.dart'; +import 'package:mom_kitchen/src/controllers/tools/weight_controller.dart'; + +class WeightBinding extends Bindings { + @override + void dependencies() { + Get.lazyPut(() => WeightController()); + } +} diff --git a/lib/src/pages/tools/health/weight_manage_page.dart b/lib/src/pages/tools/health/weight_manage_page.dart new file mode 100644 index 0000000..089f590 --- /dev/null +++ b/lib/src/pages/tools/health/weight_manage_page.dart @@ -0,0 +1,1004 @@ +/* + * 文件: weight_manage_page.dart + * 名称: 体重管理页面 + * 作用: 记录体重、折线图趋势、目标设定、周期统计、BMI跳转 + * 更新: 2026-04-16 初始创建 + */ + +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/tools/weight_controller.dart'; +import 'package:mom_kitchen/src/models/weight_record_model.dart'; +import 'package:mom_kitchen/src/widgets/charts_widgets.dart'; + +class WeightManagePage extends GetView { + const WeightManagePage({super.key}); + + @override + Widget build(BuildContext context) { + final isDark = CupertinoTheme.brightnessOf(context) == Brightness.dark; + + return CupertinoPageScaffold( + backgroundColor: isDark + ? DarkDesignTokens.background + : DesignTokens.background, + navigationBar: CupertinoNavigationBar( + middle: Text( + '⚖️ 体重管理', + style: TextStyle( + fontSize: DesignTokens.fontLg, + fontWeight: FontWeight.w600, + color: isDark ? DarkDesignTokens.text1 : DesignTokens.text1, + ), + ), + trailing: CupertinoButton( + padding: EdgeInsets.zero, + child: Container( + padding: const EdgeInsets.symmetric( + horizontal: DesignTokens.space2, + vertical: DesignTokens.space1, + ), + decoration: BoxDecoration( + color: DesignTokens.dynamicPrimary.withValues(alpha: 0.1), + borderRadius: DesignTokens.borderRadiusSm, + ), + child: Row( + mainAxisSize: MainAxisSize.min, + children: [ + Icon( + CupertinoIcons.chart_bar, + size: 14, + color: DesignTokens.dynamicPrimary, + ), + const SizedBox(width: 4), + Text( + 'BMI', + style: TextStyle( + fontSize: DesignTokens.fontSm, + fontWeight: FontWeight.w600, + color: DesignTokens.dynamicPrimary, + ), + ), + ], + ), + ), + onPressed: () => Get.toNamed('/bmi-calculator'), + ), + backgroundColor: isDark + ? DarkDesignTokens.background.withValues(alpha: 0.9) + : DesignTokens.background.withValues(alpha: 0.9), + border: null, + ), + child: SafeArea( + child: Obx( + () => SingleChildScrollView( + physics: const BouncingScrollPhysics(), + padding: const EdgeInsets.all(DesignTokens.space4), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + _buildStatCards(isDark), + const SizedBox(height: DesignTokens.space4), + _buildRecordForm(isDark), + const SizedBox(height: DesignTokens.space4), + _buildChartSection(isDark), + const SizedBox(height: DesignTokens.space4), + _buildStatsSection(isDark), + const SizedBox(height: DesignTokens.space4), + _buildGoalSection(isDark), + const SizedBox(height: DesignTokens.space4), + _buildHistoryList(isDark), + const SizedBox(height: DesignTokens.space6), + ], + ), + ), + ), + ), + ); + } + + Widget _buildStatCards(bool isDark) { + return Row( + children: [ + Expanded( + child: _statCard( + '当前体重', + controller.currentWeight != null + ? controller.displayWeight(controller.currentWeight!) + : '--', + isDark, + DesignTokens.dynamicPrimary, + ), + ), + const SizedBox(width: DesignTokens.space2), + Expanded( + child: _statCard( + '目标体重', + '${controller.goalWeight.value.toStringAsFixed(1)} kg', + isDark, + DesignTokens.orange, + ), + ), + const SizedBox(width: DesignTokens.space2), + Expanded( + child: Obx(() { + final change = controller.changeFromLast; + String label = '--'; + Color color = DesignTokens.text3; + if (change != null) { + final absChange = change.abs(); + label = + '${change > 0 ? '↑' : '↓'}${absChange.toStringAsFixed(1)}kg'; + color = change > 0 ? DesignTokens.red : DesignTokens.green; + } + return _statCard('变化', label, isDark, color); + }), + ), + ], + ); + } + + Widget _statCard(String title, String value, bool isDark, Color accentColor) { + return Container( + padding: const EdgeInsets.all(DesignTokens.space3), + decoration: BoxDecoration( + color: isDark ? DarkDesignTokens.card : DesignTokens.card, + borderRadius: DesignTokens.borderRadiusMd, + border: Border.all(color: accentColor.withValues(alpha: 0.2)), + ), + child: Column( + children: [ + Text( + title, + style: TextStyle( + fontSize: DesignTokens.fontXs, + color: isDark ? DarkDesignTokens.text3 : DesignTokens.text3, + ), + ), + const SizedBox(height: DesignTokens.space1), + Text( + value, + style: TextStyle( + fontSize: DesignTokens.fontLg, + fontWeight: FontWeight.w700, + color: accentColor, + ), + ), + ], + ), + ); + } + + Widget _buildChartSection(bool isDark) { + return Container( + width: double.infinity, + padding: const EdgeInsets.all(DesignTokens.space4), + decoration: BoxDecoration( + color: isDark ? DarkDesignTokens.card : DesignTokens.card, + borderRadius: DesignTokens.borderRadiusLg, + ), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Row( + children: [ + Text('📈 ', style: TextStyle(fontSize: DesignTokens.fontMd)), + Text( + '体重趋势', + style: TextStyle( + fontSize: DesignTokens.fontMd, + fontWeight: FontWeight.w600, + color: isDark ? DarkDesignTokens.text1 : DesignTokens.text1, + ), + ), + const Spacer(), + Obx( + () => GestureDetector( + onTap: () => controller.toggleUnit(), + child: Container( + padding: const EdgeInsets.symmetric( + horizontal: DesignTokens.space2, + vertical: DesignTokens.space1, + ), + decoration: BoxDecoration( + color: DesignTokens.dynamicPrimary.withValues(alpha: 0.1), + borderRadius: DesignTokens.borderRadiusFull, + ), + child: Text( + controller.unitMode.value == 'kg' ? 'kg ⇄ 斤' : '斤 ⇄ kg', + style: TextStyle( + fontSize: DesignTokens.fontXs, + fontWeight: FontWeight.w600, + color: DesignTokens.dynamicPrimary, + ), + ), + ), + ), + ), + ], + ), + const SizedBox(height: DesignTokens.space3), + Obx( + () => WeightLineChart( + data: controller.chartData, + goalValue: controller.goalWeight.value, + isDark: isDark, + unitLabel: controller.unitMode.value == 'kg' ? 'kg' : '斤', + ), + ), + ], + ), + ); + } + + Widget _buildStatsSection(bool isDark) { + return Container( + width: double.infinity, + padding: const EdgeInsets.all(DesignTokens.space4), + decoration: BoxDecoration( + color: isDark ? DarkDesignTokens.card : DesignTokens.card, + borderRadius: DesignTokens.borderRadiusLg, + ), + 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.space3), + Obx(() { + final weekly = controller.getWeeklyStats(); + final monthly = controller.getMonthlyStats(); + return _statsGrid(weekly, monthly, isDark); + }), + ], + ), + ); + } + + Widget _statsGrid( + Map weekly, + Map monthly, + bool isDark, + ) { + final items = [ + _StatsItem('本周平均', weekly['avg'], 'kg'), + _StatsItem('本周最高', weekly['max'], 'kg'), + _StatsItem('本周最低', weekly['min'], 'kg'), + _StatsItem('本周变化', weekly['change'], 'kg', showSign: true), + _StatsItem('本月平均', monthly['avg'], 'kg'), + _StatsItem('本月最高', monthly['max'], 'kg'), + _StatsItem('本月最低', monthly['min'], 'kg'), + _StatsItem('本月变化', monthly['change'], 'kg', showSign: true), + ]; + return Wrap( + spacing: DesignTokens.space2, + runSpacing: DesignTokens.space2, + children: items.map((item) { + final val = item.value; + final text = item.showSign + ? (val >= 0 ? '+$val' : '$val') + : val.toStringAsFixed(1); + final color = item.showSign + ? (val <= 0 ? DesignTokens.green : DesignTokens.red) + : (isDark ? DarkDesignTokens.text1 : DesignTokens.text1); + return SizedBox( + width: + (Get.width - DesignTokens.space4 * 2 - DesignTokens.space2 * 3) / + 4, + child: Container( + padding: const EdgeInsets.all(DesignTokens.space2), + decoration: BoxDecoration( + color: isDark + ? DarkDesignTokens.glass + : DesignTokens.text3.withValues(alpha: 0.04), + borderRadius: DesignTokens.borderRadiusSm, + ), + child: Column( + children: [ + Text( + item.label, + style: TextStyle( + fontSize: 10, + color: isDark ? DarkDesignTokens.text3 : DesignTokens.text3, + ), + ), + const SizedBox(height: 2), + Text( + '$text ${item.unit}', + style: TextStyle( + fontSize: DesignTokens.fontSm, + fontWeight: FontWeight.w700, + color: color, + ), + ), + ], + ), + ), + ); + }).toList(), + ); + } + + Widget _buildGoalSection(bool isDark) { + return Container( + width: double.infinity, + padding: const EdgeInsets.all(DesignTokens.space4), + decoration: BoxDecoration( + color: isDark ? DarkDesignTokens.card : DesignTokens.card, + borderRadius: DesignTokens.borderRadiusLg, + ), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Row( + children: [ + Text('⚙️ ', style: TextStyle(fontSize: DesignTokens.fontMd)), + Text( + '目标体重', + style: TextStyle( + fontSize: DesignTokens.fontMd, + fontWeight: FontWeight.w600, + color: isDark ? DarkDesignTokens.text1 : DesignTokens.text1, + ), + ), + const Spacer(), + Obx( + () => Text( + '${controller.goalWeight.value.toStringAsFixed(1)} kg', + style: TextStyle( + fontSize: DesignTokens.fontMd, + fontWeight: FontWeight.w700, + color: DesignTokens.orange, + ), + ), + ), + ], + ), + const SizedBox(height: DesignTokens.space3), + Obx( + () => CupertinoSlider( + value: controller.goalWeight.value, + min: 30, + max: 200, + divisions: 170, + onChanged: (v) => controller.updateGoal(v), + ), + ), + Padding( + padding: const EdgeInsets.symmetric( + horizontal: DesignTokens.space2, + ), + child: Row( + mainAxisAlignment: MainAxisAlignment.spaceBetween, + children: [ + Text( + '30kg', + style: TextStyle( + fontSize: DesignTokens.fontXs, + color: isDark ? DarkDesignTokens.text3 : DesignTokens.text3, + ), + ), + Text( + '200kg', + style: TextStyle( + fontSize: DesignTokens.fontXs, + color: isDark ? DarkDesignTokens.text3 : DesignTokens.text3, + ), + ), + ], + ), + ), + ], + ), + ); + } + + Widget _buildRecordForm(bool isDark) { + return Container( + width: double.infinity, + padding: const EdgeInsets.all(DesignTokens.space4), + decoration: BoxDecoration( + gradient: LinearGradient( + begin: Alignment.topLeft, + end: Alignment.bottomRight, + colors: [ + DesignTokens.dynamicPrimary.withValues(alpha: 0.08), + DesignTokens.secondary.withValues(alpha: 0.06), + ], + ), + borderRadius: DesignTokens.borderRadiusLg, + ), + 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.space3), + Row( + children: [ + Expanded( + flex: 3, + child: Container( + height: 44, + decoration: BoxDecoration( + color: isDark + ? DarkDesignTokens.glass + : CupertinoColors.white, + borderRadius: DesignTokens.borderRadiusMd, + border: Border.all( + color: isDark + ? DarkDesignTokens.glassBorder + : DesignTokens.text3.withValues(alpha: 0.15), + ), + ), + child: CupertinoTextField( + controller: controller.weightInput, + placeholder: '输入体重', + keyboardType: const TextInputType.numberWithOptions( + decimal: true, + ), + placeholderStyle: TextStyle( + fontSize: DesignTokens.fontMd, + color: isDark + ? DarkDesignTokens.text3 + : DesignTokens.text3, + ), + style: TextStyle( + fontSize: DesignTokens.fontMd, + color: isDark + ? DarkDesignTokens.text1 + : DesignTokens.text1, + ), + decoration: null, + ), + ), + ), + const SizedBox(width: DesignTokens.space2), + Expanded( + child: Obx( + () => GestureDetector( + onTap: () { + if (controller.unitMode.value != 'kg') { + controller.toggleUnit(); + } + }, + child: Container( + height: 44, + decoration: BoxDecoration( + color: controller.unitMode.value == 'kg' + ? DesignTokens.dynamicPrimary + : (isDark + ? DarkDesignTokens.glass + : CupertinoColors.white), + borderRadius: DesignTokens.borderRadiusMd, + border: controller.unitMode.value != 'kg' + ? Border.all( + color: isDark + ? DarkDesignTokens.glassBorder + : DesignTokens.text3.withValues( + alpha: 0.15, + ), + ) + : null, + ), + child: Center( + child: Text( + 'kg', + style: TextStyle( + fontSize: DesignTokens.fontSm, + fontWeight: FontWeight.w600, + color: controller.unitMode.value == 'kg' + ? CupertinoColors.white + : (isDark + ? DarkDesignTokens.text2 + : DesignTokens.text2), + ), + ), + ), + ), + ), + ), + ), + const SizedBox(width: DesignTokens.space2), + Expanded( + child: Obx( + () => GestureDetector( + onTap: () { + if (controller.unitMode.value != 'jin') { + controller.toggleUnit(); + } + }, + child: Container( + height: 44, + decoration: BoxDecoration( + color: controller.unitMode.value == 'jin' + ? DesignTokens.dynamicPrimary + : (isDark + ? DarkDesignTokens.glass + : CupertinoColors.white), + borderRadius: DesignTokens.borderRadiusMd, + border: controller.unitMode.value != 'jin' + ? Border.all( + color: isDark + ? DarkDesignTokens.glassBorder + : DesignTokens.text3.withValues( + alpha: 0.15, + ), + ) + : null, + ), + child: Center( + child: Text( + '斤', + style: TextStyle( + fontSize: DesignTokens.fontSm, + fontWeight: FontWeight.w600, + color: controller.unitMode.value == 'jin' + ? CupertinoColors.white + : (isDark + ? DarkDesignTokens.text2 + : DesignTokens.text2), + ), + ), + ), + ), + ), + ), + ), + ], + ), + const SizedBox(height: DesignTokens.space3), + Obx( + () => CupertinoSlidingSegmentedControl( + groupValue: controller.selectedTiming.value, + children: { + 'morning': Padding( + padding: const EdgeInsets.symmetric(horizontal: 8), + child: Text('🌅早晨'), + ), + 'before_meal': Padding( + padding: const EdgeInsets.symmetric(horizontal: 8), + child: Text('🍽️饭前'), + ), + 'after_meal': Padding( + padding: const EdgeInsets.symmetric(horizontal: 8), + child: Text('😋饭后'), + ), + }, + onValueChanged: (v) => controller.selectedTiming.value = v!, + ), + ), + const SizedBox(height: DesignTokens.space3), + Container( + height: 44, + decoration: BoxDecoration( + color: isDark ? DarkDesignTokens.glass : CupertinoColors.white, + borderRadius: DesignTokens.borderRadiusMd, + border: Border.all( + color: isDark + ? DarkDesignTokens.glassBorder + : DesignTokens.text3.withValues(alpha: 0.15), + ), + ), + child: CupertinoTextField( + controller: controller.noteInput, + placeholder: '备注信息(可选)', + placeholderStyle: TextStyle( + fontSize: DesignTokens.fontMd, + color: isDark ? DarkDesignTokens.text3 : DesignTokens.text3, + ), + style: TextStyle( + fontSize: DesignTokens.fontMd, + color: isDark ? DarkDesignTokens.text1 : DesignTokens.text1, + ), + decoration: null, + ), + ), + const SizedBox(height: DesignTokens.space3), + SizedBox( + width: double.infinity, + height: 44, + child: CupertinoButton.filled( + borderRadius: DesignTokens.borderRadiusMd, + onPressed: () => controller.addRecord(), + child: const Text('💾 保存记录'), + ), + ), + ], + ), + ); + } + + Widget _buildHistoryList(bool isDark) { + return Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Row( + children: [ + Text( + '📋 历史记录', + style: TextStyle( + fontSize: DesignTokens.fontMd, + fontWeight: FontWeight.w600, + color: isDark ? DarkDesignTokens.text1 : DesignTokens.text1, + ), + ), + const Spacer(), + Obx( + () => Text( + '${controller.records.length} 条', + style: TextStyle( + fontSize: DesignTokens.fontXs, + color: isDark ? DarkDesignTokens.text3 : DesignTokens.text3, + ), + ), + ), + ], + ), + const SizedBox(height: DesignTokens.space2), + Obx(() { + if (controller.records.isEmpty) { + return Center( + child: Padding( + padding: const EdgeInsets.all(DesignTokens.space6), + child: Column( + children: [ + const Text('📝', style: TextStyle(fontSize: 40)), + const SizedBox(height: DesignTokens.space2), + Text( + '还没有记录', + style: TextStyle( + fontSize: DesignTokens.fontMd, + color: isDark + ? DarkDesignTokens.text2 + : DesignTokens.text2, + ), + ), + Text( + '添加第一条体重记录开始追踪', + style: TextStyle( + fontSize: DesignTokens.fontSm, + color: isDark + ? DarkDesignTokens.text3 + : DesignTokens.text3, + ), + ), + ], + ), + ), + ); + } + return Column( + children: controller.sortedRecords.take(20).map((record) { + return Dismissible( + key: ValueKey(record.id), + direction: DismissDirection.endToStart, + background: Container( + alignment: Alignment.centerLeft, + padding: const EdgeInsets.only(left: DesignTokens.space4), + decoration: BoxDecoration( + color: DesignTokens.red, + borderRadius: DesignTokens.borderRadiusMd, + ), + child: const Icon( + CupertinoIcons.trash, + color: CupertinoColors.white, + ), + ), + confirmDismiss: (direction) async { + return await showCupertinoDialog( + context: Get.context!, + builder: (ctx) => CupertinoAlertDialog( + title: const Text('确认删除'), + content: Text( + '删除 ${record.createdAt.toString().substring(5, 16)} 的记录?', + ), + actions: [ + CupertinoDialogAction( + child: const Text('取消'), + onPressed: () => Navigator.pop(ctx, false), + ), + CupertinoDialogAction( + isDestructiveAction: true, + child: const Text('删除'), + onPressed: () => Navigator.pop(ctx, true), + ), + ], + ), + ) ?? + false; + }, + onDismissed: (_) => controller.deleteRecord(record.id), + child: GestureDetector( + onTap: () => _showEditDialog(Get.context!, record, isDark), + 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, + ), + child: Row( + children: [ + Text( + WeightRecord.timingEmoji[record.timing] ?? '📝', + style: const TextStyle(fontSize: 20), + ), + const SizedBox(width: DesignTokens.space2), + Expanded( + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text( + record.createdAt.toString().substring(0, 16), + style: TextStyle( + fontSize: DesignTokens.fontXs, + color: isDark + ? DarkDesignTokens.text3 + : DesignTokens.text3, + ), + ), + Text( + WeightRecord.timingDisplay[record.timing] ?? '', + style: TextStyle( + fontSize: DesignTokens.fontSm, + color: isDark + ? DarkDesignTokens.text2 + : DesignTokens.text2, + ), + ), + ], + ), + ), + Text( + controller.displayWeight(record.weightKg), + style: TextStyle( + fontSize: DesignTokens.fontLg, + fontWeight: FontWeight.w700, + color: isDark + ? DarkDesignTokens.text1 + : DesignTokens.text1, + ), + ), + const SizedBox(width: DesignTokens.space1), + Icon( + CupertinoIcons.pencil_outline, + size: 14, + color: isDark + ? DarkDesignTokens.text3 + : DesignTokens.text3, + ), + ], + ), + ), + ), + ); + }).toList(), + ); + }), + ], + ); + } + + void _showEditDialog(BuildContext context, WeightRecord record, bool isDark) { + final weightCtrl = TextEditingController( + text: controller.unitMode.value == 'kg' + ? record.weightKg.toStringAsFixed(1) + : WeightRecord.kgToJin(record.weightKg).toStringAsFixed(1), + ); + final noteCtrl = TextEditingController(text: record.note ?? ''); + String editTiming = record.timing; + String editUnit = controller.unitMode.value; + + showCupertinoDialog( + context: context, + builder: (ctx) => StatefulBuilder( + builder: (context, setDialogState) { + return CupertinoAlertDialog( + title: const Text('✏️ 编辑记录'), + content: Container( + width: 280, + constraints: const BoxConstraints(maxHeight: 320), + child: SingleChildScrollView( + child: Column( + mainAxisSize: MainAxisSize.min, + children: [ + Row( + children: [ + Expanded( + flex: 3, + child: Container( + height: 36, + decoration: BoxDecoration( + color: isDark + ? DarkDesignTokens.glass + : CupertinoColors.white, + borderRadius: DesignTokens.borderRadiusSm, + border: Border.all( + color: DesignTokens.dynamicPrimary.withValues( + alpha: 0.3, + ), + ), + ), + child: CupertinoTextField( + controller: weightCtrl, + keyboardType: + const TextInputType.numberWithOptions( + decimal: true, + ), + style: TextStyle( + fontSize: DesignTokens.fontSm, + color: isDark + ? DarkDesignTokens.text1 + : DesignTokens.text1, + ), + decoration: null, + ), + ), + ), + const SizedBox(width: DesignTokens.space2), + GestureDetector( + onTap: () { + setDialogState(() { + final val = double.tryParse(weightCtrl.text) ?? 0; + if (editUnit == 'kg') { + weightCtrl.text = WeightRecord.kgToJin( + val, + ).toStringAsFixed(1); + editUnit = 'jin'; + } else { + weightCtrl.text = WeightRecord.jinToKg( + val, + ).toStringAsFixed(1); + editUnit = 'kg'; + } + }); + }, + child: Container( + height: 36, + padding: const EdgeInsets.symmetric( + horizontal: DesignTokens.space2, + ), + decoration: BoxDecoration( + color: DesignTokens.dynamicPrimary.withValues( + alpha: 0.1, + ), + borderRadius: DesignTokens.borderRadiusSm, + ), + child: Center( + child: Text( + editUnit.toUpperCase(), + style: TextStyle( + fontSize: DesignTokens.fontXs, + fontWeight: FontWeight.w600, + color: DesignTokens.dynamicPrimary, + ), + ), + ), + ), + ), + ], + ), + const SizedBox(height: DesignTokens.space2), + CupertinoSlidingSegmentedControl( + groupValue: editTiming, + children: { + 'morning': Padding( + padding: const EdgeInsets.symmetric(horizontal: 6), + child: Text( + '🌅早晨', + style: const TextStyle(fontSize: 12), + ), + ), + 'before_meal': Padding( + padding: const EdgeInsets.symmetric(horizontal: 6), + child: Text( + '🍽️饭前', + style: const TextStyle(fontSize: 12), + ), + ), + 'after_meal': Padding( + padding: const EdgeInsets.symmetric(horizontal: 6), + child: Text( + '😋饭后', + style: const TextStyle(fontSize: 12), + ), + ), + }, + onValueChanged: (v) { + setDialogState(() { + editTiming = v!; + }); + }, + ), + const SizedBox(height: DesignTokens.space2), + Container( + height: 36, + decoration: BoxDecoration( + color: isDark + ? DarkDesignTokens.glass + : CupertinoColors.white, + borderRadius: DesignTokens.borderRadiusSm, + border: Border.all( + color: DesignTokens.text3.withValues(alpha: 0.15), + ), + ), + child: CupertinoTextField( + controller: noteCtrl, + placeholder: '备注(可选)', + style: TextStyle( + fontSize: DesignTokens.fontSm, + color: isDark + ? DarkDesignTokens.text1 + : DesignTokens.text1, + ), + decoration: null, + ), + ), + ], + ), + ), + ), + actions: [ + CupertinoDialogAction( + child: const Text('取消'), + onPressed: () { + weightCtrl.dispose(); + noteCtrl.dispose(); + Navigator.pop(ctx); + }, + ), + CupertinoDialogAction( + isDefaultAction: true, + child: const Text('保存'), + onPressed: () { + final w = double.tryParse(weightCtrl.text); + if (w == null || w <= 0 || w > 500) { + Navigator.pop(ctx); + weightCtrl.dispose(); + noteCtrl.dispose(); + return; + } + final kg = editUnit == 'jin' ? WeightRecord.jinToKg(w) : w; + controller.updateRecord( + record.id, + weightKg: kg, + timing: editTiming, + note: noteCtrl.text.trim().isEmpty + ? null + : noteCtrl.text.trim(), + ); + weightCtrl.dispose(); + noteCtrl.dispose(); + Navigator.pop(ctx); + }, + ), + ], + ); + }, + ), + ); + } +} + +class _StatsItem { + final String label; + final double value; + final String unit; + final bool showSign; + + const _StatsItem(this.label, this.value, this.unit, {this.showSign = false}); +} diff --git a/lib/src/pages/tools/ingredient_detail/ingredient_detail_cards.dart b/lib/src/pages/tools/ingredient_detail/ingredient_detail_cards.dart new file mode 100644 index 0000000..94b846f --- /dev/null +++ b/lib/src/pages/tools/ingredient_detail/ingredient_detail_cards.dart @@ -0,0 +1,11 @@ +/* + * 文件: ingredient_detail_cards.dart + * 名称: 食材详情卡片组件库(统一导出) + * 作用: barrel文件,统一导出所有卡片子组件 + * 创建: 2026-04-16 + * 更新: 2026-04-16 拆分为3个子文件,本文件仅做导出 + */ + +export 'ingredient_detail_nutrition_cards.dart'; +export 'ingredient_detail_info_cards.dart'; +export 'ingredient_detail_data_cards.dart'; diff --git a/lib/src/pages/tools/ingredient_detail/ingredient_detail_data_cards.dart b/lib/src/pages/tools/ingredient_detail/ingredient_detail_data_cards.dart new file mode 100644 index 0000000..08f4f94 --- /dev/null +++ b/lib/src/pages/tools/ingredient_detail/ingredient_detail_data_cards.dart @@ -0,0 +1,411 @@ +/* + * 文件: ingredient_detail_data_cards.dart + * 名称: 食材详情-数据展示卡片组件 + * 作用: 传入详情卡片、API详情卡片、相似菜谱等卡片 + * 创建: 2026-04-16 + * 更新: 2026-04-16 从ingredient_detail_cards.dart拆分 + */ + +import 'package:flutter/cupertino.dart'; +import 'package:get/get.dart'; +import 'package:mom_kitchen/src/config/design_tokens.dart'; +import 'package:mom_kitchen/src/models/recipe/ingredient_model.dart'; +import 'package:mom_kitchen/src/models/recipe/recipe_model.dart'; + +class PassedDetailCard extends StatelessWidget { + final IngredientDetail detail; + final bool isDark; + + const PassedDetailCard({ + super.key, + required this.detail, + required this.isDark, + }); + + @override + Widget build(BuildContext context) { + final hasContent = + (detail.introduction?.isNotEmpty ?? false) || + (detail.nutrition?.isNotEmpty ?? false) || + (detail.guidance?.isNotEmpty ?? false) || + (detail.effect?.isNotEmpty ?? false) || + detail.allergen.isNotEmpty; + + if (!hasContent) return const SizedBox(); + + return Container( + padding: EdgeInsets.all(DesignTokens.space3), + margin: EdgeInsets.only(bottom: DesignTokens.space3), + decoration: BoxDecoration( + color: (DesignTokens.dynamicPrimary).withValues(alpha: 0.08), + borderRadius: DesignTokens.borderRadiusMd, + border: Border.all( + color: (DesignTokens.dynamicPrimary).withValues(alpha: 0.2), + ), + ), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Row( + children: [ + const Text('📖', style: TextStyle(fontSize: 16)), + const SizedBox(width: DesignTokens.space2), + Text( + '食材详解', + style: TextStyle( + fontSize: DesignTokens.fontMd, + fontWeight: FontWeight.w600, + color: isDark ? DarkDesignTokens.text1 : DesignTokens.text1, + ), + ), + ], + ), + if (detail.introduction?.isNotEmpty ?? false) ...[ + const SizedBox(height: DesignTokens.space2), + Text( + detail.introduction!, + style: TextStyle( + fontSize: DesignTokens.fontSm, + color: isDark ? DarkDesignTokens.text2 : DesignTokens.text2, + ), + ), + ], + if (detail.nutrition?.isNotEmpty ?? false) ...[ + const SizedBox(height: DesignTokens.space2), + DetailInfoRow( + label: '📊 营养: ', + content: detail.nutrition!, + isDark: isDark, + ), + ], + if (detail.guidance?.isNotEmpty ?? false) ...[ + const SizedBox(height: DesignTokens.space2), + DetailInfoRow( + label: '🛒 选购: ', + content: detail.guidance!, + isDark: isDark, + ), + ], + if (detail.effect?.isNotEmpty ?? false) ...[ + const SizedBox(height: DesignTokens.space2), + DetailInfoRow( + label: '💪 功效: ', + content: detail.effect!, + isDark: isDark, + ), + ], + if (detail.usageTip.isNotEmpty) ...[ + const SizedBox(height: DesignTokens.space2), + DetailInfoRow( + label: '💡 技巧: ', + content: detail.usageTip.join('; '), + isDark: isDark, + ), + ], + if (detail.allergen.isNotEmpty) ...[ + const SizedBox(height: DesignTokens.space2), + Row( + children: [ + Text( + '⚠️ 过敏: ', + style: TextStyle( + fontSize: DesignTokens.fontSm, + fontWeight: FontWeight.w500, + color: DesignTokens.orange, + ), + ), + Expanded( + child: Text( + detail.allergen.join('、'), + style: TextStyle( + fontSize: DesignTokens.fontSm, + color: DesignTokens.orange, + ), + ), + ), + ], + ), + ], + ], + ), + ); + } +} + +class ApiDetailCard extends StatelessWidget { + final IngredientModel detail; + final bool isDark; + + const ApiDetailCard({super.key, required this.detail, required this.isDark}); + + @override + Widget build(BuildContext context) { + return Container( + padding: EdgeInsets.all(DesignTokens.space3), + margin: EdgeInsets.only(bottom: DesignTokens.space3), + decoration: BoxDecoration( + color: (DesignTokens.dynamicPrimary).withValues(alpha: 0.08), + borderRadius: DesignTokens.borderRadiusMd, + border: Border.all( + color: (DesignTokens.dynamicPrimary).withValues(alpha: 0.2), + ), + ), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Row( + children: [ + const Text('📖', style: TextStyle(fontSize: 16)), + const SizedBox(width: DesignTokens.space2), + Text( + '食材详情 (API数据)', + style: TextStyle( + fontSize: DesignTokens.fontMd, + fontWeight: FontWeight.w600, + color: isDark ? DarkDesignTokens.text1 : DesignTokens.text1, + ), + ), + ], + ), + if (detail.introduction != null && + detail.introduction!.isNotEmpty) ...[ + const SizedBox(height: DesignTokens.space2), + Text( + detail.introduction!, + style: TextStyle( + fontSize: DesignTokens.fontSm, + color: isDark ? DarkDesignTokens.text2 : DesignTokens.text2, + ), + ), + ], + if (detail.nutrition != null && detail.nutrition!.isNotEmpty) ...[ + const SizedBox(height: DesignTokens.space2), + DetailInfoRow( + label: '📊 营养: ', + content: detail.nutrition!, + isDark: isDark, + ), + ], + if (detail.guidance != null && detail.guidance!.isNotEmpty) ...[ + const SizedBox(height: DesignTokens.space2), + DetailInfoRow( + label: '🛒 选购: ', + content: detail.guidance!, + isDark: isDark, + ), + ], + if (detail.effect != null && detail.effect!.isNotEmpty) ...[ + const SizedBox(height: DesignTokens.space2), + DetailInfoRow( + label: '💪 功效: ', + content: detail.effect!, + isDark: isDark, + ), + ], + if (detail.allergen.isNotEmpty) ...[ + const SizedBox(height: DesignTokens.space2), + Row( + children: [ + Text( + '⚠️ 过敏: ', + style: TextStyle( + fontSize: DesignTokens.fontSm, + fontWeight: FontWeight.w500, + color: DesignTokens.orange, + ), + ), + Expanded( + child: Text( + detail.allergen.join('、'), + style: TextStyle( + fontSize: DesignTokens.fontSm, + color: DesignTokens.orange, + ), + ), + ), + ], + ), + ], + ], + ), + ); + } +} + +class DetailInfoRow extends StatelessWidget { + final String label; + final String content; + final bool isDark; + + const DetailInfoRow({ + super.key, + required this.label, + required this.content, + required this.isDark, + }); + + @override + Widget build(BuildContext context) { + return Row( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text( + label, + style: TextStyle( + fontSize: DesignTokens.fontSm, + fontWeight: FontWeight.w500, + color: isDark ? DarkDesignTokens.text1 : DesignTokens.text1, + ), + ), + Expanded( + child: Text( + content, + style: TextStyle( + fontSize: DesignTokens.fontSm, + color: isDark ? DarkDesignTokens.text2 : DesignTokens.text2, + ), + ), + ), + ], + ); + } +} + +class SimilarRecipesSection extends StatelessWidget { + final String ingredientName; + final List recipes; + final bool isLoading; + final bool isDark; + + const SimilarRecipesSection({ + super.key, + required this.ingredientName, + required this.recipes, + required this.isLoading, + required this.isDark, + }); + + @override + Widget build(BuildContext context) { + return Container( + padding: const EdgeInsets.all(DesignTokens.space4), + decoration: BoxDecoration( + color: isDark ? DarkDesignTokens.card : DesignTokens.card, + borderRadius: DesignTokens.borderRadiusLg, + boxShadow: DesignTokens.shadowsSm, + ), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Row( + children: [ + const Text('🍽️', style: TextStyle(fontSize: 18)), + const SizedBox(width: DesignTokens.space2), + Expanded( + child: Text( + '含「$ingredientName」的菜谱', + style: TextStyle( + fontSize: DesignTokens.fontLg, + fontWeight: FontWeight.w600, + color: isDark ? DarkDesignTokens.text1 : DesignTokens.text1, + ), + ), + ), + if (isLoading) const CupertinoActivityIndicator(radius: 8), + ], + ), + const SizedBox(height: DesignTokens.space3), + ...recipes + .take(4) + .map( + (recipe) => SimilarRecipeItem(recipe: recipe, isDark: isDark), + ), + ], + ), + ); + } +} + +class SimilarRecipeItem extends StatelessWidget { + final RecipeModel recipe; + final bool isDark; + + const SimilarRecipeItem({ + super.key, + required this.recipe, + required this.isDark, + }); + + @override + Widget build(BuildContext context) { + 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.segmentedBg + : DesignTokens.background, + borderRadius: DesignTokens.borderRadiusMd, + ), + child: Row( + children: [ + Container( + width: 40, + height: 40, + decoration: BoxDecoration( + color: DesignTokens.dynamicPrimary.withValues(alpha: 0.08), + borderRadius: DesignTokens.borderRadiusMd, + ), + child: Center( + child: Icon( + CupertinoIcons.star_fill, + color: DesignTokens.dynamicPrimary, + size: 20, + ), + ), + ), + const SizedBox(width: DesignTokens.space3), + Expanded( + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text( + recipe.title, + style: TextStyle( + fontSize: DesignTokens.fontSm, + fontWeight: FontWeight.w500, + color: isDark + ? DarkDesignTokens.text1 + : DesignTokens.text1, + ), + maxLines: 1, + overflow: TextOverflow.ellipsis, + ), + if (recipe.nutrition?.calories != null) + Text( + '🔥 ${recipe.nutrition!.calories!.toInt()}千卡', + style: TextStyle( + fontSize: DesignTokens.fontXs, + color: isDark + ? DarkDesignTokens.text3 + : DesignTokens.text3, + ), + ), + ], + ), + ), + Icon( + CupertinoIcons.chevron_right, + size: 14, + color: isDark ? DarkDesignTokens.text3 : DesignTokens.text3, + ), + ], + ), + ), + ); + } +} diff --git a/lib/src/pages/tools/ingredient_detail/ingredient_detail_info_cards.dart b/lib/src/pages/tools/ingredient_detail/ingredient_detail_info_cards.dart new file mode 100644 index 0000000..f61f2bf --- /dev/null +++ b/lib/src/pages/tools/ingredient_detail/ingredient_detail_info_cards.dart @@ -0,0 +1,363 @@ +/* + * 文件: ingredient_detail_info_cards.dart + * 名称: 食材详情-生活信息卡片组件 + * 作用: 时令、选购技巧、储存方法、替代建议等卡片 + * 创建: 2026-04-16 + * 更新: 2026-04-16 从ingredient_detail_cards.dart拆分 + */ + +import 'package:flutter/cupertino.dart'; +import 'package:mom_kitchen/src/config/design_tokens.dart'; +import 'package:mom_kitchen/src/services/data/ingredient_nutrition_db.dart'; +import 'package:mom_kitchen/src/pages/tools/ingredient_detail/ingredient_detail_utils.dart'; + +class SeasonCard extends StatelessWidget { + final IngredientNutritionData nutrition; + final bool isDark; + + const SeasonCard({ + super.key, + required this.nutrition, + required this.isDark, + }); + + @override + Widget build(BuildContext context) { + 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: [ + SeasonChip( + label: '春', + active: nutrition.season.contains('春'), + isDark: isDark, + ), + SeasonChip( + label: '夏', + active: nutrition.season.contains('夏'), + isDark: isDark, + ), + SeasonChip( + label: '秋', + active: nutrition.season.contains('秋'), + isDark: isDark, + ), + SeasonChip( + label: '冬', + active: nutrition.season.contains('冬'), + isDark: isDark, + ), + if (nutrition.season.contains('四季')) + const SeasonChip(label: '四季', active: true, isDark: false), + ], + ), + ], + ), + ); + } +} + +class SeasonChip extends StatelessWidget { + final String label; + final bool active; + final bool isDark; + + const SeasonChip({ + super.key, + required this.label, + required this.active, + required this.isDark, + }); + + @override + Widget build(BuildContext context) { + 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, + ), + ), + ); + } +} + +class PurchaseTipCard extends StatelessWidget { + final IngredientNutritionData nutrition; + final bool isDark; + + const PurchaseTipCard({ + super.key, + required this.nutrition, + required this.isDark, + }); + + @override + Widget build(BuildContext context) { + 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, + ), + ), + ), + ], + ), + ), + ], + ), + ); + } +} + +class StorageTipCard extends StatelessWidget { + final IngredientNutritionData nutrition; + final bool isDark; + + const StorageTipCard({ + super.key, + required this.nutrition, + required this.isDark, + }); + + @override + Widget build(BuildContext context) { + if (nutrition.storageTip.isEmpty) return const SizedBox(); + + return Container( + padding: const EdgeInsets.all(DesignTokens.space4), + decoration: BoxDecoration( + color: isDark ? DarkDesignTokens.card : DesignTokens.card, + borderRadius: DesignTokens.borderRadiusLg, + boxShadow: DesignTokens.shadowsSm, + ), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Row( + children: [ + const Text('📦', style: TextStyle(fontSize: 18)), + const SizedBox(width: DesignTokens.space2), + Text( + '储存方法', + style: TextStyle( + fontSize: DesignTokens.fontLg, + fontWeight: FontWeight.w600, + color: isDark ? DarkDesignTokens.text1 : DesignTokens.text1, + ), + ), + ], + ), + SizedBox(height: DesignTokens.space3), + Container( + width: double.infinity, + padding: EdgeInsets.all(DesignTokens.space3), + decoration: BoxDecoration( + color: + (DesignTokens.dynamicPrimary).withValues(alpha: 0.08), + borderRadius: DesignTokens.borderRadiusMd, + ), + child: Row( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + const Text('💡', style: TextStyle(fontSize: 14)), + const SizedBox(width: DesignTokens.space2), + Expanded( + child: Text( + nutrition.storageTip, + style: TextStyle( + fontSize: DesignTokens.fontMd, + color: isDark + ? DarkDesignTokens.text2 + : DesignTokens.text2, + height: 1.5, + ), + ), + ), + ], + ), + ), + ], + ), + ); + } +} + +class SubstitutionSection extends StatelessWidget { + final String name; + final bool isDark; + final VoidCallback onSubstituteTap; + + const SubstitutionSection({ + super.key, + required this.name, + required this.isDark, + required this.onSubstituteTap, + }); + + @override + Widget build(BuildContext context) { + final substitutions = IngredientDetailUtils.getSubstitutions(name); + if (substitutions.isEmpty) return const SizedBox(); + + return Container( + padding: const EdgeInsets.all(DesignTokens.space4), + decoration: BoxDecoration( + color: isDark ? DarkDesignTokens.card : DesignTokens.card, + borderRadius: DesignTokens.borderRadiusLg, + boxShadow: DesignTokens.shadowsSm, + ), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Row( + children: [ + const Text('🔄', style: TextStyle(fontSize: 18)), + const SizedBox(width: DesignTokens.space2), + Text( + '替代建议', + style: TextStyle( + fontSize: DesignTokens.fontLg, + fontWeight: FontWeight.w600, + color: isDark ? DarkDesignTokens.text1 : DesignTokens.text1, + ), + ), + ], + ), + const SizedBox(height: DesignTokens.space3), + Wrap( + spacing: DesignTokens.space2, + runSpacing: DesignTokens.space2, + children: substitutions.map((sub) { + return GestureDetector( + onTap: onSubstituteTap, + child: Container( + padding: const EdgeInsets.symmetric( + horizontal: DesignTokens.space3, + vertical: DesignTokens.space2, + ), + decoration: BoxDecoration( + color: + DesignTokens.dynamicPrimary.withValues(alpha: 0.08), + borderRadius: DesignTokens.borderRadiusMd, + border: Border.all( + color: + DesignTokens.dynamicPrimary.withValues(alpha: 0.2), + ), + ), + child: Row( + mainAxisSize: MainAxisSize.min, + children: [ + Text( + sub, + style: TextStyle( + fontSize: DesignTokens.fontSm, + fontWeight: FontWeight.w500, + color: DesignTokens.dynamicPrimary, + ), + ), + const SizedBox(width: 4), + Icon( + CupertinoIcons.arrow_right, + size: 10, + color: DesignTokens.dynamicPrimary, + ), + ], + ), + ), + ); + }).toList(), + ), + ], + ), + ); + } +} diff --git a/lib/src/pages/tools/ingredient_detail/ingredient_detail_nutrition_cards.dart b/lib/src/pages/tools/ingredient_detail/ingredient_detail_nutrition_cards.dart new file mode 100644 index 0000000..05ff168 --- /dev/null +++ b/lib/src/pages/tools/ingredient_detail/ingredient_detail_nutrition_cards.dart @@ -0,0 +1,472 @@ +/* + * 文件: ingredient_detail_nutrition_cards.dart + * 名称: 食材详情-营养相关卡片组件 + * 作用: 详情头部、营养概览、营养素占比条、关键营养素等卡片 + * 创建: 2026-04-16 + * 更新: 2026-04-16 从ingredient_detail_cards.dart拆分 + */ + +import 'package:flutter/cupertino.dart'; +import 'package:mom_kitchen/src/config/design_tokens.dart'; +import 'package:mom_kitchen/src/services/data/ingredient_nutrition_db.dart'; +import 'package:mom_kitchen/src/pages/tools/ingredient_detail/ingredient_detail_utils.dart'; + +class DetailHeaderCard extends StatelessWidget { + final String name; + final String category; + final IngredientNutritionData nutrition; + final bool isDark; + + const DetailHeaderCard({ + super.key, + required this.name, + required this.category, + required this.nutrition, + required this.isDark, + }); + + @override + Widget build(BuildContext context) { + return Container( + padding: const EdgeInsets.all(DesignTokens.space4), + decoration: BoxDecoration( + color: isDark ? DarkDesignTokens.card : DesignTokens.card, + borderRadius: DesignTokens.borderRadiusLg, + boxShadow: DesignTokens.shadowsSm, + ), + child: Column( + children: [ + Text( + IngredientDetailUtils.getEmoji(category), + style: const TextStyle(fontSize: 48), + ), + const SizedBox(height: DesignTokens.space2), + Text( + name, + style: TextStyle( + fontSize: DesignTokens.fontXxl, + fontWeight: FontWeight.w700, + color: isDark ? DarkDesignTokens.text1 : DesignTokens.text1, + ), + ), + const SizedBox(height: DesignTokens.space1), + Container( + padding: const EdgeInsets.symmetric(horizontal: 12, vertical: 4), + decoration: BoxDecoration( + color: DesignTokens.green.withValues(alpha: 0.1), + borderRadius: DesignTokens.borderRadiusMd, + ), + child: Text( + category, + style: TextStyle( + fontSize: DesignTokens.fontSm, + color: DesignTokens.green, + ), + ), + ), + SizedBox(height: DesignTokens.space3), + Row( + mainAxisAlignment: MainAxisAlignment.center, + children: [ + Text( + '${nutrition.calories.toInt()}', + style: TextStyle( + fontSize: 36, + fontWeight: FontWeight.w700, + color: DesignTokens.dynamicPrimary, + ), + ), + const SizedBox(width: 4), + Padding( + padding: const EdgeInsets.only(top: 12), + child: Text( + 'kcal/${nutrition.unit}', + style: TextStyle( + fontSize: DesignTokens.fontSm, + color: + isDark ? DarkDesignTokens.text3 : DesignTokens.text3, + ), + ), + ), + ], + ), + ], + ), + ); + } +} + +class CalorieOverviewCard extends StatelessWidget { + final IngredientNutritionData nutrition; + final bool isDark; + + const CalorieOverviewCard({ + super.key, + required this.nutrition, + required this.isDark, + }); + + @override + Widget build(BuildContext context) { + return Container( + padding: const EdgeInsets.all(DesignTokens.space4), + decoration: BoxDecoration( + color: isDark ? DarkDesignTokens.card : DesignTokens.card, + borderRadius: DesignTokens.borderRadiusLg, + boxShadow: DesignTokens.shadowsSm, + ), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Row( + children: [ + const Text('📊', style: TextStyle(fontSize: 18)), + const SizedBox(width: DesignTokens.space2), + Text( + '营养概览', + style: TextStyle( + fontSize: DesignTokens.fontLg, + fontWeight: FontWeight.w600, + color: isDark ? DarkDesignTokens.text1 : DesignTokens.text1, + ), + ), + ], + ), + const SizedBox(height: DesignTokens.space4), + Row( + children: [ + Expanded( + child: MacroItem( + label: '🔥 热量', + value: '${nutrition.calories.toInt()}', + unit: 'kcal', + color: DesignTokens.orange, + isDark: isDark, + ), + ), + Expanded( + child: MacroItem( + label: '💪 蛋白质', + value: '${nutrition.protein.toInt()}', + unit: 'g', + color: DesignTokens.red, + isDark: isDark, + ), + ), + Expanded( + child: MacroItem( + label: '🧈 脂肪', + value: '${nutrition.fat.toInt()}', + unit: 'g', + color: DesignTokens.secondary, + isDark: isDark, + ), + ), + ], + ), + const SizedBox(height: DesignTokens.space3), + Row( + children: [ + Expanded( + child: MacroItem( + label: '🍞 碳水', + value: '${nutrition.carbs.toInt()}', + unit: 'g', + color: DesignTokens.green, + isDark: isDark, + ), + ), + Expanded( + child: MacroItem( + label: '🌾 纤维', + value: '${nutrition.fiber.toInt()}', + unit: 'g', + color: DesignTokens.text2, + isDark: isDark, + ), + ), + const Expanded(child: SizedBox()), + ], + ), + ], + ), + ); + } +} + +class MacroItem extends StatelessWidget { + final String label; + final String value; + final String unit; + final Color color; + final bool isDark; + + const MacroItem({ + super.key, + required this.label, + required this.value, + required this.unit, + required this.color, + required this.isDark, + }); + + @override + Widget build(BuildContext context) { + return Column( + children: [ + Container( + width: 44, + height: 44, + decoration: BoxDecoration( + color: color.withValues(alpha: 0.12), + borderRadius: DesignTokens.borderRadiusMd, + ), + child: Center( + child: Text( + value, + style: TextStyle( + fontSize: DesignTokens.fontMd, + fontWeight: FontWeight.w700, + color: color, + ), + ), + ), + ), + const SizedBox(height: 4), + Text( + label, + style: TextStyle( + fontSize: DesignTokens.fontXs, + color: isDark ? DarkDesignTokens.text3 : DesignTokens.text3, + ), + ), + Text( + unit, + style: TextStyle( + fontSize: DesignTokens.fontXs, + color: isDark ? DarkDesignTokens.text3 : DesignTokens.text3, + ), + ), + ], + ); + } +} + +class NutritionBarsCard extends StatelessWidget { + final IngredientNutritionData nutrition; + final bool isDark; + + const NutritionBarsCard({ + super.key, + required this.nutrition, + required this.isDark, + }); + + @override + Widget build(BuildContext context) { + final total = nutrition.protein + nutrition.fat + nutrition.carbs; + if (total == 0) return const SizedBox(); + + final proteinPct = total > 0 ? nutrition.protein / total : 0.0; + final fatPct = total > 0 ? nutrition.fat / total : 0.0; + final carbsPct = total > 0 ? nutrition.carbs / total : 0.0; + + return Container( + padding: const EdgeInsets.all(DesignTokens.space4), + decoration: BoxDecoration( + color: isDark ? DarkDesignTokens.card : DesignTokens.card, + borderRadius: DesignTokens.borderRadiusLg, + boxShadow: DesignTokens.shadowsSm, + ), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Row( + children: [ + const Text('🥧', style: TextStyle(fontSize: 18)), + const SizedBox(width: DesignTokens.space2), + Text( + '营养素占比', + style: TextStyle( + fontSize: DesignTokens.fontLg, + fontWeight: FontWeight.w600, + color: isDark ? DarkDesignTokens.text1 : DesignTokens.text1, + ), + ), + ], + ), + const SizedBox(height: DesignTokens.space3), + ClipRRect( + borderRadius: DesignTokens.borderRadiusFull, + child: SizedBox( + height: 12, + child: Row( + children: [ + if (proteinPct > 0) + Expanded( + flex: (proteinPct * 100).round().clamp(1, 100), + child: Container(color: DesignTokens.red), + ), + if (fatPct > 0) + Expanded( + flex: (fatPct * 100).round().clamp(1, 100), + child: Container(color: DesignTokens.secondary), + ), + if (carbsPct > 0) + Expanded( + flex: (carbsPct * 100).round().clamp(1, 100), + child: Container(color: DesignTokens.green), + ), + ], + ), + ), + ), + const SizedBox(height: DesignTokens.space3), + Row( + mainAxisAlignment: MainAxisAlignment.spaceAround, + children: [ + LegendItem( + label: '💪 蛋白质', + value: '${(proteinPct * 100).toStringAsFixed(0)}%', + color: DesignTokens.red, + isDark: isDark, + ), + LegendItem( + label: '🧈 脂肪', + value: '${(fatPct * 100).toStringAsFixed(0)}%', + color: DesignTokens.secondary, + isDark: isDark, + ), + LegendItem( + label: '🍞 碳水', + value: '${(carbsPct * 100).toStringAsFixed(0)}%', + color: DesignTokens.green, + isDark: isDark, + ), + ], + ), + ], + ), + ); + } +} + +class LegendItem extends StatelessWidget { + final String label; + final String value; + final Color color; + final bool isDark; + + const LegendItem({ + super.key, + required this.label, + required this.value, + required this.color, + required this.isDark, + }); + + @override + Widget build(BuildContext context) { + 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, + ), + ), + ], + ); + } +} + +class KeyNutrientsCard extends StatelessWidget { + final IngredientNutritionData nutrition; + final bool isDark; + + const KeyNutrientsCard({ + super.key, + required this.nutrition, + required this.isDark, + }); + + @override + Widget build(BuildContext context) { + if (nutrition.keyNutrients.isEmpty) return const SizedBox(); + + return Container( + padding: const EdgeInsets.all(DesignTokens.space4), + decoration: BoxDecoration( + color: isDark ? DarkDesignTokens.card : DesignTokens.card, + borderRadius: DesignTokens.borderRadiusLg, + boxShadow: DesignTokens.shadowsSm, + ), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Row( + children: [ + const Text('✨', style: TextStyle(fontSize: 18)), + const SizedBox(width: DesignTokens.space2), + Text( + '关键营养素', + style: TextStyle( + fontSize: DesignTokens.fontLg, + fontWeight: FontWeight.w600, + color: isDark ? DarkDesignTokens.text1 : DesignTokens.text1, + ), + ), + ], + ), + SizedBox(height: DesignTokens.space3), + Wrap( + spacing: DesignTokens.space2, + runSpacing: DesignTokens.space2, + children: nutrition.keyNutrients.map((nutrient) { + return Container( + padding: EdgeInsets.symmetric( + horizontal: DesignTokens.space3, + vertical: DesignTokens.space2, + ), + decoration: BoxDecoration( + color: + (DesignTokens.dynamicPrimary).withValues(alpha: 0.1), + borderRadius: DesignTokens.borderRadiusFull, + ), + child: Text( + nutrient, + style: TextStyle( + fontSize: DesignTokens.fontSm, + fontWeight: FontWeight.w500, + color: DesignTokens.dynamicPrimary, + ), + ), + ); + }).toList(), + ), + ], + ), + ); + } +} diff --git a/lib/src/pages/tools/ingredient_detail/ingredient_detail_page.dart b/lib/src/pages/tools/ingredient_detail/ingredient_detail_page.dart new file mode 100644 index 0000000..20c6898 --- /dev/null +++ b/lib/src/pages/tools/ingredient_detail/ingredient_detail_page.dart @@ -0,0 +1,356 @@ +/* + * 文件: ingredient_detail_page.dart + * 名称: 食材详情查询页面 + * 作用: 查询食材营养信息与选购指南 + * 创建: 2026-04-10 + * 更新: 2026-04-16 重构为GetX架构,拆分Controller/Cards/Utils + */ + +import 'package:flutter/cupertino.dart'; +import 'package:get/get.dart'; +import 'package:mom_kitchen/src/config/design_tokens.dart'; +import 'package:mom_kitchen/src/services/data/ingredient_nutrition_db.dart'; +import 'package:mom_kitchen/src/pages/tools/ingredient_detail/ingredient_detail_utils.dart'; +import 'package:mom_kitchen/src/pages/tools/ingredient_detail/ingredient_detail_cards.dart'; +import 'package:mom_kitchen/src/controllers/tools/ingredient_detail_controller.dart'; + +class IngredientDetailBinding extends Bindings { + @override + void dependencies() { + Get.lazyPut(() => IngredientDetailController()); + } +} + +class IngredientDetailPage extends GetView { + const IngredientDetailPage({super.key}); + + @override + Widget build(BuildContext context) { + final isDark = CupertinoTheme.brightnessOf(context) == Brightness.dark; + + return CupertinoPageScaffold( + backgroundColor: isDark + ? DarkDesignTokens.background + : DesignTokens.background, + navigationBar: CupertinoNavigationBar( + middle: Text( + '🥕 食材详情', + style: TextStyle( + fontSize: DesignTokens.fontMd, + fontWeight: FontWeight.w600, + color: isDark ? DarkDesignTokens.text1 : DesignTokens.text1, + ), + ), + trailing: Obx( + () => controller.isLoadingDetail.value + ? const CupertinoActivityIndicator(radius: 10) + : const SizedBox(), + ), + backgroundColor: isDark + ? DarkDesignTokens.background.withValues(alpha: 0.9) + : DesignTokens.background.withValues(alpha: 0.9), + border: null, + ), + child: SafeArea( + top: false, + child: Column( + children: [ + _buildSearchBar(isDark), + const SizedBox(height: DesignTokens.space2), + Expanded(child: _buildContent(context, isDark)), + ], + ), + ), + ); + } + + Widget _buildSearchBar(bool isDark) { + return Padding( + padding: const EdgeInsets.all(DesignTokens.space4), + child: Container( + height: 40, + decoration: BoxDecoration( + color: isDark + ? DarkDesignTokens.segmentedBg + : DesignTokens.text3.withValues(alpha: 0.08), + borderRadius: DesignTokens.borderRadiusMd, + ), + child: Row( + children: [ + const SizedBox(width: 12), + Icon( + CupertinoIcons.search, + size: 18, + color: isDark ? DarkDesignTokens.text3 : DesignTokens.text3, + ), + const SizedBox(width: 8), + Expanded( + child: CupertinoTextField( + placeholder: '搜索食材...', + placeholderStyle: TextStyle( + fontSize: DesignTokens.fontSm, + color: isDark ? DarkDesignTokens.text3 : DesignTokens.text3, + ), + style: TextStyle( + fontSize: DesignTokens.fontSm, + color: isDark ? DarkDesignTokens.text1 : DesignTokens.text1, + ), + decoration: null, + onChanged: controller.filterIngredients, + ), + ), + Obx(() { + if (controller.searchQuery.value.isNotEmpty) { + return CupertinoButton( + padding: const EdgeInsets.only(right: 8), + minimumSize: const Size(32, 32), + onPressed: () { + controller.filterIngredients(''); + }, + child: Icon( + CupertinoIcons.clear_circled_solid, + size: 18, + color: isDark ? DarkDesignTokens.text3 : DesignTokens.text3, + ), + ); + } + return const SizedBox(); + }), + ], + ), + ), + ); + } + + Widget _buildContent(BuildContext context, bool isDark) { + return Obx(() { + if (controller.selectedIngredient.value != null) { + return _buildIngredientDetail(context, isDark); + } + + if (controller.isLoading.value) { + return const Center(child: CupertinoActivityIndicator(radius: 16)); + } + + if (controller.filteredIngredients.isEmpty) { + return Center( + child: Column( + mainAxisAlignment: MainAxisAlignment.center, + children: [ + const Text('🔍', style: TextStyle(fontSize: 48)), + const SizedBox(height: DesignTokens.space3), + Text( + '未找到相关食材', + style: TextStyle( + fontSize: DesignTokens.fontMd, + color: isDark ? DarkDesignTokens.text2 : DesignTokens.text2, + ), + ), + ], + ), + ); + } + + return ListView.separated( + padding: const EdgeInsets.symmetric(horizontal: DesignTokens.space4), + itemCount: controller.filteredIngredients.length, + separatorBuilder: (context, index) => + const SizedBox(height: DesignTokens.space2), + itemBuilder: (context, index) { + return _buildIngredientCard( + controller.filteredIngredients[index], + isDark, + ); + }, + ); + }); + } + + Widget _buildIngredientCard(Map ingredient, bool isDark) { + final name = ingredient['name'] as String? ?? ''; + final count = ingredient['count'] as int? ?? 0; + final category = ingredient['category'] as String? ?? ''; + final nutrition = IngredientNutritionDb.lookup(name); + + return GestureDetector( + onTap: () => controller.selectIngredient(ingredient), + child: Container( + padding: EdgeInsets.all(DesignTokens.space3), + decoration: BoxDecoration( + color: isDark ? DarkDesignTokens.card : DesignTokens.card, + borderRadius: DesignTokens.borderRadiusMd, + boxShadow: DesignTokens.shadowsSm, + ), + child: Row( + children: [ + Container( + width: 48, + height: 48, + decoration: BoxDecoration( + color: (DesignTokens.dynamicPrimary).withValues(alpha: 0.1), + borderRadius: DesignTokens.borderRadiusMd, + ), + child: Center( + child: Text( + IngredientDetailUtils.getEmoji(category), + style: const TextStyle(fontSize: 24), + ), + ), + ), + const SizedBox(width: DesignTokens.space3), + Expanded( + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text( + name, + style: TextStyle( + fontSize: DesignTokens.fontMd, + fontWeight: FontWeight.w600, + color: isDark + ? DarkDesignTokens.text1 + : DesignTokens.text1, + ), + ), + const SizedBox(height: 4), + Row( + children: [ + Container( + padding: const EdgeInsets.symmetric( + horizontal: 6, + vertical: 2, + ), + decoration: BoxDecoration( + color: DesignTokens.green.withValues(alpha: 0.1), + borderRadius: DesignTokens.borderRadiusSm, + ), + child: Text( + category, + style: TextStyle( + fontSize: 10, + color: DesignTokens.green, + ), + ), + ), + const SizedBox(width: 8), + if (nutrition != null) ...[ + Text( + '${nutrition.calories.toInt()} kcal/${nutrition.unit}', + style: TextStyle( + fontSize: DesignTokens.fontXs, + color: isDark + ? DarkDesignTokens.text3 + : DesignTokens.text3, + ), + ), + const SizedBox(width: 8), + ], + Text( + '$count 道菜谱', + style: TextStyle( + fontSize: DesignTokens.fontXs, + color: isDark + ? DarkDesignTokens.text3 + : DesignTokens.text3, + ), + ), + ], + ), + ], + ), + ), + Icon( + CupertinoIcons.chevron_right, + size: 16, + color: isDark ? DarkDesignTokens.text3 : DesignTokens.text3, + ), + ], + ), + ), + ); + } + + Widget _buildIngredientDetail(BuildContext context, bool isDark) { + final selected = controller.selectedIngredient.value!; + final name = selected['name'] as String? ?? ''; + final category = selected['category'] as String? ?? ''; + final count = selected['count'] as int? ?? 0; + + final nutrition = + IngredientNutritionDb.lookup(name) ?? + IngredientNutritionDb.getFallback(name, category); + + return ListView( + padding: const EdgeInsets.all(DesignTokens.space4), + children: [ + DetailHeaderCard( + name: name, + category: category, + nutrition: nutrition, + isDark: isDark, + ), + const SizedBox(height: DesignTokens.space4), + CalorieOverviewCard(nutrition: nutrition, isDark: isDark), + const SizedBox(height: DesignTokens.space3), + NutritionBarsCard(nutrition: nutrition, isDark: isDark), + const SizedBox(height: DesignTokens.space3), + KeyNutrientsCard(nutrition: nutrition, isDark: isDark), + const SizedBox(height: DesignTokens.space3), + SeasonCard(nutrition: nutrition, isDark: isDark), + const SizedBox(height: DesignTokens.space3), + PurchaseTipCard(nutrition: nutrition, isDark: isDark), + const SizedBox(height: DesignTokens.space3), + StorageTipCard(nutrition: nutrition, isDark: isDark), + const SizedBox(height: DesignTokens.space3), + Obx(() { + final detail = controller.ingredientDetail.value; + final passed = controller.passedDetail.value; + if (detail != null) { + return ApiDetailCard(detail: detail, isDark: isDark); + } else if (passed != null) { + return PassedDetailCard(detail: passed, isDark: isDark); + } + return const SizedBox(); + }), + SubstitutionSection( + name: name, + isDark: isDark, + onSubstituteTap: () => controller.navigateToIngredient(name), + ), + const SizedBox(height: DesignTokens.space4), + CupertinoButton.filled( + borderRadius: DesignTokens.borderRadiusMd, + onPressed: () { + Get.toNamed('/search', arguments: {'keyword': name}); + }, + child: Text('🔍 查看 $count 道相关菜谱'), + ), + Obx(() { + final recipes = controller.similarRecipes; + if (recipes.isNotEmpty) { + return Column( + children: [ + const SizedBox(height: DesignTokens.space5), + SimilarRecipesSection( + ingredientName: name, + recipes: recipes, + isLoading: controller.isLoadingSimilar.value, + isDark: isDark, + ), + ], + ); + } + return const SizedBox(); + }), + const SizedBox(height: DesignTokens.space3), + CupertinoButton( + onPressed: () { + Navigator.of(context).popUntil((route) => route.isFirst); + }, + child: const Text('返回首页'), + ), + const SizedBox(height: DesignTokens.space4), + ], + ); + } +} diff --git a/lib/src/pages/tools/ingredient_detail/ingredient_detail_utils.dart b/lib/src/pages/tools/ingredient_detail/ingredient_detail_utils.dart new file mode 100644 index 0000000..1d3984f --- /dev/null +++ b/lib/src/pages/tools/ingredient_detail/ingredient_detail_utils.dart @@ -0,0 +1,148 @@ +/* + * 文件: ingredient_detail_utils.dart + * 名称: 食材详情工具函数 + * 作用: 提供食材分类、emoji映射、替代建议等工具方法 + * 创建: 2026-04-16 + * 更新: 2026-04-16 从ingredient_detail_page.dart拆分 + */ + +class IngredientDetailUtils { + static String getCategory(String name) { + if (name.contains('鸡') || + name.contains('猪') || + name.contains('牛') || + name.contains('羊') || + name.contains('鱼') || + name.contains('虾') || + name.contains('蟹') || + name.contains('肉')) { + return '肉类'; + } else if (name.contains('菜') || + name.contains('椒') || + name.contains('葱') || + name.contains('蒜') || + name.contains('姜') || + name.contains('茄') || + name.contains('瓜') || + name.contains('豆角') || + name.contains('芹') || + name.contains('韭') || + name.contains('藕') || + name.contains('笋') || + name.contains('菇') || + name.contains('蘑') || + name.contains('耳') || + name.contains('带') || + name.contains('萝') || + name.contains('薯') || + name.contains('米') && name.contains('玉')) { + return '蔬菜'; + } else if (name.contains('油') || + name.contains('盐') || + name.contains('酱') || + name.contains('醋') || + name.contains('糖') || + name.contains('蜜') || + name.contains('味') || + name.contains('料') || + name.contains('酒')) { + return '调料'; + } else if (name.contains('面') || + name.contains('米') || + name.contains('粉') || + name.contains('饼') || + name.contains('馒头') || + name.contains('包')) { + return '主食'; + } else if (name.contains('蛋') || + name.contains('奶') || + name.contains('酪') || + name.contains('腐') || + name.contains('豆')) { + return '蛋奶豆'; + } else if (name.contains('果') || + name.contains('桃') || + name.contains('蕉') || + name.contains('莓') || + name.contains('橙') || + name.contains('橘') || + name.contains('柠') || + name.contains('瓜') && + !name.contains('冬') && + !name.contains('南') && + !name.contains('黄')) { + return '水果'; + } else if (name.contains('坚果') || + name.contains('花生') || + name.contains('芝麻') || + name.contains('核桃') || + name.contains('腰果') || + name.contains('栗') || + name.contains('杏')) { + return '坚果'; + } + return '其他'; + } + + static String getEmoji(String category) { + switch (category) { + case '肉类': + return '🥩'; + case '蔬菜': + return '🥬'; + case '调料': + return '🧂'; + case '主食': + return '🍚'; + case '蛋奶豆': + return '🥚'; + case '水果': + return '🍎'; + case '坚果': + return '🥜'; + default: + return '🥕'; + } + } + + static List getSubstitutions(String name) { + const subMap = { + '鸡肉': ['鸭肉', '兔肉', '豆腐'], + '鸡胸肉': ['火鸡胸肉', '瘦猪肉', '豆腐'], + '猪肉': ['牛肉', '羊肉', '鸡肉'], + '牛肉': ['猪肉', '羊肉', '鸡肉'], + '羊肉': ['牛肉', '猪肉', '鸡肉'], + '鱼': ['虾', '蟹', '豆腐'], + '虾': ['蟹', '鱼', '干贝'], + '蟹': ['虾', '鱼', '干贝'], + '鸡蛋': ['鹌鹑蛋', '豆腐', '亚麻籽蛋'], + '牛奶': ['豆浆', '燕麦奶', '椰奶'], + '黄油': ['椰子油', '橄榄油', '花生酱'], + '面粉': ['米粉', '杏仁粉', '椰子粉'], + '白糖': ['蜂蜜', '枫糖浆', '甜菊糖'], + '大豆': ['鹰嘴豆', '扁豆', '花生'], + '花生': ['杏仁', '腰果', '葵花籽'], + '番茄': ['红椒', '南瓜', '胡萝卜'], + '土豆': ['红薯', '山药', '芋头'], + '大米': ['小米', '藜麦', '糙米'], + '洋葱': ['大葱', '韭菜', '蒜苗'], + '大蒜': ['洋葱', '蒜粉', '韭菜'], + '生姜': ['姜粉', '沙姜', '白胡椒'], + '辣椒': ['黑胡椒', '花椒', '芥末'], + '豆腐': ['鸡蛋', '鸡肉', '鱼肉'], + '香菇': ['平菇', '金针菇', '杏鲍菇'], + '胡萝卜': ['南瓜', '红薯', '红椒'], + '西兰花': ['花菜', '羽衣甘蓝', '菠菜'], + '菠菜': ['油菜', '生菜', '羽衣甘蓝'], + '苹果': ['梨', '桃子', '香蕉'], + '香蕉': ['苹果', '牛油果', '芒果'], + }; + + for (final entry in subMap.entries) { + if (name.contains(entry.key) || entry.key.contains(name)) { + return entry.value; + } + } + return []; + } +} diff --git a/lib/src/pages/tools/ingredient_detail_page.dart b/lib/src/pages/tools/ingredient_detail_page.dart.bak similarity index 100% rename from lib/src/pages/tools/ingredient_detail_page.dart rename to lib/src/pages/tools/ingredient_detail_page.dart.bak diff --git a/lib/src/pages/tools/ingredient_manage_page.dart b/lib/src/pages/tools/ingredient_manage_page.dart new file mode 100644 index 0000000..57fb70d --- /dev/null +++ b/lib/src/pages/tools/ingredient_manage_page.dart @@ -0,0 +1,1001 @@ +/* + * 文件: ingredient_manage_page.dart + * 名称: 用料管理页面 + * 作用: 管理厨房用料瓶子的增删改查,支持网格布局展示 + * 创建: 2026-04-16 + * 更新: 2026-04-16 初始创建 + */ + +import 'package:flutter/cupertino.dart'; +import 'package:flutter/material.dart'; +import 'package:get/get.dart'; +import 'package:mom_kitchen/src/config/design_tokens.dart'; +import 'package:mom_kitchen/src/controllers/ingredient_manage_controller.dart'; +import 'package:mom_kitchen/src/models/bottle_model.dart'; + +class IngredientManagePage extends StatefulWidget { + const IngredientManagePage({super.key}); + + @override + State createState() => _IngredientManagePageState(); +} + +class _IngredientManagePageState extends State + with SingleTickerProviderStateMixin { + late IngredientManageController _controller; + late AnimationController _animationController; + + @override + void initState() { + super.initState(); + _controller = Get.put(IngredientManageController()); + _animationController = AnimationController( + vsync: this, + duration: const Duration(milliseconds: 600), + ); + _animationController.forward(); + } + + @override + void dispose() { + _animationController.dispose(); + super.dispose(); + } + + @override + Widget build(BuildContext context) { + final isDark = CupertinoTheme.brightnessOf(context) == Brightness.dark; + + return CupertinoPageScaffold( + backgroundColor: + isDark ? DarkDesignTokens.background : DesignTokens.background, + child: SafeArea( + child: Column( + children: [ + _buildHeader(isDark), + _buildTypeFilter(isDark), + Expanded( + child: Obx(() => _controller.isLoading.value + ? const Center(child: CupertinoActivityIndicator()) + : _buildBottleGrid(isDark)), + ), + ], + ), + ), + ); + } + + Widget _buildHeader(bool isDark) { + return Padding( + padding: const EdgeInsets.fromLTRB( + DesignTokens.space4, + DesignTokens.space3, + DesignTokens.space4, + DesignTokens.space2, + ), + child: Row( + children: [ + GestureDetector( + onTap: () => Navigator.of(context).pop(), + child: Container( + width: 40, + height: 40, + decoration: BoxDecoration( + color: isDark + ? DarkDesignTokens.glass + : DesignTokens.text3.withValues(alpha: 0.08), + borderRadius: DesignTokens.borderRadiusMd, + ), + child: Icon( + CupertinoIcons.back, + color: isDark ? DarkDesignTokens.text1 : DesignTokens.text1, + ), + ), + ), + const SizedBox(width: DesignTokens.space3), + Expanded( + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text( + '用料管理', + style: TextStyle( + fontSize: DesignTokens.fontXxl, + fontWeight: FontWeight.w700, + color: isDark ? DarkDesignTokens.text1 : DesignTokens.text1, + ), + ), + Text( + '管理厨房调味料和食材', + style: TextStyle( + fontSize: DesignTokens.fontSm, + color: isDark ? DarkDesignTokens.text3 : DesignTokens.text3, + ), + ), + ], + ), + ), + GestureDetector( + onTap: _showAddBottleDialog, + child: Container( + width: 40, + height: 40, + decoration: BoxDecoration( + gradient: LinearGradient( + begin: Alignment.topLeft, + end: Alignment.bottomRight, + colors: [ + DesignTokens.dynamicPrimary, + DesignTokens.secondary, + ], + ), + borderRadius: DesignTokens.borderRadiusMd, + boxShadow: [ + BoxShadow( + color: DesignTokens.dynamicPrimary.withValues(alpha: 0.3), + blurRadius: 8, + offset: const Offset(0, 4), + ), + ], + ), + child: const Icon( + CupertinoIcons.add, + color: CupertinoColors.white, + size: 24, + ), + ), + ), + ], + ), + ); + } + + Widget _buildTypeFilter(bool isDark) { + return Padding( + padding: const EdgeInsets.symmetric(horizontal: DesignTokens.space4), + child: Container( + height: 44, + decoration: BoxDecoration( + color: isDark + ? DarkDesignTokens.card + : DesignTokens.text3.withValues(alpha: 0.06), + borderRadius: DesignTokens.borderRadiusLg, + ), + child: Obx(() => Row( + children: BottleType.values.map((type) { + final isSelected = _controller.selectedType.value == type; + return Expanded( + child: GestureDetector( + onTap: () => _controller.setSelectedType(type), + child: AnimatedContainer( + duration: const Duration(milliseconds: 200), + margin: const EdgeInsets.all(4), + decoration: BoxDecoration( + gradient: isSelected + ? LinearGradient( + begin: Alignment.topLeft, + end: Alignment.bottomRight, + colors: [ + DesignTokens.dynamicPrimary, + DesignTokens.secondary, + ], + ) + : null, + color: isSelected + ? null + : (isDark + ? Colors.transparent + : Colors.transparent), + borderRadius: DesignTokens.borderRadiusMd, + ), + child: Center( + child: Row( + mainAxisAlignment: MainAxisAlignment.center, + children: [ + Text( + _getTypeIcon(type), + style: const TextStyle(fontSize: 16), + ), + const SizedBox(width: 6), + Text( + _getTypeName(type), + style: TextStyle( + fontSize: DesignTokens.fontSm, + fontWeight: FontWeight.w600, + color: isSelected + ? CupertinoColors.white + : (isDark + ? DarkDesignTokens.text2 + : DesignTokens.text2), + ), + ), + ], + ), + ), + ), + ), + ); + }).toList(), + )), + ), + ); + } + + String _getTypeIcon(BottleType type) { + switch (type) { + case BottleType.ratio: + return '📊'; + case BottleType.seasoning: + return '🧂'; + case BottleType.ingredient: + return '🥬'; + } + } + + String _getTypeName(BottleType type) { + switch (type) { + case BottleType.ratio: + return '比例'; + case BottleType.seasoning: + return '调味料'; + case BottleType.ingredient: + return '食材'; + } + } + + Widget _buildBottleGrid(bool isDark) { + final filteredBottles = _controller.getFilteredBottles(); + + if (filteredBottles.isEmpty) { + return Center( + child: Column( + mainAxisAlignment: MainAxisAlignment.center, + children: [ + Container( + width: 80, + height: 80, + decoration: BoxDecoration( + color: DesignTokens.dynamicPrimary.withValues(alpha: 0.1), + borderRadius: DesignTokens.borderRadiusXl, + ), + child: const Center( + child: Text('🧴', style: TextStyle(fontSize: 36)), + ), + ), + const SizedBox(height: DesignTokens.space4), + Text( + '暂无瓶子', + style: TextStyle( + fontSize: DesignTokens.fontLg, + fontWeight: FontWeight.w600, + color: isDark ? DarkDesignTokens.text1 : DesignTokens.text1, + ), + ), + const SizedBox(height: DesignTokens.space2), + Text( + '点击右上角 + 添加新瓶子', + style: TextStyle( + fontSize: DesignTokens.fontSm, + color: isDark ? DarkDesignTokens.text3 : DesignTokens.text3, + ), + ), + ], + ), + ); + } + + return Padding( + padding: const EdgeInsets.all(DesignTokens.space4), + child: GridView.builder( + gridDelegate: const SliverGridDelegateWithFixedCrossAxisCount( + crossAxisCount: 2, + crossAxisSpacing: 16, + mainAxisSpacing: 16, + childAspectRatio: 0.85, + ), + itemCount: filteredBottles.length, + itemBuilder: (context, index) { + return AnimatedBuilder( + animation: _animationController, + builder: (context, child) { + final delay = index * 0.05; + final slideAnimation = Tween( + begin: const Offset(0, 0.3), + end: Offset.zero, + ).animate( + CurvedAnimation( + parent: _animationController, + curve: Interval( + delay.clamp(0.0, 1.0), + (delay + 0.4).clamp(0.0, 1.0), + curve: Curves.easeOut, + ), + ), + ); + return SlideTransition( + position: slideAnimation, + child: child, + ); + }, + child: _buildBottleCard(filteredBottles[index], isDark), + ); + }, + ), + ); + } + + Widget _buildBottleCard(BottleModel bottle, bool isDark) { + final fillColor = _getBottleColor(bottle); + + return GestureDetector( + onTap: () => _showBottleDetailDialog(bottle), + 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.08), + ), + boxShadow: [ + BoxShadow( + color: isDark + ? Colors.black.withValues(alpha: 0.2) + : Colors.black.withValues(alpha: 0.05), + blurRadius: 16, + offset: const Offset(0, 4), + ), + ], + ), + child: Column( + children: [ + // 瓶子视觉区域 + Expanded( + child: Stack( + children: [ + // 瓶子背景 + Positioned.fill( + child: Container( + margin: const EdgeInsets.all(12), + decoration: BoxDecoration( + gradient: LinearGradient( + begin: Alignment.topCenter, + end: Alignment.bottomCenter, + colors: [ + fillColor.withValues(alpha: 0.1), + fillColor.withValues(alpha: 0.05), + ], + ), + borderRadius: DesignTokens.borderRadiusLg, + ), + ), + ), + // 瓶子填充量 + Positioned( + left: 12, + right: 12, + bottom: 12, + height: 120 * bottle.fillPercentage, + child: Container( + decoration: BoxDecoration( + gradient: LinearGradient( + begin: Alignment.topCenter, + end: Alignment.bottomCenter, + colors: [ + fillColor.withValues(alpha: 0.6), + fillColor.withValues(alpha: 0.3), + ], + ), + borderRadius: const BorderRadius.vertical( + bottom: Radius.circular(16), + ), + ), + ), + ), + // 瓶子图标 + Center( + child: Container( + width: 60, + height: 60, + decoration: BoxDecoration( + color: fillColor.withValues(alpha: 0.2), + shape: BoxShape.circle, + ), + child: Center( + child: Text( + bottle.icon ?? bottle.typeIcon, + style: const TextStyle(fontSize: 32), + ), + ), + ), + ), + // 百分比标签 + Positioned( + top: 16, + right: 16, + child: Container( + padding: const EdgeInsets.symmetric( + horizontal: 8, + vertical: 4, + ), + decoration: BoxDecoration( + color: fillColor.withValues(alpha: 0.2), + borderRadius: DesignTokens.borderRadiusSm, + ), + child: Text( + '${(bottle.fillPercentage * 100).toInt()}%', + style: TextStyle( + fontSize: DesignTokens.fontXs, + fontWeight: FontWeight.w600, + color: fillColor, + ), + ), + ), + ), + ], + ), + ), + // 瓶子信息 + Padding( + padding: const EdgeInsets.all(DesignTokens.space3), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text( + bottle.name, + 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: [ + Container( + width: 6, + height: 6, + decoration: BoxDecoration( + color: fillColor, + shape: BoxShape.circle, + ), + ), + const SizedBox(width: 6), + Text( + '${bottle.currentAmount.toStringAsFixed(1)} / ${bottle.capacity.toStringAsFixed(1)}', + style: TextStyle( + fontSize: DesignTokens.fontXs, + color: isDark ? DarkDesignTokens.text3 : DesignTokens.text3, + ), + ), + ], + ), + ], + ), + ), + ], + ), + ), + ); + } + + Color _getBottleColor(BottleModel bottle) { + if (bottle.color != null) { + return Color(int.parse(bottle.color!.replaceAll('#', '0xFF'))); + } + switch (bottle.type) { + case BottleType.ratio: + return const Color(0xFFFF6B35); + case BottleType.seasoning: + return const Color(0xFFFF9F1C); + case BottleType.ingredient: + return const Color(0xFF2ECC71); + } + } + + void _showAddBottleDialog() { + final nameController = TextEditingController(); + final capacityController = TextEditingController(text: '100'); + BottleType selectedType = _controller.selectedType.value; + + showCupertinoDialog( + context: context, + builder: (context) => CupertinoAlertDialog( + title: const Text('添加新瓶子'), + content: Column( + children: [ + const SizedBox(height: 16), + CupertinoTextField( + controller: nameController, + placeholder: '瓶子名称', + padding: const EdgeInsets.all(12), + ), + const SizedBox(height: 12), + CupertinoTextField( + controller: capacityController, + placeholder: '容量', + keyboardType: const TextInputType.numberWithOptions(decimal: true), + padding: const EdgeInsets.all(12), + ), + const SizedBox(height: 12), + CupertinoSegmentedControl( + groupValue: selectedType, + children: { + BottleType.ratio: Padding( + padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 8), + child: Text('📊 比例'), + ), + BottleType.seasoning: Padding( + padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 8), + child: Text('🧂 调味料'), + ), + BottleType.ingredient: Padding( + padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 8), + child: Text('🥬 食材'), + ), + }, + onValueChanged: (value) { + selectedType = value; + }, + ), + ], + ), + actions: [ + CupertinoDialogAction( + child: const Text('取消'), + onPressed: () => Navigator.of(context).pop(), + ), + CupertinoDialogAction( + isDefaultAction: true, + child: const Text('添加'), + onPressed: () { + final name = nameController.text.trim(); + final capacity = double.tryParse(capacityController.text) ?? 100.0; + + if (name.isEmpty) { + Get.snackbar('提示', '请输入瓶子名称'); + return; + } + + final bottle = BottleModel.createDefault( + name: name, + type: selectedType, + capacity: capacity, + ); + + _controller.addBottle(bottle); + Navigator.of(context).pop(); + }, + ), + ], + ), + ); + } + + void _showBottleDetailDialog(BottleModel bottle) { + showCupertinoModalPopup( + context: context, + builder: (context) => _BottleDetailSheet( + bottle: bottle, + controller: _controller, + onBottleUpdated: () => setState(() {}), + ), + ); + } +} + +class _BottleDetailSheet extends StatefulWidget { + final BottleModel bottle; + final IngredientManageController controller; + final VoidCallback onBottleUpdated; + + const _BottleDetailSheet({ + required this.bottle, + required this.controller, + required this.onBottleUpdated, + }); + + @override + State<_BottleDetailSheet> createState() => _BottleDetailSheetState(); +} + +class _BottleDetailSheetState extends State<_BottleDetailSheet> { + late BottleModel _bottle; + + @override + void initState() { + super.initState(); + _bottle = widget.bottle; + } + + @override + Widget build(BuildContext context) { + final isDark = CupertinoTheme.brightnessOf(context) == Brightness.dark; + final fillColor = _getBottleColor(_bottle); + + return Container( + height: 500, + decoration: BoxDecoration( + color: isDark ? DarkDesignTokens.card : DesignTokens.card, + borderRadius: const BorderRadius.vertical(top: Radius.circular(24)), + ), + child: Column( + children: [ + // 拖动指示器 + Container( + margin: const EdgeInsets.only(top: 12), + width: 36, + height: 4, + decoration: BoxDecoration( + color: isDark ? DarkDesignTokens.text3 : DesignTokens.text3, + borderRadius: BorderRadius.circular(2), + ), + ), + Expanded( + child: Padding( + padding: const EdgeInsets.all(DesignTokens.space4), + child: Column( + children: [ + // 瓶子展示 + Container( + height: 150, + decoration: BoxDecoration( + gradient: LinearGradient( + begin: Alignment.topCenter, + end: Alignment.bottomCenter, + colors: [ + fillColor.withValues(alpha: 0.15), + fillColor.withValues(alpha: 0.05), + ], + ), + borderRadius: DesignTokens.borderRadiusXl, + ), + child: Stack( + children: [ + Positioned.fill( + child: Align( + alignment: Alignment.bottomCenter, + child: Container( + height: 120 * _bottle.fillPercentage, + decoration: BoxDecoration( + gradient: LinearGradient( + begin: Alignment.topCenter, + end: Alignment.bottomCenter, + colors: [ + fillColor.withValues(alpha: 0.7), + fillColor.withValues(alpha: 0.4), + ], + ), + borderRadius: const BorderRadius.vertical( + bottom: Radius.circular(24), + ), + ), + ), + ), + ), + Center( + child: Container( + width: 80, + height: 80, + decoration: BoxDecoration( + color: fillColor.withValues(alpha: 0.2), + shape: BoxShape.circle, + ), + child: Center( + child: Text( + _bottle.icon ?? _bottle.typeIcon, + style: const TextStyle(fontSize: 40), + ), + ), + ), + ), + ], + ), + ), + const SizedBox(height: DesignTokens.space4), + // 瓶子名称 + Text( + _bottle.name, + style: TextStyle( + fontSize: DesignTokens.fontXxl, + fontWeight: FontWeight.w700, + color: isDark ? DarkDesignTokens.text1 : DesignTokens.text1, + ), + ), + const SizedBox(height: 8), + Text( + '${_bottle.typeName} · ${(_bottle.fillPercentage * 100).toInt()}%', + style: TextStyle( + fontSize: DesignTokens.fontSm, + color: isDark ? DarkDesignTokens.text3 : DesignTokens.text3, + ), + ), + const SizedBox(height: DesignTokens.space4), + // 容量控制 + Row( + mainAxisAlignment: MainAxisAlignment.center, + children: [ + _buildControlButton( + icon: CupertinoIcons.minus_circle, + onTap: () async { + await widget.controller.decreaseAmount( + _bottle.id, + _bottle.capacity * 0.1, + ); + _updateBottle(); + }, + isDark: isDark, + ), + const SizedBox(width: DesignTokens.space4), + Container( + width: 120, + padding: const EdgeInsets.symmetric( + horizontal: DesignTokens.space3, + vertical: DesignTokens.space2, + ), + decoration: BoxDecoration( + color: isDark + ? DarkDesignTokens.glass + : DesignTokens.text3.withValues(alpha: 0.06), + borderRadius: DesignTokens.borderRadiusMd, + ), + child: Column( + children: [ + Text( + '${_bottle.currentAmount.toStringAsFixed(1)}', + style: TextStyle( + fontSize: DesignTokens.fontXl, + fontWeight: FontWeight.w700, + color: + isDark ? DarkDesignTokens.text1 : DesignTokens.text1, + ), + ), + Text( + '/ ${_bottle.capacity.toStringAsFixed(1)}', + style: TextStyle( + fontSize: DesignTokens.fontXs, + color: + isDark ? DarkDesignTokens.text3 : DesignTokens.text3, + ), + ), + ], + ), + ), + const SizedBox(width: DesignTokens.space4), + _buildControlButton( + icon: CupertinoIcons.plus_circle, + onTap: () async { + await widget.controller.increaseAmount( + _bottle.id, + _bottle.capacity * 0.1, + ); + _updateBottle(); + }, + isDark: isDark, + ), + ], + ), + const SizedBox(height: DesignTokens.space4), + // 编辑和删除按钮 + Row( + children: [ + Expanded( + child: _buildActionButton( + icon: CupertinoIcons.pencil, + label: '编辑', + color: DesignTokens.dynamicPrimary, + onTap: () { + Navigator.of(context).pop(); + _showEditDialog(); + }, + isDark: isDark, + ), + ), + const SizedBox(width: DesignTokens.space3), + Expanded( + child: _buildActionButton( + icon: CupertinoIcons.delete, + label: '删除', + color: DesignTokens.red, + onTap: () { + Navigator.of(context).pop(); + _showDeleteConfirm(); + }, + isDark: isDark, + ), + ), + ], + ), + ], + ), + ), + ), + ], + ), + ); + } + + Widget _buildControlButton({ + required IconData icon, + required VoidCallback onTap, + required bool isDark, + }) { + return GestureDetector( + onTap: onTap, + child: Container( + width: 50, + height: 50, + decoration: BoxDecoration( + color: isDark + ? DarkDesignTokens.glass + : DesignTokens.text3.withValues(alpha: 0.06), + borderRadius: DesignTokens.borderRadiusMd, + ), + child: Icon( + icon, + size: 28, + color: isDark ? DarkDesignTokens.text1 : DesignTokens.text1, + ), + ), + ); + } + + Widget _buildActionButton({ + required IconData icon, + required String label, + required Color color, + required VoidCallback onTap, + required bool isDark, + }) { + return GestureDetector( + onTap: onTap, + child: Container( + height: 48, + decoration: BoxDecoration( + color: color.withValues(alpha: 0.1), + borderRadius: DesignTokens.borderRadiusMd, + border: Border.all(color: color.withValues(alpha: 0.3)), + ), + child: Row( + mainAxisAlignment: MainAxisAlignment.center, + children: [ + Icon(icon, size: 18, color: color), + const SizedBox(width: 8), + Text( + label, + style: TextStyle( + fontSize: DesignTokens.fontSm, + fontWeight: FontWeight.w600, + color: color, + ), + ), + ], + ), + ), + ); + } + + Color _getBottleColor(BottleModel bottle) { + if (bottle.color != null) { + return Color(int.parse(bottle.color!.replaceAll('#', '0xFF'))); + } + switch (bottle.type) { + case BottleType.ratio: + return const Color(0xFFFF6B35); + case BottleType.seasoning: + return const Color(0xFFFF9F1C); + case BottleType.ingredient: + return const Color(0xFF2ECC71); + } + } + + void _updateBottle() { + final updated = widget.controller.bottles.firstWhere( + (b) => b.id == _bottle.id, + orElse: () => _bottle, + ); + setState(() { + _bottle = updated; + }); + widget.onBottleUpdated(); + } + + void _showEditDialog() { + final nameController = TextEditingController(text: _bottle.name); + final capacityController = + TextEditingController(text: _bottle.capacity.toString()); + final amountController = + TextEditingController(text: _bottle.currentAmount.toString()); + + showCupertinoDialog( + context: context, + builder: (context) => CupertinoAlertDialog( + title: const Text('编辑瓶子'), + content: Column( + children: [ + const SizedBox(height: 16), + CupertinoTextField( + controller: nameController, + placeholder: '瓶子名称', + padding: const EdgeInsets.all(12), + ), + const SizedBox(height: 12), + CupertinoTextField( + controller: capacityController, + placeholder: '容量', + keyboardType: + const TextInputType.numberWithOptions(decimal: true), + padding: const EdgeInsets.all(12), + ), + const SizedBox(height: 12), + CupertinoTextField( + controller: amountController, + placeholder: '当前容量', + keyboardType: + const TextInputType.numberWithOptions(decimal: true), + padding: const EdgeInsets.all(12), + ), + ], + ), + actions: [ + CupertinoDialogAction( + child: const Text('取消'), + onPressed: () => Navigator.of(context).pop(), + ), + CupertinoDialogAction( + isDefaultAction: true, + child: const Text('保存'), + onPressed: () { + final name = nameController.text.trim(); + final capacity = double.tryParse(capacityController.text) ?? 100.0; + final amount = double.tryParse(amountController.text) ?? 0.0; + + if (name.isEmpty) { + Get.snackbar('提示', '请输入瓶子名称'); + return; + } + + final updated = _bottle.copyWith( + name: name, + capacity: capacity, + currentAmount: amount.clamp(0.0, capacity), + updatedAt: DateTime.now(), + ); + + widget.controller.updateBottle(updated); + Navigator.of(context).pop(); + }, + ), + ], + ), + ); + } + + void _showDeleteConfirm() { + showCupertinoDialog( + context: context, + builder: (context) => CupertinoAlertDialog( + title: const Text('确认删除'), + content: Text('确定要删除「${_bottle.name}」吗?'), + actions: [ + CupertinoDialogAction( + child: const Text('取消'), + onPressed: () => Navigator.of(context).pop(), + ), + CupertinoDialogAction( + isDestructiveAction: true, + child: const Text('删除'), + onPressed: () { + widget.controller.deleteBottle(_bottle.id); + Navigator.of(context).pop(); + widget.onBottleUpdated(); + }, + ), + ], + ), + ); + } +} diff --git a/lib/src/pages/tools/planning/daily_menu_page.dart b/lib/src/pages/tools/planning/daily_menu_page.dart index e52b731..310ee11 100644 --- a/lib/src/pages/tools/planning/daily_menu_page.dart +++ b/lib/src/pages/tools/planning/daily_menu_page.dart @@ -9,7 +9,6 @@ import 'dart:convert'; import 'package:flutter/cupertino.dart'; import 'package:flutter/material.dart'; -import 'package:get/get.dart'; import 'package:mom_kitchen/src/config/design_tokens.dart'; import 'package:mom_kitchen/src/models/data/daily_menu_model.dart'; import 'package:mom_kitchen/src/repositories/recipe_repository.dart'; diff --git a/lib/src/pages/tools/ranking/dish_pick_sheet.dart b/lib/src/pages/tools/ranking/dish_pick_sheet.dart new file mode 100644 index 0000000..7c300e2 --- /dev/null +++ b/lib/src/pages/tools/ranking/dish_pick_sheet.dart @@ -0,0 +1,641 @@ +/* + * 文件: dish_pick_sheet.dart + * 名称: 菜品选择面板 + * 作用: 底部弹出面板,支持从浏览记录/收藏/手动输入三种方式选择菜品加入Tier List + * 创建: 2026-04-16 初始创建 + */ + +import 'package:flutter/cupertino.dart'; +import 'package:flutter/material.dart' as material; +import 'package:get/get.dart'; +import 'package:mom_kitchen/src/config/design_tokens.dart'; +import 'package:mom_kitchen/src/controllers/data/browse_history_controller.dart'; +import 'package:mom_kitchen/src/controllers/data/favorites_controller.dart'; +import 'package:mom_kitchen/src/models/data/browse_history_model.dart'; +import 'package:mom_kitchen/src/models/dish_rank_model.dart'; +import 'package:mom_kitchen/src/models/feed_item_model.dart'; + +class DishPickSheet extends StatefulWidget { + final int targetTierIndex; + final Function(DishRankItem) onDishSelected; + final Set existingSourceIds; + + const DishPickSheet({ + super.key, + required this.targetTierIndex, + required this.onDishSelected, + this.existingSourceIds = const {}, + }); + + static Future show( + BuildContext context, { + required int targetTierIndex, + required Function(DishRankItem) onDishSelected, + Set existingSourceIds = const {}, + }) { + return showCupertinoModalPopup( + context: context, + builder: (ctx) => DishPickSheet( + targetTierIndex: targetTierIndex, + onDishSelected: onDishSelected, + existingSourceIds: existingSourceIds, + ), + ); + } + + @override + State createState() => _DishPickSheetState(); +} + +class _DishPickSheetState extends State { + int _selectedTab = 0; + String _searchQuery = ''; + final TextEditingController _nameController = TextEditingController(); + String _selectedEmoji = '🍽️'; + + static const List _tabLabels = ['浏览记录', '我的收藏', '手动输入']; + + static const List _foodEmojis = [ + '🍽️', + '🍳', + '🍔', + '🍕', + '🍜', + '🍣', + '🍱', + '🥘', + '🍲', + '🥗', + '🌮', + '🥟', + '🍗', + '🥩', + '🐟', + '🦀', + '🍰', + '🧁', + '🍦', + '🍪', + '☕', + '🍵', + '🍹', + '🍺', + ]; + + bool get isDark => CupertinoTheme.brightnessOf(context) == Brightness.dark; + + List get _filteredHistory { + try { + final controller = Get.find(); + var items = controller.history; + if (_searchQuery.isNotEmpty) { + final q = _searchQuery.toLowerCase(); + items = items.where((e) => e.title.toLowerCase().contains(q)).toList(); + } + return items; + } catch (_) { + return []; + } + } + + List get _filteredFavorites { + try { + final controller = Get.find(); + var items = controller.allFavorites; + if (_searchQuery.isNotEmpty) { + final q = _searchQuery.toLowerCase(); + items = items.where((e) => e.title.toLowerCase().contains(q)).toList(); + } + return items; + } catch (_) { + return []; + } + } + + void _onSelectFromHistory(BrowseHistoryModel item) { + if (widget.existingSourceIds.contains(item.recipeId)) return; + final dishItem = DishRankItem( + id: 'hist_${item.recipeId}_${DateTime.now().millisecondsSinceEpoch}', + name: item.title, + coverImage: item.coverImage, + tierIndex: widget.targetTierIndex, + isCustom: false, + sourceId: item.recipeId, + ); + widget.onDishSelected(dishItem); + Navigator.of(context).pop(); + } + + void _onSelectFromFavorite(FeedItemModel item) { + final sid = item.id.toString(); + if (widget.existingSourceIds.contains(sid)) return; + final dishItem = DishRankItem( + id: 'fav_${item.id}_${DateTime.now().millisecondsSinceEpoch}', + name: item.title, + emoji: item.favoriteType.icon, + coverImage: item.cover, + tierIndex: widget.targetTierIndex, + isCustom: false, + sourceId: sid, + ); + widget.onDishSelected(dishItem); + Navigator.of(context).pop(); + } + + void _onManualAdd() { + final name = _nameController.text.trim(); + if (name.isEmpty) return; + final dishItem = DishRankItem( + id: 'custom_${DateTime.now().millisecondsSinceEpoch}', + name: name, + emoji: _selectedEmoji, + tierIndex: widget.targetTierIndex, + isCustom: true, + ); + widget.onDishSelected(dishItem); + Navigator.of(context).pop(); + } + + @override + void dispose() { + _nameController.dispose(); + super.dispose(); + } + + @override + Widget build(BuildContext context) { + final bottomPadding = MediaQuery.of(context).viewInsets.bottom; + final isDark = this.isDark; + + return material.Material( + color: material.Colors.transparent, + child: Container( + constraints: BoxConstraints( + maxHeight: MediaQuery.of(context).size.height * 0.7, + ), + decoration: BoxDecoration( + color: isDark ? const Color(0xFF1C1C1E) : material.Colors.white, + borderRadius: const BorderRadius.vertical(top: Radius.circular(20)), + ), + child: Column( + mainAxisSize: material.MainAxisSize.min, + children: [ + _buildHandle(isDark), + _buildHeader(isDark), + _buildSegmentedControl(isDark), + if (_selectedTab < 2) _buildSearchBar(isDark), + Flexible(child: _buildContent(isDark, bottomPadding)), + ], + ), + ), + ); + } + + Widget _buildHandle(bool isDark) { + return Center( + child: Container( + margin: const EdgeInsets.only(top: 10), + width: 40, + height: 4, + decoration: BoxDecoration( + color: isDark ? material.Colors.white24 : material.Colors.black12, + borderRadius: BorderRadius.circular(2), + ), + ), + ); + } + + Widget _buildHeader(bool isDark) { + return Padding( + padding: EdgeInsets.symmetric( + horizontal: DesignTokens.space4, + vertical: DesignTokens.space2, + ), + child: Row( + children: [ + Text( + '添加到「${TierDefinition.getTierName(widget.targetTierIndex)}」', + style: TextStyle( + fontSize: DesignTokens.fontLg, + fontWeight: FontWeight.w700, + color: isDark ? material.Colors.white : DesignTokens.text1, + ), + ), + const Spacer(), + GestureDetector( + onTap: () => Navigator.of(context).pop(), + child: Container( + padding: const EdgeInsets.all(8), + child: Text( + '取消', + style: TextStyle( + fontSize: DesignTokens.fontMd, + color: DesignTokens.dynamicPrimary, + ), + ), + ), + ), + ], + ), + ); + } + + Widget _buildSegmentedControl(bool isDark) { + return Padding( + padding: EdgeInsets.symmetric(horizontal: DesignTokens.space4), + child: Container( + decoration: BoxDecoration( + color: isDark ? const Color(0xFF2C2C2E) : const Color(0xFFF2F2F7), + borderRadius: DesignTokens.borderRadiusSm, + ), + child: CupertinoSlidingSegmentedControl( + groupValue: _selectedTab, + children: { + for (var i = 0; i < _tabLabels.length; i++) + i: Padding( + padding: const EdgeInsets.symmetric( + horizontal: 12, + vertical: 6, + ), + child: Text( + _tabLabels[i], + style: TextStyle(fontSize: DesignTokens.fontSm), + ), + ), + }, + onValueChanged: (v) { + if (v != null) setState(() => _selectedTab = v); + }, + ), + ), + ); + } + + Widget _buildSearchBar(bool isDark) { + return Padding( + padding: EdgeInsets.fromLTRB( + DesignTokens.space4, + DesignTokens.space2, + DesignTokens.space4, + 0, + ), + child: Container( + height: 36, + decoration: BoxDecoration( + color: isDark ? const Color(0xFF2C2C2E) : const Color(0xFFF2F2F7), + borderRadius: DesignTokens.borderRadiusSm, + ), + child: Row( + children: [ + const SizedBox(width: 10), + Icon( + CupertinoIcons.search, + size: 16, + color: isDark ? material.Colors.white54 : material.Colors.black38, + ), + const SizedBox(width: 8), + Expanded( + child: CupertinoTextField( + placeholder: '搜索菜品...', + placeholderStyle: TextStyle( + fontSize: DesignTokens.fontSm, + color: isDark + ? material.Colors.white38 + : material.Colors.black26, + ), + style: TextStyle( + fontSize: DesignTokens.fontSm, + color: isDark ? material.Colors.white : material.Colors.black, + ), + decoration: null, + onChanged: (v) => setState(() => _searchQuery = v), + ), + ), + if (_searchQuery.isNotEmpty) + GestureDetector( + onTap: () => setState(() => _searchQuery = ''), + child: Padding( + padding: const EdgeInsets.only(right: 8), + child: Icon( + CupertinoIcons.clear_circled_solid, + size: 14, + color: isDark + ? material.Colors.white38 + : material.Colors.black26, + ), + ), + ), + ], + ), + ), + ); + } + + Widget _buildContent(bool isDark, double bottomPadding) { + switch (_selectedTab) { + case 0: + return _buildHistoryList(isDark, bottomPadding); + case 1: + return _buildFavoriteList(isDark, bottomPadding); + case 2: + return _buildManualInput(isDark, bottomPadding); + default: + return const SizedBox.shrink(); + } + } + + Widget _buildHistoryList(bool isDark, double bottomPadding) { + final items = _filteredHistory; + if (items.isEmpty) { + return _buildEmpty('暂无浏览记录,去发现更多菜谱吧~', isDark); + } + return ListView.separated( + shrinkWrap: true, + padding: EdgeInsets.only( + left: DesignTokens.space4, + right: DesignTokens.space4, + bottom: bottomPadding + DesignTokens.space3, + ), + itemCount: items.length, + separatorBuilder: (_, __) => material.Divider( + height: 1, + color: isDark ? material.Colors.white10 : const Color(0x0F000000), + ), + itemBuilder: (context, index) { + final item = items[index]; + final added = widget.existingSourceIds.contains(item.recipeId); + return _buildDishTile( + title: item.title, + subtitle: item.displayDate, + coverUrl: item.coverImage, + emoji: '📖', + isAdded: added, + isDark: isDark, + onTap: added ? null : () => _onSelectFromHistory(item), + ); + }, + ); + } + + Widget _buildFavoriteList(bool isDark, double bottomPadding) { + final items = _filteredFavorites; + if (items.isEmpty) { + return _buildEmpty('暂无收藏,去收藏喜欢的菜谱吧~', isDark); + } + return ListView.separated( + shrinkWrap: true, + padding: EdgeInsets.only( + left: DesignTokens.space4, + right: DesignTokens.space4, + bottom: bottomPadding + DesignTokens.space3, + ), + itemCount: items.length, + separatorBuilder: (_, __) => material.Divider( + height: 1, + color: isDark ? material.Colors.white10 : const Color(0x0F000000), + ), + itemBuilder: (context, index) { + final item = items[index]; + final sid = item.id.toString(); + final added = widget.existingSourceIds.contains(sid); + return _buildDishTile( + title: item.title, + subtitle: item.categoryName ?? item.favoriteType.label, + coverUrl: item.cover, + emoji: item.favoriteType.icon, + isAdded: added, + isDark: isDark, + onTap: added ? null : () => _onSelectFromFavorite(item), + ); + }, + ); + } + + Widget _buildManualInput(bool isDark, double bottomPadding) { + return SingleChildScrollView( + padding: EdgeInsets.only( + left: DesignTokens.space4, + right: DesignTokens.space4, + bottom: bottomPadding + DesignTokens.space3, + ), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + const SizedBox(height: DesignTokens.space2), + Text( + '菜品名称', + style: TextStyle( + fontSize: DesignTokens.fontSm, + fontWeight: FontWeight.w600, + color: isDark ? material.Colors.white60 : material.Colors.black54, + ), + ), + const SizedBox(height: 6), + Container( + decoration: BoxDecoration( + color: isDark ? const Color(0xFF2C2C2E) : const Color(0xFFF2F2F7), + borderRadius: DesignTokens.borderRadiusSm, + ), + child: CupertinoTextField( + controller: _nameController, + placeholder: '输入菜品名称...', + placeholderStyle: TextStyle( + fontSize: DesignTokens.fontMd, + color: isDark + ? material.Colors.white38 + : material.Colors.black26, + ), + style: TextStyle( + fontSize: DesignTokens.fontMd, + color: isDark ? material.Colors.white : material.Colors.black, + ), + padding: const EdgeInsets.symmetric(horizontal: 12, vertical: 10), + decoration: null, + ), + ), + const SizedBox(height: DesignTokens.space3), + Text( + '选择图标', + style: TextStyle( + fontSize: DesignTokens.fontSm, + fontWeight: FontWeight.w600, + color: isDark ? material.Colors.white60 : material.Colors.black54, + ), + ), + const SizedBox(height: 8), + Wrap( + spacing: 8, + runSpacing: 8, + children: _foodEmojis.map((emoji) { + final isSelected = emoji == _selectedEmoji; + return GestureDetector( + onTap: () => setState(() => _selectedEmoji = emoji), + child: Container( + width: 44, + height: 44, + decoration: BoxDecoration( + color: isSelected + ? DesignTokens.dynamicPrimary.withValues(alpha: 0.15) + : (isDark + ? const Color(0xFF2C2C2E) + : const Color(0xFFF2F2F7)), + borderRadius: DesignTokens.borderRadiusSm, + border: isSelected + ? Border.all( + color: DesignTokens.dynamicPrimary, + width: 2, + ) + : null, + ), + child: Center( + child: Text(emoji, style: const TextStyle(fontSize: 22)), + ), + ), + ); + }).toList(), + ), + const SizedBox(height: DesignTokens.space4), + SizedBox( + width: double.infinity, + height: 48, + child: CupertinoButton( + color: DesignTokens.dynamicPrimary, + borderRadius: DesignTokens.borderRadiusMd, + onPressed: _onManualAdd, + child: Text( + '添加到「${TierDefinition.getTierName(widget.targetTierIndex)}」', + style: const TextStyle( + fontSize: DesignTokens.fontMd, + fontWeight: FontWeight.w600, + color: material.Colors.white, + ), + ), + ), + ), + ], + ), + ); + } + + Widget _buildDishTile({ + required String title, + required String subtitle, + String? coverUrl, + required String emoji, + required bool isAdded, + required bool isDark, + VoidCallback? onTap, + }) { + return GestureDetector( + onTap: onTap, + behavior: HitTestBehavior.opaque, + child: Padding( + padding: const EdgeInsets.symmetric(vertical: 8), + child: Row( + children: [ + Container( + width: 48, + height: 48, + decoration: BoxDecoration( + color: isDark + ? const Color(0xFF2C2C2E) + : const Color(0xFFF2F2F7), + borderRadius: DesignTokens.borderRadiusSm, + ), + clipBehavior: Clip.antiAlias, + child: coverUrl != null && coverUrl.isNotEmpty + ? Image.network( + coverUrl, + fit: BoxFit.cover, + errorBuilder: (_, __, ___) => Center( + child: Text( + emoji, + style: const TextStyle(fontSize: 22), + ), + ), + ) + : Center( + child: Text(emoji, style: const TextStyle(fontSize: 22)), + ), + ), + const SizedBox(width: 12), + Expanded( + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text( + title, + maxLines: 1, + overflow: TextOverflow.ellipsis, + style: TextStyle( + fontSize: DesignTokens.fontMd, + fontWeight: FontWeight.w600, + color: isAdded + ? (isDark + ? material.Colors.white30 + : material.Colors.black26) + : (isDark + ? material.Colors.white + : DesignTokens.text1), + ), + ), + const SizedBox(height: 2), + Text( + subtitle, + style: TextStyle( + fontSize: DesignTokens.fontXs, + color: isDark + ? material.Colors.white30 + : material.Colors.black38, + ), + ), + ], + ), + ), + if (isAdded) + Container( + padding: const EdgeInsets.symmetric(horizontal: 8, vertical: 4), + decoration: BoxDecoration( + color: const Color(0x1A34C759), + borderRadius: DesignTokens.borderRadiusSm, + ), + child: Text( + '已添加', + style: TextStyle( + fontSize: DesignTokens.fontXs, + color: const Color(0xFF34C759), + ), + ), + ) + else + Icon( + CupertinoIcons.add_circled, + size: 24, + color: DesignTokens.dynamicPrimary, + ), + ], + ), + ), + ); + } + + Widget _buildEmpty(String message, bool isDark) { + return Padding( + padding: const EdgeInsets.symmetric(vertical: 48), + child: Column( + mainAxisSize: MainAxisSize.min, + children: [ + const Text('📭', style: TextStyle(fontSize: 48)), + const SizedBox(height: 12), + Text( + message, + style: TextStyle( + fontSize: DesignTokens.fontSm, + color: isDark ? material.Colors.white30 : material.Colors.black38, + ), + ), + ], + ), + ); + } +} diff --git a/lib/src/pages/tools/ranking/dish_ranking_controller.dart b/lib/src/pages/tools/ranking/dish_ranking_controller.dart new file mode 100644 index 0000000..78a8db1 --- /dev/null +++ b/lib/src/pages/tools/ranking/dish_ranking_controller.dart @@ -0,0 +1,140 @@ +/* + * 文件: dish_ranking_controller.dart + * 名称: 菜品排名控制器 + * 作用: 管理Tier List数据、拖拽排序、跨层级移动、本地持久化 + * 创建: 2026-04-16 初始创建 + */ + +import 'dart:convert'; +import 'package:flutter/foundation.dart'; +import 'package:get/get.dart'; +import 'package:shared_preferences/shared_preferences.dart'; +import 'package:mom_kitchen/src/controllers/base_controller.dart'; +import 'package:mom_kitchen/src/models/dish_rank_model.dart'; + +class DishRankingController extends BaseController { + static const String _storageKey = 'dish_ranking_data'; + + final RxList _allItems = [].obs; + + List get allItems => _allItems; + + int get totalCount => _allItems.length; + + @override + void onInit() { + super.onInit(); + loadFromStorage(); + } + + List getItemsByTier(int tierIndex) { + return _allItems + .where((item) => item.tierIndex == tierIndex) + .toList() + ..sort((a, b) => a.order.compareTo(b.order)); + } + + int getTierCount(int tierIndex) { + return _allItems.where((item) => item.tierIndex == tierIndex).length; + } + + Future loadFromStorage() async { + try { + final prefs = await SharedPreferences.getInstance(); + final data = prefs.getString(_storageKey); + if (data != null && data.isNotEmpty) { + final List jsonList = json.decode(data); + final items = jsonList + .map((json) => DishRankItem.fromJson(json as Map)) + .toList(); + _allItems.assignAll(items); + } + } catch (e) { + debugPrint('DishRankingController: 加载失败 $e'); + } + } + + Future saveToStorage() async { + try { + final prefs = await SharedPreferences.getInstance(); + final data = + json.encode(_allItems.map((item) => item.toJson()).toList()); + await prefs.setString(_storageKey, data); + } catch (e) { + debugPrint('DishRankingController: 保存失败 $e'); + } + } + + void addItem(DishRankItem item) { + final maxOrder = _getMaxOrderInTier(item.tierIndex); + final newItem = item.copyWith(order: maxOrder + 1); + _allItems.add(newItem); + saveToStorage(); + } + + void removeItem(String id) { + _allItems.removeWhere((item) => item.id == id); + saveToStorage(); + } + + void moveItem(String id, int newTierIndex) { + final index = _allItems.indexWhere((item) => item.id == id); + if (index < 0) return; + final item = _allItems[index]; + final maxOrder = _getMaxOrderInTier(newTierIndex); + _allItems[index] = item.copyWith( + tierIndex: newTierIndex, + order: maxOrder + 1, + ); + saveToStorage(); + } + + void reorderInTier(int tierIndex, int oldIndex, int newIndex) { + final tierItems = getItemsByTier(tierIndex); + if (oldIndex < 0 || oldIndex >= tierItems.length) return; + if (newIndex < 0 || newIndex >= tierItems.length) return; + if (oldIndex == newIndex) return; + + final item = tierItems[oldIndex]; + final globalIndex = _allItems.indexWhere((e) => e.id == item.id); + if (newIndex > oldIndex) { + for (var i = 0; i < tierItems.length; i++) { + if (i > oldIndex && i <= newIndex) { + final gi = _allItems.indexWhere((e) => e.id == tierItems[i].id); + _allItems[gi] = tierItems[i].copyWith(order: tierItems[i].order - 1); + } + } + _allItems[globalIndex] = item.copyWith(order: newIndex); + } else { + for (var i = 0; i < tierItems.length; i++) { + if (i >= newIndex && i < oldIndex) { + final gi = _allItems.indexWhere((e) => e.id == tierItems[i].id); + _allItems[gi] = tierItems[i].copyWith(order: tierItems[i].order + 1); + } + } + _allItems[globalIndex] = item.copyWith(order: newIndex); + } + saveToStorage(); + } + + void clearAll() { + _allItems.clear(); + saveToStorage(); + } + + bool containsItemId(String sourceId) { + return _anyWithSource(sourceId); + } + + bool _anyWithSource(String? sourceId) { + if (sourceId == null || sourceId.isEmpty) return false; + return _allItems.any((item) => item.sourceId == sourceId); + } + + int _getMaxOrderInTier(int tierIndex) { + final tierItems = + _allItems.where((item) => item.tierIndex == tierIndex); + if (tierItems.isEmpty) return -1; + return tierItems.map((item) => item.order).reduce((a, b) => a > b ? a : b); + } +} diff --git a/lib/src/pages/tools/ranking/dish_ranking_page.dart b/lib/src/pages/tools/ranking/dish_ranking_page.dart new file mode 100644 index 0000000..810df63 --- /dev/null +++ b/lib/src/pages/tools/ranking/dish_ranking_page.dart @@ -0,0 +1,617 @@ +/* + * 文件: dish_ranking_page.dart + * 名称: 菜品排名页面 + * 作用: Tier List主界面,展示5个层级的菜品排名,支持拖拽排序、跨层级移动、添加/删除 + * 创建: 2026-04-16 初始创建 + */ + +import 'package:flutter/cupertino.dart'; +import 'package:flutter/material.dart' as material; +import 'package:get/get.dart'; +import 'package:mom_kitchen/src/config/design_tokens.dart'; +import 'package:mom_kitchen/src/models/dish_rank_model.dart'; +import 'package:mom_kitchen/src/pages/tools/ranking/dish_pick_sheet.dart'; +import 'package:mom_kitchen/src/pages/tools/ranking/dish_ranking_controller.dart'; + +class DishRankingPage extends StatefulWidget { + const DishRankingPage({super.key}); + + @override + State createState() => _DishRankingPageState(); +} + +class _DishRankingPageState extends State + with TickerProviderStateMixin { + late DishRankingController _controller; + late AnimationController _animationController; + bool _isInitialized = false; + + bool get isDark => CupertinoTheme.brightnessOf(context) == Brightness.dark; + + @override + void initState() { + super.initState(); + _animationController = AnimationController( + vsync: this, + duration: const Duration(milliseconds: 800), + ); + } + + @override + void dispose() { + _animationController.dispose(); + if (Get.isRegistered()) { + Get.delete(); + } + super.dispose(); + } + + @override + void didChangeDependencies() { + super.didChangeDependencies(); + if (!_isInitialized) { + _isInitialized = true; + if (!Get.isRegistered()) { + Get.put( + DishRankingController(), + permanent: false, + ); + } + _controller = Get.find(); + Future.delayed(const Duration(milliseconds: 100), () { + if (mounted) _animationController.forward(); + }); + } + } + + @override + Widget build(BuildContext context) { + final isDark = this.isDark; + + return CupertinoPageScaffold( + backgroundColor: isDark ? material.Colors.black : DesignTokens.background, + child: SafeArea( + child: Column( + children: [ + _buildHeader(isDark), + Expanded(child: Obx(() => _buildTierList(isDark))), + ], + ), + ), + ); + } + + Widget _buildHeader(bool isDark) { + return Padding( + padding: EdgeInsets.fromLTRB( + DesignTokens.space4, + DesignTokens.space3, + DesignTokens.space4, + DesignTokens.space2, + ), + child: Row( + children: [ + Container( + width: 44, + height: 44, + decoration: BoxDecoration( + gradient: LinearGradient( + begin: Alignment.topLeft, + end: Alignment.bottomRight, + colors: [ + const Color(0xFFE63946).withValues(alpha: 0.15), + const Color(0xFFF4A261).withValues(alpha: 0.15), + ], + ), + borderRadius: DesignTokens.borderRadiusMd, + ), + child: const Center( + child: Text('🏆', style: TextStyle(fontSize: 22)), + ), + ), + const SizedBox(width: DesignTokens.space3), + Expanded( + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text( + '菜品排名', + style: TextStyle( + fontSize: DesignTokens.fontXxl, + fontWeight: FontWeight.w700, + color: isDark ? material.Colors.white : DesignTokens.text1, + ), + ), + Text( + '给你的菜品排个座次,从夯到拉', + style: TextStyle( + fontSize: DesignTokens.fontSm, + color: isDark + ? material.Colors.white54 + : DesignTokens.text3, + ), + ), + ], + ), + ), + Obx( + () => Container( + padding: EdgeInsets.symmetric( + horizontal: DesignTokens.space2, + vertical: DesignTokens.space1, + ), + decoration: BoxDecoration( + color: const Color(0x1AE63946), + borderRadius: DesignTokens.borderRadiusSm, + ), + child: Text( + '${_controller.totalCount} 道菜', + style: const TextStyle( + fontSize: DesignTokens.fontXs, + fontWeight: FontWeight.w600, + color: Color(0xFFE63946), + ), + ), + ), + ), + const SizedBox(width: 8), + GestureDetector( + onTap: () => _showResetConfirm(isDark), + child: Icon( + CupertinoIcons.trash, + size: 22, + color: isDark ? material.Colors.white54 : DesignTokens.text3, + ), + ), + ], + ), + ); + } + + Widget _buildTierList(bool isDark) { + return CustomScrollView( + physics: const BouncingScrollPhysics(), + slivers: [ + for (var i = 0; i < TierDefinition.tierNames.length; i++) + SliverToBoxAdapter( + child: AnimatedBuilder( + animation: _animationController, + builder: (context, child) { + final delay = i * 0.12; + final slideAnimation = + Tween( + begin: const Offset(0, 0.25), + end: Offset.zero, + ).animate( + CurvedAnimation( + parent: _animationController, + curve: Interval( + delay.clamp(0.0, 1.0), + (delay + 0.4).clamp(0.0, 1.0), + curve: Curves.easeOutCubic, + ), + ), + ); + final fadeAnimation = Tween(begin: 0.0, end: 1.0) + .animate( + CurvedAnimation( + parent: _animationController, + curve: Interval( + delay.clamp(0.0, 1.0), + (delay + 0.35).clamp(0.0, 1.0), + curve: Curves.easeOut, + ), + ), + ); + return FadeTransition( + opacity: fadeAnimation, + child: SlideTransition( + position: slideAnimation, + child: child, + ), + ); + }, + child: _buildTierRow(i, isDark), + ), + ), + const SliverToBoxAdapter(child: SizedBox(height: DesignTokens.space4)), + ], + ); + } + + Widget _buildTierRow(int tierIndex, bool isDark) { + final items = _controller.getItemsByTier(tierIndex); + final tierColor = TierDefinition.getTierColor(tierIndex); + final tierTextColor = TierDefinition.getTierTextColor(tierIndex); + final tierName = TierDefinition.getTierName(tierIndex); + + return Padding( + padding: EdgeInsets.fromLTRB( + DesignTokens.space4, + DesignTokens.space2, + DesignTokens.space4, + 0, + ), + child: Row( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + _buildTierLabel(tierName, tierColor, tierTextColor, isDark), + const SizedBox(width: DesignTokens.space2), + Expanded( + child: _buildTierContent(tierIndex, items, tierColor, isDark), + ), + const SizedBox(width: 8), + _buildAddButton(tierIndex, isDark), + ], + ), + ); + } + + Widget _buildTierLabel( + String name, + material.Color color, + material.Color textColor, + bool isDark, + ) { + return Container( + width: 56, + height: 80, + decoration: BoxDecoration( + color: color, + borderRadius: DesignTokens.borderRadiusMd, + boxShadow: name == '夯' + ? [ + BoxShadow( + color: color.withValues(alpha: 0.35), + blurRadius: 10, + offset: const Offset(0, 3), + ), + ] + : null, + ), + child: Center( + child: Text( + name, + style: TextStyle( + fontSize: name == 'NPC' || name == '拉完了' + ? DesignTokens.fontLg + : DesignTokens.fontXl, + fontWeight: FontWeight.w800, + color: textColor, + ), + ), + ), + ); + } + + Widget _buildTierContent( + int tierIndex, + List items, + material.Color tierColor, + bool isDark, + ) { + if (items.isEmpty) { + return GestureDetector( + onTap: () => _openPickSheet(tierIndex), + child: Container( + height: 80, + decoration: BoxDecoration( + border: Border.all( + color: tierColor.withValues(alpha: isDark ? 0.2 : 0.3), + width: 1.5, + style: material.BorderStyle.solid, + ), + borderRadius: DesignTokens.borderRadiusMd, + color: tierColor.withValues(alpha: isDark ? 0.04 : 0.06), + ), + child: Center( + child: Text( + '+ 添加菜品', + style: TextStyle( + fontSize: DesignTokens.fontSm, + color: isDark + ? material.Colors.white24 + : tierColor.withValues(alpha: 0.5), + ), + ), + ), + ), + ); + } + + return SizedBox( + height: 84, + child: ListView.separated( + scrollDirection: Axis.horizontal, + physics: const BouncingScrollPhysics(), + itemCount: items.length, + separatorBuilder: (_, __) => const SizedBox(width: 8), + itemBuilder: (context, index) { + return _DishCard( + item: items[index], + tierColor: tierColor, + isDark: isDark, + onTap: () => _showCardActionSheet(items[index], tierIndex), + onLongPressStart: () => + _showDragOverlay(context, items[index], tierIndex), + ); + }, + ), + ); + } + + Widget _buildAddButton(int tierIndex, bool isDark) { + return GestureDetector( + onTap: () => _openPickSheet(tierIndex), + child: Container( + width: 32, + height: 32, + decoration: BoxDecoration( + shape: BoxShape.circle, + color: DesignTokens.dynamicPrimary.withValues(alpha: 0.1), + border: Border.all( + color: DesignTokens.dynamicPrimary.withValues(alpha: 0.3), + width: 1, + ), + ), + child: Icon( + CupertinoIcons.add, + size: 16, + color: DesignTokens.dynamicPrimary, + ), + ), + ); + } + + void _openPickSheet(int tierIndex) { + final sourceIds = _controller.allItems + .where((e) => e.sourceId != null && e.sourceId!.isNotEmpty) + .map((e) => e.sourceId!) + .toSet(); + DishPickSheet.show( + context, + targetTierIndex: tierIndex, + onDishSelected: (dish) => _controller.addItem(dish), + existingSourceIds: sourceIds, + ); + } + + void _showCardActionSheet(DishRankItem item, int currentTier) { + showCupertinoModalPopup( + context: context, + builder: (ctx) => CupertinoActionSheet( + title: Text( + item.name, + style: const TextStyle(fontWeight: FontWeight.w600), + ), + message: Text('当前位于「${TierDefinition.getTierName(currentTier)}」'), + actions: [ + for (var t = 0; t < TierDefinition.tierNames.length; t++) + if (t != currentTier) + CupertinoActionSheetAction( + onPressed: () { + Navigator.pop(ctx); + _controller.moveItem(item.id, t); + }, + child: Row( + mainAxisSize: MainAxisSize.min, + children: [ + Container( + width: 14, + height: 14, + decoration: BoxDecoration( + color: TierDefinition.getTierColor(t), + borderRadius: BorderRadius.circular(3), + ), + ), + const SizedBox(width: 8), + Text('移至「${TierDefinition.getTierName(t)}」'), + ], + ), + ), + ], + cancelButton: CupertinoActionSheetAction( + isDestructiveAction: true, + onPressed: () { + Navigator.pop(ctx); + _controller.removeItem(item.id); + }, + child: const Text('删除此菜品'), + ), + ), + ); + } + + void _showResetConfirm(bool isDark) { + showCupertinoDialog( + context: context, + builder: (ctx) => CupertinoAlertDialog( + title: const Text('确认清空?'), + content: const Text('将清除所有层级中的菜品,此操作不可撤销。'), + actions: [ + CupertinoDialogAction( + child: const Text('取消'), + isDefaultAction: true, + onPressed: () => Navigator.pop(ctx), + ), + CupertinoDialogAction( + isDestructiveAction: true, + child: const Text('清空全部'), + onPressed: () { + Navigator.pop(ctx); + _controller.clearAll(); + }, + ), + ], + ), + ); + } + + void _showDragOverlay( + BuildContext context, + DishRankItem item, + int tierIndex, + ) { + late OverlayEntry entry; + entry = OverlayEntry( + builder: (overlayContext) => material.Positioned.fill( + child: material.Material( + color: material.Colors.black38, + child: material.GestureDetector( + behavior: material.HitTestBehavior.translucent, + onPanEnd: (_) => entry.remove(), + child: material.Center(child: _buildDragPreview(item)), + ), + ), + ), + ); + Overlay.of(context).insert(entry); + } + + Widget _buildDragPreview(DishRankItem item) { + return Container( + width: 120, + padding: const EdgeInsets.all(8), + decoration: BoxDecoration( + color: material.Colors.white, + borderRadius: DesignTokens.borderRadiusMd, + boxShadow: const [ + material.BoxShadow( + color: material.Colors.black26, + blurRadius: 16, + offset: Offset(0, 8), + ), + ], + ), + child: Column( + mainAxisSize: MainAxisSize.min, + children: [ + Text(item.emoji, style: const TextStyle(fontSize: 36)), + const SizedBox(height: 4), + Text( + item.name, + maxLines: 1, + overflow: TextOverflow.ellipsis, + style: const TextStyle( + fontSize: DesignTokens.fontSm, + fontWeight: FontWeight.w600, + ), + ), + ], + ), + ); + } +} + +class _DishCard extends StatelessWidget { + final DishRankItem item; + final material.Color tierColor; + final bool isDark; + final VoidCallback onTap; + final VoidCallback onLongPressStart; + + const _DishCard({ + required this.item, + required this.tierColor, + required this.isDark, + required this.onTap, + required this.onLongPressStart, + }); + + @override + Widget build(BuildContext context) { + return GestureDetector( + onTap: onTap, + onLongPress: onLongPressStart, + child: Container( + width: 110, + margin: const EdgeInsets.only(bottom: 2), + decoration: BoxDecoration( + color: isDark ? const Color(0xFF1C1C1E) : material.Colors.white, + borderRadius: DesignTokens.borderRadiusMd, + boxShadow: [ + BoxShadow( + color: (isDark ? material.Colors.black : tierColor).withValues( + alpha: 0.08, + ), + blurRadius: 6, + offset: const Offset(0, 2), + ), + ], + ), + clipBehavior: Clip.antiAlias, + child: Column( + children: [ + Expanded( + flex: 3, + child: item.coverImage != null && item.coverImage!.isNotEmpty + ? Image.network( + item.coverImage!, + fit: BoxFit.cover, + width: double.infinity, + errorBuilder: (_, __, ___) => Center( + child: Text( + item.emoji, + style: const TextStyle(fontSize: 32), + ), + ), + ) + : Container( + color: tierColor.withValues(alpha: 0.08), + child: Center( + child: Text( + item.emoji, + style: const TextStyle(fontSize: 32), + ), + ), + ), + ), + Expanded( + flex: 2, + child: Padding( + padding: const EdgeInsets.fromLTRB(6, 4, 6, 4), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + mainAxisAlignment: MainAxisAlignment.center, + children: [ + Text( + item.name, + maxLines: 1, + overflow: TextOverflow.ellipsis, + style: TextStyle( + fontSize: DesignTokens.fontSm, + fontWeight: FontWeight.w600, + color: isDark + ? material.Colors.white + : DesignTokens.text1, + ), + ), + if (item.isCustom) + Container( + margin: const EdgeInsets.only(top: 2), + padding: const EdgeInsets.symmetric( + horizontal: 4, + vertical: 1, + ), + decoration: BoxDecoration( + color: DesignTokens.secondary.withValues(alpha: 0.1), + borderRadius: BorderRadius.circular(4), + ), + child: const Text( + '自定义', + style: TextStyle( + fontSize: 9, + color: DesignTokens.secondary, + ), + ), + ), + ], + ), + ), + ), + ], + ), + ), + ); + } +} diff --git a/lib/src/pages/tools/tool_detail_page.dart b/lib/src/pages/tools/tool_detail_page.dart new file mode 100644 index 0000000..bace52d --- /dev/null +++ b/lib/src/pages/tools/tool_detail_page.dart @@ -0,0 +1,369 @@ +/* + * 文件: tool_detail_page.dart + * 名称: 工具详情页 + * 作用: 从瀑布流工具卡片info图标进入,展示工具详细信息和功能说明 + * 创建时间: 2026-04-17 + * 更新时间: 2026-04-17 初始创建 + * 更新: 2026-04-17 重构为与工具中心详情页一致样式(头部卡片+信息卡片+底部按钮) + */ + +import 'dart:ui'; +import 'package:flutter/cupertino.dart'; +import 'package:flutter/material.dart'; +import 'package:get/get.dart'; +import 'package:mom_kitchen/src/config/design_tokens.dart'; +import 'package:mom_kitchen/src/models/discover_model.dart'; + +class ToolDetailPage extends StatelessWidget { + const ToolDetailPage({super.key}); + + @override + Widget build(BuildContext context) { + final isDark = CupertinoTheme.brightnessOf(context) == Brightness.dark; + final tool = Get.arguments as ToolItemRef?; + + if (tool == null) { + return CupertinoPageScaffold( + backgroundColor: isDark + ? DarkDesignTokens.background + : DesignTokens.background, + child: SafeArea( + child: Center( + child: Column( + mainAxisAlignment: MainAxisAlignment.center, + children: [ + Icon( + CupertinoIcons.exclamationmark_triangle, + size: 48, + color: isDark ? DarkDesignTokens.text3 : DesignTokens.text3, + ), + const SizedBox(height: DesignTokens.space4), + Text( + '工具信息不存在', + style: TextStyle( + fontSize: DesignTokens.fontLg, + color: isDark ? DarkDesignTokens.text2 : DesignTokens.text2, + ), + ), + ], + ), + ), + ), + ); + } + + return CupertinoPageScaffold( + backgroundColor: isDark + ? DarkDesignTokens.background + : DesignTokens.background, + child: SafeArea( + child: Column( + children: [ + _buildNavigationBar(tool, isDark), + Expanded( + child: SingleChildScrollView( + physics: const BouncingScrollPhysics(), + padding: const EdgeInsets.all(DesignTokens.space4), + child: Column( + children: [ + _buildHeaderCard(tool, isDark), + const SizedBox(height: DesignTokens.space4), + _buildInfoCards(tool, isDark), + const SizedBox(height: DesignTokens.space4), + ], + ), + ), + ), + _buildBottomButton(tool, isDark), + ], + ), + ), + ); + } + + Widget _buildNavigationBar(ToolItemRef tool, bool isDark) { + return ClipRRect( + child: BackdropFilter( + filter: ImageFilter.blur(sigmaX: 20, sigmaY: 20), + child: Container( + padding: const EdgeInsets.symmetric( + horizontal: DesignTokens.space4, + vertical: DesignTokens.space2, + ), + decoration: BoxDecoration( + color: (isDark ? DarkDesignTokens.card : DesignTokens.card) + .withValues(alpha: 0.72), + border: Border( + bottom: BorderSide( + color: Colors.white.withValues(alpha: 0.1), + width: 0.5, + ), + ), + ), + child: Row( + children: [ + GestureDetector( + onTap: () => Get.back(), + child: Container( + padding: const EdgeInsets.all(DesignTokens.space2), + child: Icon( + CupertinoIcons.back, + color: DesignTokens.dynamicPrimary, + size: 22, + ), + ), + ), + const SizedBox(width: DesignTokens.space2), + Expanded( + child: Text( + tool.name, + style: TextStyle( + fontSize: DesignTokens.fontLg, + fontWeight: FontWeight.w600, + color: isDark ? DarkDesignTokens.text1 : DesignTokens.text1, + ), + ), + ), + ], + ), + ), + ), + ); + } + + Widget _buildHeaderCard(ToolItemRef tool, bool isDark) { + final gradientIndex = + tool.id.hashCode.abs() % DesignTokens.toolGradients.length; + final gradientColor = DesignTokens.toolGradients[gradientIndex]; + + return Container( + width: double.infinity, + padding: const EdgeInsets.all(DesignTokens.space5), + decoration: BoxDecoration( + borderRadius: DesignTokens.borderRadiusXl, + border: Border.all( + color: DesignTokens.dynamicPrimary.withValues(alpha: 0.1), + ), + gradient: LinearGradient( + begin: Alignment.topLeft, + end: Alignment.bottomRight, + colors: [ + gradientColor.withValues(alpha: 0.12), + gradientColor.withValues(alpha: 0.04), + ], + ), + ), + child: Column( + children: [ + Container( + width: 80, + height: 80, + decoration: BoxDecoration( + color: gradientColor.withValues(alpha: 0.15), + borderRadius: DesignTokens.borderRadiusXl, + boxShadow: [ + BoxShadow( + color: gradientColor.withValues(alpha: 0.15), + blurRadius: 16, + offset: const Offset(0, 4), + ), + ], + ), + child: Center( + child: Text(tool.icon, style: const TextStyle(fontSize: 40)), + ), + ), + const SizedBox(height: DesignTokens.space4), + Text( + tool.name, + style: TextStyle( + fontSize: DesignTokens.fontXxl, + fontWeight: FontWeight.w700, + color: isDark ? DarkDesignTokens.text1 : DesignTokens.text1, + ), + ), + if (tool.description != null) ...[ + const SizedBox(height: DesignTokens.space2), + Text( + tool.description!, + textAlign: TextAlign.center, + style: TextStyle( + fontSize: DesignTokens.fontMd, + color: isDark ? DarkDesignTokens.text2 : DesignTokens.text2, + height: 1.5, + ), + ), + ], + const SizedBox(height: DesignTokens.space4), + Container( + padding: const EdgeInsets.symmetric( + horizontal: DesignTokens.space3, + vertical: DesignTokens.space2, + ), + decoration: BoxDecoration( + color: tool.needsNetwork + ? DesignTokens.green.withValues(alpha: 0.15) + : DesignTokens.dynamicPrimary.withValues(alpha: 0.15), + borderRadius: DesignTokens.borderRadiusLg, + ), + child: Row( + mainAxisSize: MainAxisSize.min, + children: [ + Icon( + tool.needsNetwork + ? CupertinoIcons.wifi + : CupertinoIcons.device_phone_portrait, + size: 14, + color: tool.needsNetwork + ? DesignTokens.green + : DesignTokens.dynamicPrimary, + ), + const SizedBox(width: 6), + Text( + tool.needsNetwork ? '需要网络连接' : '本地运行', + style: TextStyle( + fontSize: DesignTokens.fontSm, + fontWeight: FontWeight.w500, + color: tool.needsNetwork + ? DesignTokens.green + : DesignTokens.dynamicPrimary, + ), + ), + ], + ), + ), + ], + ), + ); + } + + Widget _buildInfoCards(ToolItemRef tool, bool isDark) { + return Column( + children: [ + _buildInfoCard( + icon: CupertinoIcons.chart_bar, + iconBgColor: DesignTokens.dynamicPrimary, + title: '使用统计', + value: '已使用 ${tool.usageCount} 次', + isDark: isDark, + ), + const SizedBox(height: DesignTokens.space3), + _buildInfoCard( + icon: CupertinoIcons.folder, + iconBgColor: DesignTokens.blue, + title: '所属分类', + value: tool.categoryName, + isDark: isDark, + ), + ], + ); + } + + Widget _buildInfoCard({ + required IconData icon, + required Color iconBgColor, + required String title, + required String value, + required bool isDark, + }) { + return Container( + padding: const EdgeInsets.all(DesignTokens.space4), + decoration: BoxDecoration( + color: isDark ? DarkDesignTokens.card : DesignTokens.card, + borderRadius: DesignTokens.borderRadiusMd, + boxShadow: [ + BoxShadow( + color: Colors.black.withValues(alpha: 0.03), + blurRadius: 8, + offset: const Offset(0, 2), + ), + ], + border: Border.all( + color: isDark + ? DarkDesignTokens.glassBorder + : DesignTokens.text3.withValues(alpha: 0.08), + ), + ), + child: Row( + children: [ + Container( + width: 44, + height: 44, + decoration: BoxDecoration( + color: iconBgColor.withValues(alpha: 0.1), + borderRadius: DesignTokens.borderRadiusMd, + ), + child: Icon(icon, size: 22, color: iconBgColor), + ), + const SizedBox(width: DesignTokens.space3), + Expanded( + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text( + title, + style: TextStyle( + fontSize: DesignTokens.fontXs, + color: isDark ? DarkDesignTokens.text3 : DesignTokens.text3, + ), + ), + Text( + value, + style: TextStyle( + fontSize: DesignTokens.fontMd, + fontWeight: FontWeight.w600, + color: isDark ? DarkDesignTokens.text1 : DesignTokens.text1, + ), + ), + ], + ), + ), + ], + ), + ); + } + + Widget _buildBottomButton(ToolItemRef tool, bool isDark) { + return Container( + padding: const EdgeInsets.all(DesignTokens.space4), + decoration: BoxDecoration( + color: isDark ? DarkDesignTokens.card : DesignTokens.card, + border: Border( + top: BorderSide( + color: isDark + ? DarkDesignTokens.glassBorder + : DesignTokens.text3.withValues(alpha: 0.08), + ), + ), + ), + child: SizedBox( + width: double.infinity, + child: CupertinoButton( + padding: const EdgeInsets.symmetric(vertical: DesignTokens.space3), + borderRadius: DesignTokens.borderRadiusMd, + color: DesignTokens.dynamicPrimary, + onPressed: () => Get.toNamed(tool.route), + child: Row( + mainAxisAlignment: MainAxisAlignment.center, + children: [ + const Icon( + CupertinoIcons.arrow_right_circle, + size: 18, + color: CupertinoColors.white, + ), + const SizedBox(width: 8), + Text( + '进入工具', + style: TextStyle( + fontSize: DesignTokens.fontMd, + fontWeight: FontWeight.w600, + color: CupertinoColors.white, + ), + ), + ], + ), + ), + ), + ); + } +} diff --git a/lib/src/pages/tools/tools_center_page.dart b/lib/src/pages/tools/tools_center_page.dart index 17fc538..9d01dfd 100644 --- a/lib/src/pages/tools/tools_center_page.dart +++ b/lib/src/pages/tools/tools_center_page.dart @@ -86,12 +86,7 @@ class _ToolsCenterPageState extends State ); if (widget.embedded) { - return CupertinoPageScaffold( - backgroundColor: isDark - ? DarkDesignTokens.background - : DesignTokens.background, - child: body, - ); + return body; } return CupertinoPageScaffold( @@ -604,7 +599,7 @@ class _ToolsCenterPageState extends State ), const SizedBox(width: 6), Text( - '进入工具', + '工具详情', style: TextStyle( fontSize: DesignTokens.fontSm, fontWeight: FontWeight.w600, diff --git a/lib/src/repositories/action_repository.dart b/lib/src/repositories/action_repository.dart index 33884e1..125907c 100644 --- a/lib/src/repositories/action_repository.dart +++ b/lib/src/repositories/action_repository.dart @@ -1,7 +1,8 @@ -// 2026-04-09 | ActionRepository | 互动操作仓库 | 封装api_action.php调用 +// 2026-04-16 | ActionRepository | 互动操作仓库 | 封装api_action.php调用 // 2026-04-09 | 修改写操作使用POST方法,符合REST规范 // 2026-04-09 | 添加429限流错误友好提示 // 2026-04-12 | API v3.2.0: recommend接口改为rate评分接口(1-5分) +// 2026-04-16 | 写操作改回POST(api_action.php v2.1.0已支持POST),读操作保持GET import 'package:mom_kitchen/src/config/api_config.dart'; import 'package:mom_kitchen/src/models/api_response.dart'; import 'package:mom_kitchen/src/services/api/api_service.dart'; @@ -28,8 +29,6 @@ class ActionRepository { return apiResponse.data as Map? ?? {}; } - // ─── 评分接口(原recommend改为rate) ─── - Future> rate({ required String type, required int id, @@ -42,12 +41,7 @@ class ActionRepository { try { final response = await _api.post( ApiConfig.action, - data: { - 'act': 'rate', - 'type': type, - 'id': id, - 'score': score, - }, + data: {'act': 'rate', 'type': type, 'id': id, 'score': score}, ); final apiResponse = ApiResponse.fromJson( response.data as Map, @@ -67,7 +61,6 @@ class ActionRepository { } } - // ─── 兼容旧 recommend 方法(已废弃,请使用 rate) ─── @Deprecated('Use rate() instead. recommend接口已改为rate评分接口') Future> recommend({ required String type, @@ -124,7 +117,6 @@ class IpStatus { bool get canRate => rateRemaining > 0; - // 兼容旧字段名 @Deprecated('Use canRate instead') bool get canRecommend => canRate; @@ -136,7 +128,9 @@ class IpStatus { rateLimit: json['daily_limit'] as int? ?? json['rate_limit'] as int? ?? 30, rateRemaining: - json['remaining_rate'] as int? ?? json['rate_remaining'] as int? ?? 30, + json['remaining_rate'] as int? ?? + json['rate_remaining'] as int? ?? + 30, ); } } diff --git a/lib/src/repositories/recipe_repository.dart b/lib/src/repositories/recipe_repository.dart index 33d5ac3..3b7c8dd 100644 --- a/lib/src/repositories/recipe_repository.dart +++ b/lib/src/repositories/recipe_repository.dart @@ -172,19 +172,23 @@ class RecipeRepository { int id, { bool refresh = false, bool viewnums = false, + void Function(String source)? onSourceDetected, }) async { final cacheKey = '$_recipeDetailCachePrefix$id'; if (!refresh) { try { await CacheService().init(); - final cached = await CacheService().get( - cacheKey, - fromJson: (json) => RecipeModel.fromJson(json), - ); - if (cached != null && cached.id > 0 && cached.title.isNotEmpty) { - debugPrint('RecipeRepository: 从缓存加载详情 id=$id'); - return cached; + if (CacheService().isReady) { + final cached = await CacheService().get( + cacheKey, + fromJson: (json) => RecipeModel.fromJson(json), + ); + if (cached != null && cached.id > 0 && cached.title.isNotEmpty) { + debugPrint('RecipeRepository: 从缓存加载详情 id=$id'); + onSourceDetected?.call('cache'); + return cached; + } } } catch (e) { debugPrint('RecipeRepository: 读取缓存失败: $e'); @@ -236,12 +240,13 @@ class RecipeRepository { final recipe = apiResponse.data!; await _saveRecipeToCache(recipe); + onSourceDetected?.call('api'); return recipe; } Future _saveRecipeToCache(RecipeModel recipe) async { try { - await CacheService().init(); + if (!CacheService().isReady) return; final cacheKey = '$_recipeDetailCachePrefix${recipe.id}'; await CacheService().set( cacheKey, @@ -258,7 +263,7 @@ class RecipeRepository { Future getCachedRecipeCount() async { try { - await CacheService().init(); + if (!CacheService().isReady) return 0; return CacheService().cacheSize; } catch (e) { return 0; @@ -267,7 +272,7 @@ class RecipeRepository { Future clearRecipeDetailCache() async { try { - await CacheService().init(); + if (!CacheService().isReady) return; await CacheService().clear(); debugPrint('RecipeRepository: 已清理所有菜品详情缓存'); } catch (e) { diff --git a/lib/src/services/api/api_service.dart b/lib/src/services/api/api_service.dart index 05f3427..ec9e555 100644 --- a/lib/src/services/api/api_service.dart +++ b/lib/src/services/api/api_service.dart @@ -2,8 +2,13 @@ // 2026-04-10 | API v2.0.0: 新增 _format/_stale/_refresh/_pretty 参数支持 // 2026-04-11 | 优化: 增强日志拦截器、添加重试机制、统一离线检查、缓存数据解析修复 // 2026-04-12 | Web端CORS修复: 添加CORS代理支持、优化Web端错误处理 +// 2026-04-15 | 修复ANR: connectTimeout从15s缩短到5s,防止DNS解析卡死主线程 +// 2026-04-15 | 新增DNS预检机制: 启动时在Isolate中预解析DNS,失败则跳过所有网络请求 +// 2026-04-17 | Web端修复v2: 优先直连请求(10s超时),CORS代理降级为备用方案 import 'dart:async'; import 'dart:convert'; +import 'dart:io'; +import 'dart:isolate'; 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'; @@ -27,9 +32,91 @@ class ApiService { static const int _maxRetries = 2; static const Duration _retryDelay = Duration(seconds: 1); + static const int _maxConcurrentRequests = 2; + int _activeRequestCount = 0; + final List> _requestQueue = []; static const String _corsProxy = 'https://corsproxy.io/?'; + bool _dnsReachable = true; + bool _dnsChecked = false; + Completer? _dnsReadyCompleter; + DateTime? _lastDnsCheckTime; + static const Duration _dnsCheckInterval = Duration(minutes: 5); + + bool get isDnsReachable => _dnsReachable; + + Future waitForDnsReady() async { + if (_dnsChecked) return; + _dnsReadyCompleter ??= Completer(); + return _dnsReadyCompleter!.future; + } + + Future _acquireRequestSlot() async { + if (_activeRequestCount < _maxConcurrentRequests) { + _activeRequestCount++; + return; + } + final completer = Completer(); + _requestQueue.add(completer); + await completer.future; + _activeRequestCount++; + } + + void _releaseRequestSlot() { + _activeRequestCount--; + if (_requestQueue.isNotEmpty && + _activeRequestCount < _maxConcurrentRequests) { + final next = _requestQueue.removeAt(0); + next.complete(); + } + } + + Future preCheckDns() async { + if (kIsWeb) { + _dnsReachable = true; + _dnsChecked = true; + return; + } + + if (_dnsChecked && + _lastDnsCheckTime != null && + DateTime.now().difference(_lastDnsCheckTime!) < _dnsCheckInterval) { + return; + } + + try { + final host = Uri.parse(ApiConfig.baseUrl).host; + final result = await Isolate.run(() async { + try { + final lookup = await InternetAddress.lookup( + host, + ).timeout(const Duration(seconds: 2)); + return lookup.isNotEmpty && lookup.first.rawAddress.isNotEmpty; + } catch (_) { + return false; + } + }); + _dnsReachable = result; + _dnsChecked = true; + _lastDnsCheckTime = DateTime.now(); + _dnsReadyCompleter?.complete(); + _dnsReadyCompleter = null; + if (!result) { + debugPrint('⚠️ [ApiService] DNS预检失败: $host 不可达,将使用缓存模式'); + } else { + debugPrint('✅ [ApiService] DNS预检通过: $host 可达'); + } + } catch (e) { + _dnsReachable = false; + _dnsChecked = true; + _lastDnsCheckTime = DateTime.now(); + _dnsReadyCompleter?.complete(); + _dnsReadyCompleter = null; + debugPrint('⚠️ [ApiService] DNS预检异常: $e,将使用缓存模式'); + } + } + String _buildUrl(String path, [Map? queryParameters]) { final baseUrl = '${ApiConfig.baseUrl}$path'; if (!kIsWeb) return baseUrl; @@ -41,14 +128,21 @@ class ApiService { .join('&'); fullUrl = '$fullUrl?$queryString'; } - return '$_corsProxy${Uri.encodeComponent(fullUrl)}'; + return fullUrl; + } + + String _buildProxyUrl(String path, [Map? queryParameters]) { + final directUrl = _buildUrl(path, queryParameters); + return '$_corsProxy${Uri.encodeComponent(directUrl)}'; } ApiService._internal() { final options = BaseOptions( baseUrl: kIsWeb ? '' : ApiConfig.baseUrl, - connectTimeout: const Duration(seconds: 15), - receiveTimeout: const Duration(seconds: 15), + connectTimeout: kIsWeb + ? const Duration(seconds: 10) + : const Duration(seconds: 2), + receiveTimeout: const Duration(seconds: 8), headers: { 'Accept': 'application/json', 'Content-Type': 'application/json', @@ -60,7 +154,9 @@ class ApiService { if (kIsWeb) { _dio.options.extra = {'withCredentials': false}; _dio.interceptors.add(_buildLogInterceptor()); - LoggerService().info('ApiService: Web mode with CORS proxy enabled'); + LoggerService().info( + 'ApiService: Web mode - direct request with CORS proxy fallback', + ); } else { _initCacheAsync(); } @@ -129,6 +225,11 @@ class ApiService { Future _isOffline() async { if (kIsWeb) return false; + + await waitForDnsReady(); + + if (!_dnsReachable) return true; + try { final result = await Connectivity().checkConnectivity().timeout( const Duration(seconds: 3), @@ -158,6 +259,7 @@ class ApiService { bool pretty = false, }) async { await _ensureCacheReady(); + await _acquireRequestSlot(); try { final isOffline = await _isOffline(); @@ -191,9 +293,20 @@ class ApiService { ), ); - // 检查304响应是否返回了有效数据 - if (response.statusCode == 304 || _isInvalidCacheData(response.data)) { - debugPrint('ApiService: 检测到无效缓存响应,清除缓存并重试'); + // 304 = Not Modified,表示服务端确认缓存仍然有效,直接使用缓存数据 + if (response.statusCode == 304) { + final cached = await _tryGetCache(path, queryParameters); + if (cached != null) { + debugPrint('ApiService: 304 缓存命中,使用本地缓存'); + return cached; + } + debugPrint('ApiService: 304 但无可用缓存,继续使用当前响应'); + return response; + } + + // 非304响应时,检查返回数据是否无效(如空数据/损坏) + if (_isInvalidCacheData(response.data)) { + debugPrint('ApiService: 检测到无效响应数据,清除缓存并重试'); await _clearCacheForUrl(path, queryParameters); response = await _executeWithRetry( () => _dio.get( @@ -208,6 +321,23 @@ class ApiService { return response; } on DioException catch (e) { + // Web端直连失败时,尝试CORS代理 + if (kIsWeb && _shouldTryProxy(e)) { + debugPrint('ApiService: 直连失败,尝试CORS代理...'); + try { + final proxyUrl = _buildProxyUrl(path, queryParameters); + final proxyResponse = await _executeWithRetry( + () => _dio.get( + proxyUrl, + queryParameters: Map.from(queryParameters ?? {}), + options: Options(headers: {'Accept': 'application/json'}), + ), + ); + return proxyResponse; + } catch (proxyError) { + debugPrint('ApiService: CORS代理也失败: $proxyError'); + } + } final cached = await _tryGetCache(path, queryParameters); if (cached != null) return cached; throw _convertDioException(e); @@ -218,6 +348,8 @@ class ApiService { message: e.toString(), cause: e, ); + } finally { + _releaseRequestSlot(); } } @@ -225,7 +357,7 @@ class ApiService { bool _isInvalidCacheData(dynamic data) { if (data == null) return true; if (data is! Map) return false; - final map = data as Map; + final map = data; final dataField = map['data']; if (dataField == null) return false; if (dataField is! Map) return false; @@ -269,6 +401,10 @@ class ApiService { ? Options(headers: {'Accept': 'application/json'}) : null, ), + path: path, + queryParameters: queryParameters, + data: data, + method: 'post', ); } @@ -285,6 +421,10 @@ class ApiService { ? Options(headers: {'Accept': 'application/json'}) : null, ), + path: path, + queryParameters: queryParameters, + data: data, + method: 'put', ); } @@ -299,12 +439,20 @@ class ApiService { ? Options(headers: {'Accept': 'application/json'}) : null, ), + path: path, + queryParameters: queryParameters, + method: 'delete', ); } Future _executeWithOfflineCheck( - Future Function() request, - ) async { + Future Function() request, { + String? path, + Map? queryParameters, + dynamic data, + String method = 'get', + }) async { + await _acquireRequestSlot(); try { if (await _isOffline()) { throw ApiException( @@ -312,7 +460,48 @@ class ApiService { message: '无网络连接,请检查网络设置', ); } - return await _executeWithRetry(request); + try { + return await _executeWithRetry(request); + } on DioException catch (e) { + if (kIsWeb && path != null && _shouldTryProxy(e)) { + debugPrint('ApiService: 直连失败($method),尝试CORS代理...'); + final proxyUrl = _buildProxyUrl(path, queryParameters); + switch (method) { + case 'post': + return await _executeWithRetry( + () => _dio.post( + proxyUrl, + data: data, + options: Options(headers: {'Accept': 'application/json'}), + ), + ); + case 'put': + return await _executeWithRetry( + () => _dio.put( + proxyUrl, + data: data, + options: Options(headers: {'Accept': 'application/json'}), + ), + ); + case 'delete': + return await _executeWithRetry( + () => _dio.delete( + proxyUrl, + options: Options(headers: {'Accept': 'application/json'}), + ), + ); + default: + return await _executeWithRetry( + () => _dio.get( + proxyUrl, + queryParameters: queryParameters, + options: Options(headers: {'Accept': 'application/json'}), + ), + ); + } + } + rethrow; + } } on DioException catch (e) { throw _convertDioException(e); } catch (e) { @@ -322,6 +511,8 @@ class ApiService { message: e.toString(), cause: e, ); + } finally { + _releaseRequestSlot(); } } @@ -377,6 +568,21 @@ class ApiService { return null; } + bool _shouldTryProxy(DioException e) { + if (e.type == DioExceptionType.connectionError) return true; + if (e.type == DioExceptionType.connectionTimeout) return true; + if (e.type == DioExceptionType.unknown && + e.message?.contains('onerror') == true) + return true; + final response = e.response; + if (response != null) { + final statusCode = response.statusCode; + if (statusCode == 0) return true; + if (statusCode != null && statusCode >= 500) return true; + } + return false; + } + ApiException _convertDioException(DioException e) { switch (e.type) { case DioExceptionType.connectionTimeout: diff --git a/lib/src/services/crash_guard_service.dart b/lib/src/services/crash_guard_service.dart index 72bd90d..d048b79 100644 --- a/lib/src/services/crash_guard_service.dart +++ b/lib/src/services/crash_guard_service.dart @@ -3,18 +3,18 @@ * 名称: 全局闪退守护服务(catcher_2 集成) * 作用: 基于 catcher_2 捕获全局未处理异常,弹出 iOS 风格错误报告对话框 * 创建: 2026-04-15 - * 更新: 2026-04-15 修正初始化时序、ReportMode context处理 + * 更新: 2026-04-15 重写ReportMode: isContextRequired=false确保无context也能捕获+增强重试机制 + * 更新: 2026-04-17 Web端兼容: 使用PlatformUtils替代直接dart:io调用,修复Platform._operatingSystem崩溃 */ -import 'dart:io'; import 'package:flutter/cupertino.dart'; -import 'package:flutter/foundation.dart'; import 'package:flutter/material.dart' show Colors, SelectableText, showDialog; import 'package:flutter/services.dart'; import 'package:get/get.dart'; import 'package:catcher_2/catcher_2.dart'; import 'package:catcher_2/model/platform_type.dart'; import 'package:mom_kitchen/src/services/ui/theme_service.dart'; +import 'package:mom_kitchen/src/utils/platform_utils.dart'; class CrashGuardService extends GetxService { static CrashGuardService get instance => Get.find(); @@ -29,7 +29,7 @@ class CrashGuardService extends GetxService { Catcher2Options buildDebugOptions() { return Catcher2Options( - _CupertinoDialogReportMode(), + CupertinoDialogReportMode(), [ ConsoleHandler( enableApplicationParameters: true, @@ -59,7 +59,7 @@ class CrashGuardService extends GetxService { Catcher2Options buildReleaseOptions() { return Catcher2Options( - _CupertinoDialogReportMode(), + CupertinoDialogReportMode(), [ ConsoleHandler( enableApplicationParameters: true, @@ -135,7 +135,7 @@ class CrashReportEntry { final buffer = StringBuffer(); buffer.writeln('=== 🐛 小妈厨房 错误报告 ==='); buffer.writeln('⏰ 时间: ${timestamp.toString().substring(0, 19)}'); - buffer.writeln('📱 设备: ${Platform.operatingSystem}'); + buffer.writeln('📱 设备: ${PlatformUtils().operatingSystemName}'); buffer.writeln(); buffer.writeln('💬 错误详情:'); buffer.writeln(error); @@ -157,40 +157,52 @@ class CrashReportEntry { } } -class _CupertinoDialogReportMode extends ReportMode { +class CupertinoDialogReportMode extends ReportMode { + int _retryCount = 0; + static const int _maxRetries = 6; + @override void requestAction(Report report, BuildContext? context) { - if (context != null && context.mounted) { - _showCupertinoCrashDialog(report, context); - return; - } - - final navContext = Catcher2.navigatorKey.currentContext; - if (navContext != null && navContext.mounted) { - _showCupertinoCrashDialog(report, navContext); - return; - } - - debugPrint('🐛 [CrashGuard] 无法获取context,延迟重试显示弹窗'); - _retryShowDialog(report); + _retryCount = 0; + _tryShowDialog(report, context); } - void _retryShowDialog(Report report) { - Future.delayed(const Duration(milliseconds: 500), () { + void _tryShowDialog(Report report, BuildContext? context) { + BuildContext? effectiveContext; + + if (context != null && context.mounted) { + effectiveContext = context; + } else { final navContext = Catcher2.navigatorKey.currentContext; if (navContext != null && navContext.mounted) { - _showCupertinoCrashDialog(report, navContext); - } else { - debugPrint('🐛 [CrashGuard] 重试失败,放弃显示弹窗,记录到控制台'); - debugPrint('🐛 错误: ${report.error}'); - debugPrint('🐛 堆栈: ${report.stackTrace}'); - onActionConfirmed(report); + effectiveContext = navContext; } - }); + } + + if (effectiveContext != null) { + _showCupertinoCrashDialog(report, effectiveContext); + return; + } + + if (_retryCount < _maxRetries) { + _retryCount++; + final delay = Duration(milliseconds: 300 * _retryCount); + debugPrint( + '🐛 [CrashGuard] 无context,第$_retryCount次重试(${delay.inMilliseconds}ms后)', + ); + Future.delayed(delay, () { + _tryShowDialog(report, null); + }); + } else { + debugPrint('🐛 [CrashGuard] 重试$_maxRetries次后仍无context,记录到控制台'); + debugPrint('🐛 错误: ${report.error}'); + debugPrint('🐛 堆栈: ${report.stackTrace}'); + onActionConfirmed(report); + } } @override - bool isContextRequired() => true; + bool isContextRequired() => false; @override List getSupportedPlatforms() => [ @@ -206,7 +218,9 @@ class _CupertinoDialogReportMode extends ReportMode { final isDark = _isDarkMode(); try { - CrashGuardService.instance.recordCrash(report); + if (Get.isRegistered()) { + CrashGuardService.instance.recordCrash(report); + } } catch (_) {} showDialog( @@ -230,11 +244,11 @@ class _CupertinoDialogReportMode extends ReportMode { bool _isDarkMode() { try { - final themeService = Get.find(); - return themeService.isDarkMode.value; - } catch (_) { - return false; - } + if (Get.isRegistered()) { + return Get.find().isDarkMode.value; + } + } catch (_) {} + return false; } void _copyReportToClipboard(Report report) { @@ -410,7 +424,7 @@ class _CupertinoCrashDialogState extends State<_CupertinoCrashDialog> { const SizedBox(width: 6), Expanded( child: Text( - '${Platform.operatingSystem} · Flutter', + '${PlatformUtils().operatingSystemName} · Flutter', style: TextStyle( fontSize: 12, color: textColor.withValues(alpha: 0.55), diff --git a/lib/src/services/data/cache_service.dart b/lib/src/services/data/cache_service.dart index 8bf4162..754c111 100644 --- a/lib/src/services/data/cache_service.dart +++ b/lib/src/services/data/cache_service.dart @@ -4,10 +4,12 @@ * 作用: API数据本地持久化,支持离线访问和缓存过期 * 更新: 2026-04-10 新建,配合Repository层使用 * 更新: 2026-04-13 新增 getCacheBox 方法,支持缓存管理页面遍历缓存 + * 更新: 2026-04-15 修复init()异常传播导致闪退:不rethrow,失败后降级为无缓存模式 */ import 'dart:async'; import 'dart:convert'; +import 'package:flutter/foundation.dart'; import 'package:hive_ce/hive.dart'; import 'package:mom_kitchen/src/services/data/hive_service.dart'; import 'package:mom_kitchen/src/services/log/logger_service.dart'; @@ -52,10 +54,14 @@ class CacheService { Box? _cacheBox; bool _initialized = false; + bool _initFailed = false; // 标记初始化是否已失败过,避免无限重试 Completer? _initCompleter; CacheService._internal(); + /// 初始化是否成功(外部可查询,决定是否走缓存路径) + bool get isReady => _initialized && _cacheBox != null; + Future?> _openBoxSafe(String name, {int maxRetries = 3}) async { for (var i = 0; i < maxRetries; i++) { try { @@ -74,32 +80,58 @@ class CacheService { return null; } + /// 初始化缓存服务(安全降级,不会抛出异常) + /// 即使 Hive/HiveService 未就绪,也不会导致应用崩溃 Future init() async { if (_initialized) return; + // 如果之前已失败,不再无限重试(避免启动阶段反复失败拖慢速度) + if (_initFailed) { + debugPrint('⚠️ CacheService: 初始化之前已失败,跳过重试(降级为无缓存模式)'); + return; + } + // 防止并发初始化:如果正在初始化,等待完成 if (_initCompleter != null) { - await _initCompleter!.future; + try { + await _initCompleter!.future; + } catch (e) { + // 之前的初始化失败,不再传播异常 + debugPrint('⚠️ CacheService: 并发等待的初始化失败: $e'); + } return; } _initCompleter = Completer(); try { // 确保 Hive 已正确初始化(路径/适配器/基础 boxes) - // 否则 Hive.openBox 可能抛异常,导致缓存永远走 API。 - await HiveService().init(); + // 否则 Hive.openBox 可能抛异常 + try { + await HiveService().init(); + } catch (e) { + // HiveService 初始化失败不传播,降级为无缓存模式 + LoggerService().warning('CacheService: HiveService未就绪,降级为无缓存模式: $e'); + _initFailed = true; + _initCompleter!.complete(); + return; + } _cacheBox = await _openBoxSafe(_cacheBoxName); if (_cacheBox == null) { - throw Exception('Failed to open cache box: $_cacheBoxName'); + // 打开box失败,降级为无缓存模式,不抛异常 + LoggerService().warning('CacheService: 无法打开缓存box,降级为无缓存模式'); + _initFailed = true; + _initCompleter!.complete(); + return; } _initialized = true; LoggerService().info('CacheService initialized, box keys: ${_cacheBox?.keys.length ?? 0}'); _initCompleter!.complete(); } catch (e) { - LoggerService().error('CacheService init failed: $e'); - _initCompleter!.completeError(e); - _initCompleter = null; + // 兜底:任何未预见的异常都不传播,降级为无缓存模式 + LoggerService().error('CacheService init failed (降级为无缓存模式): $e'); + _initFailed = true; + _initCompleter!.complete(); // complete而非completeError,不传播异常 } } diff --git a/lib/src/services/data/email_service.dart b/lib/src/services/data/email_service.dart index cb0c81e..ee39950 100644 --- a/lib/src/services/data/email_service.dart +++ b/lib/src/services/data/email_service.dart @@ -2,8 +2,10 @@ // 2026-04-14 | 初始创建,基于mailer库实现SMTP邮件发送 // 2026-04-14 | 新增多线路支持:官方线路1(mboxhosting)/线路2(QQ邮箱)/自定义SMTP // 2026-04-15 | 发送成功/失败后自动记录到 EmailHistoryController +// 2026-04-17 | SMTP凭证迁移至.env,移除硬编码敏感信息防泄露 import 'dart:io'; +import 'package:flutter_dotenv/flutter_dotenv.dart'; import 'package:get/get.dart'; import 'package:mom_kitchen/src/models/recipe/recipe_model.dart'; import 'package:mom_kitchen/src/models/data/email_record_model.dart'; @@ -40,25 +42,25 @@ class EmailService { static final EmailService _instance = EmailService._internal(); factory EmailService() => _instance; - /// 预设 SMTP 线路 - static const List presetRoutes = [ + /// 预设 SMTP 线路(从 .env 读取凭证,避免硬编码泄露) + static List get presetRoutes => [ SmtpRoute( - name: '官方线路1', - icon: '🚀', - host: 'free.mboxhosting.com', - port: 465, - ssl: true, - username: 'gg@0gg.cc', - password: '520kiss123', + name: dotenv.env['SMTP_ROUTE1_NAME'] ?? '官方线路1', + icon: dotenv.env['SMTP_ROUTE1_ICON'] ?? '🚀', + host: dotenv.env['SMTP_ROUTE1_HOST'] ?? '', + port: int.tryParse(dotenv.env['SMTP_ROUTE1_PORT'] ?? '465') ?? 465, + ssl: dotenv.env['SMTP_ROUTE1_SSL']?.toLowerCase() != 'false', + username: dotenv.env['SMTP_ROUTE1_USERNAME'] ?? '', + password: dotenv.env['SMTP_ROUTE1_PASSWORD'] ?? '', ), SmtpRoute( - name: '官方线路2', - icon: '✉️', - host: 'smtp.qq.com', - port: 465, - ssl: true, - username: '2821981550@qq.com', - password: '520kiss123', + name: dotenv.env['SMTP_ROUTE2_NAME'] ?? '官方线路2', + icon: dotenv.env['SMTP_ROUTE2_ICON'] ?? '✉️', + host: dotenv.env['SMTP_ROUTE2_HOST'] ?? '', + port: int.tryParse(dotenv.env['SMTP_ROUTE2_PORT'] ?? '465') ?? 465, + ssl: dotenv.env['SMTP_ROUTE2_SSL']?.toLowerCase() != 'false', + username: dotenv.env['SMTP_ROUTE2_USERNAME'] ?? '', + password: dotenv.env['SMTP_ROUTE2_PASSWORD'] ?? '', ), ]; @@ -225,8 +227,9 @@ class EmailService { if (recipe.content != null && recipe.content!.isNotEmpty) { buffer.writeln('👨‍🍳 步骤'); buffer.writeln('─' * 20); - final steps = - recipe.content!.split(RegExp(r'\n+')).where((s) => s.trim().isNotEmpty); + final steps = recipe.content! + .split(RegExp(r'\n+')) + .where((s) => s.trim().isNotEmpty); var stepNum = 1; for (final step in steps) { buffer.writeln(' $stepNum. ${step.trim()}'); @@ -258,29 +261,49 @@ class EmailService { buffer.writeln(''); buffer.writeln(''); buffer.writeln(''); // 头部 @@ -299,10 +322,12 @@ class EmailService { buffer.writeln('
'); buffer.writeln('

🥘 食材

'); for (final ing in recipe.ingredients) { - buffer.writeln('
' - '${ing.name}' - '${ing.amount ?? ''} ${ing.unit ?? ''}' - '
'); + buffer.writeln( + '
' + '${ing.name}' + '${ing.amount ?? ''} ${ing.unit ?? ''}' + '
', + ); } buffer.writeln('
'); } @@ -316,10 +341,12 @@ class EmailService { .where((s) => s.trim().isNotEmpty); var stepNum = 1; for (final step in steps) { - buffer.writeln('
' - '$stepNum' - '${step.trim()}' - '
'); + buffer.writeln( + '
' + '$stepNum' + '${step.trim()}' + '
', + ); stepNum++; } buffer.writeln(''); @@ -332,28 +359,36 @@ class EmailService { buffer.writeln('

💪 营养信息

'); buffer.writeln('
'); if (n.calories != null) { - buffer.writeln('
' - '
🔥 热量
' - '
${n.calories}
' - '
'); + buffer.writeln( + '
' + '
🔥 热量
' + '
${n.calories}
' + '
', + ); } if (n.protein != null) { - buffer.writeln('
' - '
💪 蛋白质
' - '
${n.protein}
' - '
'); + buffer.writeln( + '
' + '
💪 蛋白质
' + '
${n.protein}
' + '
', + ); } if (n.fat != null) { - buffer.writeln('
' - '
🧈 脂肪
' - '
${n.fat}
' - '
'); + buffer.writeln( + '
' + '
🧈 脂肪
' + '
${n.fat}
' + '
', + ); } if (n.carbs != null) { - buffer.writeln('
' - '
🍞 碳水
' - '
${n.carbs}
' - '
'); + buffer.writeln( + '
' + '
🍞 碳水
' + '
${n.carbs}
' + '
', + ); } buffer.writeln('
'); } diff --git a/lib/src/services/data/hive_service.dart b/lib/src/services/data/hive_service.dart index b224d7e..02d4f12 100644 --- a/lib/src/services/data/hive_service.dart +++ b/lib/src/services/data/hive_service.dart @@ -1,5 +1,6 @@ // 2026-04-09 | HiveService | Hive本地数据库服务 | Web端使用IndexedDB,未初始化时安全返回 // 2026-04-10 | 新增数据迁移机制,支持Box schema版本升级 +// 2026-04-15 | 修复init()的rethrow导致连锁崩溃:改为安全降级,不抛异常 import 'package:flutter/foundation.dart'; import 'package:hive_ce/hive.dart'; import 'package:path_provider/path_provider.dart'; @@ -61,8 +62,11 @@ class HiveService { _initialized = true; LoggerService().info('HiveService initialized successfully'); } catch (e) { - LoggerService().error('HiveService init failed: $e'); - rethrow; + // 安全降级:不再rethrow,避免上层连锁崩溃导致闪退 + // 所有CRUD方法都已检查_initialized,未初始化时安全返回null/空 + LoggerService().error('HiveService init failed (降级为无本地存储): $e'); + debugPrint('⚠️ HiveService: 初始化失败,降级为无本地存储模式,所有CRUD操作将安全返回空值'); + _initialized = false; } } @@ -100,6 +104,11 @@ class HiveService { if (weeklyMenuBox != null) { _dynamicBoxCache['weekly_menu'] = weeklyMenuBox; } + + final weightRecordBox = await _openBoxSafe('weightRecordBox'); + if (weightRecordBox != null) { + _dynamicBoxCache['weightRecordBox'] = weightRecordBox; + } } Future?> _openBoxSafe(String name, {int maxRetries = 3}) async { diff --git a/lib/src/services/tools/order_api_service.dart b/lib/src/services/tools/order_api_service.dart new file mode 100644 index 0000000..498a347 --- /dev/null +++ b/lib/src/services/tools/order_api_service.dart @@ -0,0 +1,169 @@ +/* + * 文件: order_api_service.dart + * 名称: 点餐助手API服务 + * 作用: 提供点单CRUD接口,使用项目已有ApiService(dio)对接后端 + * 创建时间: 2026-04-17 + * 更新时间: 2026-04-17 新增clearAll接口,确保只清理kitchen数据 + */ + +import 'package:flutter/foundation.dart'; +import 'package:mom_kitchen/src/models/tools/order_model.dart'; +import 'package:mom_kitchen/src/services/api/api_service.dart'; + +class OrderApiService { + static const String _basePath = '/kitchen/kitchen.php'; + + final ApiService _api = ApiService(); + + Future createOrder(Order order) async { + debugPrint('[OrderApi] createOrder: ${order.orderNo}'); + try { + final resp = await _api.post( + '$_basePath?act=create', + data: order.toJson(), + ); + final data = resp.data as Map; + if (data['code'] == 200 && data['data'] != null) { + return Order.fromJson(data['data'] as Map); + } + debugPrint('[OrderApi] createOrder failed: ${data['message']}'); + } catch (e) { + debugPrint('[OrderApi] createOrder error: $e'); + } + return order; + } + + Future getOrder(String orderId) async { + debugPrint('[OrderApi] getOrder: $orderId'); + try { + final resp = await _api.get( + _basePath, + queryParameters: {'act': 'get', 'id': orderId}, + ); + final data = resp.data as Map; + if (data['code'] == 200 && data['data'] != null) { + return Order.fromJson(data['data'] as Map); + } + } catch (e) { + debugPrint('[OrderApi] getOrder error: $e'); + } + return null; + } + + Future updateOrder(Order order) async { + debugPrint('[OrderApi] updateOrder: ${order.orderNo}'); + try { + final resp = await _api.post( + '$_basePath?act=update', + data: order.toJson(), + ); + final data = resp.data as Map; + if (data['code'] == 200 && data['data'] != null) { + return Order.fromJson(data['data'] as Map); + } + } catch (e) { + debugPrint('[OrderApi] updateOrder error: $e'); + } + return order; + } + + Future> getQrUrls(String orderId) async { + return { + 'qrUrl': 'https://eat.wktyl.com/api/kitchen/?id=$orderId', + 'barcodeUrl': 'https://eat.wktyl.com/api/kitchen/?id=$orderId', + }; + } + + Future> getOrders({ + int page = 1, + int limit = 20, + OrderStatus? status, + }) async { + debugPrint('[OrderApi] getOrders: page=$page, limit=$limit'); + try { + final params = { + 'act': 'list', + 'page': page, + 'limit': limit, + }; + if (status != null) params['status'] = status.index; + final resp = await _api.get(_basePath, queryParameters: params); + final data = resp.data as Map; + if (data['code'] == 200 && data['data'] != null) { + return data['data'] as Map; + } + } catch (e) { + debugPrint('[OrderApi] getOrders error: $e'); + } + return {'list': [], 'total': 0}; + } + + Future deleteOrder(String orderId) async { + debugPrint('[OrderApi] deleteOrder: $orderId'); + try { + final resp = await _api.get( + _basePath, + queryParameters: {'act': 'delete', 'id': orderId}, + ); + final data = resp.data as Map; + return data['code'] == 200; + } catch (e) { + debugPrint('[OrderApi] deleteOrder error: $e'); + } + return false; + } + + Future cleanupExpired({int days = 30}) async { + debugPrint('[OrderApi] cleanupExpired: days=$days'); + try { + final resp = await _api.get( + _basePath, + queryParameters: {'act': 'cleanup', 'days': days}, + ); + final data = resp.data as Map; + if (data['code'] == 200 && data['data'] != null) { + return data['data']['deleted_count'] as int? ?? 0; + } + } catch (e) { + debugPrint('[OrderApi] cleanupExpired error: $e'); + } + return 0; + } + + Future> getStats() async { + try { + final resp = await _api.get(_basePath, queryParameters: {'act': 'stats'}); + final data = resp.data as Map; + if (data['code'] == 200 && data['data'] != null) { + return data['data'] as Map; + } + } catch (e) { + debugPrint('[OrderApi] getStats error: $e'); + } + return {}; + } + + Future> clearAll() async { + debugPrint('[OrderApi] clearAll: clearing kitchen data only'); + try { + final resp = await _api.post( + '$_basePath?act=clear_all&confirm=yes', + data: {}, + ); + final data = resp.data as Map; + if (data['code'] == 200 && data['data'] != null) { + return data['data'] as Map; + } + return { + 'deleted_files': [], + 'errors': [data['message']], + }; + } catch (e) { + debugPrint('[OrderApi] clearAll error: $e'); + return { + 'deleted_files': [], + 'errors': [e.toString()], + }; + } + } +} diff --git a/lib/src/services/ui/toast_service.dart b/lib/src/services/ui/toast_service.dart index 3c0fd39..c34f7d8 100644 --- a/lib/src/services/ui/toast_service.dart +++ b/lib/src/services/ui/toast_service.dart @@ -1,4 +1,5 @@ // 2026-04-09 | ToastService | 提示服务 | Web端强制使用GetX snackbar +// 2026-04-16 | 修复FToast context unmounted导致toast无法显示的问题,添加context有效性检查和fallback import 'package:flutter/cupertino.dart'; import 'package:flutter/foundation.dart'; import 'package:get/get.dart'; @@ -24,6 +25,18 @@ class ToastService { _context = context; } + static BuildContext? _getValidContext() { + final ctx = _context; + if (ctx != null && ctx.mounted) return ctx; + final overlayCtx = Get.overlayContext; + if (overlayCtx != null && overlayCtx.mounted) { + _fToast.init(overlayCtx); + _context = overlayCtx; + return overlayCtx; + } + return null; + } + static Future show({ required String message, ToastType type = ToastType.info, @@ -45,8 +58,8 @@ class ToastService { await _showNativeToast(message: message, type: type, gravity: gravity); break; case ToastStyleType.custom: - final context = _context; - if (context == null) { + final validContext = _getValidContext(); + if (validContext == null) { await _showNativeToast( message: message, type: type, @@ -54,7 +67,7 @@ class ToastService { ); return; } - final standards = PageStandards.of(context); + final standards = PageStandards.of(validContext); _showCustomToast( message: message, type: type, diff --git a/lib/src/widgets/charts_widgets.dart b/lib/src/widgets/charts_widgets.dart index 5bf1554..4484177 100644 --- a/lib/src/widgets/charts_widgets.dart +++ b/lib/src/widgets/charts_widgets.dart @@ -568,3 +568,247 @@ class _MealTypeLegendItem { const _MealTypeLegendItem(this.label, this.value, this.color); } + +class WeightLineChart extends StatelessWidget { + final Map data; + final double goalValue; + final bool isDark; + final Color lineColor; + final String unitLabel; + + WeightLineChart({ + super.key, + required this.data, + this.goalValue = 60, + required this.isDark, + Color? lineColor, + this.unitLabel = 'kg', + }) : lineColor = lineColor ?? DesignTokens.dynamicPrimary; + + @override + Widget build(BuildContext context) { + if (data.isEmpty) { + return SizedBox( + height: 220, + 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, + ), + ), + Text( + '点击下方按钮添加第一条记录', + style: TextStyle( + fontSize: DesignTokens.fontSm, + color: + isDark ? DarkDesignTokens.text3 : DesignTokens.text3, + ), + ), + ], + ), + ), + ); + } + + final spots = _buildSpots(); + final goalSpots = _buildGoalSpots(); + + return SizedBox( + height: 220, + child: Padding( + padding: const EdgeInsets.only( + right: DesignTokens.space4, + top: DesignTokens.space3, + bottom: DesignTokens.space3, + ), + child: LineChart( + LineChartData( + gridData: FlGridData( + show: true, + drawVerticalLine: false, + horizontalInterval: _calculateInterval(), + getDrawingHorizontalLine: (value) => FlLine( + color: isDark + ? DarkDesignTokens.text3.withValues(alpha: 0.15) + : DesignTokens.text3.withValues(alpha: 0.1), + strokeWidth: 1, + ), + ), + titlesData: FlTitlesData( + topTitles: + const AxisTitles(sideTitles: SideTitles(showTitles: false)), + rightTitles: + const AxisTitles(sideTitles: SideTitles(showTitles: false)), + bottomTitles: AxisTitles( + sideTitles: SideTitles( + showTitles: true, + reservedSize: 28, + interval: _bottomInterval(), + getTitlesWidget: (value, meta) => + _buildBottomTitle(value, meta), + ), + ), + leftTitles: AxisTitles( + sideTitles: SideTitles( + showTitles: true, + reservedSize: 42, + interval: _calculateInterval(), + getTitlesWidget: (value, meta) => + _buildLeftTitle(value, meta), + ), + ), + ), + borderData: FlBorderData(show: false), + minX: 0, + maxX: (data.length - 1).toDouble(), + minY: _calculateMinY(), + maxY: _calculateMaxY(), + lineBarsData: [ + LineChartBarData( + spots: goalSpots, + isCurved: false, + color: DesignTokens.orange.withValues(alpha: 0.5), + barWidth: 1.5, + dashArray: [6, 4], + dotData: const FlDotData(show: false), + belowBarData: BarAreaData(show: false), + ), + LineChartBarData( + spots: spots, + isCurved: true, + preventCurveOverShooting: true, + color: lineColor, + barWidth: 2.5, + dotData: FlDotData( + show: true, + getDotPainter: (spot, percent, barData, index) => + FlDotCirclePainter( + radius: 4, + color: lineColor, + strokeWidth: 1.5, + strokeColor: isDark + ? DarkDesignTokens.card + : DesignTokens.card, + ), + ), + belowBarData: BarAreaData( + show: true, + color: lineColor.withValues(alpha: 0.08), + ), + ), + ], + lineTouchData: LineTouchData( + touchTooltipData: LineTouchTooltipData( + getTooltipColor: (_) => + isDark ? DarkDesignTokens.card : DesignTokens.card, + getTooltipItems: (touchedSpots) { + return touchedSpots.map((spot) { + if (spot.barIndex == 0) { + return LineTooltipItem( + '目标: ${goalValue.toStringAsFixed(1)} $unitLabel', + TextStyle( + color: DesignTokens.orange, + fontSize: DesignTokens.fontSm, + fontWeight: FontWeight.w500, + ), + ); + } + return LineTooltipItem( + '${spot.y.toStringAsFixed(1)} $unitLabel', + TextStyle( + color: lineColor, + fontSize: DesignTokens.fontSm, + fontWeight: FontWeight.w600, + ), + ); + }).toList(); + }, + ), + ), + ), + ), + ), + ); + } + + List _buildSpots() { + final entries = data.entries.toList(); + return entries.asMap().entries + .map((e) => FlSpot(e.key.toDouble(), e.value.value)) + .toList(); + } + + List _buildGoalSpots() => [ + FlSpot(0, goalValue), + FlSpot((data.length - 1).toDouble(), goalValue) + ]; + + double _calculateMaxY() { + final maxVal = + data.values.fold(0.0, (max, v) => v > max ? v : max); + final top = maxVal > goalValue ? maxVal : goalValue; + return top * 1.1; + } + + double _calculateMinY() { + final minVal = + data.values.fold(999.0, (min, v) => v < min ? v : min); + final bottom = minVal < goalValue ? minVal : goalValue; + return (bottom * 0.9).clamp(0, bottom - 5); + } + + double _calculateInterval() { + final range = _calculateMaxY() - _calculateMinY(); + if (range <= 10) return 2; + if (range <= 30) return 5; + if (range <= 60) return 10; + return 20; + } + + double _bottomInterval() { + if (data.length <= 7) return 1; + if (data.length <= 15) return 2; + return 3; + } + + Widget _buildBottomTitle(double value, TitleMeta meta) { + final entries = data.entries.toList(); + final index = value.toInt(); + if (index < 0 || index >= entries.length) return const SizedBox.shrink(); + + final shouldShow = data.length <= 7 || + index % _bottomInterval().toInt() == 0 || + index == entries.length - 1; + if (!shouldShow) return const SizedBox.shrink(); + + return SideTitleWidget( + meta: meta, + child: Text( + entries[index].key, + style: TextStyle( + fontSize: DesignTokens.fontXs, + color: isDark ? DarkDesignTokens.text2 : DesignTokens.text2, + ), + ), + ); + } + + Widget _buildLeftTitle(double value, TitleMeta meta) { + return Text( + value.toStringAsFixed(1), + style: TextStyle( + fontSize: DesignTokens.fontXs, + color: isDark ? DarkDesignTokens.text3 : DesignTokens.text3, + ), + textAlign: TextAlign.right, + ); + } +} diff --git a/lib/src/widgets/custom_widgets.dart b/lib/src/widgets/custom_widgets.dart index fac3114..65dc820 100644 --- a/lib/src/widgets/custom_widgets.dart +++ b/lib/src/widgets/custom_widgets.dart @@ -70,15 +70,32 @@ class _MiniCalendarState extends State { } Widget _buildHeader(Color textColor, Color subColor) { + final isDark = CupertinoTheme.brightnessOf(context) == Brightness.dark; final monthStr = '${_displayMonth.year}年${_displayMonth.month}月'; return Row( mainAxisAlignment: MainAxisAlignment.spaceBetween, children: [ + // 上一个月按钮 GestureDetector( onTap: _previousMonth, child: Container( - padding: const EdgeInsets.all(DesignTokens.space2), - child: Icon(CupertinoIcons.chevron_left, size: 18, color: subColor), + width: 44, + height: 44, + alignment: Alignment.center, + decoration: BoxDecoration( + color: isDark + ? DarkDesignTokens.background + : DesignTokens.background, + shape: BoxShape.circle, + border: Border.all( + color: subColor.withValues(alpha: 0.2), + ), + ), + child: Icon( + CupertinoIcons.chevron_left, + size: 22, + color: subColor, + ), ), ), Text( @@ -89,13 +106,25 @@ class _MiniCalendarState extends State { color: textColor, ), ), + // 下一个月按钮 GestureDetector( onTap: _nextMonth, child: Container( - padding: const EdgeInsets.all(DesignTokens.space2), + width: 44, + height: 44, + alignment: Alignment.center, + decoration: BoxDecoration( + color: isDark + ? DarkDesignTokens.background + : DesignTokens.background, + shape: BoxShape.circle, + border: Border.all( + color: subColor.withValues(alpha: 0.2), + ), + ), child: Icon( CupertinoIcons.chevron_right, - size: 18, + size: 22, color: subColor, ), ), diff --git a/lib/src/widgets/discover/category_discover_card.dart b/lib/src/widgets/discover/category_discover_card.dart index e7c35ec..f9f14a9 100644 --- a/lib/src/widgets/discover/category_discover_card.dart +++ b/lib/src/widgets/discover/category_discover_card.dart @@ -3,18 +3,20 @@ * 名称: 分类发现卡片 * 作用: 瀑布流中的分类展示卡片,Liquid Glass风格 * 创建时间: 2026-04-12 - * 更新时间: 2026-04-13 - * 更新: 2026-04-13 添加最小高度约束,修复瀑布流布局问题 - * 更新: 2026-04-13 适配API新字段:recipe_count、ingredient_count、parent_name + * 更新时间: 2026-04-16 菜品分类点击底部弹窗显示菜谱列表,食材类型跳转食材详情页 */ import 'package:flutter/cupertino.dart'; +import 'package:flutter/material.dart'; import 'package:get/get.dart'; import 'package:mom_kitchen/src/config/app_routes.dart'; import 'package:mom_kitchen/src/config/design_tokens.dart'; import 'package:mom_kitchen/src/widgets/glass/glass_container.dart'; import 'package:mom_kitchen/src/models/discover_model.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'; +import 'package:mom_kitchen/src/widgets/recipe/recipe_image.dart'; class CategoryDiscoverCard extends StatelessWidget { final DiscoverCategory category; @@ -41,16 +43,14 @@ class CategoryDiscoverCard extends StatelessWidget { return GestureDetector( onTap: () { - final catModel = CategoryModel( - id: category.id, - name: category.name, - count: category.count, - children: [], - ); - Get.toNamed( - AppRoutes.categoryBrowse, - arguments: {'category': catModel}, - ); + if (category.type == 'ingredient') { + Get.toNamed( + AppRoutes.toolsIngredient, + arguments: {'name': category.name, 'id': 0}, + ); + } else { + _showCategorySheet(context, color); + } }, child: GlassContainer( backgroundColor: color.withValues(alpha: 0.06), @@ -117,4 +117,341 @@ class CategoryDiscoverCard extends StatelessWidget { ), ); } + + void _showCategorySheet(BuildContext context, Color color) { + final isDark = CupertinoTheme.brightnessOf(context) == Brightness.dark; + final screenHeight = MediaQuery.of(context).size.height; + + showCupertinoModalPopup( + context: context, + builder: (ctx) => _CategorySheetContent( + categoryId: category.id, + categoryName: category.name, + parentName: category.parentName, + accentColor: color, + isDark: isDark, + maxHeight: screenHeight * 0.75, + ), + ); + } +} + +class _CategorySheetContent extends StatefulWidget { + final int categoryId; + final String categoryName; + final String parentName; + final Color accentColor; + final bool isDark; + final double maxHeight; + + const _CategorySheetContent({ + required this.categoryId, + required this.categoryName, + required this.parentName, + required this.accentColor, + required this.isDark, + required this.maxHeight, + }); + + @override + State<_CategorySheetContent> createState() => _CategorySheetContentState(); +} + +class _CategorySheetContentState extends State<_CategorySheetContent> { + final RecipeRepository _repo = RecipeRepository(); + List _recipes = []; + bool _isLoading = true; + String _error = ''; + + @override + void initState() { + super.initState(); + _loadRecipes(); + } + + Future _loadRecipes() async { + try { + final result = await _repo.fetchList( + categoryId: widget.categoryId, + page: 1, + limit: 30, + ); + if (!mounted) return; + setState(() { + _recipes = result.items; + _isLoading = false; + }); + } catch (e) { + if (!mounted) return; + setState(() { + _error = e.toString(); + _isLoading = false; + }); + } + } + + @override + Widget build(BuildContext context) { + return Container( + constraints: BoxConstraints(maxHeight: widget.maxHeight), + decoration: BoxDecoration( + color: widget.isDark + ? DarkDesignTokens.background + : DesignTokens.background, + borderRadius: const BorderRadius.vertical(top: Radius.circular(20)), + ), + child: Column( + mainAxisSize: MainAxisSize.min, + children: [ + _buildHandle(), + _buildHeader(), + const Divider(height: 1), + Flexible( + child: _isLoading + ? const Center( + child: Padding( + padding: EdgeInsets.all(40), + child: CupertinoActivityIndicator(radius: 14), + ), + ) + : _error.isNotEmpty + ? _buildError() + : _recipes.isEmpty + ? _buildEmpty() + : _buildRecipeList(), + ), + ], + ), + ); + } + + Widget _buildHandle() { + return Center( + child: Container( + margin: const EdgeInsets.only(top: 10), + width: 36, + height: 5, + decoration: BoxDecoration( + color: widget.isDark ? DarkDesignTokens.text3 : DesignTokens.text3, + borderRadius: BorderRadius.circular(3), + ), + ), + ); + } + + Widget _buildHeader() { + return Padding( + padding: const EdgeInsets.fromLTRB(20, 12, 20, 12), + child: Row( + children: [ + Expanded( + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + mainAxisSize: MainAxisSize.min, + children: [ + Text( + widget.categoryName, + style: TextStyle( + fontSize: DesignTokens.fontLg, + fontWeight: FontWeight.bold, + color: widget.isDark + ? DarkDesignTokens.text1 + : DesignTokens.text1, + ), + ), + if (widget.parentName.isNotEmpty) + Text( + widget.parentName, + style: TextStyle( + fontSize: DesignTokens.fontXs, + color: widget.accentColor.withValues(alpha: 0.7), + ), + ), + ], + ), + ), + GestureDetector( + onTap: () => Get.back(), + child: Container( + padding: const EdgeInsets.all(8), + decoration: BoxDecoration( + shape: BoxShape.circle, + color: widget.isDark + ? DarkDesignTokens.card + : DesignTokens.card, + ), + child: Icon( + CupertinoIcons.xmark, + size: 16, + color: widget.isDark + ? DarkDesignTokens.text2 + : DesignTokens.text2, + ), + ), + ), + ], + ), + ); + } + + Widget _buildError() { + return Center( + child: Column( + mainAxisAlignment: MainAxisAlignment.center, + children: [ + const Text('⚠️', style: TextStyle(fontSize: 40)), + const SizedBox(height: DesignTokens.space3), + Text( + '加载失败', + style: TextStyle( + fontSize: DesignTokens.fontMd, + color: widget.isDark + ? DarkDesignTokens.text2 + : DesignTokens.text2, + ), + ), + const SizedBox(height: DesignTokens.space2), + CupertinoButton( + padding: const EdgeInsets.symmetric(horizontal: 20), + onPressed: () { + setState(() { + _isLoading = true; + _error = ''; + }); + _loadRecipes(); + }, + child: const Text('重试'), + ), + ], + ), + ); + } + + Widget _buildEmpty() { + 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: widget.isDark + ? DarkDesignTokens.text2 + : DesignTokens.text2, + ), + ), + ], + ), + ); + } + + Widget _buildRecipeList() { + return ListView.separated( + padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 8), + itemCount: _recipes.length, + separatorBuilder: (_, __) => const SizedBox(height: DesignTokens.space2), + itemBuilder: (context, index) { + final recipe = _recipes[index]; + return _RecipeListItem(recipe: recipe, accentColor: widget.accentColor); + }, + ); + } +} + +class _RecipeListItem extends StatelessWidget { + final RecipeModel recipe; + final Color accentColor; + + const _RecipeListItem({required this.recipe, required this.accentColor}); + + @override + Widget build(BuildContext context) { + final isDark = CupertinoTheme.brightnessOf(context) == Brightness.dark; + + return GestureDetector( + onTap: () { + Get.back(); + Get.toNamed(AppRoutes.recipeDetail, arguments: recipe.id); + }, + child: Container( + decoration: BoxDecoration( + borderRadius: DesignTokens.borderRadiusMd, + color: isDark ? DarkDesignTokens.card : DesignTokens.card, + ), + clipBehavior: Clip.antiAlias, + child: Row( + children: [ + ClipRRect( + borderRadius: const BorderRadius.horizontal( + left: Radius.circular(DesignTokens.radiusMd), + ), + child: SizedBox( + width: 80, + height: 80, + child: RecipeImage( + recipeId: recipe.id, + coverUrl: recipe.cover, + fit: BoxFit.cover, + ), + ), + ), + const SizedBox(width: DesignTokens.space3), + Expanded( + child: Padding( + padding: const EdgeInsets.symmetric(vertical: 10), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text( + recipe.title, + style: TextStyle( + fontSize: DesignTokens.fontSm, + fontWeight: FontWeight.w600, + color: isDark + ? DarkDesignTokens.text1 + : DesignTokens.text1, + ), + maxLines: 2, + overflow: TextOverflow.ellipsis, + ), + const SizedBox(height: 4), + if (recipe.categoryName != null && + recipe.categoryName!.isNotEmpty) + Container( + padding: const EdgeInsets.symmetric( + horizontal: 6, + vertical: 2, + ), + decoration: BoxDecoration( + color: accentColor.withValues(alpha: 0.1), + borderRadius: BorderRadius.circular(4), + ), + child: Text( + recipe.categoryName!, + style: TextStyle( + fontSize: 10, + color: accentColor, + fontWeight: FontWeight.w500, + ), + ), + ), + ], + ), + ), + ), + const SizedBox(width: DesignTokens.space2), + Icon( + CupertinoIcons.chevron_right, + size: 16, + color: isDark ? DarkDesignTokens.text3 : DesignTokens.text3, + ), + const SizedBox(width: 8), + ], + ), + ), + ); + } } diff --git a/lib/src/widgets/discover/discover_waterfall.dart b/lib/src/widgets/discover/discover_waterfall.dart index 7083ba9..6edfb9a 100644 --- a/lib/src/widgets/discover/discover_waterfall.dart +++ b/lib/src/widgets/discover/discover_waterfall.dart @@ -1,12 +1,14 @@ /* * 文件: discover_waterfall.dart * 名称: 发现页瀑布流容器 - * 作用: 单一SliverMasonryGrid 2列瀑布流,迷你卡片以SliverToBoxAdapter间隔插入 + * 作用: 单一SliverMasonryGrid 2列瀑布流,插槽卡片以SliverToBoxAdapter间隔插入 * 创建时间: 2026-04-12 - * 更新时间: 2026-04-14 彻底重构:消除SliverMainAxisGroup分片导致的滚动抖动 + * 更新时间: 2026-04-15 修复底部栏Row溢出:加载更多按钮和返回顶部按钮用Flexible包裹 + * 更新: 2026-04-14 彻底重构:消除SliverMainAxisGroup分片导致的滚动抖动 * 更新: 2026-04-14 迷你卡片改为SliverToBoxAdapter全宽横幅,不破坏MasonryGrid布局连续性 * 更新: 2026-04-14 缓存合并列表,避免每次build重新计算 * 更新: 2026-04-14 恢复底部栏液态玻璃效果,视觉拉满 + * 更新: 2026-04-17 接入WaterfallSlotRegistry统一插槽系统,支持miniCard和toolCard交替插入 */ import 'dart:math'; @@ -23,8 +25,11 @@ import 'package:mom_kitchen/src/widgets/discover/tag_discover_card.dart'; import 'package:mom_kitchen/src/widgets/discover/nutrition_discover_card.dart'; import 'package:mom_kitchen/src/widgets/discover/meal_time_discover_card.dart'; import 'package:mom_kitchen/src/widgets/discover/mini_card_discover_card.dart'; +import 'package:mom_kitchen/src/widgets/discover/tool_card_discover_card.dart'; import 'package:mom_kitchen/src/models/discover_model.dart'; import 'package:mom_kitchen/src/models/mini_card_model.dart'; +import 'package:mom_kitchen/src/models/tool_item_model.dart'; +import 'package:mom_kitchen/src/models/waterfall_slot.dart'; class DiscoverWaterfall extends StatefulWidget { final DiscoverData data; @@ -35,8 +40,7 @@ class DiscoverWaterfall extends StatefulWidget { final Set dismissedRecipeIds; final List miniCardRecipes; final MiniCardMeta? miniCardMeta; - - static const int _miniCardInterval = 20; + final List toolCards; const DiscoverWaterfall({ super.key, @@ -48,6 +52,7 @@ class DiscoverWaterfall extends StatefulWidget { this.dismissedRecipeIds = const {}, this.miniCardRecipes = const [], this.miniCardMeta, + this.toolCards = const [], }); @override @@ -59,19 +64,23 @@ class _DiscoverWaterfallState extends State { List? _lastFlattenedItems; Set? _lastDismissedIds; int _lastMiniCardCount = -1; + int _lastToolCardCount = -1; List get _mergedItems { final items = widget.data.flattenedItems; final dismissed = widget.dismissedRecipeIds; final miniCards = widget.miniCardRecipes; + final toolCards = widget.toolCards; final miniCardCountChanged = miniCards.length != _lastMiniCardCount; + final toolCardCountChanged = toolCards.length != _lastToolCardCount; if (_cachedMergedItems != null && identical(items, _lastFlattenedItems) && _lastDismissedIds != null && _lastDismissedIds!.length == dismissed.length && _lastDismissedIds!.containsAll(dismissed) && - !miniCardCountChanged) { + !miniCardCountChanged && + !toolCardCountChanged) { return _cachedMergedItems!; } @@ -85,24 +94,57 @@ class _DiscoverWaterfallState extends State { .toList() : items; + final slots = WaterfallSlotRegistry.buildSlots( + miniCards: miniCards, + toolCards: toolCards, + ); + final merged = []; - int miniCardIdx = 0; + int slotIdx = 0; + for (int i = 0; i < filtered.length; i++) { merged.add(filtered[i]); - if ((i + 1) % DiscoverWaterfall._miniCardInterval == 0 && - miniCardIdx < miniCards.length) { - merged.add( - DiscoverItem.miniCard( - MiniCardRecipeRef( - id: miniCards[miniCardIdx].id, - name: miniCards[miniCardIdx].name, - category: miniCards[miniCardIdx].category, - categoryName: miniCards[miniCardIdx].categoryName, - image: miniCards[miniCardIdx].image, - ), - ), - ); - miniCardIdx++; + + while (slotIdx < slots.length && slots[slotIdx].position == i + 1) { + final slot = slots[slotIdx]; + if (slot.type == WaterfallSlotType.miniCard) { + final idx = slot.data['index'] as int; + if (idx < miniCards.length) { + merged.add( + DiscoverItem.miniCard( + MiniCardRecipeRef( + id: miniCards[idx].id, + name: miniCards[idx].name, + category: miniCards[idx].category, + categoryName: miniCards[idx].categoryName, + image: miniCards[idx].image, + ), + ), + ); + } + } else if (slot.type == WaterfallSlotType.toolCard) { + final idx = slot.data['index'] as int; + if (idx < toolCards.length) { + final t = toolCards[idx]; + merged.add( + DiscoverItem.toolCard( + ToolItemRef( + id: t.id, + name: t.name, + icon: t.icon, + category: t.category, + categoryName: ToolRegistry.getCategoryLabel(t.category), + route: t.route, + needsNetwork: t.needsNetwork, + usageCount: t.usageCount, + description: t.description, + badge: t.waterfallSlot.badge, + ), + ), + ); + } + } + slotIdx++; } } @@ -110,6 +152,7 @@ class _DiscoverWaterfallState extends State { _lastFlattenedItems = items; _lastDismissedIds = Set.from(dismissed); _lastMiniCardCount = miniCards.length; + _lastToolCardCount = toolCards.length; return merged; } @@ -202,6 +245,9 @@ class _DiscoverWaterfallState extends State { meta: widget.miniCardMeta, ); break; + case DiscoverItemType.toolCard: + card = ToolCardDiscoverCard(key: key, tool: item.toolItemRef!); + break; } return RepaintBoundary( @@ -228,6 +274,8 @@ class _DiscoverWaterfallState extends State { return 'mealtime_${item.mealTime?.name ?? index}'; case DiscoverItemType.miniCard: return 'minicard_${item.miniCardRecipe?.id ?? index}'; + case DiscoverItemType.toolCard: + return 'toolcard_${item.toolItemRef?.id ?? index}'; } } @@ -297,13 +345,16 @@ class _DiscoverWaterfallState extends State { : DesignTokens.text3, ), const SizedBox(width: DesignTokens.space2), - Text( - '正在加载更多...', - style: TextStyle( - fontSize: DesignTokens.fontXs, - color: isDark - ? DarkDesignTokens.text3 - : DesignTokens.text3, + Flexible( + child: Text( + '正在加载更多...', + style: TextStyle( + fontSize: DesignTokens.fontXs, + color: isDark + ? DarkDesignTokens.text3 + : DesignTokens.text3, + ), + overflow: TextOverflow.ellipsis, ), ), ], @@ -336,12 +387,15 @@ class _DiscoverWaterfallState extends State { color: DesignTokens.dynamicPrimary, ), SizedBox(width: 4), - Text( - '加载更多', - style: TextStyle( - fontSize: 11, - color: DesignTokens.dynamicPrimary, - fontWeight: FontWeight.w500, + Flexible( + child: Text( + '加载更多', + style: TextStyle( + fontSize: 11, + color: DesignTokens.dynamicPrimary, + fontWeight: FontWeight.w500, + ), + overflow: TextOverflow.ellipsis, ), ), ], diff --git a/lib/src/widgets/discover/mini_card_discover_card.dart b/lib/src/widgets/discover/mini_card_discover_card.dart index a94bbd7..2fe6f7d 100644 --- a/lib/src/widgets/discover/mini_card_discover_card.dart +++ b/lib/src/widgets/discover/mini_card_discover_card.dart @@ -60,7 +60,7 @@ class MiniCardDiscoverCard extends StatelessWidget { color: DesignTokens.background, child: const Center(child: CupertinoActivityIndicator()), ), - errorWidget: (_, __, ___) => Container( + errorWidget: (_, __, _) => Container( decoration: BoxDecoration( gradient: LinearGradient( begin: Alignment.topLeft, diff --git a/lib/src/widgets/discover/nutrition_discover_card.dart b/lib/src/widgets/discover/nutrition_discover_card.dart index c9b0c87..dfffe76 100644 --- a/lib/src/widgets/discover/nutrition_discover_card.dart +++ b/lib/src/widgets/discover/nutrition_discover_card.dart @@ -1,9 +1,9 @@ /* * 文件: nutrition_discover_card.dart * 名称: 营养成分发现卡片 - * 作用: 瀑布流中的营养成分展示卡片,Liquid Glass风格 + * 作用: 瀑布流中的营养成分展示卡片,Liquid Glass风格,点击跳转含该营养成分的菜品列表 * 创建时间: 2026-04-13 - * 更新时间: 2026-04-13 + * 更新时间: 2026-04-16 点击跳转营养成分菜品列表页(/nutrition-recipe-list) */ import 'package:flutter/cupertino.dart'; @@ -41,8 +41,12 @@ class NutritionDiscoverCard extends StatelessWidget { final color = _getColor(); return GestureDetector( - onTap: () => - Get.toNamed(AppRoutes.search, arguments: {'keyword': nutrition.name}), + onTap: () { + Get.toNamed( + AppRoutes.nutritionRecipeList, + arguments: {'nutritionName': nutrition.name}, + ); + }, child: GlassContainer( backgroundColor: color.withValues(alpha: 0.06), borderColor: color.withValues(alpha: 0.15), diff --git a/lib/src/widgets/discover/recipe_discover_card.dart b/lib/src/widgets/discover/recipe_discover_card.dart index fb08ebd..8a52bce 100644 --- a/lib/src/widgets/discover/recipe_discover_card.dart +++ b/lib/src/widgets/discover/recipe_discover_card.dart @@ -3,8 +3,7 @@ * 名称: 菜品发现卡片 * 作用: 瀑布流中的菜品展示卡片,Liquid Glass风格,支持懒加载图片+长按关闭 * 创建时间: 2026-04-12 - * 更新时间: 2026-04-13 增加shouldLoadImage懒加载控制,避免瀑布流一次性加载全部图片 - * 更新: 2026-04-14 使用DiscoverRecipe.picId和resolvedCoverUrl,简化图片加载逻辑 + * 更新: 2026-04-13 增加长按显示关闭按钮,点击X移除卡片 */ @@ -157,39 +156,55 @@ class _RecipeDiscoverCardState extends State ), ), ), - const SizedBox(width: DesignTokens.space2), if (widget.recipe.rating.hasRating) ...[ - Icon( - CupertinoIcons.star_fill, - size: 12, - color: DesignTokens.orange, - ), - const SizedBox(width: 2), - Text( - widget.recipe.rating.score.toStringAsFixed(1), - style: TextStyle( - fontSize: DesignTokens.fontXs, - fontWeight: FontWeight.w600, - color: DesignTokens.orange, + const SizedBox(width: DesignTokens.space2), + Flexible( + child: Row( + mainAxisSize: MainAxisSize.min, + children: [ + Icon( + CupertinoIcons.star_fill, + size: 12, + color: DesignTokens.orange, + ), + const SizedBox(width: 2), + Text( + widget.recipe.rating.score.toStringAsFixed(1), + style: TextStyle( + fontSize: DesignTokens.fontXs, + fontWeight: FontWeight.w600, + color: DesignTokens.orange, + ), + overflow: TextOverflow.ellipsis, + ), + ], ), ), ], const Spacer(), - Icon( - CupertinoIcons.eye, - size: 12, - color: isDark - ? DarkDesignTokens.text3 - : DesignTokens.text3, - ), - const SizedBox(width: 2), - Text( - _formatCount(widget.recipe.views), - style: TextStyle( - fontSize: DesignTokens.fontXs, - color: isDark - ? DarkDesignTokens.text3 - : DesignTokens.text3, + Flexible( + child: Row( + mainAxisSize: MainAxisSize.min, + children: [ + Icon( + CupertinoIcons.eye, + size: 12, + color: isDark + ? DarkDesignTokens.text3 + : DesignTokens.text3, + ), + const SizedBox(width: 2), + Text( + _formatCount(widget.recipe.views), + style: TextStyle( + fontSize: DesignTokens.fontXs, + color: isDark + ? DarkDesignTokens.text3 + : DesignTokens.text3, + ), + overflow: TextOverflow.ellipsis, + ), + ], ), ), ], diff --git a/lib/src/widgets/discover/tag_discover_card.dart b/lib/src/widgets/discover/tag_discover_card.dart index 0122fbc..6f637ed 100644 --- a/lib/src/widgets/discover/tag_discover_card.dart +++ b/lib/src/widgets/discover/tag_discover_card.dart @@ -1,17 +1,20 @@ /* * 文件: tag_discover_card.dart * 名称: 标签发现卡片(口味/工艺) - * 作用: 瀑布流中的标签展示卡片,Liquid Glass风格,支持数量显示 + * 作用: 瀑布流中的标签展示卡片,Liquid Glass风格,点击底部弹窗显示对应标签列表 * 创建时间: 2026-04-13 - * 更新时间: 2026-04-13 新增数量显示功能 + * 更新时间: 2026-04-16 点击底部弹窗显示口味/做法标签列表 */ import 'package:flutter/cupertino.dart'; +import 'package:flutter/material.dart'; import 'package:get/get.dart'; import 'package:mom_kitchen/src/config/app_routes.dart'; import 'package:mom_kitchen/src/config/design_tokens.dart'; import 'package:mom_kitchen/src/widgets/glass/glass_container.dart'; import 'package:mom_kitchen/src/models/discover_model.dart'; +import 'package:mom_kitchen/src/models/recipe/tag_model.dart'; +import 'package:mom_kitchen/src/repositories/recipe_repository.dart'; class TagDiscoverCard extends StatelessWidget { final DiscoverTag tag; @@ -27,8 +30,7 @@ class TagDiscoverCard extends StatelessWidget { : DesignTokens.orange; return GestureDetector( - onTap: () => - Get.toNamed(AppRoutes.search, arguments: {'keyword': tag.name}), + onTap: () => _showTagSheet(context, baseColor), child: GlassContainer( backgroundColor: baseColor.withValues(alpha: 0.05), borderColor: baseColor.withValues(alpha: 0.12), @@ -101,6 +103,7 @@ class TagDiscoverCard extends StatelessWidget { fontWeight: FontWeight.w700, ), overflow: TextOverflow.ellipsis, + maxLines: 1, ), ), ), @@ -118,15 +121,17 @@ class TagDiscoverCard extends StatelessWidget { : DesignTokens.text3, ), SizedBox(width: 2), - Text( - _formatCount(tag.count), - style: TextStyle( - fontSize: 10, - color: isDark - ? DarkDesignTokens.text3 - : DesignTokens.text3, + Flexible( + child: Text( + _formatCount(tag.count), + style: TextStyle( + fontSize: 10, + color: isDark + ? DarkDesignTokens.text3 + : DesignTokens.text3, + ), + overflow: TextOverflow.ellipsis, ), - overflow: TextOverflow.ellipsis, ), ], ), @@ -144,6 +149,22 @@ class TagDiscoverCard extends StatelessWidget { ); } + void _showTagSheet(BuildContext context, Color baseColor) { + final isDark = CupertinoTheme.brightnessOf(context) == Brightness.dark; + final screenHeight = MediaQuery.of(context).size.height; + final isTaste = tag.type == 'taste'; + + showCupertinoModalPopup( + context: context, + builder: (ctx) => _TagSheetContent( + tagType: tag.type, + accentColor: baseColor, + isDark: isDark, + maxHeight: screenHeight * 0.65, + ), + ); + } + String _formatCount(int count) { if (count >= 10000) { return '${(count / 10000).toStringAsFixed(1)}万'; @@ -153,3 +174,244 @@ class TagDiscoverCard extends StatelessWidget { return count.toString(); } } + +class _TagSheetContent extends StatefulWidget { + final String tagType; + final Color accentColor; + final bool isDark; + final double maxHeight; + + const _TagSheetContent({ + required this.tagType, + required this.accentColor, + required this.isDark, + required this.maxHeight, + }); + + @override + State<_TagSheetContent> createState() => _TagSheetContentState(); +} + +class _TagSheetContentState extends State<_TagSheetContent> { + final RecipeRepository _repo = RecipeRepository(); + List _tags = []; + bool _isLoading = true; + + bool get _isTaste => widget.tagType == 'taste'; + + String get _title => _isTaste ? '🏷️ 口味' : '🔥 做法'; + + @override + void initState() { + super.initState(); + _loadTags(); + } + + Future _loadTags() async { + try { + final result = _isTaste + ? await _repo.fetchTasteTags(limit: 50) + : await _repo.fetchCookingTags(limit: 50); + if (!mounted) return; + setState(() { + _tags = result; + _isLoading = false; + }); + } catch (e) { + debugPrint('Load tags error: $e'); + if (!mounted) return; + setState(() => _isLoading = false); + } + } + + @override + Widget build(BuildContext context) { + return Container( + constraints: BoxConstraints(maxHeight: widget.maxHeight), + decoration: BoxDecoration( + color: widget.isDark + ? DarkDesignTokens.background + : DesignTokens.background, + borderRadius: const BorderRadius.vertical(top: Radius.circular(20)), + ), + child: Column( + mainAxisSize: MainAxisSize.min, + children: [ + _buildHandle(), + _buildHeader(), + const Divider(height: 1), + Flexible( + child: _isLoading + ? const Center( + child: Padding( + padding: EdgeInsets.all(40), + child: CupertinoActivityIndicator(radius: 14), + ), + ) + : _tags.isEmpty + ? _buildEmpty() + : _buildTagGrid(), + ), + ], + ), + ); + } + + Widget _buildHandle() { + return Center( + child: Container( + margin: const EdgeInsets.only(top: 10), + width: 36, + height: 5, + decoration: BoxDecoration( + color: widget.isDark ? DarkDesignTokens.text3 : DesignTokens.text3, + borderRadius: BorderRadius.circular(3), + ), + ), + ); + } + + Widget _buildHeader() { + return Padding( + padding: const EdgeInsets.fromLTRB(20, 12, 20, 12), + child: Row( + children: [ + Expanded( + child: Text( + _title, + style: TextStyle( + fontSize: DesignTokens.fontLg, + fontWeight: FontWeight.bold, + color: widget.isDark + ? DarkDesignTokens.text1 + : DesignTokens.text1, + ), + ), + ), + GestureDetector( + onTap: () => Get.back(), + child: Container( + padding: const EdgeInsets.all(8), + decoration: BoxDecoration( + shape: BoxShape.circle, + color: widget.isDark + ? DarkDesignTokens.card + : DesignTokens.card, + ), + child: Icon( + CupertinoIcons.xmark, + size: 16, + color: widget.isDark + ? DarkDesignTokens.text2 + : DesignTokens.text2, + ), + ), + ), + ], + ), + ); + } + + Widget _buildEmpty() { + return Center( + child: Column( + mainAxisAlignment: MainAxisAlignment.center, + children: [ + Text(_isTaste ? '🏷️' : '🔥', style: const TextStyle(fontSize: 48)), + const SizedBox(height: DesignTokens.space3), + Text( + '暂无${_isTaste ? "口味" : "做法"}标签', + style: TextStyle( + fontSize: DesignTokens.fontMd, + color: widget.isDark + ? DarkDesignTokens.text2 + : DesignTokens.text2, + ), + ), + ], + ), + ); + } + + Widget _buildTagGrid() { + return Padding( + padding: const EdgeInsets.all(16), + child: GridView.builder( + shrinkWrap: true, + physics: const ClampingScrollPhysics(), + gridDelegate: const SliverGridDelegateWithFixedCrossAxisCount( + crossAxisCount: 3, + mainAxisSpacing: 10, + crossAxisSpacing: 10, + childAspectRatio: 2.4, + ), + itemCount: _tags.length, + itemBuilder: (context, index) { + final t = _tags[index]; + return _TagChipItem( + tag: t, + accentColor: widget.accentColor, + isDark: widget.isDark, + ); + }, + ), + ); + } +} + +class _TagChipItem extends StatelessWidget { + final TagModel tag; + final Color accentColor; + final bool isDark; + + const _TagChipItem({ + required this.tag, + required this.accentColor, + required this.isDark, + }); + + @override + Widget build(BuildContext context) { + return GestureDetector( + onTap: () { + Get.back(); + Get.toNamed(AppRoutes.search, arguments: {'keyword': tag.name}); + }, + child: Container( + decoration: BoxDecoration( + borderRadius: DesignTokens.borderRadiusSm, + color: accentColor.withValues(alpha: 0.06), + border: Border.all(color: accentColor.withValues(alpha: 0.15)), + ), + alignment: Alignment.center, + child: Column( + mainAxisAlignment: MainAxisAlignment.center, + mainAxisSize: MainAxisSize.min, + children: [ + Text( + tag.name, + style: TextStyle( + fontSize: DesignTokens.fontXs, + fontWeight: FontWeight.w600, + color: isDark ? DarkDesignTokens.text1 : DesignTokens.text1, + ), + maxLines: 1, + overflow: TextOverflow.ellipsis, + textAlign: TextAlign.center, + ), + if (tag.count != null && tag.count! > 0) ...[ + const SizedBox(height: 2), + Text( + '${tag.count}道菜', + style: TextStyle( + fontSize: 9, + color: accentColor.withValues(alpha: 0.7), + ), + ), + ], + ], + ), + ), + ); + } +} diff --git a/lib/src/widgets/discover/tool_card_discover_card.dart b/lib/src/widgets/discover/tool_card_discover_card.dart new file mode 100644 index 0000000..3475fd9 --- /dev/null +++ b/lib/src/widgets/discover/tool_card_discover_card.dart @@ -0,0 +1,273 @@ +/* + * 文件: tool_card_discover_card.dart + * 名称: 瀑布流工具卡片 + * 作用: 首页瀑布流中嵌入的工具推荐卡片,毛玻璃中等卡片样式 + * 创建时间: 2026-04-17 + * 更新时间: 2026-04-17 初始创建 + */ + +import 'dart:ui'; +import 'package:flutter/cupertino.dart'; +import 'package:flutter/material.dart'; +import 'package:get/get.dart'; +import 'package:mom_kitchen/src/config/design_tokens.dart'; +import 'package:mom_kitchen/src/config/app_routes.dart'; +import 'package:mom_kitchen/src/models/discover_model.dart'; + +class ToolCardDiscoverCard extends StatelessWidget { + final ToolItemRef tool; + + const ToolCardDiscoverCard({super.key, required this.tool}); + + @override + Widget build(BuildContext context) { + final isDark = CupertinoTheme.brightnessOf(context) == Brightness.dark; + final gradientIndex = tool.id.hashCode.abs() % DesignTokens.toolGradients.length; + final gradientColor = DesignTokens.toolGradients[gradientIndex]; + + return GestureDetector( + onTap: () => Get.toNamed(tool.route), + child: Container( + height: 190, + decoration: BoxDecoration( + borderRadius: BorderRadius.circular(DesignTokens.radiusLg), + color: isDark ? DarkDesignTokens.card : DesignTokens.card, + boxShadow: [ + BoxShadow( + color: Colors.black.withValues(alpha: 0.1), + blurRadius: 16, + offset: const Offset(0, 6), + ), + BoxShadow( + color: gradientColor.withValues(alpha: 0.08), + blurRadius: 32, + offset: const Offset(0, 2), + ), + ], + ), + clipBehavior: Clip.antiAlias, + child: Stack( + fit: StackFit.expand, + children: [ + _buildGradientBackground(gradientColor, isDark), + _buildGlassOverlay(isDark), + _buildContent(isDark, gradientColor), + _buildInfoButton(isDark), + if (tool.badge != null) _buildBadge(), + ], + ), + ), + ); + } + + Widget _buildGradientBackground(Color gradientColor, bool isDark) { + return Positioned.fill( + child: Container( + decoration: BoxDecoration( + gradient: LinearGradient( + begin: Alignment.topLeft, + end: Alignment.bottomRight, + colors: [ + gradientColor.withValues(alpha: isDark ? 0.15 : 0.12), + gradientColor.withValues(alpha: isDark ? 0.05 : 0.03), + ], + ), + ), + ), + ); + } + + Widget _buildGlassOverlay(bool isDark) { + return Positioned.fill( + child: ClipRRect( + borderRadius: BorderRadius.circular(DesignTokens.radiusLg), + child: BackdropFilter( + filter: ImageFilter.blur(sigmaX: 20, sigmaY: 20), + child: Container( + decoration: BoxDecoration( + color: (isDark ? DarkDesignTokens.card : DesignTokens.card) + .withValues(alpha: 0.55), + border: Border.all( + color: Colors.white.withValues(alpha: 0.15), + width: 0.5, + ), + gradient: LinearGradient( + begin: Alignment.topLeft, + end: Alignment.bottomRight, + colors: [ + Colors.white.withValues(alpha: 0.1), + Colors.white.withValues(alpha: 0.03), + ], + ), + ), + ), + ), + ), + ); + } + + Widget _buildContent(bool isDark, Color gradientColor) { + return Positioned.fill( + child: Padding( + padding: const EdgeInsets.all(DesignTokens.space4), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + const SizedBox(height: DesignTokens.space5), + Center( + child: Text( + tool.icon, + style: const TextStyle(fontSize: 40), + ), + ), + const SizedBox(height: DesignTokens.space4), + Center( + child: Text( + tool.name, + style: TextStyle( + fontSize: DesignTokens.fontLg, + fontWeight: FontWeight.w700, + color: isDark ? DarkDesignTokens.text1 : DesignTokens.text1, + ), + textAlign: TextAlign.center, + maxLines: 1, + overflow: TextOverflow.ellipsis, + ), + ), + if (tool.description != null) ...[ + const SizedBox(height: DesignTokens.space1), + Center( + child: Text( + tool.description!, + style: TextStyle( + fontSize: DesignTokens.fontXs, + color: + isDark ? DarkDesignTokens.text3 : DesignTokens.text3, + ), + textAlign: TextAlign.center, + maxLines: 1, + overflow: TextOverflow.ellipsis, + ), + ), + ], + const Spacer(), + Center( + child: Container( + padding: const EdgeInsets.symmetric( + horizontal: DesignTokens.space3, + vertical: DesignTokens.space1, + ), + decoration: BoxDecoration( + color: gradientColor.withValues(alpha: 0.15), + borderRadius: DesignTokens.borderRadiusFull, + border: Border.all( + color: gradientColor.withValues(alpha: 0.3), + width: 0.5, + ), + ), + child: Row( + mainAxisSize: MainAxisSize.min, + children: [ + Text( + _getCategoryIcon(tool.category), + style: const TextStyle(fontSize: 10), + ), + const SizedBox(width: 4), + Text( + tool.categoryName, + style: TextStyle( + fontSize: DesignTokens.fontXs, + fontWeight: FontWeight.w500, + color: isDark + ? DarkDesignTokens.text2 + : DesignTokens.text2, + ), + ), + ], + ), + ), + ), + ], + ), + ), + ); + } + + Widget _buildInfoButton(bool isDark) { + return Positioned( + top: 8, + right: 8, + child: GestureDetector( + onTap: () => Get.toNamed( + AppRoutes.toolDetail, + arguments: tool, + ), + child: ClipRRect( + borderRadius: BorderRadius.circular(DesignTokens.radiusFull), + child: BackdropFilter( + filter: ImageFilter.blur(sigmaX: 12, sigmaY: 12), + child: Container( + width: 28, + height: 28, + decoration: BoxDecoration( + color: Colors.white.withValues(alpha: 0.15), + shape: BoxShape.circle, + border: Border.all( + color: Colors.white.withValues(alpha: 0.25), + width: 0.5, + ), + ), + child: Icon( + CupertinoIcons.info, + size: 14, + color: isDark ? DarkDesignTokens.text2 : DesignTokens.text2, + ), + ), + ), + ), + ), + ); + } + + Widget _buildBadge() { + return Positioned( + top: 8, + left: 8, + child: ClipRRect( + borderRadius: BorderRadius.circular(DesignTokens.radiusFull), + child: BackdropFilter( + filter: ImageFilter.blur(sigmaX: 12, sigmaY: 12), + child: Container( + padding: const EdgeInsets.symmetric(horizontal: 8, vertical: 3), + decoration: BoxDecoration( + color: DesignTokens.dynamicPrimary.withValues(alpha: 0.2), + borderRadius: DesignTokens.borderRadiusFull, + border: Border.all( + color: DesignTokens.dynamicPrimary.withValues(alpha: 0.4), + width: 0.5, + ), + ), + child: Text( + tool.badge!, + style: TextStyle( + fontSize: 10, + fontWeight: FontWeight.w600, + color: DesignTokens.dynamicPrimary, + ), + ), + ), + ), + ), + ); + } + + String _getCategoryIcon(String category) { + const icons = { + 'cooking': '🍳', + 'health': '💊', + 'data': '📊', + 'planning': '📅', + }; + return icons[category] ?? '📋'; + } +} diff --git a/lib/src/widgets/glass/nav/home_app_bar.dart b/lib/src/widgets/glass/nav/home_app_bar.dart index 6d8956a..27db9d3 100644 --- a/lib/src/widgets/glass/nav/home_app_bar.dart +++ b/lib/src/widgets/glass/nav/home_app_bar.dart @@ -5,7 +5,7 @@ * Layer1: 固定顶栏(Logo+标题+操作按钮) 永不隐藏 * Layer2: 可折叠副栏(搜索栏) 随滚动自动显隐 * 创建时间: 2026-04-13 - * 更新时间: 2026-04-14 移除问候语,已集成到轮播组件中 + * 更新时间: 2026-04-15 修复窄屏Row右侧溢出5.1px:Logo区改Flexible,标题加overflow */ import 'dart:ui'; @@ -107,30 +107,34 @@ class _HomeAppBarState extends State { height: 44, child: Row( children: [ - const SizedBox(width: DesignTokens.space3), - // Logo + 标题 - GestureDetector( - onTap: () => Get.toNamed(AppRoutes.home), - child: Row( - children: [ - const Text('🍳', style: TextStyle(fontSize: 24)), - const SizedBox(width: DesignTokens.space2), - Text( - '小妈厨房', - style: TextStyle( - fontSize: DesignTokens.fontLg, - fontWeight: FontWeight.w700, - letterSpacing: -0.5, - color: isDark - ? DarkDesignTokens.text1 - : DesignTokens.text1, + // Logo + 标题(Expanded吸收剩余空间,防窄屏溢出) + Expanded( + child: GestureDetector( + onTap: () => Get.toNamed(AppRoutes.home), + child: Row( + children: [ + const SizedBox(width: DesignTokens.space3), + const Text('🍳', style: TextStyle(fontSize: 24)), + const SizedBox(width: DesignTokens.space2), + Flexible( + child: Text( + '小妈厨房', + overflow: TextOverflow.ellipsis, + style: TextStyle( + fontSize: DesignTokens.fontLg, + fontWeight: FontWeight.w700, + letterSpacing: -0.5, + color: isDark + ? DarkDesignTokens.text1 + : DesignTokens.text1, + ), + ), ), - ), - ], + ], + ), ), ), - const Spacer(), - // 操作按钮组 + // 操作按钮组(固定宽度,不被压缩) _buildActionButton( icon: CupertinoIcons.bell, onTap: widget.onNotificationTap, diff --git a/lib/src/widgets/recipe/recipe_card.dart b/lib/src/widgets/recipe/recipe_card.dart index 1c81e1c..52108c0 100644 --- a/lib/src/widgets/recipe/recipe_card.dart +++ b/lib/src/widgets/recipe/recipe_card.dart @@ -26,7 +26,7 @@ class RecipeCard extends StatelessWidget { recipeId: recipe.id, viewCount: recipe.statistics?.views, likeCount: recipe.statistics?.likes, - recommendCount: recipe.statistics?.recommends, + recommendCount: recipe.statistics?.rateNums, onTap: () { if (recipe.id <= 0) { Get.snackbar('提示', '菜谱ID无效', snackPosition: SnackPosition.BOTTOM); diff --git a/lib/src/widgets/recipe/recipe_image.dart b/lib/src/widgets/recipe/recipe_image.dart index c03dba6..5498bea 100644 --- a/lib/src/widgets/recipe/recipe_image.dart +++ b/lib/src/widgets/recipe/recipe_image.dart @@ -112,8 +112,12 @@ class _RecipeImageState extends State { void _onImageError() { if (_currentUrlIndex < _urlChain.length - 1) { - setState(() { - _currentUrlIndex++; + WidgetsBinding.instance.addPostFrameCallback((_) { + if (mounted) { + setState(() { + _currentUrlIndex++; + }); + } }); } } diff --git a/lib/src/widgets/recipe_detail/content/recipe_ingredient_details.dart b/lib/src/widgets/recipe_detail/content/recipe_ingredient_details.dart index 8559d9a..018fd0c 100644 --- a/lib/src/widgets/recipe_detail/content/recipe_ingredient_details.dart +++ b/lib/src/widgets/recipe_detail/content/recipe_ingredient_details.dart @@ -19,8 +19,7 @@ class RecipeIngredientDetails extends StatelessWidget { recipe.categorizedIngredients?.all .where((i) => i.detail != null) .toList() ?? - recipe.ingredients.where((i) => i.detail != null).toList() ?? - []; + recipe.ingredients.where((i) => i.detail != null).toList(); if (ingredientsWithDetail.isEmpty) return const SizedBox(); diff --git a/lib/src/widgets/recipe_detail/content/recipe_picid_card.dart b/lib/src/widgets/recipe_detail/content/recipe_picid_card.dart index e6a20b6..ded77e7 100644 --- a/lib/src/widgets/recipe_detail/content/recipe_picid_card.dart +++ b/lib/src/widgets/recipe_detail/content/recipe_picid_card.dart @@ -19,7 +19,7 @@ class RecipePicIdCard extends StatelessWidget { '🔍 PicId调试: picId=$picId, coverUrl=$coverUrl, recipeId=${recipe.id}', ); - if (picId == null && (coverUrl == null || coverUrl!.isEmpty)) { + if (picId == null && (coverUrl == null || coverUrl.isEmpty)) { return const SizedBox(); } @@ -30,9 +30,10 @@ class RecipePicIdCard extends StatelessWidget { if (hasValidPicId) { mainImageUrl = 'https://eat.wktyl.com/api/assets/pic/${picId}a.jpg'; } else if (coverUrl?.isNotEmpty == true) { - mainImageUrl = coverUrl!.startsWith('http://') - ? coverUrl!.replaceFirst('http://', 'https://') - : coverUrl!; + final url = coverUrl!; + mainImageUrl = url.startsWith('http://') + ? url.replaceFirst('http://', 'https') + : url; } debugPrint('🔍 PicId显示: hasValidPicId=$hasValidPicId, picIdStr=$picIdStr'); diff --git a/lib/src/widgets/recipe_detail/header/recipe_time_info.dart b/lib/src/widgets/recipe_detail/header/recipe_time_info.dart index 97a1a0c..4e6c22d 100644 --- a/lib/src/widgets/recipe_detail/header/recipe_time_info.dart +++ b/lib/src/widgets/recipe_detail/header/recipe_time_info.dart @@ -12,7 +12,7 @@ class RecipeTimeInfo extends StatelessWidget { final hasCreated = recipe.createdAt != null; final hasUpdated = recipe.updatedAt != null; final hasCategory = recipe.categoryName != null; - final hasCode = recipe.hasCode ?? false; + final hasCode = recipe.hasCode; final hasStatus = recipe.status != null; if (!hasCreated && !hasUpdated && !hasCategory && !hasCode && !hasStatus) { diff --git a/lib/src/widgets/recipe_detail/header/recipe_title_section.dart b/lib/src/widgets/recipe_detail/header/recipe_title_section.dart index 411ca06..8e5e385 100644 --- a/lib/src/widgets/recipe_detail/header/recipe_title_section.dart +++ b/lib/src/widgets/recipe_detail/header/recipe_title_section.dart @@ -1,11 +1,26 @@ +// 2026-04-16 | RecipeTitleSection | 菜品标题区域 | 新增收藏爱心按钮在名称右边 +// 2026-04-16 | 新增点赞icon按钮在收藏按钮左边 import 'package:flutter/cupertino.dart'; import 'package:mom_kitchen/src/config/design_tokens.dart'; import 'package:mom_kitchen/src/models/recipe/recipe_model.dart'; class RecipeTitleSection extends StatelessWidget { final RecipeModel recipe; + final bool isFavorite; + final bool isLiked; + final int likeCount; + final VoidCallback? onToggleFavorite; + final VoidCallback? onLike; - const RecipeTitleSection({super.key, required this.recipe}); + const RecipeTitleSection({ + super.key, + required this.recipe, + this.isFavorite = false, + this.isLiked = false, + this.likeCount = 0, + this.onToggleFavorite, + this.onLike, + }); @override Widget build(BuildContext context) { @@ -16,13 +31,23 @@ class RecipeTitleSection extends StatelessWidget { child: Column( crossAxisAlignment: CrossAxisAlignment.start, children: [ - Text( - recipe.title, - style: TextStyle( - fontSize: DesignTokens.fontLg, - fontWeight: FontWeight.w700, - color: isDark ? DarkDesignTokens.text1 : DesignTokens.text1, - ), + Row( + children: [ + Expanded( + child: Text( + recipe.title, + style: TextStyle( + fontSize: DesignTokens.fontLg, + fontWeight: FontWeight.w700, + color: isDark ? DarkDesignTokens.text1 : DesignTokens.text1, + ), + ), + ), + const SizedBox(width: DesignTokens.space2), + _buildLikeButton(isDark), + const SizedBox(width: DesignTokens.space2), + _buildFavoriteButton(isDark), + ], ), if (recipe.intro != null && recipe.intro!.isNotEmpty) ...[ const SizedBox(height: DesignTokens.space2), @@ -49,4 +74,74 @@ class RecipeTitleSection extends StatelessWidget { ), ); } + + Widget _buildLikeButton(bool isDark) { + return GestureDetector( + onTap: onLike, + child: Container( + width: 36, + height: 36, + decoration: BoxDecoration( + color: isLiked + ? DesignTokens.dynamicPrimary.withValues(alpha: 0.12) + : (isDark + ? DarkDesignTokens.background + : DesignTokens.background), + shape: BoxShape.circle, + border: Border.all( + color: isLiked + ? DesignTokens.dynamicPrimary.withValues(alpha: 0.3) + : (isDark + ? DarkDesignTokens.glassBorder + : DesignTokens.text3.withValues(alpha: 0.2)), + ), + ), + child: Center( + child: Icon( + isLiked + ? CupertinoIcons.hand_thumbsup_fill + : CupertinoIcons.hand_thumbsup, + size: 17, + color: isLiked + ? DesignTokens.dynamicPrimary + : (isDark ? DarkDesignTokens.text3 : DesignTokens.text3), + ), + ), + ), + ); + } + + Widget _buildFavoriteButton(bool isDark) { + return GestureDetector( + onTap: onToggleFavorite, + child: Container( + width: 36, + height: 36, + decoration: BoxDecoration( + color: isFavorite + ? DesignTokens.red.withValues(alpha: 0.12) + : (isDark + ? DarkDesignTokens.background + : DesignTokens.background), + shape: BoxShape.circle, + border: Border.all( + color: isFavorite + ? DesignTokens.red.withValues(alpha: 0.3) + : (isDark + ? DarkDesignTokens.glassBorder + : DesignTokens.text3.withValues(alpha: 0.2)), + ), + ), + child: Center( + child: Icon( + isFavorite ? CupertinoIcons.heart_fill : CupertinoIcons.heart, + size: 18, + color: isFavorite + ? DesignTokens.red + : (isDark ? DarkDesignTokens.text3 : DesignTokens.text3), + ), + ), + ), + ); + } } diff --git a/lib/src/widgets/recipe_detail/info/recipe_meal_record_sheet.dart b/lib/src/widgets/recipe_detail/info/recipe_meal_record_sheet.dart new file mode 100644 index 0000000..e7b38be --- /dev/null +++ b/lib/src/widgets/recipe_detail/info/recipe_meal_record_sheet.dart @@ -0,0 +1,655 @@ +/* + * 文件: recipe_meal_record_sheet.dart + * 名称: 菜品饮食记录弹窗 + * 作用: 从菜品详情页快捷记录饮食,支持份量比例+自定义克数+用餐时段 + * 创建: 2026-04-16 + */ + +import 'package:flutter/cupertino.dart'; +import 'package:get/get.dart'; +import 'package:mom_kitchen/src/config/design_tokens.dart'; +import 'package:mom_kitchen/src/models/recipe/recipe_model.dart'; +import 'package:mom_kitchen/src/models/data/meal_record_model.dart'; +import 'package:mom_kitchen/src/controllers/data/meal_record_controller.dart'; +import 'package:mom_kitchen/src/services/ui/toast_service.dart'; + +class RecipeMealRecordSheet extends StatefulWidget { + final RecipeModel recipe; + + const RecipeMealRecordSheet({super.key, required this.recipe}); + + @override + State createState() => _RecipeMealRecordSheetState(); +} + +class _RecipeMealRecordSheetState extends State { + double _selectedRatio = 1.0; + final _gramsController = TextEditingController(text: ''); + MealType _selectedMealType = MealType.lunch; + bool _useGrams = false; + + double get _calories => + (widget.recipe.nutrition?.calories ?? 0) * _effectiveRatio; + double get _protein => + (widget.recipe.nutrition?.protein ?? 0) * _effectiveRatio; + double get _fat => (widget.recipe.nutrition?.fat ?? 0) * _effectiveRatio; + double get _carbs => (widget.recipe.nutrition?.carbs ?? 0) * _effectiveRatio; + double get _fiber => (widget.recipe.nutrition?.fiber ?? 0) * _effectiveRatio; + + double get _effectiveRatio { + if (_useGrams) { + final grams = double.tryParse(_gramsController.text) ?? 0; + return grams > 0 ? grams / 100 : 0; + } + return _selectedRatio; + } + + static const List _ratioOptions = [0.25, 0.5, 1.0, 1.5, 2.0]; + + static String _ratioLabel(double r) { + if (r == 0.25) return '¼'; + if (r == 0.5) return '½'; + if (r == 1.0) return '1'; + if (r == 1.5) return '1.5'; + if (r == 2.0) return '2'; + return r.toStringAsFixed(1); + } + + @override + void dispose() { + _gramsController.dispose(); + super.dispose(); + } + + @override + Widget build(BuildContext context) { + final isDark = CupertinoTheme.brightnessOf(context) == Brightness.dark; + + return Container( + constraints: BoxConstraints( + maxHeight: MediaQuery.of(context).size.height * 0.7, + ), + decoration: BoxDecoration( + color: isDark ? DarkDesignTokens.background : DesignTokens.background, + borderRadius: const BorderRadius.vertical( + top: Radius.circular(DesignTokens.radiusXl), + ), + ), + child: Column( + mainAxisSize: MainAxisSize.min, + children: [ + _buildDragHandle(isDark), + _buildHeader(isDark), + Flexible( + child: SingleChildScrollView( + padding: const EdgeInsets.symmetric( + horizontal: DesignTokens.space4, + ), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + const SizedBox(height: DesignTokens.space3), + _buildRecipeInfo(isDark), + const SizedBox(height: DesignTokens.space4), + _buildRatioSection(isDark), + const SizedBox(height: DesignTokens.space4), + _buildGramsSection(isDark), + const SizedBox(height: DesignTokens.space4), + _buildMealTypeSection(isDark), + const SizedBox(height: DesignTokens.space4), + _buildNutritionPreview(isDark), + const SizedBox(height: DesignTokens.space4), + _buildConfirmButton(isDark), + SizedBox( + height: + MediaQuery.of(context).padding.bottom + + DesignTokens.space3, + ), + ], + ), + ), + ), + ], + ), + ); + } + + Widget _buildDragHandle(bool isDark) { + return Padding( + padding: const EdgeInsets.symmetric(vertical: DesignTokens.space2), + child: Container( + width: 36, + height: 4, + decoration: BoxDecoration( + color: (isDark ? DarkDesignTokens.text3 : DesignTokens.text3) + .withValues(alpha: 0.3), + borderRadius: BorderRadius.circular(2), + ), + ), + ); + } + + Widget _buildHeader(bool isDark) { + return Padding( + padding: const EdgeInsets.symmetric(horizontal: DesignTokens.space4), + child: Row( + children: [ + const Text('📝', style: TextStyle(fontSize: 20)), + const SizedBox(width: DesignTokens.space2), + Expanded( + child: Text( + '记录饮食', + style: TextStyle( + fontSize: DesignTokens.fontLg, + fontWeight: FontWeight.w700, + color: isDark ? DarkDesignTokens.text1 : DesignTokens.text1, + ), + ), + ), + CupertinoButton( + padding: EdgeInsets.zero, + minimumSize: const Size(28, 28), + onPressed: () => Navigator.of(context).pop(), + child: Icon( + CupertinoIcons.xmark_circle_fill, + size: 24, + color: isDark ? DarkDesignTokens.text3 : DesignTokens.text3, + ), + ), + ], + ), + ); + } + + Widget _buildRecipeInfo(bool isDark) { + return Container( + padding: const EdgeInsets.all(DesignTokens.space3), + decoration: BoxDecoration( + color: DesignTokens.dynamicPrimary.withValues(alpha: 0.06), + borderRadius: DesignTokens.borderRadiusMd, + border: Border.all( + color: DesignTokens.dynamicPrimary.withValues(alpha: 0.12), + ), + ), + child: Row( + children: [ + Container( + width: 40, + height: 40, + decoration: BoxDecoration( + color: DesignTokens.dynamicPrimary.withValues(alpha: 0.1), + borderRadius: DesignTokens.borderRadiusMd, + ), + child: const Center( + child: Text('🍳', style: TextStyle(fontSize: 20)), + ), + ), + const SizedBox(width: DesignTokens.space3), + Expanded( + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text( + widget.recipe.title, + style: TextStyle( + fontSize: DesignTokens.fontMd, + fontWeight: FontWeight.w600, + color: isDark ? DarkDesignTokens.text1 : DesignTokens.text1, + ), + maxLines: 1, + overflow: TextOverflow.ellipsis, + ), + const SizedBox(height: 2), + Text( + '🔥 ${(widget.recipe.nutrition?.calories ?? 0).toInt()} kcal / 份', + style: TextStyle( + fontSize: DesignTokens.fontXs, + color: isDark ? DarkDesignTokens.text3 : DesignTokens.text3, + ), + ), + ], + ), + ), + ], + ), + ); + } + + Widget _buildRatioSection(bool isDark) { + return Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Row( + children: [ + const Text('📐', style: TextStyle(fontSize: 16)), + const SizedBox(width: DesignTokens.space1), + Text( + '份量比例', + style: TextStyle( + fontSize: DesignTokens.fontMd, + fontWeight: FontWeight.w600, + color: isDark ? DarkDesignTokens.text1 : DesignTokens.text1, + ), + ), + const SizedBox(width: DesignTokens.space2), + Text( + '份', + style: TextStyle( + fontSize: DesignTokens.fontXs, + color: isDark ? DarkDesignTokens.text3 : DesignTokens.text3, + ), + ), + ], + ), + const SizedBox(height: DesignTokens.space2), + Row( + children: _ratioOptions.map((ratio) { + final isSelected = !_useGrams && ratio == _selectedRatio; + return Expanded( + child: Padding( + padding: const EdgeInsets.symmetric(horizontal: 3), + child: GestureDetector( + onTap: () { + setState(() { + _useGrams = false; + _selectedRatio = ratio; + _gramsController.clear(); + }); + }, + child: Container( + height: 40, + decoration: BoxDecoration( + color: isSelected + ? DesignTokens.dynamicPrimary + : (isDark + ? DarkDesignTokens.card + : DesignTokens.card), + borderRadius: DesignTokens.borderRadiusMd, + border: Border.all( + color: isSelected + ? DesignTokens.dynamicPrimary + : (isDark + ? DarkDesignTokens.glassBorder + : DesignTokens.text3.withValues(alpha: 0.15)), + ), + ), + child: Center( + child: Text( + _ratioLabel(ratio), + style: TextStyle( + fontSize: DesignTokens.fontMd, + fontWeight: isSelected + ? FontWeight.w700 + : FontWeight.w500, + color: isSelected + ? CupertinoColors.white + : (isDark + ? DarkDesignTokens.text2 + : DesignTokens.text2), + ), + ), + ), + ), + ), + ), + ); + }).toList(), + ), + ], + ); + } + + Widget _buildGramsSection(bool isDark) { + return Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Row( + children: [ + const Text('⚖️', style: TextStyle(fontSize: 16)), + const SizedBox(width: DesignTokens.space1), + Text( + '自定义克数(份量|克数二选一)', + style: TextStyle( + fontSize: DesignTokens.fontMd, + fontWeight: FontWeight.w600, + color: isDark ? DarkDesignTokens.text1 : DesignTokens.text1, + ), + ), + ], + ), + const SizedBox(height: DesignTokens.space2), + Row( + children: [ + Expanded( + child: CupertinoTextField( + controller: _gramsController, + placeholder: '输入克数', + keyboardType: const TextInputType.numberWithOptions( + decimal: true, + ), + 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: _useGrams + ? DesignTokens.dynamicPrimary + : (isDark + ? DarkDesignTokens.glassBorder + : DesignTokens.text3.withValues(alpha: 0.15)), + ), + ), + style: TextStyle( + fontSize: DesignTokens.fontMd, + color: isDark ? DarkDesignTokens.text1 : DesignTokens.text1, + ), + onChanged: (value) { + if (value.isNotEmpty) { + setState(() { + _useGrams = true; + }); + } else { + setState(() { + _useGrams = false; + }); + } + }, + ), + ), + const SizedBox(width: DesignTokens.space2), + Text( + 'g', + style: TextStyle( + fontSize: DesignTokens.fontMd, + fontWeight: FontWeight.w600, + color: isDark ? DarkDesignTokens.text2 : DesignTokens.text2, + ), + ), + const SizedBox(width: DesignTokens.space2), + ...[100, 200, 300].map((grams) { + final isSelected = + _useGrams && _gramsController.text == grams.toString(); + return Padding( + padding: const EdgeInsets.only(left: DesignTokens.space1), + child: GestureDetector( + onTap: () { + setState(() { + _useGrams = true; + _gramsController.text = grams.toString(); + _selectedRatio = 1.0; + }); + }, + child: Container( + padding: const EdgeInsets.symmetric( + horizontal: DesignTokens.space2, + vertical: DesignTokens.space1, + ), + decoration: BoxDecoration( + color: isSelected + ? DesignTokens.dynamicPrimary + : (isDark + ? DarkDesignTokens.card + : DesignTokens.card), + borderRadius: DesignTokens.borderRadiusSm, + border: Border.all( + color: isSelected + ? DesignTokens.dynamicPrimary + : (isDark + ? DarkDesignTokens.glassBorder + : DesignTokens.text3.withValues(alpha: 0.15)), + ), + ), + child: Text( + '${grams}g', + style: TextStyle( + fontSize: DesignTokens.fontXs, + fontWeight: isSelected + ? FontWeight.w700 + : FontWeight.w500, + color: isSelected + ? CupertinoColors.white + : (isDark + ? DarkDesignTokens.text3 + : DesignTokens.text3), + ), + ), + ), + ), + ); + }), + ], + ), + ], + ); + } + + Widget _buildMealTypeSection(bool isDark) { + return Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Row( + children: [ + const Text('🕐', style: TextStyle(fontSize: 16)), + const SizedBox(width: DesignTokens.space1), + Text( + '用餐时段', + style: TextStyle( + fontSize: DesignTokens.fontMd, + fontWeight: FontWeight.w600, + color: isDark ? DarkDesignTokens.text1 : DesignTokens.text1, + ), + ), + ], + ), + const SizedBox(height: DesignTokens.space2), + Row( + children: MealType.values.map((type) { + final isSelected = type == _selectedMealType; + return Expanded( + child: Padding( + padding: const EdgeInsets.symmetric(horizontal: 3), + child: GestureDetector( + onTap: () => setState(() => _selectedMealType = type), + child: Container( + height: 44, + decoration: BoxDecoration( + color: isSelected + ? DesignTokens.dynamicPrimary.withValues(alpha: 0.12) + : (isDark + ? DarkDesignTokens.card + : DesignTokens.card), + borderRadius: DesignTokens.borderRadiusMd, + border: Border.all( + color: isSelected + ? DesignTokens.dynamicPrimary.withValues(alpha: 0.3) + : (isDark + ? DarkDesignTokens.glassBorder + : DesignTokens.text3.withValues(alpha: 0.15)), + ), + ), + child: Column( + mainAxisAlignment: MainAxisAlignment.center, + children: [ + Text(type.emoji, style: const TextStyle(fontSize: 14)), + const SizedBox(height: 1), + Text( + type.label, + style: TextStyle( + fontSize: 10, + fontWeight: isSelected + ? FontWeight.w700 + : FontWeight.w500, + color: isSelected + ? DesignTokens.dynamicPrimary + : (isDark + ? DarkDesignTokens.text3 + : DesignTokens.text3), + ), + ), + ], + ), + ), + ), + ), + ); + }).toList(), + ), + ], + ); + } + + Widget _buildNutritionPreview(bool isDark) { + return 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.fontSm, + fontWeight: FontWeight.w600, + color: isDark ? DarkDesignTokens.text2 : DesignTokens.text2, + ), + ), + const SizedBox(height: DesignTokens.space2), + Row( + mainAxisAlignment: MainAxisAlignment.spaceAround, + children: [ + _buildNutrientItem( + '🔥', + '${_calories.toInt()}', + 'kcal', + DesignTokens.orange, + isDark, + ), + _buildNutrientItem( + '💪', + '${_protein.toInt()}', + 'g', + DesignTokens.green, + isDark, + ), + _buildNutrientItem( + '🧈', + '${_fat.toInt()}', + 'g', + DesignTokens.orange, + isDark, + ), + _buildNutrientItem( + '🍞', + '${_carbs.toInt()}', + 'g', + DesignTokens.secondary, + isDark, + ), + ], + ), + ], + ), + ); + } + + Widget _buildNutrientItem( + String emoji, + String value, + String unit, + Color color, + bool isDark, + ) { + return Column( + children: [ + Text(emoji, style: const TextStyle(fontSize: 16)), + const SizedBox(height: 2), + Text( + value, + style: TextStyle( + fontSize: DesignTokens.fontMd, + fontWeight: FontWeight.w700, + color: color, + ), + ), + Text( + unit, + style: TextStyle( + fontSize: DesignTokens.fontXs, + color: isDark ? DarkDesignTokens.text3 : DesignTokens.text3, + ), + ), + ], + ); + } + + Widget _buildConfirmButton(bool isDark) { + final isValid = _effectiveRatio > 0; + return SizedBox( + width: double.infinity, + child: CupertinoButton( + padding: const EdgeInsets.symmetric(vertical: DesignTokens.space3), + borderRadius: DesignTokens.borderRadiusMd, + color: isValid + ? DesignTokens.dynamicPrimary + : DesignTokens.text3.withValues(alpha: 0.3), + onPressed: isValid ? _onConfirm : null, + child: Row( + mainAxisAlignment: MainAxisAlignment.center, + children: [ + const Icon( + CupertinoIcons.checkmark_circle, + size: 18, + color: CupertinoColors.white, + ), + const SizedBox(width: DesignTokens.space1), + Text( + '确认记录', + style: TextStyle( + fontSize: DesignTokens.fontMd, + fontWeight: FontWeight.w600, + color: CupertinoColors.white.withValues( + alpha: isValid ? 1 : 0.5, + ), + ), + ), + ], + ), + ), + ); + } + + void _onConfirm() { + try { + final controller = Get.find(); + final record = MealRecordModel( + date: MealRecordModel.todayKey(), + mealType: _selectedMealType.name, + recipeId: widget.recipe.id, + recipeTitle: widget.recipe.title, + calories: _calories, + protein: _protein, + fat: _fat, + carbs: _carbs, + fiber: _fiber, + note: _useGrams + ? '${_gramsController.text}g' + : '${_ratioLabel(_selectedRatio)}份', + createdAt: DateTime.now().toIso8601String(), + ); + controller.addRecord(record); + Navigator.of(context).pop(); + } catch (e) { + ToastService.show(message: '记录失败:$e'); + } + } +} diff --git a/lib/src/widgets/recipe_detail/info/recipe_nutrition_section.dart b/lib/src/widgets/recipe_detail/info/recipe_nutrition_section.dart index f888629..eb724db 100644 --- a/lib/src/widgets/recipe_detail/info/recipe_nutrition_section.dart +++ b/lib/src/widgets/recipe_detail/info/recipe_nutrition_section.dart @@ -1,9 +1,10 @@ /* * 文件: recipe_nutrition_section.dart * 名称: 菜品营养成分展示组件 - * 作用: 展示菜品营养概览和详细营养成分,支持展开更多弹窗+点击跳转 + * 作用: 展示菜品营养概览和详细营养成分,支持展开更多弹窗+点击跳转+快捷记录饮食 * 创建: 2026-04-11 * 更新: 2026-04-14 新增展开更多按钮+营养成分弹窗+点击跳转菜品列表 + * 更新: 2026-04-16 标题改左对齐"营养可视化",新增📝记录按钮+饮食记录弹窗 */ import 'package:flutter/cupertino.dart'; @@ -11,7 +12,11 @@ import 'package:get/get.dart'; import 'package:mom_kitchen/src/config/app_routes.dart'; import 'package:mom_kitchen/src/config/design_tokens.dart'; import 'package:mom_kitchen/src/models/recipe/recipe_model.dart'; +import 'package:mom_kitchen/src/models/data/meal_record_model.dart'; +import 'package:mom_kitchen/src/controllers/data/meal_record_controller.dart'; +import 'package:mom_kitchen/src/services/ui/toast_service.dart'; import 'package:mom_kitchen/src/widgets/recipe_detail/info/nutrition_ring_chart.dart'; +import 'package:mom_kitchen/src/widgets/recipe_detail/info/recipe_meal_record_sheet.dart'; class RecipeNutritionSection extends StatelessWidget { final RecipeModel recipe; @@ -23,6 +28,7 @@ class RecipeNutritionSection extends StatelessWidget { final isDark = CupertinoTheme.brightnessOf(context) == Brightness.dark; return Column( children: [ + _buildSectionHeader(isDark, context), if (recipe.nutrition != null) NutritionRingChart( calories: recipe.nutrition!.calories ?? 0, @@ -36,6 +42,29 @@ class RecipeNutritionSection extends StatelessWidget { ); } + Widget _buildSectionHeader(bool isDark, BuildContext context) { + return Padding( + padding: const EdgeInsets.symmetric( + horizontal: DesignTokens.space4, + vertical: DesignTokens.space2, + ), + child: Row( + children: [ + Text( + '🧬 可记录今日摄入量', + style: TextStyle( + fontSize: DesignTokens.fontMd, + fontWeight: FontWeight.w600, + color: isDark ? DarkDesignTokens.text1 : DesignTokens.text1, + ), + ), + const Spacer(), + _buildRecordButton(isDark, context), + ], + ), + ); + } + Widget _buildNutritionDetail(bool isDark, BuildContext context) { final items = recipe.nutrition?.items; if (items == null || items.isEmpty) return const SizedBox(); @@ -74,7 +103,7 @@ class RecipeNutritionSection extends StatelessWidget { ), CupertinoButton( padding: EdgeInsets.zero, - minSize: 32, + minimumSize: const Size(32, 32), onPressed: () => _showNutritionSheet(context, isDark), child: Row( mainAxisSize: MainAxisSize.min, @@ -133,6 +162,51 @@ class RecipeNutritionSection extends StatelessWidget { ); } + Widget _buildRecordButton(bool isDark, BuildContext context) { + return GestureDetector( + onTap: () => _showMealRecordSheet(context), + child: Container( + padding: const EdgeInsets.symmetric( + horizontal: DesignTokens.space2, + vertical: DesignTokens.space1, + ), + decoration: BoxDecoration( + color: DesignTokens.dynamicPrimary.withValues(alpha: 0.1), + borderRadius: DesignTokens.borderRadiusSm, + border: Border.all( + color: DesignTokens.dynamicPrimary.withValues(alpha: 0.2), + ), + ), + child: Row( + mainAxisSize: MainAxisSize.min, + children: [ + Icon( + CupertinoIcons.pencil_outline, + size: 13, + color: DesignTokens.dynamicPrimary, + ), + const SizedBox(width: 3), + Text( + '记录', + style: TextStyle( + fontSize: DesignTokens.fontXs, + fontWeight: FontWeight.w600, + color: DesignTokens.dynamicPrimary, + ), + ), + ], + ), + ), + ); + } + + void _showMealRecordSheet(BuildContext context) { + showCupertinoModalPopup( + context: context, + builder: (_) => RecipeMealRecordSheet(recipe: recipe), + ); + } + void _showNutritionSheet(BuildContext context, bool isDark) { final items = recipe.nutrition?.items ?? []; showCupertinoModalPopup( @@ -178,7 +252,7 @@ class RecipeNutritionSection extends StatelessWidget { ), CupertinoButton( padding: EdgeInsets.zero, - minSize: 28, + minimumSize: const Size(28, 28), onPressed: () => Navigator.of(ctx).pop(), child: Icon( CupertinoIcons.xmark_circle_fill, diff --git a/lib/src/widgets/recipe_detail/interaction/recipe_action_bar.dart b/lib/src/widgets/recipe_detail/interaction/recipe_action_bar.dart index 402c403..28f9a29 100644 --- a/lib/src/widgets/recipe_detail/interaction/recipe_action_bar.dart +++ b/lib/src/widgets/recipe_detail/interaction/recipe_action_bar.dart @@ -1,20 +1,19 @@ // 2026-04-09 | RecipeActionBar | 菜谱操作栏 | 点赞/评分/分享/笔记/购物/缩放 // 2026-04-12 | API v3.2.0: recommend改为rate评分接口(1-5分) // 2026-04-13 | 新增IP状态显示,评分前显示剩余次数;新增二维码海报按钮 +// 2026-04-16 | 移除点赞按钮(已移至标题区域RecipeTitleSection) import 'package:flutter/cupertino.dart'; import 'package:mom_kitchen/src/config/design_tokens.dart'; import 'package:mom_kitchen/src/widgets/recipe_detail/interaction/recipe_qr_poster.dart'; class RecipeActionBar extends StatelessWidget { - final int likeCount; - final bool isLiked; + final int? likeCount; final int? userRating; final int? rateRemaining; final String? recipeCode; final String? recipeTitle; final String? categoryName; final double? ratingScore; - final VoidCallback onLike; final void Function(int score) onRate; final VoidCallback onShare; final VoidCallback onNote; @@ -24,15 +23,13 @@ class RecipeActionBar extends StatelessWidget { const RecipeActionBar({ super.key, - required this.likeCount, - required this.isLiked, + this.likeCount, required this.userRating, this.rateRemaining, this.recipeCode, this.recipeTitle, this.categoryName, this.ratingScore, - required this.onLike, required this.onRate, required this.onShare, required this.onNote, @@ -60,14 +57,6 @@ class RecipeActionBar extends StatelessWidget { scrollDirection: Axis.horizontal, child: Row( children: [ - _buildActionButton( - context, - icon: isLiked ? CupertinoIcons.heart_fill : CupertinoIcons.heart, - label: '$likeCount', - color: isLiked ? DesignTokens.red : null, - onTap: onLike, - ), - const SizedBox(width: DesignTokens.space2), _buildRatingButton(context), const SizedBox(width: DesignTokens.space2), _buildActionButton( diff --git a/local.properties b/local.properties deleted file mode 100644 index 6608cf1..0000000 --- a/local.properties +++ /dev/null @@ -1 +0,0 @@ -flutter.sdk=E:\\sdk\\flutter-ohos\\flutter_flutter diff --git a/ohos/AppScope/resources/base/media/app_icon.png b/ohos/AppScope/resources/base/media/app_icon.png index ce307a8..aa376f1 100644 Binary files a/ohos/AppScope/resources/base/media/app_icon.png and b/ohos/AppScope/resources/base/media/app_icon.png differ diff --git a/ohos/entry/src/main/resources/base/media/icon.png b/ohos/entry/src/main/resources/base/media/icon.png index ce307a8..aa376f1 100644 Binary files a/ohos/entry/src/main/resources/base/media/icon.png and b/ohos/entry/src/main/resources/base/media/icon.png differ diff --git a/packages/fluttertoast_ohos/ohos/oh-package-lock.json5 b/packages/fluttertoast_ohos/ohos/oh-package-lock.json5 index de19157..8b40a53 100644 --- a/packages/fluttertoast_ohos/ohos/oh-package-lock.json5 +++ b/packages/fluttertoast_ohos/ohos/oh-package-lock.json5 @@ -7,8 +7,7 @@ "ATTENTION": "THIS IS AN AUTOGENERATED FILE. DO NOT EDIT THIS FILE DIRECTLY.", "specifiers": { "@ohos/flutter_ohos@../../../../../../../sdk/flutter-ohos/flutter_flutter/bin/cache/artifacts/engine/ohos-arm64/flutter_embedding_debug.har": "@ohos/flutter_ohos@../../../../../../../sdk/flutter-ohos/flutter_flutter/bin/cache/artifacts/engine/ohos-arm64/flutter_embedding_debug.har", - "flutter_native_arm64_v8a@../../../../../../../sdk/flutter-ohos/flutter_flutter/bin/cache/artifacts/engine/ohos-arm64/arm64_v8a_debug.har": "flutter_native_arm64_v8a@../../../../../../../sdk/flutter-ohos/flutter_flutter/bin/cache/artifacts/engine/ohos-arm64/arm64_v8a_debug.har", - "flutter_native_x86_64@../../../../../../../sdk/flutter-ohos/flutter_flutter/bin/cache/artifacts/engine/ohos-x64/x86_64_debug.har": "flutter_native_x86_64@../../../../../../../sdk/flutter-ohos/flutter_flutter/bin/cache/artifacts/engine/ohos-x64/x86_64_debug.har" + "flutter_native_arm64_v8a@../../../../../../../sdk/flutter-ohos/flutter_flutter/bin/cache/artifacts/engine/ohos-arm64/arm64_v8a_debug.har": "flutter_native_arm64_v8a@../../../../../../../sdk/flutter-ohos/flutter_flutter/bin/cache/artifacts/engine/ohos-arm64/arm64_v8a_debug.har" }, "packages": { "@ohos/flutter_ohos@../../../../../../../sdk/flutter-ohos/flutter_flutter/bin/cache/artifacts/engine/ohos-arm64/flutter_embedding_debug.har": { @@ -22,12 +21,6 @@ "version": "1.0.0-e34a685f4b", "resolved": "../../../../../../../sdk/flutter-ohos/flutter_flutter/bin/cache/artifacts/engine/ohos-arm64/arm64_v8a_debug.har", "registryType": "local" - }, - "flutter_native_x86_64@../../../../../../../sdk/flutter-ohos/flutter_flutter/bin/cache/artifacts/engine/ohos-x64/x86_64_debug.har": { - "name": "flutter_native_x86_64", - "version": "1.0.0-e34a685f4b", - "resolved": "../../../../../../../sdk/flutter-ohos/flutter_flutter/bin/cache/artifacts/engine/ohos-x64/x86_64_debug.har", - "registryType": "local" } } } \ No newline at end of file diff --git a/packages/fluttertoast_ohos/ohos/oh_modules/flutter_native_x86_64/BuildProfile.ets b/packages/fluttertoast_ohos/ohos/oh_modules/flutter_native_x86_64/BuildProfile.ets deleted file mode 100644 index 568cf85..0000000 --- a/packages/fluttertoast_ohos/ohos/oh_modules/flutter_native_x86_64/BuildProfile.ets +++ /dev/null @@ -1,17 +0,0 @@ -/** - * Use these variables when you tailor your ArkTS code. They must be of the const type. - */ -export const HAR_VERSION = '1.0.0-e34a685f4b'; -export const BUILD_MODE_NAME = 'debug'; -export const DEBUG = true; -export const TARGET_NAME = 'default'; - -/** - * BuildProfile Class is used only for compatibility purposes. - */ -export default class BuildProfile { - static readonly HAR_VERSION = HAR_VERSION; - static readonly BUILD_MODE_NAME = BUILD_MODE_NAME; - static readonly DEBUG = DEBUG; - static readonly TARGET_NAME = TARGET_NAME; -} \ No newline at end of file diff --git a/packages/fluttertoast_ohos/ohos/oh_modules/flutter_native_x86_64/Index.ets b/packages/fluttertoast_ohos/ohos/oh_modules/flutter_native_x86_64/Index.ets deleted file mode 100644 index e69de29..0000000 diff --git a/packages/fluttertoast_ohos/ohos/oh_modules/flutter_native_x86_64/build-profile.json5 b/packages/fluttertoast_ohos/ohos/oh_modules/flutter_native_x86_64/build-profile.json5 deleted file mode 100644 index e3fb899..0000000 --- a/packages/fluttertoast_ohos/ohos/oh_modules/flutter_native_x86_64/build-profile.json5 +++ /dev/null @@ -1,34 +0,0 @@ -{ - "apiType": "stageMode", - "buildOption": { - "nativeLib": { - "debugSymbol": { - "strip": false, - "exclude": [] - } - } - }, - "buildOptionSet": [ - { - "name": "release", - "arkOptions": { - "obfuscation": { - "ruleOptions": { - "enable": false, - "files": [ - "./obfuscation-rules.txt" - ] - }, - "consumerFiles": [ - "./consumer-rules.txt" - ] - } - }, - }, - ], - "targets": [ - { - "name": "default" - } - ] -} diff --git a/packages/fluttertoast_ohos/ohos/oh_modules/flutter_native_x86_64/consumer-rules.txt b/packages/fluttertoast_ohos/ohos/oh_modules/flutter_native_x86_64/consumer-rules.txt deleted file mode 100644 index e69de29..0000000 diff --git a/packages/fluttertoast_ohos/ohos/oh_modules/flutter_native_x86_64/hvigorfile.ts b/packages/fluttertoast_ohos/ohos/oh_modules/flutter_native_x86_64/hvigorfile.ts deleted file mode 100644 index 4218707..0000000 --- a/packages/fluttertoast_ohos/ohos/oh_modules/flutter_native_x86_64/hvigorfile.ts +++ /dev/null @@ -1,6 +0,0 @@ -import { harTasks } from '@ohos/hvigor-ohos-plugin'; - -export default { - system: harTasks, /* Built-in plugin of Hvigor. It cannot be modified. */ - plugins:[] /* Custom plugin to extend the functionality of Hvigor. */ -} diff --git a/packages/fluttertoast_ohos/ohos/oh_modules/flutter_native_x86_64/libs/x86_64/libflutter.so b/packages/fluttertoast_ohos/ohos/oh_modules/flutter_native_x86_64/libs/x86_64/libflutter.so deleted file mode 100644 index ee19029..0000000 Binary files a/packages/fluttertoast_ohos/ohos/oh_modules/flutter_native_x86_64/libs/x86_64/libflutter.so and /dev/null differ diff --git a/packages/fluttertoast_ohos/ohos/oh_modules/flutter_native_x86_64/obfuscation-rules.txt b/packages/fluttertoast_ohos/ohos/oh_modules/flutter_native_x86_64/obfuscation-rules.txt deleted file mode 100644 index 272efb6..0000000 --- a/packages/fluttertoast_ohos/ohos/oh_modules/flutter_native_x86_64/obfuscation-rules.txt +++ /dev/null @@ -1,23 +0,0 @@ -# Define project specific obfuscation rules here. -# You can include the obfuscation configuration files in the current module's build-profile.json5. -# -# For more details, see -# https://developer.huawei.com/consumer/cn/doc/harmonyos-guides-V5/source-obfuscation-V5 - -# Obfuscation options: -# -disable-obfuscation: disable all obfuscations -# -enable-property-obfuscation: obfuscate the property names -# -enable-toplevel-obfuscation: obfuscate the names in the global scope -# -compact: remove unnecessary blank spaces and all line feeds -# -remove-log: remove all console.* statements -# -print-namecache: print the name cache that contains the mapping from the old names to new names -# -apply-namecache: reuse the given cache file - -# Keep options: -# -keep-property-name: specifies property names that you want to keep -# -keep-global-name: specifies names that you want to keep in the global scope - --enable-property-obfuscation --enable-toplevel-obfuscation --enable-filename-obfuscation --enable-export-obfuscation \ No newline at end of file diff --git a/packages/fluttertoast_ohos/ohos/oh_modules/flutter_native_x86_64/oh-package.json5 b/packages/fluttertoast_ohos/ohos/oh_modules/flutter_native_x86_64/oh-package.json5 deleted file mode 100644 index 7188456..0000000 --- a/packages/fluttertoast_ohos/ohos/oh_modules/flutter_native_x86_64/oh-package.json5 +++ /dev/null @@ -1 +0,0 @@ -{"name":"flutter_native_x86_64","version":"1.0.0-e34a685f4b","description":"Place so files for flutter on ohos.","main":"Index.ets","author":"","license":"Apache-2.0","dependencies":{},"metadata":{"sourceRoots":["./src/main"],"debug":true,"nativeDebugSymbol":false},"compatibleSdkVersionStage":"beta1","compatibleSdkVersion":12,"compatibleSdkType":"HarmonyOS","obfuscated":false} diff --git a/packages/fluttertoast_ohos/ohos/oh_modules/flutter_native_x86_64/src/main/module.json b/packages/fluttertoast_ohos/ohos/oh_modules/flutter_native_x86_64/src/main/module.json deleted file mode 100644 index 84b4141..0000000 --- a/packages/fluttertoast_ohos/ohos/oh_modules/flutter_native_x86_64/src/main/module.json +++ /dev/null @@ -1,30 +0,0 @@ -{ - "app": { - "bundleName": "com.example.config", - "debug": true, - "versionCode": 1000000, - "versionName": "1.0.0", - "minAPIVersion": 50000012, - "targetAPIVersion": 60001021, - "apiReleaseType": "Release", - "targetMinorAPIVersion": 0, - "targetPatchAPIVersion": 0, - "compileSdkVersion": "6.0.1.112", - "compileSdkType": "HarmonyOS", - "appEnvironments": [], - "bundleType": "app", - "buildMode": "debug" - }, - "module": { - "name": "flutter_native", - "type": "har", - "deviceTypes": [ - "default" - ], - "packageName": "flutter_native_x86_64", - "installationFree": false, - "virtualMachine": "ark", - "compileMode": "esmodule", - "dependencies": [] - } -} diff --git a/pubspec.lock b/pubspec.lock index c5e12ff..f34b2c8 100644 --- a/pubspec.lock +++ b/pubspec.lock @@ -294,6 +294,14 @@ packages: relative: true source: path version: "7.2.0-ohos.1" + flutter_dotenv: + dependency: "direct main" + description: + name: flutter_dotenv + sha256: b7c7be5cd9f6ef7a78429cabd2774d3c4af50e79cb2b7593e3d5d763ef95c61b + url: "https://pub.flutter-io.cn" + source: hosted + version: "5.2.1" flutter_lints: dependency: "direct dev" description: diff --git a/pubspec.yaml b/pubspec.yaml index 03d3f3e..b96bb8d 100644 --- a/pubspec.yaml +++ b/pubspec.yaml @@ -145,6 +145,8 @@ dependencies: mailer: ^7.1.0 + flutter_dotenv: ^5.2.1 + flutter_markdown_plus: path: packages/flutter_markdown_plus @@ -191,6 +193,7 @@ flutter: - assets/md/tips/ - assets/md/tips/advanced/ - assets/md/tips/learn/ + - .env # An image asset can refer to one or more resolution-specific "variants", see # https://flutter.dev/to/resolution-aware-images @@ -217,3 +220,5 @@ flutter: # # For details regarding fonts from package dependencies, # see https://flutter.dev/to/font-from-package + +# Icons Launcher configuration diff --git a/scripts/test_action_api.dart b/scripts/test_action_api.dart new file mode 100644 index 0000000..0f50be6 --- /dev/null +++ b/scripts/test_action_api.dart @@ -0,0 +1,153 @@ +// 2026-04-16 | test_action_api.dart | 动作接口测试 | 验证api_action.php GET/POST请求 +// 运行: dart run scripts/test_action_api.dart + +import 'dart:convert'; +import 'dart:io'; + +const String baseUrl = 'https://eat.wktyl.com/api'; + +Future main() async { + print('=== api_action.php 接口测试 ===\n'); + print('目标: 验证 GET 和 POST 两种请求方式是否都能正常工作\n'); + + const testId = 32892; + + // 1. IP状态查询 (GET) + print('━━━ 1. IP状态查询 (GET) ━━━'); + await testGet('ip_status'); + + // 2. 点赞 - GET方式 + print('\n━━━ 2. 点赞 - GET方式 ━━━'); + await testGet('like', params: {'type': 'recipe', 'id': '$testId', 'action': 'like'}); + + // 3. 点赞 - POST方式 (JSON body) + print('\n━━━ 3. 点赞 - POST方式 (JSON body) ━━━'); + await testPost('like', body: {'type': 'recipe', 'id': testId, 'action': 'like'}); + + // 4. 取消点赞 - GET方式 + print('\n━━━ 4. 取消点赞 - GET方式 ━━━'); + await testGet('like', params: {'type': 'recipe', 'id': '$testId', 'action': 'unlike'}); + + // 5. 评分 - GET方式 + print('\n━━━ 5. 评分 - GET方式 ━━━'); + await testGet('rate', params: {'type': 'recipe', 'id': '$testId', 'score': '4'}); + + // 6. 评分 - POST方式 (JSON body) + print('\n━━━ 6. 评分 - POST方式 (JSON body) ━━━'); + await testPost('rate', body: {'type': 'recipe', 'id': testId, 'score': 5}); + + // 7. 浏览量 - POST方式 + print('\n━━━ 7. 浏览量 - POST方式 ━━━'); + await testPost('view', body: {'type': 'recipe', 'id': testId, 'count': 1}); + + // 8. CORS预检 - OPTIONS + print('\n━━━ 8. CORS预检 (OPTIONS) ━━━'); + await testOptions(); + + print('\n=== 测试完成 ==='); +} + +Future testGet(String act, {Map? params}) async { + final client = HttpClient(); + try { + final queryParams = {'act': act, ...?params}; + final queryString = queryParams.entries.map((e) => '${e.key}=${Uri.encodeComponent(e.value)}').join('&'); + final url = Uri.parse('$baseUrl/api_action.php?$queryString'); + print(' GET $url'); + + final request = await client.getUrl(url); + request.headers.set('Accept', 'application/json'); + final response = await request.close(); + final body = await response.transform(utf8.decoder).join(); + + _printResult(response.statusCode, body); + } catch (e) { + print(' ❌ 请求失败: $e'); + } finally { + client.close(); + } +} + +Future testPost(String act, {Map? body}) async { + final client = HttpClient(); + try { + final url = Uri.parse('$baseUrl/api_action.php?act=$act'); + print(' POST $url'); + if (body != null) { + print(' Body: ${jsonEncode(body)}'); + } + + final request = await client.postUrl(url); + request.headers.set('Accept', 'application/json'); + request.headers.set('Content-Type', 'application/json'); + if (body != null) { + request.write(jsonEncode(body)); + } + + final response = await request.close(); + final responseBody = await response.transform(utf8.decoder).join(); + + _printResult(response.statusCode, responseBody); + } catch (e) { + print(' ❌ 请求失败: $e'); + } finally { + client.close(); + } +} + +Future testOptions() async { + final client = HttpClient(); + try { + final url = Uri.parse('$baseUrl/api_action.php'); + print(' OPTIONS $url'); + + final request = await client.openUrl('OPTIONS', url); + request.headers.set('Accept', 'application/json'); + request.headers.set('Access-Control-Request-Method', 'POST'); + request.headers.set('Origin', 'http://localhost'); + + final response = await request.close(); + print(' 状态码: ${response.statusCode}'); + print(' CORS头: Access-Control-Allow-Methods = ${response.headers.value('access-control-allow-methods') ?? '未设置'}'); + print(' CORS头: Access-Control-Allow-Origin = ${response.headers.value('access-control-allow-origin') ?? '未设置'}'); + + if (response.statusCode == 204) { + print(' ✅ CORS预检通过'); + } else { + print(' ⚠️ 预期204,实际${response.statusCode}'); + } + } catch (e) { + print(' ❌ 请求失败: $e'); + } finally { + client.close(); + } +} + +void _printResult(int statusCode, String body) { + try { + final json = jsonDecode(body) as Map; + final code = json['code']; + final message = json['message']; + final data = json['data']; + + if (code == 200) { + print(' ✅ HTTP $statusCode | code: $code | $message'); + if (data != null) { + final dataStr = jsonEncode(data); + if (dataStr.length > 200) { + print(' data: ${dataStr.substring(0, 200)}...'); + } else { + print(' data: $dataStr'); + } + } + } else { + print(' ⚠️ HTTP $statusCode | code: $code | $message'); + } + } catch (e) { + if (body.length > 200) { + print(' HTTP $statusCode | ${body.substring(0, 200)}...'); + } else { + print(' HTTP $statusCode | $body'); + } + } +} diff --git a/scripts/test_kitchen_api.dart b/scripts/test_kitchen_api.dart new file mode 100644 index 0000000..cc23cef --- /dev/null +++ b/scripts/test_kitchen_api.dart @@ -0,0 +1,352 @@ +// 2026-04-17 | test_kitchen_api.dart | 点餐助手API接口测试 | 验证kitchen.php CRUD + SSE +// 运行: dart run scripts/test_kitchen_api.dart + +import 'dart:convert'; +import 'dart:io'; + +const String baseUrl = 'https://eat.wktyl.com/api/kitchen'; + +String? createdOrderId; +String? createdOrderNo; + +int _curlCounter = 0; + +Future _curl( + String method, + String url, { + String? body, + Map? headers, +}) async { + _curlCounter++; + final tmpFile = File( + '${Directory.systemTemp.path}/kitchen_test_$_curlCounter.json', + ); + final args = [ + '-sS', + '-X', + method, + '--max-time', + '15', + '--compressed', + '-o', + tmpFile.path, + ]; + if (headers != null) { + headers.forEach((k, v) { + args.addAll(['-H', '$k: $v']); + }); + } + if (body != null) { + args.addAll([ + '-H', + 'Content-Type: application/json; charset=utf-8', + '-d', + body, + ]); + } + args.add(url); + + final result = await Process.run('curl.exe', args); + final stderr = (result.stderr as String).trim(); + if (!tmpFile.existsSync()) { + throw Exception('curl无输出: $stderr'); + } + final bytes = tmpFile.readAsBytesSync(); + try { + tmpFile.deleteSync(); + } catch (_) {} + if (bytes.isEmpty && stderr.isNotEmpty) { + throw Exception('curl error: $stderr'); + } + final output = utf8.decode(bytes, allowMalformed: true); + return _extractJson(output); +} + +String _extractJson(String raw) { + var s = raw.trim(); + final jsonStart = s.indexOf('{'); + if (jsonStart > 0) { + s = s.substring(jsonStart); + } + final jsonEnd = s.lastIndexOf('}'); + if (jsonEnd >= 0 && jsonEnd < s.length - 1) { + s = s.substring(0, jsonEnd + 1); + } + return s; +} + +Future main() async { + print('╔══════════════════════════════════════════════════╗'); + print('║ 🍽️ 点餐助手 API 接口测试 ║'); + print('║ 目标: $baseUrl ║'); + print('╚══════════════════════════════════════════════════╝\n'); + + print('━━━ 1. 接口首页 (index) ━━━'); + await testGet({'act': 'index'}); + + print('\n━━━ 2. CORS预检 (OPTIONS) ━━━'); + await testOptions(); + + print('\n━━━ 3. 创建点单 (POST JSON body) ━━━'); + await testCreateOrder(); + + if (createdOrderId == null) { + print('\n❌ 创建点单失败,后续测试跳过'); + return; + } + + print('\n━━━ 4. 获取点单 (GET ?act=get&id=xxx) ━━━'); + await testGet({'act': 'get', 'id': createdOrderId!}); + + print('\n━━━ 5. 更新点单 (POST ?act=update) ━━━'); + await testUpdateOrder(); + + print('\n━━━ 6. 点单列表 (GET ?act=list) ━━━'); + await testGet({'act': 'list', 'page': '1', 'limit': '5'}); + + print('\n━━━ 7. 统计信息 (GET ?act=stats) ━━━'); + await testGet({'act': 'stats'}); + + print('\n━━━ 8. SSE连接测试 ━━━'); + await testSSE(); + + print('\n━━━ 9. 清理过期数据 (GET ?act=cleanup&days=999) ━━━'); + await testGet({'act': 'cleanup', 'days': '999'}); + + print('\n━━━ 10. 删除点单 (GET ?act=delete&id=xxx) ━━━'); + await testGet({'act': 'delete', 'id': createdOrderId!}); + + print('\n━━━ 11. 确认删除 - 再次获取 ━━━'); + await testGet({'act': 'get', 'id': createdOrderId!}); + + print('\n╔══════════════════════════════════════════════════╗'); + print('║ ✅ 全部测试完成 ║'); + print('╚══════════════════════════════════════════════════╝'); +} + +Future testGet(Map params) async { + final qs = params.entries + .map((e) => '${e.key}=${Uri.encodeComponent(e.value)}') + .join('&'); + final url = '$baseUrl/kitchen.php?$qs'; + print(' GET $url'); + try { + final output = await _curl( + 'GET', + url, + headers: {'Accept': 'application/json'}, + ); + _printResult(output); + } catch (e) { + print(' ❌ 请求失败: $e'); + } +} + +Future testOptions() async { + final url = '$baseUrl/kitchen.php'; + print(' OPTIONS $url'); + try { + final args = [ + '-sS', + '-X', + 'OPTIONS', + '-o', + 'NUL', + '-w', + '%{http_code}\\n%header{Access-Control-Allow-Origin}\\n%header{Access-Control-Allow-Methods}', + '--max-time', + '15', + url, + ]; + final result = await Process.run('curl.exe', args); + final output = (result.stdout as String).trim(); + final lines = output.split('\n'); + final statusCode = lines.isNotEmpty ? lines[0].trim() : '???'; + print(' 状态码: $statusCode'); + if (lines.length > 1) print(' Allow-Origin: ${lines[1].trim()}'); + if (lines.length > 2) print(' Allow-Methods: ${lines[2].trim()}'); + if (statusCode == '204') { + print(' ✅ CORS预检通过'); + } else { + print(' ⚠️ 预期204,实际$statusCode'); + } + } catch (e) { + print(' ❌ 请求失败: $e'); + } +} + +Future testCreateOrder() async { + final orderData = { + 'type': 0, + 'status': 1, + 'tableNo': 'A1', + 'note': '接口测试订单,可删除', + 'items': [ + { + 'id': 'item_1', + 'name': '红烧肉', + 'source': 0, + 'quantity': 1, + 'price': 38.0, + 'ingredients': '五花肉、酱油、冰糖', + 'note': null, + }, + { + 'id': 'item_2', + 'name': '番茄炒蛋', + 'source': 2, + 'quantity': 2, + 'price': 18.0, + 'ingredients': null, + 'note': '少盐', + }, + ], + }; + + final url = '$baseUrl/kitchen.php?act=create'; + print(' POST $url'); + try { + final output = await _curl( + 'POST', + url, + body: jsonEncode(orderData), + headers: {'Accept': 'application/json'}, + ); + print(' Raw: ${output.length > 500 ? output.substring(0, 500) : output}'); + final result = _parseResult(output); + if (result['code'] == 200 && result['data'] != null) { + final data = result['data'] as Map; + createdOrderId = data['id']?.toString(); + createdOrderNo = data['orderNo']?.toString(); + print(' ✅ 创建成功! id=$createdOrderId, orderNo=$createdOrderNo'); + } else { + print(' ❌ 创建失败: ${result['message']}'); + } + } catch (e) { + print(' ❌ 请求失败: $e'); + } +} + +Future testUpdateOrder() async { + final updateData = { + 'id': createdOrderId, + 'status': 2, + 'note': '接口测试 - 已更新', + 'items': [ + { + 'id': 'item_1', + 'name': '红烧肉', + 'source': 0, + 'quantity': 2, + 'price': 38.0, + 'ingredients': '五花肉、酱油、冰糖', + 'note': '多放糖', + }, + ], + }; + + final url = '$baseUrl/kitchen.php?act=update'; + print(' POST $url'); + try { + final output = await _curl( + 'POST', + url, + body: jsonEncode(updateData), + headers: {'Accept': 'application/json'}, + ); + _printResult(output); + } catch (e) { + print(' ❌ 请求失败: $e'); + } +} + +Future testSSE() async { + final url = '$baseUrl/kitchen_sse.php?order_id=${createdOrderId ?? "test"}'; + print(' GET (SSE) $url'); + try { + _curlCounter++; + final tmpFile = File( + '${Directory.systemTemp.path}/kitchen_sse_test_$_curlCounter.txt', + ); + final args = [ + '-sS', + '-N', + '--max-time', + '6', + '--compressed', + '-o', + tmpFile.path, + '-H', + 'Accept: text/event-stream', + '-H', + 'Cache-Control: no-cache', + url, + ]; + await Process.run('curl.exe', args); + if (!tmpFile.existsSync()) { + print(' ⚠️ SSE无输出'); + return; + } + final bytes = tmpFile.readAsBytesSync(); + try { + tmpFile.deleteSync(); + } catch (_) {} + final output = utf8.decode(bytes, allowMalformed: true).trim(); + if (output.isEmpty) { + print(' ⚠️ SSE无输出'); + return; + } + final lines = output.split('\n').where((l) => l.trim().isNotEmpty).toList(); + print(' 收到 ${lines.length} 行SSE数据:'); + for (var i = 0; i < lines.length && i < 10; i++) { + print(' 📡 ${lines[i]}'); + } + final hasConnected = lines.any((l) => l.contains('connected')); + final hasHeartbeat = lines.any((l) => l.contains('heartbeat')); + final hasOrderUpdate = lines.any((l) => l.contains('order_update')); + if (hasConnected) print(' ✅ SSE连接事件已收到'); + if (hasHeartbeat) print(' ✅ SSE心跳事件已收到'); + if (hasOrderUpdate) print(' ✅ SSE订单更新事件已收到'); + if (!hasConnected && !hasHeartbeat) print(' ⚠️ 未收到标准SSE事件'); + } catch (e) { + print(' ❌ SSE请求失败: $e'); + } +} + +Map _parseResult(String body) { + try { + return jsonDecode(body) as Map; + } catch (e) { + return {'code': -1, 'message': 'JSON解析失败', 'raw': body}; + } +} + +void _printResult(String body) { + try { + final json = jsonDecode(body) as Map; + final code = json['code']; + final message = json['message']; + if (code == 200) { + print(' ✅ code: $code | $message'); + final data = json['data']; + if (data != null) { + final dataStr = jsonEncode(data); + if (dataStr.length > 300) { + print(' data: ${dataStr.substring(0, 300)}...'); + } else { + print(' data: $dataStr'); + } + } + } else { + print(' ⚠️ code: $code | $message'); + final data = json['data']; + if (data != null) print(' data: ${jsonEncode(data)}'); + } + } catch (e) { + if (body.length > 300) { + print(' ${body.substring(0, 300)}...'); + } else { + print(' $body'); + } + } +} diff --git a/web/favicon.png b/web/favicon.png index 8aaa46a..aa376f1 100644 Binary files a/web/favicon.png and b/web/favicon.png differ diff --git a/web/icons/Icon-192.png b/web/icons/Icon-192.png index b749bfe..aa376f1 100644 Binary files a/web/icons/Icon-192.png and b/web/icons/Icon-192.png differ diff --git a/web/icons/Icon-512.png b/web/icons/Icon-512.png index 88cfd48..aa376f1 100644 Binary files a/web/icons/Icon-512.png and b/web/icons/Icon-512.png differ diff --git a/web/icons/Icon-maskable-192.png b/web/icons/Icon-maskable-192.png index eb9b4d7..aa376f1 100644 Binary files a/web/icons/Icon-maskable-192.png and b/web/icons/Icon-maskable-192.png differ diff --git a/web/icons/Icon-maskable-512.png b/web/icons/Icon-maskable-512.png index d69c566..aa376f1 100644 Binary files a/web/icons/Icon-maskable-512.png and b/web/icons/Icon-maskable-512.png differ diff --git a/web_order/index.html b/web_order/index.html new file mode 100644 index 0000000..4842756 --- /dev/null +++ b/web_order/index.html @@ -0,0 +1,825 @@ + + + + + + 🍽️ 点餐助手 - 小妈厨房 + + + +
+
+

🍽️ 点餐助手

+
加载中...
+
+ + 连接中... +
+
+ +
+
+
+
+
正在加载点单信息...
+
+
+
+ +
+ + + +
+ +
+ 🔧 调试信息 +
等待数据...
+
+
+ +
+ + + +