diff --git a/CHANGELOG.md b/CHANGELOG.md index cc81e20..c25d3a5 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -3,6 +3,210 @@ All notable changes to this project will be documented in this file. +## [0.98.2] - 2026-04-18 + +### 🐛 修复 — 小妈菜园性能优化 + GetX报错修复 + +#### 变更 +- ⏱️ **生长计时器间隔优化**:从10秒调整为30秒,减少不必要的性能开销,配置项移入 `FarmConfig` +- 🔄 **应用生命周期管理**:添加 `WidgetsBindingObserver`,应用进入后台时暂停定时器,恢复前台时立即更新生长状态并重启定时器 +- 🐛 **修复GetX improper use报错**:重构 `_buildGardenGrid` 中 `Obx` 与 `LayoutBuilder` 的嵌套顺序,确保可观察变量在 `Obx` 直接作用域内被访问 +- 🌾 **空数据状态处理**:菜园数据为空时显示加载提示,避免 Obx 无响应式变量注册 + +#### 修改文件 +- `lib/src/controllers/farm/farm_game_controller.dart` — 计时器间隔配置化、添加生命周期管理 +- `lib/src/pages/tools/farm/farm_game_page.dart` — 修复 Obx 嵌套顺序、添加空数据状态 +- `lib/src/config/farm_config.dart` — 新增 `growthCheckIntervalSeconds` 配置项 + + +## [0.98.1] - 2026-04-18 + +### 🐛 修复 — 收藏页面排序功能恢复 + 编辑模式侧栏悬浮 + +#### 变更 +- 🔀 **恢复排序功能**:将排序按钮集成到类型标签栏右侧,显示当前排序方式标签(最新/最早/A-Z/Z-A) +- 🎯 **排序弹窗优化**:当前选中排序方式显示 ✓ 标识,每个选项增加 Emoji 图标 +- 📌 **编辑模式改为侧栏悬浮**:底部操作栏改为右侧悬浮按钮组(取消/删除/全选),不再被底部 tab 栏遮挡 +- 🧹 **清理冗余代码**:移除未使用的 `_buildToolbar` 和 `_buildToolbarButton` 方法 + +#### 修改文件 +- `lib/src/pages/profile/social/favorites_page.dart` — 恢复排序入口、编辑模式改为悬浮侧栏 + + +## [0.98.0] - 2026-04-18 + +### 🌾 新增 — 小妈菜园游戏系统 + +#### 核心游戏功能 +- 🌱 **种植系统**:12 块土地,支持播种、浇水、收获、清理枯萎作物 +- ⏰ **实时生长计时**:4 阶段生长过程(种子→幼苗→生长中→成熟),10秒自动刷新 +- 💧 **浇水机制**:浇水提升生长速度,2小时未浇水作物枯萎 +- 🎉 **收获奖励**:获得金币和经验值,果实自动存入背包 +- 🔓 **土地扩建**:消耗 200 金币解锁新土地 + +#### 玩家系统 +- ⭐ **等级经验系统**:升级奖励金币+钻石,自动解锁新作物 +- 🎒 **背包系统**:种子/果实分类管理,购买/收获自动更新 +- 🏪 **种子商店**:12种作物,不同生长时间和价格,按等级解锁 +- 🏆 **成就系统**:6个初始成就(初次收获、丰收达人、等级达成等) + +#### 作物配置 +- 🥕 **12种作物**:萝卜、土豆、卷心菜、西红柿、玉米、辣椒、茄子、草莓、南瓜、西瓜、葡萄 +- 📊 **生长阶段**:每个作物 4 个生长阶段,不同 Emoji 显示 +- 💰 **经济系统**:金币购买种子,收获获得更高回报 +- 🔒 **等级解锁**:从 Lv.1 到 Lv.10 逐步解锁所有作物 + +#### 技术实现 +- 💾 **Hive 本地存储**:3个 Box(玩家/土地/背包),手动编写适配器 +- 🎨 **iOS 26 Liquid Glass 风格**:Cupertino 组件 + DesignTokens 统一主题 +- 🌗 **深色/浅色模式**:完美适配双主题 +- 🐛 **调试功能**:加金币、加速作物、解锁全部、重置数据 +- 📱 **响应式布局**:3-4列自适应网格,支持平板和桌面 + +#### 修改文件 +- `lib/src/models/farm/farm_player.dart` + `.g.dart` — 玩家数据模型 +- `lib/src/models/farm/farm_land.dart` + `.g.dart` — 土地数据模型 +- `lib/src/models/farm/inventory_item.dart` + `.g.dart` — 背包物品模型 +- `lib/src/models/farm/crop_config.dart` — 作物配置 +- `lib/src/models/farm/achievement_config.dart` — 成就配置 +- `lib/src/models/farm/crop_registry.dart` — 12种作物注册表 +- `lib/src/models/farm/achievement_registry.dart` — 6个成就注册表 +- `lib/src/config/farm_config.dart` — 游戏全局配置 +- `lib/src/controllers/farm/farm_game_controller.dart` — 核心游戏控制器 +- `lib/src/controllers/farm/farm_shop_controller.dart` — 商店控制器 +- `lib/src/controllers/farm/farm_inventory_controller.dart` — 背包控制器 +- `lib/src/controllers/farm/farm_achievement_controller.dart` — 成就控制器 +- `lib/src/pages/tools/farm/farm_game_page.dart` — 主游戏页面 +- `lib/src/pages/tools/farm/farm_shop_page.dart` — 种子商店页面 +- `lib/src/pages/tools/farm/farm_inventory_page.dart` — 背包页面 +- `lib/src/pages/tools/farm/farm_achievement_page.dart` — 成就中心页面 +- `lib/src/services/data/hive_service.dart` — 扩展农场数据 Box +- `lib/src/config/app_routes.dart` — 新增 4 个农场路由 +- `lib/src/models/tool_item_model.dart` — 注册工具中心入口 +- `lib/src/app_binding.dart` — 注册游戏控制器 + + +## [0.97.38] - 2026-04-18 + +### 🐛 修复 — 7个UI/功能Bug + +#### Bug修复 +- 🐛 **收藏页面**:移除"全部菜品"下方多余的"全部菜谱"栏 +- 🐛 **收藏页面**:修复编辑按钮报错 `Incorrect use of ParentDataWidget`(Positioned放在Column中) +- 🐛 **收藏页面**:修复编辑模式底部操作栏被底部Tab遮住的问题 +- 🐛 **缓存管理**:修复点击已缓存菜谱跳转显示加载失败(路由参数格式错误) +- 🐛 **数据导出**:修复弹出系统分享后未操作就显示导出成功(Toast时机修正) +- 🐛 **笔记页面**:添加右上角缺失的添加笔记按钮 +- 🐛 **缓存管理**:优化已缓存菜品和食材列表,改为可折叠展示 + +### ✨ 新增 — 数据导入功能 + +#### 数据导入 +- 📥 **DataExportService 导入服务**:支持 JSON 格式导入,与导出格式一致 + - `previewImport()` 方法:预览导入数据,显示各数据源条数 + - `importFromJson()` 方法:执行导入,支持选择数据源 + - `ImportPreview` 类:导入预览数据模型 +- 📥 **DataExportPage 导入UI**:新增"数据导入"区域 + - 选择 JSON 文件导入入口 + - 从其他应用分享导入入口 + - 导入预览(显示文件名、各数据源条数) + - 确认导入对话框 +- 🔌 **receive_sharing_intent 集成**:支持从其他应用分享 JSON 文件到本应用 + - 监听分享流(getMediaStream)和初始分享(getInitialMedia) + - 自动识别 JSON 文件并预览 +- 🔌 **各 Controller importFromJson 方法**: + - `FavoritesController`: addFavoriteFromJson + - `ShoppingListController`: importFromJson + - `MealRecordController`: importFromJson + - `CookingNoteController`: importFromJson + - `WeeklyMenuController`: importFromJson + - `BrowseHistoryController`: importFromJson + +#### 平台配置 +- 🤖 **Android**:AndroidManifest.xml 添加 SEND/SEND_MULTIPLE/VIEW intent-filter(application/json) +- 🍎 **iOS**:Info.plist 添加 CFBundleDocumentTypes + UTImportedTypeDeclarations(public.json) +- 📱 **鸿蒙**:module.json5 添加 ohos.want.action.sendData skill + general.entity/general.object utd + READ_MEDIA 权限 + +#### 修改文件 +- `lib/src/pages/profile/social/favorites_page.dart` — 修复布局Bug +- `lib/src/pages/profile/data/cache_manage_page.dart` — 可折叠列表优化 +- `lib/src/pages/profile/data_export_page.dart` — 新增导入UI + receive_sharing_intent集成 +- `lib/src/pages/tools/cooking/cooking_note_page.dart` — 添加笔记按钮 +- `lib/src/config/app_routes.dart` — 修复路由参数 +- `lib/src/services/data/data_export_service.dart` — 新增导入功能 +- `lib/src/controllers/data/favorites_controller.dart` — 新增 addFavoriteFromJson +- `lib/src/controllers/data/shopping_list_controller.dart` — 新增 importFromJson +- `lib/src/controllers/data/meal_record_controller.dart` — 新增 importFromJson +- `lib/src/controllers/data/cooking_note_controller.dart` — 新增 importFromJson +- `lib/src/controllers/data/weekly_menu_controller.dart` — 新增 importFromJson +- `lib/src/controllers/data/browse_history_controller.dart` — 新增 importFromJson +- `lib/src/models/data/weekly_menu_model.dart` — DayMenu 添加 copyWith +- `android/app/src/main/AndroidManifest.xml` — 添加分享intent-filter +- `ios/Runner/Info.plist` — 添加JSON文件类型声明 +- `ohos/entry/src/main/module.json5` — 添加分享skill和权限 +- `ohos/entry/src/main/resources/base/element/string.json` — 添加权限说明 +- `ohos/entry/src/main/resources/zh_CN/element/string.json` — 添加中文权限说明 +- `ohos/entry/src/main/resources/en_US/element/string.json` — 添加英文权限说明 + + +## [0.97.37] - 2026-04-18 + +### ✨ 新增 — 离线模式增强 + 数据导出功能 + +#### 离线模式增强 +- 📡 **OfflineService 离线服务核心**:统一管理离线状态、操作守卫、功能可用性列表 + - `guard()` 方法:离线时拦截操作,支持排队等待网络恢复后自动执行 + - 可用/不可用功能列表:离线时明确告知用户哪些功能可用 + - 离线持续时间追踪:显示已离线时长 + - 排队操作计数:显示待执行操作数量 +- 🔔 **增强离线指示器 OfflineIndicator**:显示离线状态+持续时间+排队数,点击查看详情 +- 🔄 **ConnectivityService 生命周期感知**:监听 App 生命周期,`resumed` 时重新检查网络状态 +- 🛡️ **DNS 预检重置机制**:后台恢复时自动重置 DNS 预检状态,避免假离线 + +#### 数据导出功能 +- 📦 **DataExportService 统一导出服务**:支持 6 种数据源、3 种格式(JSON/CSV/Markdown) + - 数据源:收藏、购物清单、饮食记录、烹饪笔记、每周菜单、浏览记录 + - 格式:JSON(数据备份)、CSV(Excel 编辑)、Markdown(阅读分享) + - 支持单源导出和一键全量导出 + - 导出后支持系统分享 +- 📄 **DataExportPage 数据导出页面**:iOS 风格 UI,选择格式+数据源+导出/分享 +- 🔌 **各 Controller 导出方法**: + - `FavoritesController`: exportToJson/exportToCsv/exportToMarkdown + - `ShoppingListController`: exportToJson/exportToCsv/exportToMarkdown + - `MealRecordController`: exportToJson/exportToCsv/exportToMarkdown + - `CookingNoteController`: exportToJson/exportToCsv/exportToMarkdown + - `WeeklyMenuController`: exportToJson/exportToCsv/exportToMarkdown + - `BrowseHistoryController`: exportToJson/exportToCsv + +#### 🐛 修复 — 后台恢复假离线 +- **问题**:返回桌面后,后台会断网,再打开一直显示无网络,过一会被系统清理后台 +- **根因**:应用从后台恢复时,DNS 预检结果过期(`_dnsChecked=true` 但 `_dnsReachable=false`),5 分钟内不重新检查,导致所有请求走缓存或失败 +- **修复**: + - `ConnectivityService` 添加 `WidgetsBindingObserver`,`resumed` 时主动检查网络 + - 网络恢复时触发 DNS 预检重置(`ApiService.resetDnsCheck()`) + - 新增异步恢复回调机制 `addOnNetworkRestoredAsync()` + +#### 修改文件 +- `lib/src/services/data/offline_service.dart` — 新建:离线服务核心 +- `lib/src/services/data/data_export_service.dart` — 新建:数据导出服务 +- `lib/src/widgets/states/offline_indicator.dart` — 新建:增强版离线指示器 +- `lib/src/pages/profile/data_export_page.dart` — 新建:数据导出页面 +- `lib/src/services/connectivity_service.dart` — 生命周期感知+DNS重检+异步恢复回调 +- `lib/src/services/api/api_service.dart` — 新增 resetDnsCheck() 方法 +- `lib/src/services/data/hive_service.dart` — 新增 getAllMealRecords() 方法 +- `lib/src/widgets/states/offline_banner.dart` — 优先使用 OfflineService 数据源 +- `lib/src/app_binding.dart` — 注册 OfflineService 和 DataExportService +- `lib/src/services/core/app_service.dart` — 注册 DNS 重检回调 +- `lib/src/config/app_routes.dart` — 新增 dataExport 路由 +- `lib/src/pages/profile/profile_settings.dart` — 新增数据导出入口 +- `lib/src/controllers/data/favorites_controller.dart` — 新增 exportToMarkdown +- `lib/src/controllers/data/shopping_list_controller.dart` — 新增 exportToJson/exportToCsv/exportToMarkdown +- `lib/src/controllers/data/meal_record_controller.dart` — 新增 exportToJson/exportToCsv/exportToMarkdown +- `lib/src/controllers/data/cooking_note_controller.dart` — 新增 exportToJson/exportToCsv/exportToMarkdown +- `lib/src/controllers/data/weekly_menu_controller.dart` — 新增 exportToJson/exportToCsv/exportToMarkdown +- `lib/src/controllers/data/browse_history_controller.dart` — 新增 exportToJson/exportToCsv + + ## [0.97.36] - 2026-04-17 ### 🔧 修复 — Web端 Platform._operatingSystem 崩溃 @@ -92,71 +296,7 @@ All notable changes to this project will be documented in this file. - `lib/main.dart` — 启动时检查协议同意状态 -## [0.97.32] - 2026-04-17 -### ✨ 新增 — 点餐助手分享功能 + UI布局优化 + 权限页面 - -#### 新增功能 -- 📝 **生成文本分享**:列表区域新增"生成文本"按钮,调用系统分享接口分享格式化订单文本 -- 🖼️ **生成图片分享**:列表区域新增"生成图片"按钮,弹出预览卡片(完整渲染所有菜品),确认后截图生成PNG图片并调用系统分享 -- 📤 **分享区域**:独立的"分享订单"卡片组件,与底部栏"关闭订单/生成账单"按钮分离 -- 🗑️ **关闭订单**:底部栏左侧新增"关闭订单"按钮,确认后标记取消并从服务器删除 -- 👥 **用餐人数**:桌号下方新增人数选择器,支持1/2/3/4/5/6/8/10常量快速选择和自定义输入 -- 🪑 **添加菜品按钮移至单号下方**:布局调整,添加菜品按钮紧跟订单头部 -- 🔒 **软件权限页面**:新增权限管理页面,展示应用所需权限说明和沙盒运行说明 - -#### 修改文件 -- `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` — 新增软件权限入口 - - -## [0.97.31] - 2026-04-17 - -### 🔧 修复 — 二维码扫码显示网页而非JSON - -#### 修复内容 -- 🐛 **QR URL修正**:二维码/条形码URL从 `kitchen.php?act=get&id=xxx`(返回JSON)改为 `?id=xxx`(加载index.html网页) -- 扫码后现在正确显示点单网页,包含菜品列表、金额、备注、桌号等信息 -- 网页端通过 `?id=xxx` 参数自动调用API获取订单数据并渲染 - -#### 修改文件 -- `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.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 @@ -194,85 +334,6 @@ All notable changes to this project will be documented in this file. - `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)工具 @@ -299,48 +360,6 @@ All notable changes to this project will be documented in this file. - `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 ### 🐛 修复 — 发现页工具中心交互优化 diff --git a/android/app/src/main/AndroidManifest.xml b/android/app/src/main/AndroidManifest.xml index f83c35b..32bb818 100644 --- a/android/app/src/main/AndroidManifest.xml +++ b/android/app/src/main/AndroidManifest.xml @@ -36,6 +36,23 @@ + + + + + + + + + + + + + + + + + diff --git a/android/build.gradle.kts b/android/build.gradle.kts index dbee657..a8fb8bf 100644 --- a/android/build.gradle.kts +++ b/android/build.gradle.kts @@ -15,8 +15,25 @@ subprojects { val newSubprojectBuildDir: Directory = newBuildDir.dir(project.name) project.layout.buildDirectory.value(newSubprojectBuildDir) } + subprojects { - project.evaluationDependsOn(":app") + afterEvaluate { + val javaVersion = JavaVersion.VERSION_11 + if (extensions.findByName("android") != null) { + extensions.configure("android") { + compileOptions { + sourceCompatibility = javaVersion + targetCompatibility = javaVersion + } + } + } + } + + tasks.withType().configureEach { + kotlinOptions { + jvmTarget = "11" + } + } } tasks.register("clean") { diff --git a/docs/superpowers/plans/2026-04-18-farm-game-implementation.md b/docs/superpowers/plans/2026-04-18-farm-game-implementation.md new file mode 100644 index 0000000..e7b9c80 --- /dev/null +++ b/docs/superpowers/plans/2026-04-18-farm-game-implementation.md @@ -0,0 +1,3012 @@ +# 小妈菜园 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:** 本地优先架构,复用项目现有服务(HiveService、AnimationService),GetX 管理状态,share_plus 实现分享功能 + +**Tech Stack:** +- **已有库**: get (状态管理), hive_ce (本地存储), share_plus (分享), animations (动画), cupertino_icons (iOS图标), path_provider, uuid, logger, intl, badges, fl_chart +- **需新增**: build_runner (^2.4.13), hive_ce_generator (^1.8.0) [dev_dependencies] + +**安装新依赖命令**: +```bash +flutter pub add --dev build_runner +flutter pub add --dev hive_ce_generator +``` + +--- + +## 文件结构总览 + +### 新增文件 + +``` +lib/src/ +├── models/farm/ +│ ├── farm_player.dart # 玩家数据模型 +│ ├── farm_player.g.dart # Hive 适配器(自动生成) +│ ├── farm_land.dart # 土地数据模型 +│ ├── farm_land.g.dart # Hive 适配器(自动生成) +│ ├── inventory_item.dart # 背包物品模型 +│ ├── inventory_item.g.dart # Hive 适配器(自动生成) +│ ├── crop_config.dart # 作物配置(普通类,无需 Hive) +│ ├── crop_registry.dart # 作物注册表(静态配置) +│ ├── achievement_config.dart # 成就配置(普通类,无需 Hive) +│ └── achievement_registry.dart # 成就注册表(静态配置) +│ +├── controllers/farm/ +│ ├── farm_game_controller.dart # 核心游戏控制器 +│ ├── farm_shop_controller.dart # 商店控制器 +│ ├── farm_inventory_controller.dart # 背包控制器 +│ └── farm_achievement_controller.dart # 成就控制器 +│ +├── pages/tools/farm/ +│ ├── farm_game_page.dart # 主游戏页面 +│ ├── farm_shop_page.dart # 商店页面 +│ ├── farm_inventory_page.dart # 背包页面 +│ ├── farm_achievement_page.dart # 成就页面 +│ └── widgets/ +│ ├── land_widget.dart # 土地 Widget +│ └── farm_share_painter.dart # 分享图片绘制器 +│ +├── config/ +│ └── farm_config.dart # 游戏全局配置常量 +│ +└── utils/ + └── farm_share_util.dart # 分享功能工具类 + +docs/superpowers/ +└── specs/ + └── 2026-04-18-farm-game-design.md # 设计文档(已创建) +``` + +### 修改文件 + +``` +lib/src/services/data/hive_service.dart # 扩展农场数据支持 +lib/src/config/app_routes.dart # 新增路由定义 +lib/src/models/tool_item_model.dart # 新增工具注册项 +lib/src/app_binding.dart # 注册控制器 +docs/design/IOS26_UI_DESIGN.md # (可选)更新设计参考 +CHANGELOG.md # 更新变更日志 +``` + +--- + +## 开发任务 + +### Task 1: 数据模型层 - 基础模型定义 + +**目标:** 创建所有 Hive 数据模型,确保数据可持久化 + +**Files:** +- Create: `lib/src/models/farm/farm_player.dart` +- Create: `lib/src/models/farm/farm_land.dart` +- Create: `lib/src/models/farm/inventory_item.dart` +- Create: `lib/src/models/farm/crop_config.dart` +- Create: `lib/src/models/farm/achievement_config.dart` + +#### Step 1: 创建玩家模型 + +Create: `lib/src/models/farm/farm_player.dart` + +```dart +/* + * 文件: farm_player.dart + * 名称: 农场玩家模型 + * 作用: 存储玩家等级、经验、金币、解锁状态等数据 + * 更新: 2026-04-18 初始创建 + */ + +import 'package:hive_ce/hive.dart'; + +part 'farm_player.g.dart'; + +@HiveType(typeId: 100) +class FarmPlayer extends HiveObject { + @HiveField(0) + String playerId; + + @HiveField(1) + String playerName; + + @HiveField(2) + int level; + + @HiveField(3) + int experience; + + @HiveField(4) + int gold; + + @HiveField(5) + int diamond; + + @HiveField(6) + DateTime createTime; + + @HiveField(7) + int totalHarvest; + + @HiveField(8) + int totalPlant; + + @HiveField(9) + List unlockedCrops; + + @HiveField(10) + List achievements; + + FarmPlayer({ + required this.playerId, + required this.playerName, + this.level = 1, + this.experience = 0, + this.gold = 100, + this.diamond = 10, + required this.createTime, + this.totalHarvest = 0, + this.totalPlant = 0, + List? unlockedCrops, + List? achievements, + }) : unlockedCrops = unlockedCrops ?? [], + achievements = achievements ?? []; + + int get expToNextLevel => level * 100; + + double get expProgress => experience / expToNextLevel; + + factory FarmPlayer.createDefault(String deviceId) { + return FarmPlayer( + playerId: deviceId, + playerName: '小厨神', + createTime: DateTime.now(), + unlockedCrops: ['radish', 'potato', 'cabbage'], + ); + } +} +``` + +#### Step 2: 创建土地模型 + +Create: `lib/src/models/farm/farm_land.dart` + +```dart +/* + * 文件: farm_land.dart + * 名称: 农场土地模型 + * 作用: 存储每块土地的种植状态、作物生长信息 + * 更新: 2026-04-18 初始创建 + */ + +import 'package:hive_ce/hive.dart'; + +part 'farm_land.g.dart'; + +@HiveType(typeId: 101) +class FarmLand extends HiveObject { + @HiveField(0) + int landId; + + @HiveField(1) + bool isUnlocked; + + @HiveField(2) + String? cropId; + + @HiveField(3) + DateTime? plantTime; + + @HiveField(4) + int growthStage; + + @HiveField(5) + bool needWater; + + @HiveField(6) + bool needFertilizer; + + @HiveField(7) + DateTime? lastWaterTime; + + @HiveField(8) + bool isWithered; + + @HiveField(9) + bool isReady; + + FarmLand({ + required this.landId, + this.isUnlocked = false, + this.cropId, + this.plantTime, + this.growthStage = 0, + this.needWater = false, + this.needFertilizer = false, + this.lastWaterTime, + this.isWithered = false, + this.isReady = false, + }); + + double get growthProgress { + if (plantTime == null || cropId == null) return 0.0; + final crop = CropRegistry.getById(cropId!); + if (crop == null) return 0.0; + + final elapsed = DateTime.now().difference(plantTime!).inMinutes; + return (elapsed / crop.growthTime).clamp(0.0, 1.0); + } + + String get currentDisplayEmoji { + if (cropId == null) return '🟫'; + final crop = CropRegistry.getById(cropId!); + if (crop == null) return '🟫'; + + if (isWithered) return '🥀'; + if (isReady) return crop.stages.last.emoji; + + final index = growthStage.clamp(0, crop.stages.length - 1); + return crop.stages[index].emoji; + } + + factory FarmLand.initial(int id, {bool unlocked = false}) { + return FarmLand(landId: id, isUnlocked: unlocked); + } +} +``` + +#### Step 3: 创建背包物品模型 + +Create: `lib/src/models/farm/inventory_item.dart` + +```dart +/* + * 文件: inventory_item.dart + * 名称: 背包物品模型 + * 作用: 存储种子、果实、道具等背包物品 + * 更新: 2026-04-18 初始创建 + */ + +import 'package:hive_ce/hive.dart'; + +part 'inventory_item.g.dart'; + +@HiveType(typeId: 102) +class InventoryItem extends HiveObject { + @HiveField(0) + String itemId; + + @HiveField(1) + String itemName; + + @HiveField(2) + String itemType; // seed, fruit, fertilizer, tool + + @HiveField(3) + int quantity; + + @HiveField(4) + String emoji; + + @HiveField(5) + int price; + + InventoryItem({ + required this.itemId, + required this.itemName, + required this.itemType, + required this.quantity, + required this.emoji, + this.price = 0, + }); + + factory InventoryItem.seed({ + required String cropId, + required String name, + required String emoji, + required int price, + int quantity = 1, + }) { + return InventoryItem( + itemId: '${cropId}_seed', + itemName: '$name种子', + itemType: 'seed', + quantity: quantity, + emoji: emoji, + price: price, + ); + } + + bool get isSeed => itemType == 'seed'; + bool get isFruit => itemType == 'fruit'; +} +``` + +#### Step 4: 创建作物配置模型 + +Create: `lib/src/models/farm/crop_config.dart` + +```dart +/* + * 文件: crop_config.dart + * 名称: 作物配置模型 + * 作用: 定义作物的生长时间、价格、阶段等静态配置 + * 更新: 2026-04-18 初始创建 + */ + +class StageInfo { + final int stage; + final String emoji; + final double durationPercent; + final bool needWater; + + const StageInfo({ + required this.stage, + required this.emoji, + required this.durationPercent, + this.needWater = false, + }); +} + +class CropConfig { + final String id; + final String name; + final String emoji; + final int growthTime; // 分钟 + final int seedPrice; + final int harvestPrice; + final int harvestExp; + final int unlockLevel; + final List stages; + + const CropConfig({ + required this.id, + required this.name, + required this.emoji, + required this.growthTime, + required this.seedPrice, + required this.harvestPrice, + required this.harvestExp, + required this.unlockLevel, + required this.stages, + }); +} +``` + +#### Step 5: 创建成就配置模型 + +Create: `lib/src/models/farm/achievement_config.dart` + +```dart +/* + * 文件: achievement_config.dart + * 名称: 成就配置模型 + * 作用: 定义成就条件和奖励 + * 更新: 2026-04-18 初始创建 + */ + +class AchievementConfig { + final String id; + final String name; + final String description; + final String emoji; + final int rewardGold; + final int rewardExp; + final int rewardDiamond; + final String conditionType; + final int conditionValue; + + const AchievementConfig({ + required this.id, + required this.name, + required this.description, + required this.emoji, + this.rewardGold = 0, + this.rewardExp = 0, + this.rewardDiamond = 0, + required this.conditionType, + required this.conditionValue, + }); +} +``` + +--- + +### Task 2: 配置注册表 - 静态数据注册 + +**目标:** 创建作物和成就的注册表,提供便捷的查询接口 + +**Files:** +- Create: `lib/src/models/farm/crop_registry.dart` +- Create: `lib/src/models/farm/achievement_registry.dart` +- Create: `lib/src/config/farm_config.dart` + +#### Step 1: 创建作物注册表 + +Create: `lib/src/models/farm/crop_registry.dart` + +```dart +/* + * 文件: crop_registry.dart + * 名称: 作物注册表 + * 作用: 注册所有作物配置,提供查询接口 + * 更新: 2026-04-18 初始创建 + */ + +import 'crop_config.dart'; + +class CropRegistry { + CropRegistry._(); + + static const Map _crops = { + 'radish': CropConfig( + id: 'radish', + name: '萝卜', + emoji: '🥕', + growthTime: 30, + seedPrice: 10, + harvestPrice: 25, + harvestExp: 15, + unlockLevel: 1, + stages: [ + StageInfo(stage: 0, emoji: '🌱', durationPercent: 0.05, needWater: true), + StageInfo(stage: 1, emoji: '🌿', durationPercent: 0.25, needWater: true), + StageInfo(stage: 2, emoji: '☘️', durationPercent: 0.55, needWater: true), + StageInfo(stage: 3, emoji: '🥕', durationPercent: 0.15, needWater: false), + ], + ), + 'potato': CropConfig( + id: 'potato', + name: '土豆', + emoji: '🥔', + growthTime: 45, + seedPrice: 15, + harvestPrice: 40, + harvestExp: 25, + unlockLevel: 1, + stages: [ + StageInfo(stage: 0, emoji: '🌱', durationPercent: 0.05, needWater: true), + StageInfo(stage: 1, emoji: '🌿', durationPercent: 0.25, needWater: true), + StageInfo(stage: 2, emoji: '🪴', durationPercent: 0.55, needWater: true), + StageInfo(stage: 3, emoji: '🥔', durationPercent: 0.15, needWater: false), + ], + ), + 'cabbage': CropConfig( + id: 'cabbage', + name: '卷心菜', + emoji: '🥬', + growthTime: 40, + seedPrice: 12, + harvestPrice: 35, + harvestExp: 20, + unlockLevel: 1, + stages: [ + StageInfo(stage: 0, emoji: '🌱', durationPercent: 0.05, needWater: true), + StageInfo(stage: 1, emoji: '🌿', durationPercent: 0.25, needWater: true), + StageInfo(stage: 2, emoji: '☘️', durationPercent: 0.55, needWater: true), + StageInfo(stage: 3, emoji: '🥬', durationPercent: 0.15, needWater: false), + ], + ), + 'tomato': CropConfig( + id: 'tomato', + name: '西红柿', + emoji: '🍅', + growthTime: 60, + seedPrice: 25, + harvestPrice: 60, + harvestExp: 35, + unlockLevel: 2, + stages: [ + StageInfo(stage: 0, emoji: '🌱', durationPercent: 0.05, needWater: true), + StageInfo(stage: 1, emoji: '🌿', durationPercent: 0.25, needWater: true), + StageInfo(stage: 2, emoji: '🪴', durationPercent: 0.55, needWater: true), + StageInfo(stage: 3, emoji: '🍅', durationPercent: 0.15, needWater: false), + ], + ), + 'carrot': CropConfig( + id: 'carrot', + name: '胡萝卜', + emoji: '🥕', + growthTime: 50, + seedPrice: 20, + harvestPrice: 50, + harvestExp: 30, + unlockLevel: 2, + stages: [ + StageInfo(stage: 0, emoji: '🌱', durationPercent: 0.05, needWater: true), + StageInfo(stage: 1, emoji: '🌿', durationPercent: 0.25, needWater: true), + StageInfo(stage: 2, emoji: '☘️', durationPercent: 0.55, needWater: true), + StageInfo(stage: 3, emoji: '🥕', durationPercent: 0.15, needWater: false), + ], + ), + 'corn': CropConfig( + id: 'corn', + name: '玉米', + emoji: '🌽', + growthTime: 90, + seedPrice: 40, + harvestPrice: 95, + harvestExp: 55, + unlockLevel: 3, + stages: [ + StageInfo(stage: 0, emoji: '🌱', durationPercent: 0.05, needWater: true), + StageInfo(stage: 1, emoji: '🌿', durationPercent: 0.25, needWater: true), + StageInfo(stage: 2, emoji: '🪴', durationPercent: 0.55, needWater: true), + StageInfo(stage: 3, emoji: '🌽', durationPercent: 0.15, needWater: false), + ], + ), + 'pepper': CropConfig( + id: 'pepper', + name: '辣椒', + emoji: '🌶️', + growthTime: 70, + seedPrice: 30, + harvestPrice: 75, + harvestExp: 45, + unlockLevel: 3, + stages: [ + StageInfo(stage: 0, emoji: '🌱', durationPercent: 0.05, needWater: true), + StageInfo(stage: 1, emoji: '🌿', durationPercent: 0.25, needWater: true), + StageInfo(stage: 2, emoji: '🪴', durationPercent: 0.55, needWater: true), + StageInfo(stage: 3, emoji: '🌶️', durationPercent: 0.15, needWater: false), + ], + ), + 'eggplant': CropConfig( + id: 'eggplant', + name: '茄子', + emoji: '🍆', + growthTime: 80, + seedPrice: 35, + harvestPrice: 85, + harvestExp: 50, + unlockLevel: 4, + stages: [ + StageInfo(stage: 0, emoji: '🌱', durationPercent: 0.05, needWater: true), + StageInfo(stage: 1, emoji: '🌿', durationPercent: 0.25, needWater: true), + StageInfo(stage: 2, emoji: '🪴', durationPercent: 0.55, needWater: true), + StageInfo(stage: 3, emoji: '🍆', durationPercent: 0.15, needWater: false), + ], + ), + 'strawberry': CropConfig( + id: 'strawberry', + name: '草莓', + emoji: '🍓', + growthTime: 120, + seedPrice: 60, + harvestPrice: 140, + harvestExp: 80, + unlockLevel: 5, + stages: [ + StageInfo(stage: 0, emoji: '🌱', durationPercent: 0.05, needWater: true), + StageInfo(stage: 1, emoji: '🌿', durationPercent: 0.25, needWater: true), + StageInfo(stage: 2, emoji: '🌸', durationPercent: 0.55, needWater: true), + StageInfo(stage: 3, emoji: '🍓', durationPercent: 0.15, needWater: false), + ], + ), + 'pumpkin': CropConfig( + id: 'pumpkin', + name: '南瓜', + emoji: '🎃', + growthTime: 150, + seedPrice: 80, + harvestPrice: 180, + harvestExp: 100, + unlockLevel: 6, + stages: [ + StageInfo(stage: 0, emoji: '🌱', durationPercent: 0.05, needWater: true), + StageInfo(stage: 1, emoji: '🌿', durationPercent: 0.25, needWater: true), + StageInfo(stage: 2, emoji: '🪴', durationPercent: 0.55, needWater: true), + StageInfo(stage: 3, emoji: '🎃', durationPercent: 0.15, needWater: false), + ], + ), + 'watermelon': CropConfig( + id: 'watermelon', + name: '西瓜', + emoji: '🍉', + growthTime: 180, + seedPrice: 100, + harvestPrice: 220, + harvestExp: 120, + unlockLevel: 8, + stages: [ + StageInfo(stage: 0, emoji: '🌱', durationPercent: 0.05, needWater: true), + StageInfo(stage: 1, emoji: '🌿', durationPercent: 0.25, needWater: true), + StageInfo(stage: 2, emoji: '🪴', durationPercent: 0.55, needWater: true), + StageInfo(stage: 3, emoji: '🍉', durationPercent: 0.15, needWater: false), + ], + ), + 'grape': CropConfig( + id: 'grape', + name: '葡萄', + emoji: '🍇', + growthTime: 200, + seedPrice: 120, + harvestPrice: 260, + harvestExp: 140, + unlockLevel: 10, + stages: [ + StageInfo(stage: 0, emoji: '🌱', durationPercent: 0.05, needWater: true), + StageInfo(stage: 1, emoji: '🌿', durationPercent: 0.25, needWater: true), + StageInfo(stage: 2, emoji: '🌸', durationPercent: 0.55, needWater: true), + StageInfo(stage: 3, emoji: '🍇', durationPercent: 0.15, needWater: false), + ], + ), + }; + + static CropConfig? getById(String id) => _crops[id]; + + static List getAll() => _crops.values.toList(); + + static List getUnlockedCrops(int level) { + return _crops.values.where((c) => c.unlockLevel <= level).toList(); + } + + static List getAvailableForLevel(int level) { + return _crops.values + .where((c) => c.unlockLevel == level) + .toList(); + } +} +``` + +#### Step 2: 创建成就注册表 + +Create: `lib/src/models/farm/achievement_registry.dart` + +```dart +/* + * 文件: achievement_registry.dart + * 名称: 成就注册表 + * 作用: 注册所有成就配置,提供查询和验证接口 + * 更新: 2026-04-18 初始创建 + */ + +import 'achievement_config.dart'; + +class AchievementRegistry { + AchievementRegistry._(); + + static const Map _achievements = { + 'first_harvest': AchievementConfig( + id: 'first_harvest', + name: '初次收获', + description: '完成第一次收获', + emoji: '🌾', + rewardGold: 50, + rewardExp: 20, + conditionType: 'totalHarvest', + conditionValue: 1, + ), + 'harvest_10': AchievementConfig( + id: 'harvest_10', + name: '小有成就', + description: '收获 10 次', + emoji: '🏅', + rewardGold: 100, + rewardExp: 50, + conditionType: 'totalHarvest', + conditionValue: 10, + ), + 'harvest_50': AchievementConfig( + id: 'harvest_50', + name: '丰收达人', + description: '收获 50 次', + emoji: '🏆', + rewardGold: 300, + rewardExp: 100, + rewardDiamond: 10, + conditionType: 'totalHarvest', + conditionValue: 50, + ), + 'level_5': AchievementConfig( + id: 'level_5', + name: '初出茅庐', + description: '达到 5 级', + emoji: '⭐', + rewardGold: 200, + rewardExp: 0, + conditionType: 'level', + conditionValue: 5, + ), + 'level_10': AchievementConfig( + id: 'level_10', + name: '农场老手', + description: '达到 10 级', + emoji: '🌟', + rewardGold: 500, + rewardExp: 0, + rewardDiamond: 20, + conditionType: 'level', + conditionValue: 10, + ), + 'unlock_all': AchievementConfig( + id: 'unlock_all', + name: '丰收大师', + description: '解锁所有作物', + emoji: '👑', + rewardGold: 1000, + rewardExp: 0, + rewardDiamond: 50, + conditionType: 'unlockedCrops', + conditionValue: 12, + ), + }; + + static AchievementConfig? getById(String id) => _achievements[id]; + + static Map getAll() => _achievements; + + static List checkNewAchievements( + String playerLevel, + int playerValue, + List completedIds, + ) { + return _achievements.values + .where((a) => + !completedIds.contains(a.id) && + a.conditionType == playerLevel && + playerValue >= a.conditionValue) + .toList(); + } +} +``` + +#### Step 3: 创建游戏配置文件 + +Create: `lib/src/config/farm_config.dart` + +```dart +/* + * 文件: farm_config.dart + * 名称: 农场游戏配置 + * 作用: 定义游戏全局常量配置 + * 更新: 2026-04-18 初始创建 + */ + +class FarmConfig { + FarmConfig._(); + + /// 土地总数 + static const int totalLands = 12; + + /// 初始解锁土地数 + static const int initialUnlockedLands = 6; + + /// 解锁新土地所需金币 + static const int unlockLandCost = 200; + + /// 浇水加速比例(%) + static const double waterSpeedBoost = 0.2; + + /// 枯萎时间(分钟) + static const int witherTimeMinutes = 120; + + /// 成熟后过期时间(分钟) + static const int matureExpireMinutes = 1440; // 24 小时 + + /// 初始金币 + static const int initialGold = 100; + + /// 初始钻石 + static const int initialDiamond = 10; + + /// 初始种子数量 + static const int initialSeedQuantity = 5; + + /// 分享图片尺寸 + static const double shareImageWidth = 800; + static const double shareImageHeight = 1000; + + /// 调试模式(发布时设为 false) + static const bool debugMode = true; +} +``` + +--- + +### Task 3: 数据服务层 - 扩展现有 HiveService + +**目标:** 扩展现有的 HiveService,添加农场游戏的 Box 支持 + +**Files:** +- Modify: `lib/src/services/data/hive_service.dart` (扩展 Box 和注册适配器) + +#### Step 1: 在 HiveService 中添加农场游戏 Box + +Modify: `lib/src/services/data/hive_service.dart` + +**重要**: 不需要创建新的 `FarmDataService`,直接扩展现有的 `HiveService` 类。 + +在 HiveService 类中添加: + +```dart +// 1. 在变量声明区域添加 +late Box _farmPlayer; +late Box _farmLands; +late Box _farmInventory; + +// 2. 添加 getter 访问器 +Box get farmPlayer => _farmPlayer; +Box get farmLands => _farmLands; +Box get farmInventory => _farmInventory; + +// 3. 在 _registerAdapters() 方法中添加 +void _registerAdapters() { + // ... 现有代码 ... + + // 农场游戏适配器 (typeId: 100-102) + if (!Hive.isAdapterRegistered(100)) { + Hive.registerAdapter(FarmPlayerAdapter()); + } + if (!Hive.isAdapterRegistered(101)) { + Hive.registerAdapter(FarmLandAdapter()); + } + if (!Hive.isAdapterRegistered(102)) { + Hive.registerAdapter(InventoryItemAdapter()); + } +} + +// 4. 在 _openBoxes() 方法中添加 +Future _openBoxes() async { + // ... 现有代码 ... + + // 农场游戏 Boxes + _farmPlayer = await _openBoxSafe('farmPlayer'); + _farmLands = await _openBoxSafe('farmLands'); + _farmInventory = await _openBoxSafe('farmInventory'); +} + +// 5. 在 _initializeDefaultData() 方法中添加 +Future _initializeDefaultData() async { + // ... 现有代码 ... + + // 农场游戏初始化 + await _initializeFarmData(); +} + +// 6. 添加新方法 +Future _initializeFarmData() async { + if (_farmPlayer.isEmpty) { + final deviceId = 'device_${DateTime.now().millisecondsSinceEpoch}'; + final player = FarmPlayer.createDefault(deviceId); + await _farmPlayer.put('player', player); + + // 初始化 12 块土地(前 6 块解锁) + for (int i = 0; i < FarmConfig.totalLands; i++) { + final land = FarmLand.initial(i, unlocked: i < FarmConfig.initialUnlockedLands); + await _farmLands.put(i, land); + } + + // 初始种子 + await _farmInventory.put( + 'radish_seed', + InventoryItem.seed( + cropId: 'radish', + name: '萝卜', + emoji: '🥕', + price: 10, + quantity: FarmConfig.initialSeedQuantity, + ), + ); + } +} +``` + +#### Step 2: 添加必要的 import + +在 HiveService 文件顶部添加: + +```dart +import 'package:mom_kitchen/src/models/farm/farm_player.dart'; +import 'package:mom_kitchen/src/models/farm/farm_land.dart'; +import 'package:mom_kitchen/src/models/farm/inventory_item.dart'; +import 'package:mom_kitchen/src/config/farm_config.dart'; +``` + +--- + +### Task 4: 控制器层 - 核心游戏逻辑 + +**目标:** 实现游戏核心控制器,处理所有游戏逻辑 + +**Files:** +- Create: `lib/src/controllers/farm/farm_game_controller.dart` +- Create: `lib/src/controllers/farm/farm_shop_controller.dart` +- Create: `lib/src/controllers/farm/farm_inventory_controller.dart` +- Create: `lib/src/controllers/farm/farm_achievement_controller.dart` +- Modify: `lib/src/app_binding.dart` (注册控制器) + +#### Step 1: 创建核心游戏控制器 + +Create: `lib/src/controllers/farm/farm_game_controller.dart` + +(文件较长,包含种植、浇水、收获、升级等核心逻辑) + +```dart +/* + * 文件: farm_game_controller.dart + * 名称: 农场游戏控制器 + * 作用: 管理游戏核心逻辑:种植、生长、浇水、收获、升级 + * 更新: 2026-04-18 初始创建 + */ + +import 'dart:async'; +import 'package:flutter/material.dart'; +import 'package:get/get.dart'; +import 'package:mom_kitchen/src/models/farm/farm_player.dart'; +import 'package:mom_kitchen/src/models/farm/farm_land.dart'; +import 'package:mom_kitchen/src/models/farm/inventory_item.dart'; +import 'package:mom_kitchen/src/models/farm/crop_config.dart'; +import 'package:mom_kitchen/src/models/farm/crop_registry.dart'; +import 'package:mom_kitchen/src/models/farm/achievement_config.dart'; +import 'package:mom_kitchen/src/models/farm/achievement_registry.dart'; +import 'package:mom_kitchen/src/services/data/hive_service.dart'; +import 'package:mom_kitchen/src/config/farm_config.dart'; + +class FarmGameController extends GetxController { + final _hiveService = Get.find(); + + final Rx player = FarmPlayer.createDefault('').obs; + final RxList lands = [].obs; + final RxList inventory = [].obs; + final RxList unlockedAchievements = [].obs; + + Timer? _growthTimer; + + @override + void onInit() { + super.onInit(); + _loadData(); + _startGrowthTimer(); + } + + @override + void onClose() { + _growthTimer?.cancel(); + super.onClose(); + } + + void _loadData() { + final hiveService = Get.find(); + player.value = hiveService.farmPlayer.get('player') ?? FarmPlayer.createDefault(''); + lands.assignAll(hiveService.farmLands.values.toList()); + inventory.assignAll(hiveService.farmInventory.values.toList()); + unlockedAchievements.assignAll(player.value.achievements); + _updateGrowthStages(); + } + + void _startGrowthTimer() { + _growthTimer = Timer.periodic(const Duration(seconds: 10), (_) { + _updateGrowthStages(); + }); + } + + void _updateGrowthStages() { + bool changed = false; + final now = DateTime.now(); + + for (final land in lands) { + if (land.cropId == null || land.isWithered || land.isReady) continue; + + final crop = CropRegistry.getById(land.cropId!); + if (crop == null) continue; + + final elapsed = now.difference(land.plantTime!).inMinutes.toDouble(); + final progress = elapsed / crop.growthTime; + + // 检查枯萎 + if (land.needWater && land.lastWaterTime != null) { + final sinceWater = now.difference(land.lastWaterTime!).inMinutes; + if (sinceWater > FarmConfig.witherTimeMinutes) { + land.isWithered = true; + changed = true; + continue; + } + } + + // 更新生长阶段 + double accumulated = 0; + for (final stage in crop.stages) { + accumulated += crop.growthTime * stage.durationPercent; + if (elapsed >= accumulated) { + final newStage = stage.stage; + if (newStage != land.growthStage) { + land.growthStage = newStage; + land.needWater = stage.needWater; + changed = true; + } + } + } + + // 检查是否成熟 + if (progress >= 1.0 && !land.isReady) { + land.isReady = true; + changed = true; + } + } + + if (changed) { + _saveLands(); + lands.refresh(); + } + } + + Future plantCrop({required int landId, required String cropId}) async { + final land = lands.firstWhere((l) => l.landId == landId); + if (land.cropId != null) { + Get.snackbar('提示', '这块土地已经种植了作物'); + return; + } + + final seedItemId = '${cropId}_seed'; + final seedItem = inventory.firstWhere( + (item) => item.itemId == seedItemId, + orElse: () => throw Exception('没有该作物种子'), + ); + + if (seedItem.quantity <= 0) { + Get.snackbar('提示', '种子数量不足,请前往商店购买'); + return; + } + + // 消耗种子 + seedItem.quantity--; + if (seedItem.quantity == 0) { + inventory.remove(seedItem); + } + await _saveInventory(); + + // 种植 + land.cropId = cropId; + land.plantTime = DateTime.now(); + land.growthStage = 0; + land.needWater = true; + land.isWithered = false; + land.isReady = false; + land.lastWaterTime = null; + + player.value.totalPlant++; + await _savePlayer(); + await _saveLands(); + + Get.snackbar('🌱 种植成功', '已开始种植${CropRegistry.getById(cropId)?.name}'); + } + + Future waterLand(int landId) async { + final land = lands.firstWhere((l) => l.landId == landId); + if (!land.needWater || land.isWithered || land.isReady) { + Get.snackbar('提示', '这块土地不需要浇水'); + return; + } + + land.needWater = false; + land.lastWaterTime = DateTime.now(); + await _saveLands(); + + Get.snackbar('💧 浇水成功', '作物生长速度已提升'); + } + + Future harvestCrop(int landId) async { + final land = lands.firstWhere((l) => l.landId == landId); + if (!land.isReady) { + Get.snackbar('提示', '作物还未成熟'); + return; + } + + final crop = CropRegistry.getById(land.cropId!); + if (crop == null) return; + + // 增加奖励 + player.value.gold += crop.harvestPrice; + player.value.experience += crop.harvestExp; + player.value.totalHarvest++; + + // 添加果实到背包 + final fruitItem = InventoryItem( + itemId: '${crop.id}_fruit', + itemName: crop.name, + itemType: 'fruit', + quantity: 1, + emoji: crop.emoji, + price: crop.harvestPrice, + ); + + final existingFruit = inventory.where( + (item) => item.itemId == fruitItem.itemId, + ); + if (existingFruit.isNotEmpty) { + existingFruit.first.quantity++; + } else { + inventory.add(fruitItem); + } + + // 重置土地 + land.cropId = null; + land.plantTime = null; + land.growthStage = 0; + land.isReady = false; + land.isWithered = false; + land.needWater = false; + + await _savePlayer(); + await _saveInventory(); + await _saveLands(); + + // 检查升级 + _checkLevelUp(); + + // 检查成就 + _checkAchievements(); + + Get.snackbar('🎉 收获成功', '获得 ${crop.harvestPrice} 金币,+${crop.harvestExp} 经验'); + } + + Future clearWitheredLand(int landId) async { + final land = lands.firstWhere((l) => l.landId == landId); + if (!land.isWithered) return; + + land.cropId = null; + land.plantTime = null; + land.growthStage = 0; + land.isWithered = false; + land.isReady = false; + land.needWater = false; + + await _saveLands(); + Get.snackbar('🧹 清理完成', '土地已恢复'); + } + + void _checkLevelUp() { + while (player.value.experience >= player.value.expToNextLevel) { + player.value.experience -= player.value.expToNextLevel; + player.value.level++; + + // 升级奖励 + player.value.gold += player.value.level * 50; + player.value.diamond += 5; + + // 解锁新作物 + final newCrops = CropRegistry.getAvailableForLevel(player.value.level); + for (final crop in newCrops) { + if (!player.value.unlockedCrops.contains(crop.id)) { + player.value.unlockedCrops.add(crop.id); + } + } + + Get.snackbar( + '🎊 升级!', + '当前等级:Lv.${player.value.level}\n奖励:${player.value.level * 50} 金币 + 5 钻石', + duration: const Duration(seconds: 3), + ); + + _checkAchievements(); + } + _savePlayer(); + } + + void _checkAchievements() { + final completed = player.value.achievements; + final newOnes = AchievementRegistry.checkNewAchievements( + 'totalHarvest', + player.value.totalHarvest, + completed, + ); + + newOnes.addAll(AchievementRegistry.checkNewAchievements( + 'level', + player.value.level, + completed, + )); + + for (final achievement in newOnes) { + if (!completed.contains(achievement.id)) { + completed.add(achievement.id); + player.value.gold += achievement.rewardGold; + player.value.experience += achievement.rewardExp; + player.value.diamond += achievement.rewardDiamond; + + newAchievements.add(achievement); + Get.snackbar( + '🏆 成就解锁!', + '${achievement.emoji} ${achievement.name}\n${achievement.description}', + duration: const Duration(seconds: 3), + ); + } + } + + _savePlayer(); + } + + Future _savePlayer() async { + await _dataService.savePlayer(player.value); + } + + Future _saveLands() async { + await _dataService.saveAllLands(lands); + } + + Future _saveInventory() async { + for (final item in inventory) { + await _dataService.saveItem(item); + } + } + + // 调试功能 + Future debugAddGold() async { + player.value.gold += 1000; + await _savePlayer(); + Get.snackbar('调试', '已添加 1000 金币'); + } + + Future debugSpeedUp() async { + for (final land in lands) { + if (land.cropId != null && !land.isReady) { + land.plantTime = DateTime.now().subtract( + Duration( + minutes: CropRegistry.getById(land.cropId!)!.growthTime, + ), + ); + } + } + await _saveLands(); + Get.snackbar('调试', '所有作物已加速成熟'); + } +} +``` + +#### Step 2: 创建商店控制器 + +Create: `lib/src/controllers/farm/farm_shop_controller.dart` + +```dart +/* + * 文件: farm_shop_controller.dart + * 名称: 农场商店控制器 + * 作用: 管理种子购买逻辑 + * 更新: 2026-04-18 初始创建 + */ + +import 'package:get/get.dart'; +import 'package:mom_kitchen/src/models/farm/crop_config.dart'; +import 'package:mom_kitchen/src/models/farm/crop_registry.dart'; +import 'package:mom_kitchen/src/models/farm/inventory_item.dart'; +import 'package:mom_kitchen/src/services/data/farm_data_service.dart'; +import 'package:mom_kitchen/src/controllers/farm/farm_game_controller.dart'; + +class FarmShopController extends GetxController { + final _dataService = FarmDataService.instance; + final _gameController = Get.find(); + + RxList availableCrops = [].obs; + + @override + void onInit() { + super.onInit(); + _loadAvailableCrops(); + } + + void _loadAvailableCrops() { + availableCrops.assignAll(CropRegistry.getAll()); + } + + Future buySeed(String cropId) async { + final crop = CropRegistry.getById(cropId); + if (crop == null) return; + + final player = _gameController.player.value; + if (player.gold < crop.seedPrice) { + Get.snackbar('金币不足', '需要 ${crop.seedPrice} 金币,当前 ${player.gold} 金币'); + return; + } + + // 扣除金币 + player.gold -= crop.seedPrice; + _gameController.player.value = player; + await _dataService.savePlayer(player); + + // 添加种子到背包 + final seedItem = InventoryItem.seed( + cropId: crop.id, + name: crop.name, + emoji: crop.emoji, + price: crop.seedPrice, + quantity: 1, + ); + + final inventory = _gameController.inventory; + final existing = inventory.where( + (item) => item.itemId == seedItem.itemId, + ); + if (existing.isNotEmpty) { + existing.first.quantity++; + } else { + inventory.add(seedItem); + } + + Get.snackbar('🛒 购买成功', '已购买 ${crop.name}种子'); + } +} +``` + +#### Step 3: 创建背包控制器 + +Create: `lib/src/controllers/farm/farm_inventory_controller.dart` + +```dart +/* + * 文件: farm_inventory_controller.dart + * 名称: 农场背包控制器 + * 作用: 管理背包物品显示和分类 + * 更新: 2026-04-18 初始创建 + */ + +import 'package:get/get.dart'; +import 'package:mom_kitchen/src/models/farm/inventory_item.dart'; +import 'package:mom_kitchen/src/controllers/farm/farm_game_controller.dart'; + +class FarmInventoryController extends GetxController { + final _gameController = Get.find(); + + final RxString selectedTab = 'seed'.obs; + + List get seeds => + _gameController.inventory.where((i) => i.isSeed).toList(); + + List get fruits => + _gameController.inventory.where((i) => i.isFruit).toList(); + + List get tools => + _gameController.inventory.where((i) => i.itemType == 'tool').toList(); + + List get filteredItems { + switch (selectedTab.value) { + case 'seed': + return seeds; + case 'fruit': + return fruits; + case 'tool': + return tools; + default: + return _gameController.inventory; + } + } + + void selectTab(String tab) { + selectedTab.value = tab; + } + + int get totalItems => _gameController.inventory.fold( + 0, + (sum, item) => sum + item.quantity, + ); +} +``` + +#### Step 4: 创建成就控制器 + +Create: `lib/src/controllers/farm/farm_achievement_controller.dart` + +```dart +/* + * 文件: farm_achievement_controller.dart + * 名称: 农场成就控制器 + * 作用: 管理成就显示和进度 + * 更新: 2026-04-18 初始创建 + */ + +import 'package:get/get.dart'; +import 'package:mom_kitchen/src/models/farm/achievement_config.dart'; +import 'package:mom_kitchen/src/models/farm/achievement_registry.dart'; +import 'package:mom_kitchen/src/controllers/farm/farm_game_controller.dart'; + +class FarmAchievementController extends GetxController { + final _gameController = Get.find(); + + List get allAchievements => + AchievementRegistry.getAll().values.toList(); + + List get completedAchievements => allAchievements + .where((a) => _gameController.player.value.achievements.contains(a.id)) + .toList(); + + List get pendingAchievements => allAchievements + .where((a) => + !_gameController.player.value.achievements.contains(a.id)) + .toList(); + + double getProgress(AchievementConfig achievement) { + final player = _gameController.player.value; + + switch (achievement.conditionType) { + case 'totalHarvest': + return (player.totalHarvest / achievement.conditionValue) + .clamp(0.0, 1.0); + case 'level': + return (player.level / achievement.conditionValue).clamp(0.0, 1.0); + case 'unlockedCrops': + return (player.unlockedCrops.length / achievement.conditionValue) + .clamp(0.0, 1.0); + default: + return 0.0; + } + } +} +``` + +#### Step 5: 注册控制器到 AppBinding + +Modify: `lib/src/app_binding.dart` + +在 `dependencies()` 方法末尾添加: + +```dart +// --- 农场游戏控制器 --- +Get.lazyPut(() => FarmGameController(), fenix: true); +Get.lazyPut(() => FarmShopController(), fenix: true); +Get.lazyPut(() => FarmInventoryController(), fenix: true); +Get.lazyPut(() => FarmAchievementController(), fenix: true); +``` + +同时在文件顶部添加导入: + +```dart +import 'package:mom_kitchen/src/controllers/farm/farm_game_controller.dart'; +import 'package:mom_kitchen/src/controllers/farm/farm_shop_controller.dart'; +import 'package:mom_kitchen/src/controllers/farm/farm_inventory_controller.dart'; +import 'package:mom_kitchen/src/controllers/farm/farm_achievement_controller.dart'; +``` + +--- + +### Task 5: UI 层 - 主游戏页面 + +**目标:** 创建主游戏页面,包含 Canvas 绘制的菜园网格和交互逻辑 + +**Files:** +- Create: `lib/src/pages/tools/farm/widgets/farm_grid_painter.dart` +- Create: `lib/src/pages/tools/farm/widgets/land_widget.dart` +- Create: `lib/src/pages/tools/farm/widgets/crop_widget.dart` +- Create: `lib/src/pages/tools/farm/farm_game_page.dart` + +#### Step 1: 创建土地 Widget + +Create: `lib/src/pages/tools/farm/widgets/land_widget.dart` + +```dart +/* + * 文件: land_widget.dart + * 名称: 土地 Widget + * 作用: 显示单块土地及其作物状态 + * 更新: 2026-04-18 初始创建 + */ + +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/farm/farm_land.dart'; +import 'package:mom_kitchen/src/controllers/farm/farm_game_controller.dart'; + +class LandWidget extends StatelessWidget { + final FarmLand land; + final VoidCallback onTap; + + const LandWidget({ + super.key, + required this.land, + required this.onTap, + }); + + @override + Widget build(BuildContext context) { + final isDark = CupertinoTheme.brightnessOf(context) == Brightness.dark; + + return GestureDetector( + onTap: onTap, + child: AnimatedContainer( + duration: DesignTokens.durationNormal, + decoration: BoxDecoration( + color: land.isUnlocked + ? (isDark + ? const Color(0xFF3D2B1F) + : const Color(0xFFDEB887)) + : (isDark ? DarkDesignTokens.card : DesignTokens.text3.withValues(alpha: 0.1)), + borderRadius: DesignTokens.borderRadiusMd, + border: Border.all( + color: land.isUnlocked + ? (isDark ? const Color(0xFF5C4033) : const Color(0xFFCD853F)) + : (isDark ? DarkDesignTokens.glassBorder : DesignTokens.text3.withValues(alpha: 0.2)), + width: 2, + ), + boxShadow: [ + BoxShadow( + color: isDark ? Colors.black.withValues(alpha: 0.3) : Colors.black.withValues(alpha: 0.1), + blurRadius: 8, + offset: const Offset(0, 4), + ), + ], + ), + child: land.isUnlocked + ? _buildLandContent() + : _buildLockedOverlay(), + ), + ); + } + + Widget _buildLandContent() { + if (land.cropId == null) { + return Center( + child: Text( + '🟫', + style: const TextStyle(fontSize: 32), + ), + ); + } + + return Stack( + children: [ + Center( + child: AnimatedSwitcher( + duration: DesignTokens.durationNormal, + child: Text( + land.currentDisplayEmoji, + key: ValueKey(land.currentDisplayEmoji), + style: const TextStyle(fontSize: 36), + ), + ), + ), + if (land.needWater && !land.isWithered) + Positioned( + top: 4, + right: 4, + child: Container( + padding: const EdgeInsets.all(2), + decoration: BoxDecoration( + color: Colors.blue.withValues(alpha: 0.8), + borderRadius: DesignTokens.borderRadiusFull, + ), + child: const Text('💧', style: TextStyle(fontSize: 10)), + ), + ), + if (land.isReady) + Positioned( + top: 4, + right: 4, + child: Container( + padding: const EdgeInsets.all(2), + decoration: BoxDecoration( + color: Colors.green.withValues(alpha: 0.8), + borderRadius: DesignTokens.borderRadiusFull, + ), + child: const Text('✨', style: TextStyle(fontSize: 10)), + ), + ), + if (land.isWithered) + Positioned.fill( + child: Container( + decoration: BoxDecoration( + color: Colors.black.withValues(alpha: 0.3), + borderRadius: DesignTokens.borderRadiusMd, + ), + child: const Center( + child: Text('🥀', style: TextStyle(fontSize: 24)), + ), + ), + ), + ], + ); + } + + Widget _buildLockedOverlay() { + return Center( + child: Column( + mainAxisAlignment: MainAxisAlignment.center, + children: [ + const Text('🔒', style: TextStyle(fontSize: 24)), + const SizedBox(height: 4), + Text( + '未解锁', + style: TextStyle( + fontSize: DesignTokens.fontXs, + color: CupertinoTheme.brightnessOf(Get.context!) == Brightness.dark + ? DarkDesignTokens.text3 + : DesignTokens.text3, + ), + ), + ], + ), + ); + } +} +``` + +#### Step 2: 创建主游戏页面 + +Create: `lib/src/pages/tools/farm/farm_game_page.dart` + +```dart +/* + * 文件: farm_game_page.dart + * 名称: 农场主游戏页面 + * 作用: 展示菜园网格,提供种植、浇水、收获等操作 + * 更新: 2026-04-18 初始创建 + */ + +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/farm/farm_game_controller.dart'; +import 'package:mom_kitchen/src/pages/tools/farm/widgets/land_widget.dart'; + +class FarmGamePage extends StatefulWidget { + const FarmGamePage({super.key}); + + @override + State createState() => _FarmGamePageState(); +} + +class _FarmGamePageState extends State { + late FarmGameController _controller; + bool _isInitialized = false; + + @override + void didChangeDependencies() { + super.didChangeDependencies(); + if (!_isInitialized) { + _isInitialized = true; + _controller = Get.find(); + } + } + + @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), + _buildStatusBar(isDark), + Expanded(child: _buildGardenGrid(isDark)), + _buildActionBar(isDark), + ], + ), + ), + ); + } + + Widget _buildHeader(bool isDark) { + return Padding( + padding: const EdgeInsets.fromLTRB( + DesignTokens.space4, + DesignTokens.space3, + DesignTokens.space4, + DesignTokens.space2, + ), + child: Row( + children: [ + GestureDetector( + onTap: () => Get.back(), + child: Container( + width: 40, + height: 40, + decoration: BoxDecoration( + color: isDark + ? DarkDesignTokens.glass + : DesignTokens.text3.withValues(alpha: 0.08), + borderRadius: DesignTokens.borderRadiusMd, + ), + child: const Icon(CupertinoIcons.back, size: 20), + ), + ), + 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, + ), + ), + ], + ), + ), + Row( + children: [ + _buildNavButton( + icon: CupertinoIcons.bag, + onTap: () => Get.toNamed('/farm-inventory'), + isDark: isDark, + ), + const SizedBox(width: 8), + _buildNavButton( + icon: CupertinoIcons.cart, + onTap: () => Get.toNamed('/farm-shop'), + isDark: isDark, + ), + const SizedBox(width: 8), + _buildNavButton( + icon: CupertinoIcons.trophy, + onTap: () => Get.toNamed('/farm-achievement'), + isDark: isDark, + ), + ], + ), + ], + ), + ); + } + + Widget _buildNavButton({ + required IconData icon, + required VoidCallback onTap, + required bool isDark, + }) { + return GestureDetector( + onTap: onTap, + child: Container( + width: 40, + height: 40, + decoration: BoxDecoration( + color: DesignTokens.dynamicPrimary.withValues(alpha: 0.1), + borderRadius: DesignTokens.borderRadiusMd, + ), + child: Icon(icon, size: 20, color: DesignTokens.dynamicPrimary), + ), + ); + } + + Widget _buildStatusBar(bool isDark) { + return Obx(() { + final player = _controller.player.value; + return Container( + padding: const EdgeInsets.symmetric( + horizontal: DesignTokens.space4, + vertical: DesignTokens.space2, + ), + margin: const EdgeInsets.symmetric(horizontal: DesignTokens.space4), + decoration: BoxDecoration( + color: isDark ? DarkDesignTokens.card : DesignTokens.card, + borderRadius: DesignTokens.borderRadiusMd, + border: Border.all( + color: isDark + ? DarkDesignTokens.glassBorder + : DesignTokens.text3.withValues(alpha: 0.08), + ), + ), + child: Row( + children: [ + _buildStatusItem( + icon: '⭐', + value: 'Lv.${player.level}', + progress: player.expProgress, + isDark: isDark, + ), + const SizedBox(width: DesignTokens.space3), + _buildStatusItem( + icon: '💰', + value: '${player.gold}', + isDark: isDark, + ), + const SizedBox(width: DesignTokens.space3), + _buildStatusItem( + icon: '💎', + value: '${player.diamond}', + isDark: isDark, + ), + const Spacer(), + _buildStatusItem( + icon: '🌾', + value: '${player.totalHarvest}', + label: '收获', + isDark: isDark, + ), + ], + ), + ); + }); + } + + Widget _buildStatusItem({ + required String icon, + required String value, + double? progress, + String? label, + required bool isDark, + }) { + return Row( + mainAxisSize: MainAxisSize.min, + children: [ + Text(icon, style: const TextStyle(fontSize: 16)), + const SizedBox(width: 4), + Text( + value, + style: TextStyle( + fontSize: DesignTokens.fontMd, + fontWeight: FontWeight.w600, + color: isDark ? DarkDesignTokens.text1 : DesignTokens.text1, + ), + ), + if (progress != null) ...[ + const SizedBox(width: 4), + SizedBox( + width: 40, + child: LinearProgressIndicator( + value: progress, + backgroundColor: isDark + ? DarkDesignTokens.glassBorder + : DesignTokens.text3.withValues(alpha: 0.1), + valueColor: AlwaysStoppedAnimation(DesignTokens.dynamicPrimary), + ), + ), + ], + if (label != null) ...[ + const SizedBox(width: 4), + Text( + label, + style: TextStyle( + fontSize: DesignTokens.fontXs, + color: isDark ? DarkDesignTokens.text3 : DesignTokens.text3, + ), + ), + ], + ], + ); + } + + Widget _buildGardenGrid(bool isDark) { + return Obx(() { + final lands = _controller.lands; + return Padding( + padding: const EdgeInsets.all(DesignTokens.space4), + child: LayoutBuilder( + builder: (context, constraints) { + final crossAxisCount = constraints.maxWidth > 600 ? 4 : 3; + final itemWidth = + (constraints.maxWidth - 12 * (crossAxisCount - 1)) / crossAxisCount; + + return GridView.builder( + physics: const BouncingScrollPhysics(), + gridDelegate: SliverGridDelegateWithFixedCrossAxisCount( + crossAxisCount: crossAxisCount, + mainAxisSpacing: 12, + crossAxisSpacing: 12, + childAspectRatio: 1, + ), + itemCount: lands.length, + itemBuilder: (context, index) { + final land = lands[index]; + return SizedBox( + width: itemWidth, + child: LandWidget( + land: land, + onTap: () => _showLandActionSheet(land, isDark), + ), + ); + }, + ); + }, + ), + ); + }); + } + + void _showLandActionSheet(dynamic land, bool isDark) { + showCupertinoModalPopup( + context: context, + builder: (context) { + return CupertinoActionSheet( + title: Text('土地 ${land.landId + 1}'), + actions: [ + if (land.cropId == null && land.isUnlocked) + CupertinoActionSheetAction( + onPressed: () { + Get.back(); + _showPlantSelector(land.landId); + }, + child: const Text('🌱 播种'), + ), + if (land.needWater && !land.isWithered) + CupertinoActionSheetAction( + onPressed: () { + Get.back(); + _controller.waterLand(land.landId); + }, + child: const Text('💧 浇水'), + ), + if (land.isReady) + CupertinoActionSheetAction( + onPressed: () { + Get.back(); + _controller.harvestCrop(land.landId); + }, + child: const Text('🎉 收获'), + ), + if (land.isWithered) + CupertinoActionSheetAction( + onPressed: () { + Get.back(); + _controller.clearWitheredLand(land.landId); + }, + child: const Text('🧹 清理'), + ), + if (!land.isUnlocked) + CupertinoActionSheetAction( + onPressed: () { + Get.back(); + _unlockLand(land.landId); + }, + child: const Text('🔓 解锁土地'), + ), + ], + cancelButton: CupertinoActionSheetAction( + isDefaultAction: true, + onPressed: () => Get.back(), + child: const Text('取消'), + ), + ); + }, + ); + } + + void _showPlantSelector(int landId) { + final seeds = _controller.inventory.where((i) => i.isSeed && i.quantity > 0).toList(); + + showCupertinoModalPopup( + context: context, + builder: (context) { + return CupertinoActionSheet( + title: const Text('选择要播种的种子'), + actions: seeds.map((seed) { + return CupertinoActionSheetAction( + onPressed: () { + Get.back(); + final cropId = seed.itemId.replaceAll('_seed', ''); + _controller.plantCrop(landId: landId, cropId: cropId); + }, + child: Text('${seed.emoji} ${seed.itemName} x${seed.quantity}'), + ); + }).toList(), + cancelButton: CupertinoActionSheetAction( + isDefaultAction: true, + onPressed: () => Get.back(), + child: const Text('取消'), + ), + ); + }, + ); + } + + void _unlockLand(int landId) { + showCupertinoDialog( + context: context, + builder: (context) { + return CupertinoAlertDialog( + title: const Text('解锁土地'), + content: Text('解锁这块土地需要 200 金币,是否确认解锁?'), + actions: [ + CupertinoDialogAction( + child: const Text('取消'), + onPressed: () => Get.back(), + ), + CupertinoDialogAction( + isDefaultAction: true, + child: const Text('确认'), + onPressed: () { + Get.back(); + final player = _controller.player.value; + if (player.gold >= 200) { + player.gold -= 200; + final land = _controller.lands.firstWhere( + (l) => l.landId == landId, + ); + land.isUnlocked = true; + _controller.player.value = player; + Get.snackbar('解锁成功', '土地 ${landId + 1} 已解锁'); + } else { + Get.snackbar('金币不足', '需要 200 金币解锁土地'); + } + }, + ), + ], + ); + }, + ); + } + + Widget _buildActionBar(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: Row( + children: [ + _buildDebugButton(isDark), + const Spacer(), + ElevatedButton.icon( + onPressed: () => _showShareSheet(), + icon: const Icon(CupertinoIcons.share), + label: const Text('分享收获'), + style: ElevatedButton.styleFrom( + backgroundColor: DesignTokens.dynamicPrimary, + foregroundColor: CupertinoColors.white, + padding: const EdgeInsets.symmetric( + horizontal: DesignTokens.space4, + vertical: DesignTokens.space3, + ), + shape: RoundedRectangleBorder( + borderRadius: DesignTokens.borderRadiusMd, + ), + ), + ), + ], + ), + ); + } + + Widget _buildDebugButton(bool isDark) { + return CupertinoButton( + padding: EdgeInsets.zero, + onPressed: () { + showCupertinoModalPopup( + context: context, + builder: (context) { + return CupertinoActionSheet( + title: const Text('🐛 调试功能'), + message: const Text('以下功能仅用于开发测试'), + actions: [ + CupertinoActionSheetAction( + onPressed: () { + Get.back(); + _controller.debugAddGold(); + }, + child: const Text('💰 添加 1000 金币'), + ), + CupertinoActionSheetAction( + onPressed: () { + Get.back(); + _controller.debugSpeedUp(); + }, + child: const Text('⚡ 加速所有作物'), + ), + ], + cancelButton: CupertinoActionSheetAction( + isDefaultAction: true, + onPressed: () => Get.back(), + child: const Text('关闭'), + ), + ); + }, + ); + }, + child: Icon( + CupertinoIcons.hammer, + color: isDark ? DarkDesignTokens.text3 : DesignTokens.text3, + ), + ); + } + + void _showShareSheet() { + Get.snackbar('分享功能', '正在生成分享图片...', duration: const Duration(seconds: 1)); + // TODO: 实现分享逻辑 + } +} +``` + +--- + +### Task 6: UI 层 - 商店页面 + +**目标:** 创建种子商店页面,支持购买操作 + +**Files:** +- Create: `lib/src/pages/tools/farm/farm_shop_page.dart` + +#### Step 1: 创建商店页面 + +Create: `lib/src/pages/tools/farm/farm_shop_page.dart` + +```dart +/* + * 文件: farm_shop_page.dart + * 名称: 农场商店页面 + * 作用: 显示可购买的种子列表,支持购买操作 + * 更新: 2026-04-18 初始创建 + */ + +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/farm/farm_shop_controller.dart'; +import 'package:mom_kitchen/src/controllers/farm/farm_game_controller.dart'; +import 'package:mom_kitchen/src/models/farm/crop_config.dart'; + +class FarmShopPage extends StatefulWidget { + const FarmShopPage({super.key}); + + @override + State createState() => _FarmShopPageState(); +} + +class _FarmShopPageState extends State { + late FarmShopController _shopController; + late FarmGameController _gameController; + bool _isInitialized = false; + + @override + void didChangeDependencies() { + super.didChangeDependencies(); + if (!_isInitialized) { + _isInitialized = true; + _shopController = Get.find(); + _gameController = Get.find(); + } + } + + @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), + Expanded(child: _buildShopList(isDark)), + ], + ), + ), + ); + } + + Widget _buildHeader(bool isDark) { + return Padding( + padding: const EdgeInsets.all(DesignTokens.space4), + child: Row( + children: [ + GestureDetector( + onTap: () => Get.back(), + child: Container( + width: 40, + height: 40, + decoration: BoxDecoration( + color: isDark + ? DarkDesignTokens.glass + : DesignTokens.text3.withValues(alpha: 0.08), + borderRadius: DesignTokens.borderRadiusMd, + ), + child: const Icon(CupertinoIcons.back, size: 20), + ), + ), + 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, + ), + ), + Obx(() => Text( + '💰 ${_gameController.player.value.gold} 金币', + style: TextStyle( + fontSize: DesignTokens.fontSm, + color: isDark ? DarkDesignTokens.text3 : DesignTokens.text3, + ), + )), + ], + ), + ), + ], + ), + ); + } + + Widget _buildShopList(bool isDark) { + return Obx(() { + final crops = _shopController.availableCrops; + return ListView.separated( + padding: const EdgeInsets.all(DesignTokens.space4), + itemCount: crops.length, + separatorBuilder: (context, index) => const SizedBox(height: 12), + itemBuilder: (context, index) { + final crop = crops[index]; + final isUnlocked = _gameController.player.value.unlockedCrops.contains(crop.id); + + return _buildCropCard(crop, isUnlocked, isDark); + }, + ); + }); + } + + Widget _buildCropCard(CropConfig crop, bool isUnlocked, bool isDark) { + return Container( + padding: const EdgeInsets.all(DesignTokens.space4), + decoration: BoxDecoration( + color: isDark ? DarkDesignTokens.card : DesignTokens.card, + borderRadius: DesignTokens.borderRadiusLg, + border: Border.all( + color: isDark + ? DarkDesignTokens.glassBorder + : DesignTokens.text3.withValues(alpha: 0.08), + ), + boxShadow: DesignTokens.shadowsMd, + ), + child: Row( + children: [ + Container( + width: 64, + height: 64, + decoration: BoxDecoration( + color: DesignTokens.dynamicPrimary.withValues(alpha: 0.1), + borderRadius: DesignTokens.borderRadiusMd, + ), + child: Center( + child: Text(crop.emoji, style: const TextStyle(fontSize: 36)), + ), + ), + const SizedBox(width: DesignTokens.space4), + Expanded( + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Row( + children: [ + Text( + crop.name, + style: TextStyle( + fontSize: DesignTokens.fontLg, + fontWeight: FontWeight.w600, + color: isDark ? DarkDesignTokens.text1 : DesignTokens.text1, + ), + ), + if (!isUnlocked) ...[ + const SizedBox(width: 8), + Container( + padding: const EdgeInsets.symmetric( + horizontal: 8, + vertical: 2, + ), + decoration: BoxDecoration( + color: DesignTokens.orange.withValues(alpha: 0.2), + borderRadius: DesignTokens.borderRadiusSm, + ), + child: Text( + 'Lv.${crop.unlockLevel} 解锁', + style: TextStyle( + fontSize: DesignTokens.fontXs, + color: DesignTokens.orange, + ), + ), + ), + ], + ], + ), + const SizedBox(height: 8), + Row( + children: [ + _buildInfoTag('⏱️', '${crop.growthTime} 分钟', isDark), + const SizedBox(width: 8), + _buildInfoTag('💰', '收获 ${crop.harvestPrice}', isDark), + const SizedBox(width: 8), + _buildInfoTag('⭐', '+${crop.harvestExp} EXP', isDark), + ], + ), + ], + ), + ), + const SizedBox(width: DesignTokens.space3), + SizedBox( + width: 80, + child: CupertinoButton( + padding: EdgeInsets.zero, + color: isUnlocked ? DesignTokens.dynamicPrimary : DesignTokens.text3, + borderRadius: DesignTokens.borderRadiusMd, + onPressed: isUnlocked ? () => _shopController.buySeed(crop.id) : null, + child: Text( + '${crop.seedPrice}💰', + style: TextStyle( + fontSize: DesignTokens.fontMd, + fontWeight: FontWeight.w600, + color: CupertinoColors.white, + ), + ), + ), + ), + ], + ), + ); + } + + Widget _buildInfoTag(String icon, String text, bool isDark) { + return Row( + mainAxisSize: MainAxisSize.min, + children: [ + Text(icon, style: const TextStyle(fontSize: 12)), + const SizedBox(width: 4), + Text( + text, + style: TextStyle( + fontSize: DesignTokens.fontSm, + color: isDark ? DarkDesignTokens.text2 : DesignTokens.text2, + ), + ), + ], + ); + } +} +``` + +--- + +### Task 7: UI 层 - 背包和成就页面 + +**目标:** 创建背包和成就页面 + +**Files:** +- Create: `lib/src/pages/tools/farm/farm_inventory_page.dart` +- Create: `lib/src/pages/tools/farm/farm_achievement_page.dart` + +#### Step 1: 创建背包页面 + +Create: `lib/src/pages/tools/farm/farm_inventory_page.dart` + +```dart +/* + * 文件: farm_inventory_page.dart + * 名称: 农场背包页面 + * 作用: 显示背包物品,支持分类查看 + * 更新: 2026-04-18 初始创建 + */ + +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/farm/farm_inventory_controller.dart'; +import 'package:mom_kitchen/src/models/farm/inventory_item.dart'; + +class FarmInventoryPage extends StatefulWidget { + const FarmInventoryPage({super.key}); + + @override + State createState() => _FarmInventoryPageState(); +} + +class _FarmInventoryPageState extends State { + late FarmInventoryController _controller; + bool _isInitialized = false; + + @override + void didChangeDependencies() { + super.didChangeDependencies(); + if (!_isInitialized) { + _isInitialized = true; + _controller = Get.find(); + } + } + + @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), + _buildTabs(isDark), + Expanded(child: _buildInventoryList(isDark)), + ], + ), + ), + ); + } + + Widget _buildHeader(bool isDark) { + return Padding( + padding: const EdgeInsets.all(DesignTokens.space4), + child: Row( + children: [ + GestureDetector( + onTap: () => Get.back(), + child: Container( + width: 40, + height: 40, + decoration: BoxDecoration( + color: isDark + ? DarkDesignTokens.glass + : DesignTokens.text3.withValues(alpha: 0.08), + borderRadius: DesignTokens.borderRadiusMd, + ), + child: const Icon(CupertinoIcons.back, size: 20), + ), + ), + const SizedBox(width: DesignTokens.space3), + Expanded( + child: Text( + '🎒 我的背包', + style: TextStyle( + fontSize: DesignTokens.fontXxl, + fontWeight: FontWeight.w700, + color: isDark ? DarkDesignTokens.text1 : DesignTokens.text1, + ), + ), + ), + Obx(() => Text( + '共 ${_controller.totalItems} 件', + style: TextStyle( + fontSize: DesignTokens.fontMd, + color: isDark ? DarkDesignTokens.text3 : DesignTokens.text3, + ), + )), + ], + ), + ); + } + + Widget _buildTabs(bool isDark) { + return Obx(() { + final tabs = [ + {'id': 'seed', 'label': '🌱 种子', 'count': _controller.seeds.length}, + {'id': 'fruit', 'label': '🍎 果实', 'count': _controller.fruits.length}, + {'id': 'tool', 'label': '🔧 道具', 'count': _controller.tools.length}, + ]; + + return Container( + margin: const EdgeInsets.symmetric(horizontal: DesignTokens.space4), + padding: const EdgeInsets.all(4), + decoration: BoxDecoration( + color: isDark ? DarkDesignTokens.segmentedBg : DesignTokens.segmentedBg, + borderRadius: DesignTokens.borderRadiusMd, + ), + child: Row( + children: tabs.map((tab) { + final isSelected = _controller.selectedTab.value == tab['id']; + return Expanded( + child: GestureDetector( + onTap: () => _controller.selectTab(tab['id'] as String), + child: Container( + padding: const EdgeInsets.symmetric(vertical: 10), + decoration: BoxDecoration( + color: isSelected + ? (isDark ? DarkDesignTokens.card : DesignTokens.card) + : Colors.transparent, + borderRadius: DesignTokens.borderRadiusSm, + boxShadow: isSelected ? DesignTokens.shadowsSm : null, + ), + child: Center( + child: Text( + '${tab['label']} (${tab['count']})', + style: TextStyle( + fontSize: DesignTokens.fontSm, + fontWeight: isSelected ? FontWeight.w600 : FontWeight.normal, + color: isDark ? DarkDesignTokens.text1 : DesignTokens.text1, + ), + ), + ), + ), + ), + ); + }).toList(), + ), + ); + }); + } + + Widget _buildInventoryList(bool isDark) { + return Obx(() { + final items = _controller.filteredItems; + + if (items.isEmpty) { + return Center( + child: Column( + mainAxisAlignment: MainAxisAlignment.center, + children: [ + const Text('📭', style: TextStyle(fontSize: 64)), + 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 GridView.builder( + padding: const EdgeInsets.all(DesignTokens.space4), + gridDelegate: const SliverGridDelegateWithFixedCrossAxisCount( + crossAxisCount: 3, + mainAxisSpacing: 12, + crossAxisSpacing: 12, + childAspectRatio: 0.8, + ), + itemCount: items.length, + itemBuilder: (context, index) { + return _buildItemCard(items[index], isDark); + }, + ); + }); + } + + Widget _buildItemCard(InventoryItem item, bool isDark) { + return Container( + padding: const EdgeInsets.all(DesignTokens.space3), + decoration: BoxDecoration( + color: isDark ? DarkDesignTokens.card : DesignTokens.card, + borderRadius: DesignTokens.borderRadiusLg, + border: Border.all( + color: isDark + ? DarkDesignTokens.glassBorder + : DesignTokens.text3.withValues(alpha: 0.08), + ), + boxShadow: DesignTokens.shadowsSm, + ), + child: Column( + mainAxisAlignment: MainAxisAlignment.center, + children: [ + Text(item.emoji, style: const TextStyle(fontSize: 40)), + const SizedBox(height: 8), + Text( + item.itemName, + style: TextStyle( + fontSize: DesignTokens.fontSm, + fontWeight: FontWeight.w600, + color: isDark ? DarkDesignTokens.text1 : DesignTokens.text1, + ), + textAlign: TextAlign.center, + maxLines: 2, + overflow: TextOverflow.ellipsis, + ), + const SizedBox(height: 4), + Text( + 'x${item.quantity}', + style: TextStyle( + fontSize: DesignTokens.fontLg, + fontWeight: FontWeight.w700, + color: DesignTokens.dynamicPrimary, + ), + ), + ], + ), + ); + } +} +``` + +#### Step 2: 创建成就页面 + +Create: `lib/src/pages/tools/farm/farm_achievement_page.dart` + +```dart +/* + * 文件: farm_achievement_page.dart + * 名称: 农场成就页面 + * 作用: 显示成就列表和进度 + * 更新: 2026-04-18 初始创建 + */ + +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/farm/farm_achievement_controller.dart'; +import 'package:mom_kitchen/src/models/farm/achievement_config.dart'; + +class FarmAchievementPage extends StatefulWidget { + const FarmAchievementPage({super.key}); + + @override + State createState() => _FarmAchievementPageState(); +} + +class _FarmAchievementPageState extends State { + late FarmAchievementController _controller; + bool _isInitialized = false; + + @override + void didChangeDependencies() { + super.didChangeDependencies(); + if (!_isInitialized) { + _isInitialized = true; + _controller = Get.find(); + } + } + + @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), + Expanded(child: _buildAchievementList(isDark)), + ], + ), + ), + ); + } + + Widget _buildHeader(bool isDark) { + return Padding( + padding: const EdgeInsets.all(DesignTokens.space4), + child: Row( + children: [ + GestureDetector( + onTap: () => Get.back(), + child: Container( + width: 40, + height: 40, + decoration: BoxDecoration( + color: isDark + ? DarkDesignTokens.glass + : DesignTokens.text3.withValues(alpha: 0.08), + borderRadius: DesignTokens.borderRadiusMd, + ), + child: const Icon(CupertinoIcons.back, size: 20), + ), + ), + const SizedBox(width: DesignTokens.space3), + Expanded( + child: Text( + '🏆 成就中心', + style: TextStyle( + fontSize: DesignTokens.fontXxl, + fontWeight: FontWeight.w700, + color: isDark ? DarkDesignTokens.text1 : DesignTokens.text1, + ), + ), + ), + Obx(() => Text( + '${_controller.completedAchievements.length}/${_controller.allAchievements.length}', + style: TextStyle( + fontSize: DesignTokens.fontMd, + color: isDark ? DarkDesignTokens.text3 : DesignTokens.text3, + ), + )), + ], + ), + ); + } + + Widget _buildAchievementList(bool isDark) { + return ListView.separated( + padding: const EdgeInsets.all(DesignTokens.space4), + itemCount: _controller.allAchievements.length, + separatorBuilder: (context, index) => const SizedBox(height: 12), + itemBuilder: (context, index) { + final achievement = _controller.allAchievements[index]; + final isCompleted = _controller.completedAchievements.contains(achievement); + return _buildAchievementCard(achievement, isCompleted, isDark); + }, + ); + } + + Widget _buildAchievementCard( + AchievementConfig achievement, + bool isCompleted, + bool isDark, + ) { + final progress = _controller.getProgress(achievement); + + return Container( + padding: const EdgeInsets.all(DesignTokens.space4), + decoration: BoxDecoration( + color: isCompleted + ? (DesignTokens.green.withValues(alpha: isDark ? 0.1 : 0.05)) + : (isDark ? DarkDesignTokens.card : DesignTokens.card), + borderRadius: DesignTokens.borderRadiusLg, + border: Border.all( + color: isCompleted + ? DesignTokens.green.withValues(alpha: 0.3) + : (isDark + ? DarkDesignTokens.glassBorder + : DesignTokens.text3.withValues(alpha: 0.08)), + ), + boxShadow: DesignTokens.shadowsSm, + ), + child: Row( + children: [ + Container( + width: 56, + height: 56, + decoration: BoxDecoration( + color: isCompleted + ? DesignTokens.green.withValues(alpha: 0.2) + : (isDark + ? DarkDesignTokens.glass + : DesignTokens.text3.withValues(alpha: 0.06)), + borderRadius: DesignTokens.borderRadiusMd, + ), + child: Center( + child: Opacity( + opacity: isCompleted ? 1.0 : 0.4, + child: Text( + achievement.emoji, + style: const TextStyle(fontSize: 28), + ), + ), + ), + ), + const SizedBox(width: DesignTokens.space4), + Expanded( + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Row( + children: [ + Text( + achievement.name, + style: TextStyle( + fontSize: DesignTokens.fontLg, + fontWeight: FontWeight.w600, + color: isCompleted + ? DesignTokens.green + : (isDark + ? DarkDesignTokens.text1 + : DesignTokens.text1), + ), + ), + const SizedBox(width: 8), + if (isCompleted) + const Icon( + CupertinoIcons.checkmark_circle_fill, + size: 16, + color: Colors.green, + ), + ], + ), + const SizedBox(height: 4), + Text( + achievement.description, + style: TextStyle( + fontSize: DesignTokens.fontSm, + color: isDark ? DarkDesignTokens.text2 : DesignTokens.text2, + ), + ), + const SizedBox(height: 8), + LinearProgressIndicator( + value: progress, + backgroundColor: isDark + ? DarkDesignTokens.glassBorder + : DesignTokens.text3.withValues(alpha: 0.1), + valueColor: AlwaysStoppedAnimation( + isCompleted ? DesignTokens.green : DesignTokens.dynamicPrimary, + ), + ), + const SizedBox(height: 4), + Row( + children: [ + Text( + '奖励:${achievement.rewardGold}💰 ${achievement.rewardExp}⭐', + style: TextStyle( + fontSize: DesignTokens.fontXs, + color: isDark ? DarkDesignTokens.text3 : DesignTokens.text3, + ), + ), + ], + ), + ], + ), + ), + ], + ), + ); + } + + List get completedAchievements { + return _controller.allAchievements + .where((a) => _controller.completedAchievements.contains(a)) + .toList(); + } +} +``` + +--- + +### Task 8: 路由配置和工具注册 + +**目标:** 在工具中心注册小妈菜园的入口 + +**Files:** +- Modify: `lib/src/config/app_routes.dart` +- Modify: `lib/src/models/tool_item_model.dart` + +#### Step 1: 添加路由定义 + +Modify: `lib/src/config/app_routes.dart` + +在常量定义区域添加: + +```dart +static const String farmGame = '/farm-game'; +static const String farmShop = '/farm-shop'; +static const String farmInventory = '/farm-inventory'; +static const String farmAchievement = '/farm-achievement'; +``` + +在 `pages` 列表中添加: + +```dart +GetPage( + name: farmGame, + page: () => const FarmGamePage(), + middlewares: [PageStandardsMiddleware()], +), +GetPage( + name: farmShop, + page: () => const FarmShopPage(), + middlewares: [PageStandardsMiddleware()], +), +GetPage( + name: farmInventory, + page: () => const FarmInventoryPage(), + middlewares: [PageStandardsMiddleware()], +), +GetPage( + name: farmAchievement, + page: () => const FarmAchievementPage(), + middlewares: [PageStandardsMiddleware()], +), +``` + +在文件顶部添加导入: + +```dart +import 'package:mom_kitchen/src/pages/tools/farm/farm_game_page.dart'; +import 'package:mom_kitchen/src/pages/tools/farm/farm_shop_page.dart'; +import 'package:mom_kitchen/src/pages/tools/farm/farm_inventory_page.dart'; +import 'package:mom_kitchen/src/pages/tools/farm/farm_achievement_page.dart'; +``` + +#### Step 2: 在工具中心添加工具项 + +Modify: `lib/src/models/tool_item_model.dart` + +在 `ToolRegistry.defaultTools` 列表末尾添加: + +```dart +ToolItem( + id: 'farm_game', + name: '小妈菜园', + icon: '🌾', + needsNetwork: false, + category: 'planning', + route: '/farm-game', + description: '种菜收菜,体验农场乐趣', + waterfallSlot: WaterfallSlotConfig( + show: true, + priority: 10, + badge: 'NEW', + ), +), +``` + +--- + +### Task 9: 初始化服务和数据 + +**目标:** 确保游戏数据服务在启动时正确初始化 + +**Files:** +- Modify: `lib/main.dart` 或初始化入口文件 + +#### Step 1: 在应用启动时初始化农场数据 + +在 `main()` 函数或 `AppService` 初始化部分添加: + +```dart +// 导入 +import 'package:mom_kitchen/src/services/data/farm_data_service.dart'; + +// 在现有初始化逻辑中添加 +await FarmDataService.instance.init(); +``` + +--- + +### Task 10: 测试和验证 + +**目标:** 验证所有功能正常工作 + +#### Step 1: 运行项目 + +```bash +flutter run +``` + +#### Step 2: 测试清单 + +- [ ] 进入工具中心,确认"小妈菜园"入口显示 +- [ ] 点击进入游戏,确认页面正常渲染 +- [ ] 点击空地,测试播种功能 +- [ ] 等待生长或点击调试加速,测试生长阶段更新 +- [ ] 测试浇水功能 +- [ ] 作物成熟后测试收获功能 +- [ ] 确认金币和经验增加 +- [ ] 测试升级提示 +- [ ] 进入商店,测试购买种子 +- [ ] 进入背包,确认物品显示正确 +- [ ] 进入成就页面,确认成就进度显示 +- [ ] 测试调试功能(添加金币、加速作物) +- [ ] 切换深色模式,确认 UI 适配 +- [ ] 退出应用后重新进入,确认数据持久化 + +#### Step 3: 空指针检测 + +```bash +flutter analyze +``` + +确保无警告和错误。 + +--- + +## 执行顺序 + +1. **Task 1-2**: 数据模型和配置注册表(无依赖,可并行) +2. **Task 3**: 数据服务层(依赖 Task 1) +3. **Task 4**: 控制器层(依赖 Task 2-3) +4. **Task 5-7**: UI 页面(依赖 Task 4) +5. **Task 8-9**: 路由和初始化(依赖 Task 5-7) +6. **Task 10**: 测试验证 + +--- + +## 总结 + +本计划包含 10 个主要任务,涵盖数据层、业务层、UI 层和集成测试。按照任务顺序逐步执行,每个任务完成后应确保编译通过再进行下一个任务。 + +预计总文件数: +- 新增文件:约 20 个 +- 修改文件:约 4 个 +- 总代码量:约 2500-3000 行 + +所有代码遵循项目现有的 iOS 26 Liquid Glass 设计规范和 GetX 状态管理模式。 diff --git a/docs/superpowers/plans/2026-04-18-offline-enhance-and-data-export.md b/docs/superpowers/plans/2026-04-18-offline-enhance-and-data-export.md new file mode 100644 index 0000000..eb12d18 --- /dev/null +++ b/docs/superpowers/plans/2026-04-18-offline-enhance-and-data-export.md @@ -0,0 +1,1496 @@ +# 离线模式增强 + 数据导出 实施计划 + +> **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:** 增强离线体验(离线指示器、操作守卫、网络恢复自动刷新、关键数据预加载)+ 统一数据导出服务(8种数据源、3种格式、保存/分享) + +**Architecture:** 新建 OfflineService 统一管理离线状态和动作队列,增强 ConnectivityService 添加恢复回调机制;新建 DataExportService 统一导出逻辑,各 Controller 扩展 toJson/toCsv/toMarkdown 方法,新建 DataExportPage 作为导出入口 + +**Tech Stack:** Flutter/Dart, GetX, Hive, share_plus, path_provider, connectivity_plus + +--- + +## File Structure + +### 新建文件 +| 文件 | 职责 | +|------|------| +| `lib/src/services/data/offline_service.dart` | 离线服务:状态管理、操作守卫、动作队列、网络恢复通知 | +| `lib/src/services/data/data_export_service.dart` | 数据导出服务:格式化、文件生成、保存/分享 | +| `lib/src/pages/profile/data/data_export_page.dart` | 数据导出页面:iOS风格设置页,选择数据源+格式+导出方式 | +| `lib/src/widgets/states/offline_indicator.dart` | 增强版离线指示器:替换现有 OfflineBanner | + +### 修改文件 +| 文件 | 修改内容 | +|------|---------| +| `lib/src/services/connectivity_service.dart` | 添加网络恢复回调机制 | +| `lib/src/widgets/states/offline_banner.dart` | 重构为使用 OfflineService | +| `lib/src/app_binding.dart` | 注册 OfflineService | +| `lib/src/config/app_routes.dart` | 添加 dataExport 路由 | +| `lib/src/pages/profile/profile_settings.dart` | 添加"数据导出"入口 | +| `lib/src/controllers/data/browse_history_controller.dart` | 添加 exportToJson/exportToCsv | +| `lib/src/controllers/data/shopping_list_controller.dart` | 添加 exportToJson/exportToCsv/exportToMarkdown | +| `lib/src/controllers/data/meal_record_controller.dart` | 添加 exportToJson/exportToCsv | +| `lib/src/controllers/data/cooking_note_controller.dart` | 添加 exportToJson/exportToMarkdown | +| `lib/src/controllers/data/weekly_menu_controller.dart` | 添加 exportToJson/exportToMarkdown | +| `lib/src/controllers/data/email_history_controller.dart` | 添加 exportToJson/exportToCsv | +| `lib/src/pages/tools/ranking/dish_ranking_controller.dart` | 添加 exportToJson/exportToCsv | +| `CHANGELOG.md` | 记录版本变更 | + +--- + +## Task 1: OfflineService - 离线服务核心 + +**Files:** +- Create: `lib/src/services/data/offline_service.dart` +- Modify: `lib/src/services/connectivity_service.dart` +- Modify: `lib/src/app_binding.dart` + +- [ ] **Step 1: 创建 OfflineService** + +```dart +// lib/src/services/data/offline_service.dart +// 2026-04-18 | OfflineService | 离线模式服务 | 统一管理离线状态、操作守卫、动作队列、网络恢复通知 + +import 'package:flutter/cupertino.dart'; +import 'package:get/get.dart'; +import 'package:mom_kitchen/src/services/connectivity_service.dart'; +import 'package:mom_kitchen/src/services/ui/toast_service.dart'; + +typedef OfflineAction = Future Function(); + +class OfflineService extends GetxService { + static OfflineService get to => Get.find(); + + final RxBool isOffline = false.obs; + final Rx lastOnlineTime = Rx(null); + final Rx wentOfflineAt = Rx(null); + + final List<_QueuedAction> _actionQueue = []; + + final RxList offlineFeatures = [].obs; + final RxList availableFeatures = [].obs; + + static const List _allFeatures = [ + '浏览缓存菜谱', + '查看收藏', + '查看购物清单', + '查看饮食记录', + '查看烹饪笔记', + '查看每周菜单', + '查看浏览记录', + ]; + + static const List _offlineUnavailable = [ + '搜索菜谱', + '发现页刷新', + '热门排行刷新', + '菜谱详情加载', + '分享到邮箱', + '数据同步', + ]; + + @override + void onInit() { + super.onInit(); + _initConnectivityListener(); + _updateFeatureLists(); + } + + void _initConnectivityListener() { + if (!Get.isRegistered()) return; + final connectivity = ConnectivityService.to; + isOffline.value = connectivity.isOffline; + if (!isOffline.value) { + lastOnlineTime.value = DateTime.now(); + } else { + wentOfflineAt.value = DateTime.now(); + } + + ever(connectivity.status, (status) { + final wasOffline = isOffline.value; + isOffline.value = status == ConnectivityStatus.offline; + + if (isOffline.value && !wasOffline) { + wentOfflineAt.value = DateTime.now(); + _updateFeatureLists(); + ToastService.show(message: '网络已断开,部分功能不可用 📵'); + } else if (!isOffline.value && wasOffline) { + lastOnlineTime.value = DateTime.now(); + _updateFeatureLists(); + _executeQueuedActions(); + } + }); + } + + void _updateFeatureLists() { + if (isOffline.value) { + offlineFeatures.value = List.from(_allFeatures); + availableFeatures.value = List.from(_offlineUnavailable); + } else { + offlineFeatures.clear(); + availableFeatures.value = List.from(_allFeatures) + ..addAll(_offlineUnavailable); + } + } + + bool guard(String operationName, {OfflineAction? onRetry}) { + if (!isOffline.value) return true; + + if (onRetry != null) { + _actionQueue.add(_QueuedAction( + name: operationName, + action: onRetry, + queuedAt: DateTime.now(), + )); + ToastService.show(message: '📵 网络不可用,"$operationName"将在恢复后自动执行'); + } else { + ToastService.show(message: '📵 网络不可用,无法$operationName'); + } + return false; + } + + Future _executeQueuedActions() async { + if (_actionQueue.isEmpty) return; + + final actions = List<_QueuedAction>.from(_actionQueue); + _actionQueue.clear(); + + ToastService.show(message: '📡 网络已恢复,正在执行 ${actions.length} 个待办操作...'); + + for (final item in actions) { + try { + await item.action(); + } catch (e) { + debugPrint('OfflineService: 执行排队操作"${item.name}"失败: $e'); + } + } + } + + int get queuedActionCount => _actionQueue.length; + + void clearQueue() { + _actionQueue.clear(); + } + + String get offlineDuration { + if (wentOfflineAt.value == null) return ''; + final duration = DateTime.now().difference(wentOfflineAt.value!); + if (duration.inMinutes < 1) return '刚刚断开'; + if (duration.inHours < 1) return '已离线 ${duration.inMinutes} 分钟'; + return '已离线 ${duration.inHours} 小时 ${duration.inMinutes % 60} 分钟'; + } +} + +class _QueuedAction { + final String name; + final OfflineAction action; + final DateTime queuedAt; + + _QueuedAction({ + required this.name, + required this.action, + required this.queuedAt, + }); +} +``` + +- [ ] **Step 2: 修改 ConnectivityService - 添加 onRestoredCallbacks** + +在 `connectivity_service.dart` 的 `_onRestored` 方法中添加回调机制: + +```dart +// 在类中添加字段 +final List _onRestoredCallbacks = []; + +void addOnRestoredCallback(VoidCallback callback) { + _onRestoredCallbacks.add(callback); +} + +void removeOnRestoredCallback(VoidCallback callback) { + _onRestoredCallbacks.remove(callback); +} + +// 修改 _onRestored 方法 +void _onRestored() { + Get.snackbar( + '网络已恢复 📡', + '数据将自动刷新', + snackPosition: SnackPosition.TOP, + duration: const Duration(seconds: 2), + ); + for (final cb in _onRestoredCallbacks) { + cb(); + } +} +``` + +- [ ] **Step 3: 在 AppBinding 中注册 OfflineService** + +在 `app_binding.dart` 的 `dependencies()` 方法中,服务层注册区域添加: + +```dart +// --- 离线服务 --- +Get.put(OfflineService(), permanent: true); +``` + +并添加 import: +```dart +import 'package:mom_kitchen/src/services/data/offline_service.dart'; +``` + +- [ ] **Step 4: 验证编译通过** + +Run: `cd e:\project\flutter\f\mom_kitchen && flutter analyze lib/src/services/data/offline_service.dart lib/src/services/connectivity_service.dart lib/src/app_binding.dart` + +--- + +## Task 2: 增强离线指示器 + +**Files:** +- Create: `lib/src/widgets/states/offline_indicator.dart` +- Modify: `lib/src/widgets/states/offline_banner.dart` + +- [ ] **Step 1: 创建增强版 OfflineIndicator** + +```dart +// lib/src/widgets/states/offline_indicator.dart +// 2026-04-18 | OfflineIndicator | 增强版离线指示器 | 显示离线状态、持续时间、可用功能提示 + +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/offline_service.dart'; + +class OfflineIndicator extends StatelessWidget { + const OfflineIndicator({super.key}); + + @override + Widget build(BuildContext context) { + if (!Get.isRegistered()) { + return const SizedBox.shrink(); + } + + final offlineService = OfflineService.to; + + return Obx(() { + if (!offlineService.isOffline.value) return const SizedBox.shrink(); + + final isDark = CupertinoTheme.brightnessOf(context) == Brightness.dark; + final duration = offlineService.offlineDuration; + final queuedCount = offlineService.queuedActionCount; + + return Container( + padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 8), + decoration: BoxDecoration( + color: CupertinoColors.systemOrange.withValues(alpha: 0.12), + border: Border( + bottom: BorderSide( + color: CupertinoColors.systemOrange.withValues(alpha: 0.25), + width: 0.5, + ), + ), + ), + child: GestureDetector( + onTap: () => _showOfflineDetail(context, offlineService, isDark), + child: Row( + mainAxisAlignment: MainAxisAlignment.center, + children: [ + Icon( + CupertinoIcons.wifi_slash, + size: 14, + color: CupertinoColors.systemOrange.darkColor, + ), + const SizedBox(width: 6), + Text( + '离线模式', + style: TextStyle( + fontSize: 12, + fontWeight: FontWeight.w600, + color: CupertinoColors.systemOrange.darkColor, + ), + ), + if (duration.isNotEmpty) ...[ + const SizedBox(width: 4), + Text( + '· $duration', + style: TextStyle( + fontSize: 11, + color: CupertinoColors.systemOrange.darkColor + .withValues(alpha: 0.7), + ), + ), + ], + if (queuedCount > 0) ...[ + const SizedBox(width: 6), + Container( + padding: const EdgeInsets.symmetric(horizontal: 6, vertical: 1), + decoration: BoxDecoration( + color: CupertinoColors.systemOrange.darkColor, + borderRadius: BorderRadius.circular(8), + ), + child: Text( + '$queuedCount', + style: const TextStyle(fontSize: 10, color: CupertinoColors.white), + ), + ), + ], + const SizedBox(width: 4), + Icon( + CupertinoIcons.chevron_down, + size: 10, + color: CupertinoColors.systemOrange.darkColor.withValues(alpha: 0.5), + ), + ], + ), + ), + ); + }); + } + + void _showOfflineDetail( + BuildContext context, + OfflineService offlineService, + bool isDark, + ) { + showCupertinoModalPopup( + context: context, + builder: (ctx) => CupertinoActionSheet( + title: const Text('📵 离线模式'), + message: Column( + children: [ + Text(offlineService.offlineDuration), + const SizedBox(height: 8), + const Text('✅ 可用功能:', style: TextStyle(fontWeight: FontWeight.w600)), + ...offlineService.offlineFeatures.take(4).map( + (f) => Text(' · $f', style: const TextStyle(fontSize: 13)), + ), + const SizedBox(height: 8), + const Text('❌ 不可用:', style: TextStyle(fontWeight: FontWeight.w600)), + ...offlineService.availableFeatures.take(3).map( + (f) => Text(' · $f', style: const TextStyle(fontSize: 13)), + ), + if (offlineService.queuedActionCount > 0) ...[ + const SizedBox(height: 8), + Text('⏳ 恢复后自动执行 ${offlineService.queuedActionCount} 个操作'), + ], + ], + ), + cancelButton: CupertinoActionSheetAction( + onPressed: () => Navigator.pop(ctx), + child: const Text('知道了'), + ), + ), + ); + } +} +``` + +- [ ] **Step 2: 修改 OfflineBanner 使用 OfflineService** + +将 `offline_banner.dart` 改为使用 OfflineService 作为数据源,同时保持向后兼容: + +```dart +// lib/src/widgets/states/offline_banner.dart +// 2026-04-09 | OfflineBanner | 离线状态横幅 | 增强版:使用OfflineService数据源 +import 'package:flutter/cupertino.dart'; +import 'package:flutter/foundation.dart'; +import 'package:get/get.dart'; +import 'package:mom_kitchen/src/services/data/offline_service.dart'; +import 'package:mom_kitchen/src/services/connectivity_service.dart'; + +class OfflineBanner extends StatelessWidget { + const OfflineBanner({super.key}); + + @override + Widget build(BuildContext context) { + if (kIsWeb) return const SizedBox.shrink(); + + if (Get.isRegistered()) { + final offline = OfflineService.to; + return Obx(() { + if (!offline.isOffline.value) return const SizedBox.shrink(); + return Container( + padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 6), + decoration: BoxDecoration( + color: CupertinoColors.systemOrange.withValues(alpha: 0.15), + border: Border( + bottom: BorderSide( + color: CupertinoColors.systemOrange.withValues(alpha: 0.3), + width: 0.5, + ), + ), + ), + child: Row( + mainAxisAlignment: MainAxisAlignment.center, + children: [ + Icon( + CupertinoIcons.wifi_slash, + size: 14, + color: CupertinoColors.systemOrange.darkColor, + ), + const SizedBox(width: 6), + Text( + '网络已断开,显示缓存数据', + style: TextStyle( + fontSize: 12, + fontWeight: FontWeight.w500, + color: CupertinoColors.systemOrange.darkColor, + ), + ), + ], + ), + ); + }); + } + + if (!Get.isRegistered()) { + return const SizedBox.shrink(); + } + + final connectivity = ConnectivityService.to; + + return Obx(() { + final isOffline = connectivity.isOffline; + if (!isOffline) return const SizedBox.shrink(); + + return Container( + padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 6), + decoration: BoxDecoration( + color: CupertinoColors.systemOrange.withValues(alpha: 0.15), + border: Border( + bottom: BorderSide( + color: CupertinoColors.systemOrange.withValues(alpha: 0.3), + width: 0.5, + ), + ), + ), + child: Row( + mainAxisAlignment: MainAxisAlignment.center, + children: [ + Icon( + CupertinoIcons.wifi_slash, + size: 14, + color: CupertinoColors.systemOrange.darkColor, + ), + const SizedBox(width: 6), + Text( + '网络已断开,显示缓存数据', + style: TextStyle( + fontSize: 12, + fontWeight: FontWeight.w500, + color: CupertinoColors.systemOrange.darkColor, + ), + ), + ], + ), + ); + }); + } +} +``` + +- [ ] **Step 3: 验证编译通过** + +Run: `cd e:\project\flutter\f\mom_kitchen && flutter analyze lib/src/widgets/states/` + +--- + +## Task 3: 各 Controller 添加导出方法 + +**Files:** +- Modify: `lib/src/controllers/data/browse_history_controller.dart` +- Modify: `lib/src/controllers/data/shopping_list_controller.dart` +- Modify: `lib/src/controllers/data/meal_record_controller.dart` +- Modify: `lib/src/controllers/data/cooking_note_controller.dart` +- Modify: `lib/src/controllers/data/weekly_menu_controller.dart` +- Modify: `lib/src/controllers/data/email_history_controller.dart` +- Modify: `lib/src/pages/tools/ranking/dish_ranking_controller.dart` + +- [ ] **Step 1: BrowseHistoryController 添加导出** + +在 `browse_history_controller.dart` 末尾(`}` 之前)添加: + +```dart + String exportToJson() { + final data = _history.map((e) => e.toJson()).toList(); + return const JsonEncoder.withIndent(' ').convert(data); + } + + String exportToCsv() { + final buffer = StringBuffer(); + buffer.writeln('ID,菜谱ID,标题,封面,分类,浏览时间,浏览次数'); + for (final item in _history) { + buffer.writeln([ + item.id, + item.recipeId, + '"${item.title.replaceAll('"', '""')}"', + item.coverImage ?? '', + item.category ?? '', + item.viewedAt, + item.viewCount, + ].join(',')); + } + return buffer.toString(); + } +``` + +添加 import:`import 'dart:convert';` + +- [ ] **Step 2: ShoppingListController 添加导出** + +在 `shopping_list_controller.dart` 末尾添加: + +```dart + String exportToJson() { + final data = items.map((e) => _itemToMap(e)).toList(); + return const JsonEncoder.withIndent(' ').convert(data); + } + + String exportToCsv() { + final buffer = StringBuffer(); + buffer.writeln('名称,数量,单位,分类,已购买,关联菜谱ID,创建时间'); + for (final item in items) { + buffer.writeln([ + '"${item.name.replaceAll('"', '""')}"', + item.amount ?? '', + item.unit ?? '', + item.categoryLabel, + item.isChecked ? '是' : '否', + item.recipeId ?? '', + item.createdAt, + ].join(',')); + } + return buffer.toString(); + } + + String exportToMarkdown() { + final buffer = StringBuffer(); + buffer.writeln('# 🛒 购物清单'); + buffer.writeln(); + + final grouped = groupedItemsWithKeys; + for (final entry in grouped.entries) { + buffer.writeln('## ${entry.key.emoji} ${entry.key.label}'); + buffer.writeln(); + for (final item in entry.value) { + final check = item.value.isChecked ? 'x' : ' '; + buffer.writeln('- [$check] ${item.value.name} ${item.value.displayAmount}'); + } + buffer.writeln(); + } + + buffer.writeln('---'); + buffer.writeln('总计: ${totalCount}项 | 未购: ${uncheckedCount}项 | 已购: ${checkedCount}项'); + return buffer.toString(); + } + + Map _itemToMap(ShoppingItemModel item) { + return { + 'name': item.name, + 'amount': item.amount, + 'unit': item.unit, + 'category': item.category, + 'isChecked': item.isChecked, + 'recipeId': item.recipeId, + 'createdAt': item.createdAt, + }; + } +``` + +添加 import:`import 'dart:convert';` + +- [ ] **Step 3: MealRecordController 添加导出** + +在 `meal_record_controller.dart` 末尾添加: + +```dart + String exportToJson() { + final data = >[]; + for (final date in recordedDates) { + selectDate(date); + for (final record in dayRecords) { + data.add(_recordToMap(record)); + } + } + return const JsonEncoder.withIndent(' ').convert(data); + } + + String exportToCsv() { + final buffer = StringBuffer(); + buffer.writeln('日期,餐次,菜谱ID,菜谱名称,热量(kcal),蛋白质(g),脂肪(g),碳水(g),纤维(g),备注'); + for (final date in recordedDates) { + selectDate(date); + for (final record in dayRecords) { + buffer.writeln([ + record.date, + record.mealTypeLabel, + record.recipeId ?? '', + '"${record.recipeTitle.replaceAll('"', '""')}"', + record.calories.toStringAsFixed(1), + record.protein.toStringAsFixed(1), + record.fat.toStringAsFixed(1), + record.carbs.toStringAsFixed(1), + record.fiber.toStringAsFixed(1), + record.note ?? '', + ].join(',')); + } + } + return buffer.toString(); + } + + Map _recordToMap(MealRecordModel record) { + return { + 'date': record.date, + 'mealType': record.mealType, + 'recipeId': record.recipeId, + 'recipeTitle': record.recipeTitle, + 'calories': record.calories, + 'protein': record.protein, + 'fat': record.fat, + 'carbs': record.carbs, + 'fiber': record.fiber, + 'note': record.note, + 'createdAt': record.createdAt, + }; + } +``` + +添加 import:`import 'dart:convert';` + +- [ ] **Step 4: CookingNoteController 添加导出** + +在 `cooking_note_controller.dart` 末尾添加: + +```dart + String exportToJson() { + final data = _notes.map((e) => e.toJson()).toList(); + return const JsonEncoder.withIndent(' ').convert(data); + } + + String exportToMarkdown() { + final buffer = StringBuffer(); + buffer.writeln('# 📝 烹饪笔记'); + buffer.writeln(); + for (final note in _notes) { + buffer.writeln('## ${note.title ?? "无标题"}'); + buffer.writeln(); + if (note.tags.isNotEmpty) { + buffer.writeln('标签: ${note.tags.map((t) => '`$t`').join(' ')}'); + buffer.writeln(); + } + buffer.writeln(note.content); + buffer.writeln(); + buffer.writeln('---'); + buffer.writeln(); + } + return buffer.toString(); + } +``` + +添加 import:`import 'dart:convert';` + +- [ ] **Step 5: WeeklyMenuController 添加导出** + +在 `weekly_menu_controller.dart` 末尾添加: + +```dart + String exportToJson() { + if (currentMenu.value == null) return '{}'; + return const JsonEncoder.withIndent(' ').convert(currentMenu.value!.toJson()); + } + + String exportToMarkdown() { + final menu = currentMenu.value; + if (menu == null) return '# 📅 每周菜单\n\n暂无数据'; + + final buffer = StringBuffer(); + buffer.writeln('# 📅 每周菜单 ${menu.weekLabel}'); + buffer.writeln(); + + final dayNames = ['周一', '周二', '周三', '周四', '周五', '周六', '周日']; + + for (var i = 0; i < 7; i++) { + final date = menu.startDate.add(Duration(days: i)); + final key = _formatDateKey(date); + final dayMenu = menu.dailyMenus[key]; + + buffer.writeln('## ${dayNames[i]} (${key})'); + buffer.writeln(); + + if (dayMenu == null || !dayMenu.hasAnyMeal) { + buffer.writeln('- 未规划'); + buffer.writeln(); + continue; + } + + if (dayMenu.breakfast != null) { + buffer.writeln('- 🌅 早餐: ${dayMenu.breakfast!.recipeTitle}'); + } + if (dayMenu.lunch != null) { + buffer.writeln('- ☀️ 午餐: ${dayMenu.lunch!.recipeTitle}'); + } + if (dayMenu.dinner != null) { + buffer.writeln('- 🌙 晚餐: ${dayMenu.dinner!.recipeTitle}'); + } + buffer.writeln(); + } + + buffer.writeln('---'); + buffer.writeln('已完成 $completedMeals/$totalPossibleMeals 餐'); + return buffer.toString(); + } +``` + +添加 import:`import 'dart:convert';` + +- [ ] **Step 6: EmailHistoryController 添加导出** + +在 `email_history_controller.dart` 末尾添加: + +```dart + String exportToJson() { + final data = _records.map((e) => e.toJson()).toList(); + return const JsonEncoder.withIndent(' ').convert(data); + } + + String exportToCsv() { + final buffer = StringBuffer(); + buffer.writeln('ID,菜谱ID,菜谱标题,收件人,发件人,状态,发送时间,错误信息'); + for (final record in _records) { + buffer.writeln([ + record.id, + record.recipeId, + '"${record.recipeTitle.replaceAll('"', '""')}"', + record.recipientEmail, + record.senderEmail, + record.status.name, + record.sentAt, + record.errorMessage ?? '', + ].join(',')); + } + return buffer.toString(); + } +``` + +添加 import:`import 'dart:convert';` (已有则跳过) + +- [ ] **Step 7: DishRankingController 添加导出** + +在 `dish_ranking_controller.dart` 末尾添加: + +```dart + String exportToJson() { + final data = _allItems.map((e) => e.toJson()).toList(); + return const JsonEncoder.withIndent(' ').convert(data); + } + + String exportToCsv() { + final buffer = StringBuffer(); + buffer.writeln('ID,名称,Emoji,层级,排序,自定义,来源ID'); + for (final item in _allItems) { + buffer.writeln([ + item.id, + '"${item.name.replaceAll('"', '""')}"', + item.emoji, + item.tierIndex, + item.order, + item.isCustom ? '是' : '否', + item.sourceId ?? '', + ].join(',')); + } + return buffer.toString(); + } +``` + +添加 import:`import 'dart:convert';` (已有则跳过) + +- [ ] **Step 8: 验证所有修改编译通过** + +Run: `cd e:\project\flutter\f\mom_kitchen && flutter analyze lib/src/controllers/data/ lib/src/pages/tools/ranking/` + +--- + +## Task 4: DataExportService - 统一导出服务 + +**Files:** +- Create: `lib/src/services/data/data_export_service.dart` + +- [ ] **Step 1: 创建 DataExportService** + +```dart +// lib/src/services/data/data_export_service.dart +// 2026-04-18 | DataExportService | 数据导出服务 | 统一管理数据格式化、文件生成、保存与分享 + +import 'dart:io'; +import 'package:flutter/foundation.dart'; +import 'package:get/get.dart'; +import 'package:path_provider/path_provider.dart'; +import 'package:share_plus/share_plus.dart'; +import 'package:mom_kitchen/src/services/ui/toast_service.dart'; + +enum ExportFormat { json, csv, markdown } + +extension ExportFormatExtension on ExportFormat { + String get label { + switch (this) { + case ExportFormat.json: + return 'JSON'; + case ExportFormat.csv: + return 'CSV'; + case ExportFormat.markdown: + return 'Markdown'; + } + } + + String get emoji { + switch (this) { + case ExportFormat.json: + return '📋'; + case ExportFormat.csv: + return '📊'; + case ExportFormat.markdown: + return '📝'; + } + } + + String get extension { + switch (this) { + case ExportFormat.json: + return '.json'; + case ExportFormat.csv: + return '.csv'; + case ExportFormat.markdown: + return '.md'; + } + } + + String get mimeType { + switch (this) { + case ExportFormat.json: + return 'application/json'; + case ExportFormat.csv: + return 'text/csv'; + case ExportFormat.markdown: + return 'text/markdown'; + } + } +} + +class DataSourceInfo { + final String key; + final String name; + final String emoji; + final List supportedFormats; + + const DataSourceInfo({ + required this.key, + required this.name, + required this.emoji, + required this.supportedFormats, + }); +} + +class DataExportService extends GetxService { + static DataExportService get to => Get.find(); + + static const List dataSources = [ + DataSourceInfo( + key: 'favorites', + name: '收藏', + emoji: '❤️', + supportedFormats: [ExportFormat.json, ExportFormat.csv], + ), + DataSourceInfo( + key: 'browse_history', + name: '浏览记录', + emoji: '🕐', + supportedFormats: [ExportFormat.json, ExportFormat.csv], + ), + DataSourceInfo( + key: 'shopping_list', + name: '购物清单', + emoji: '🛒', + supportedFormats: [ExportFormat.json, ExportFormat.csv, ExportFormat.markdown], + ), + DataSourceInfo( + key: 'meal_record', + name: '饮食记录', + emoji: '🍽️', + supportedFormats: [ExportFormat.json, ExportFormat.csv], + ), + DataSourceInfo( + key: 'cooking_note', + name: '烹饪笔记', + emoji: '📝', + supportedFormats: [ExportFormat.json, ExportFormat.markdown], + ), + DataSourceInfo( + key: 'weekly_menu', + name: '每周菜单', + emoji: '📅', + supportedFormats: [ExportFormat.json, ExportFormat.markdown], + ), + DataSourceInfo( + key: 'email_history', + name: '邮件记录', + emoji: '📧', + supportedFormats: [ExportFormat.json, ExportFormat.csv], + ), + DataSourceInfo( + key: 'dish_ranking', + name: '菜品排行', + emoji: '🏆', + supportedFormats: [ExportFormat.json, ExportFormat.csv], + ), + ]; + + String? _getExportData(String sourceKey, ExportFormat format) { + switch (sourceKey) { + case 'favorites': + final ctrl = _findController(); + if (ctrl == null) return null; + return format == ExportFormat.csv ? ctrl.exportToCsv() : ctrl.exportToJson(); + + case 'browse_history': + final ctrl = _findController(); + if (ctrl == null) return null; + return format == ExportFormat.csv ? ctrl.exportToCsv() : ctrl.exportToJson(); + + case 'shopping_list': + final ctrl = _findController(); + if (ctrl == null) return null; + switch (format) { + case ExportFormat.csv: + return ctrl.exportToCsv(); + case ExportFormat.markdown: + return ctrl.exportToMarkdown(); + case ExportFormat.json: + return ctrl.exportToJson(); + } + + case 'meal_record': + final ctrl = _findController(); + if (ctrl == null) return null; + return format == ExportFormat.csv ? ctrl.exportToCsv() : ctrl.exportToJson(); + + case 'cooking_note': + final ctrl = _findController(); + if (ctrl == null) return null; + return format == ExportFormat.markdown ? ctrl.exportToMarkdown() : ctrl.exportToJson(); + + case 'weekly_menu': + final ctrl = _findController(); + if (ctrl == null) return null; + return format == ExportFormat.markdown ? ctrl.exportToMarkdown() : ctrl.exportToJson(); + + case 'email_history': + final ctrl = _findController(); + if (ctrl == null) return null; + return format == ExportFormat.csv ? ctrl.exportToCsv() : ctrl.exportToJson(); + + case 'dish_ranking': + final ctrl = _findController(); + if (ctrl == null) return null; + return format == ExportFormat.csv ? ctrl.exportToCsv() : ctrl.exportToJson(); + + default: + return null; + } + } + + T? _findController() { + if (!Get.isRegistered()) return null; + return Get.find(); + } + + Future exportToFile( + String sourceKey, + ExportFormat format, { + String? customFileName, + }) async { + try { + final data = _getExportData(sourceKey, format); + if (data == null || data.isEmpty) { + ToastService.show(message: '没有可导出的数据 📭'); + return false; + } + + final source = dataSources.firstWhere((s) => s.key == sourceKey); + final fileName = customFileName ?? + '${source.emoji.replaceAll(RegExp(r'[^\w]'), '')}_${source.name}_${DateTime.now().millisecondsSinceEpoch}${format.extension}'; + + if (kIsWeb) { + await _shareText(data, subject: '$source.name 导出'); + return true; + } + + final directory = await getApplicationDocumentsDirectory(); + final file = File('${directory.path}/$fileName'); + await file.writeAsString(data); + + ToastService.show(message: '已保存到 ${file.path} ✅'); + return true; + } catch (e) { + debugPrint('DataExportService: 导出失败 $e'); + ToastService.show(message: '导出失败 ❌'); + return false; + } + } + + Future shareExport( + String sourceKey, + ExportFormat format, + ) async { + try { + final data = _getExportData(sourceKey, format); + if (data == null || data.isEmpty) { + ToastService.show(message: '没有可导出的数据 📭'); + return; + } + + final source = dataSources.firstWhere((s) => s.key == sourceKey); + final fileName = '${source.name}_${DateTime.now().millisecondsSinceEpoch}${format.extension}'; + + if (kIsWeb) { + await _shareText(data, subject: '$source.name 导出'); + return; + } + + final directory = await getTemporaryDirectory(); + final file = File('${directory.path}/$fileName'); + await file.writeAsString(data); + + await Share.shareXFiles( + [XFile(file.path)], + subject: '${source.name} 导出', + text: '${source.emoji} ${source.name}数据导出 (${format.label})', + ); + } catch (e) { + debugPrint('DataExportService: 分享失败 $e'); + ToastService.show(message: '分享失败 ❌'); + } + } + + Future _shareText(String text, {String? subject}) async { + await Share.share(text, subject: subject); + } + + int getDataSourceCount(String sourceKey) { + switch (sourceKey) { + case 'favorites': + final ctrl = _findController(); + return ctrl?.count ?? 0; + case 'browse_history': + final ctrl = _findController(); + return ctrl?.history.length ?? 0; + case 'shopping_list': + final ctrl = _findController(); + return ctrl?.totalCount ?? 0; + case 'meal_record': + final ctrl = _findController(); + return ctrl?.recordedDates.length ?? 0; + case 'cooking_note': + final ctrl = _findController(); + return ctrl?.notes.length ?? 0; + case 'weekly_menu': + final ctrl = _findController(); + return ctrl?.completedMeals ?? 0; + case 'email_history': + final ctrl = _findController(); + return ctrl?.records.length ?? 0; + case 'dish_ranking': + final ctrl = _findController(); + return ctrl?.totalCount ?? 0; + default: + return 0; + } + } +} +``` + +需要添加的 imports(在文件顶部): +```dart +import 'package:mom_kitchen/src/controllers/data/favorites_controller.dart'; +import 'package:mom_kitchen/src/controllers/data/browse_history_controller.dart'; +import 'package:mom_kitchen/src/controllers/data/shopping_list_controller.dart'; +import 'package:mom_kitchen/src/controllers/data/meal_record_controller.dart'; +import 'package:mom_kitchen/src/controllers/data/cooking_note_controller.dart'; +import 'package:mom_kitchen/src/controllers/data/weekly_menu_controller.dart'; +import 'package:mom_kitchen/src/controllers/data/email_history_controller.dart'; +import 'package:mom_kitchen/src/pages/tools/ranking/dish_ranking_controller.dart'; +``` + +- [ ] **Step 2: 在 AppBinding 中注册 DataExportService** + +在 `app_binding.dart` 添加: +```dart +import 'package:mom_kitchen/src/services/data/data_export_service.dart'; +``` + +在 `dependencies()` 服务层区域添加: +```dart +Get.put(DataExportService(), permanent: true); +``` + +- [ ] **Step 3: 验证编译通过** + +Run: `cd e:\project\flutter\f\mom_kitchen && flutter analyze lib/src/services/data/data_export_service.dart` + +--- + +## Task 5: DataExportPage - 数据导出页面 + +**Files:** +- Create: `lib/src/pages/profile/data/data_export_page.dart` +- Modify: `lib/src/config/app_routes.dart` +- Modify: `lib/src/pages/profile/profile_settings.dart` + +- [ ] **Step 1: 创建 DataExportPage** + +```dart +// lib/src/pages/profile/data/data_export_page.dart +// 2026-04-18 | DataExportPage | 数据导出页面 | iOS风格设置页,选择数据源+格式+导出方式 + +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/data_export_service.dart'; +import 'package:mom_kitchen/src/services/ui/theme_service.dart'; + +class DataExportPage extends StatefulWidget { + const DataExportPage({super.key}); + + @override + State createState() => _DataExportPageState(); +} + +class _DataExportPageState extends State { + final _exportService = DataExportService.to; + final _selectedFormats = {}; + + @override + void initState() { + super.initState(); + for (final source in DataExportService.dataSources) { + _selectedFormats[source.key] = source.supportedFormats.first; + } + } + + @override + Widget build(BuildContext context) { + final isDark = CupertinoTheme.brightnessOf(context) == Brightness.dark; + final theme = ThemeService.instance; + + return CupertinoPageScaffold( + navigationBar: CupertinoNavigationBar( + middle: const Text('数据导出'), + backgroundColor: isDark + ? DarkDesignTokens.cardAlpha + : DesignTokens.cardAlpha.withValues(alpha: 0.85), + border: null, + ), + child: SafeArea( + child: ListView( + padding: const EdgeInsets.symmetric(vertical: 16), + children: [ + _buildHeader(isDark), + const SizedBox(height: 16), + ...DataExportService.dataSources.map( + (source) => _buildDataSourceTile(source, isDark), + ), + const SizedBox(height: 24), + _buildExportAllButton(isDark, theme), + const SizedBox(height: 40), + ], + ), + ), + ); + } + + Widget _buildHeader(bool isDark) { + return Padding( + padding: const EdgeInsets.symmetric(horizontal: 20), + child: Row( + children: [ + Text( + '📦', + style: const TextStyle(fontSize: 28), + ), + const SizedBox(width: 10), + Expanded( + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text( + '导出你的数据', + style: TextStyle( + fontSize: 18, + fontWeight: FontWeight.w600, + color: isDark ? DarkDesignTokens.text1 : DesignTokens.text1, + ), + ), + const SizedBox(height: 2), + Text( + '选择数据类型和格式,保存或分享', + style: TextStyle( + fontSize: 13, + color: isDark ? DarkDesignTokens.text3 : DesignTokens.text3, + ), + ), + ], + ), + ), + ], + ), + ); + } + + Widget _buildDataSourceTile(DataSourceInfo source, bool isDark) { + final count = _exportService.getDataSourceCount(source.key); + final selectedFormat = _selectedFormats[source.key] ?? source.supportedFormats.first; + + return Container( + margin: const EdgeInsets.symmetric(horizontal: 16, vertical: 4), + decoration: BoxDecoration( + color: isDark ? DarkDesignTokens.card : DesignTokens.card, + borderRadius: BorderRadius.circular(DesignTokens.radiusMd), + ), + child: CupertinoListTile( + leading: Text(source.emoji, style: const TextStyle(fontSize: 24)), + title: Text( + source.name, + style: TextStyle( + fontSize: 15, + fontWeight: FontWeight.w500, + color: isDark ? DarkDesignTokens.text1 : DesignTokens.text1, + ), + ), + subtitle: Row( + children: [ + Text( + '$count 条记录', + style: TextStyle( + fontSize: 12, + color: isDark ? DarkDesignTokens.text3 : DesignTokens.text3, + ), + ), + const SizedBox(width: 8), + _buildFormatSelector(source, selectedFormat, isDark), + ], + ), + trailing: Row( + mainAxisSize: MainAxisSize.min, + children: [ + GestureDetector( + onTap: count > 0 + ? () => _exportService.exportToFile(source.key, selectedFormat) + : null, + child: Container( + padding: const EdgeInsets.all(6), + decoration: BoxDecoration( + color: count > 0 + ? DesignTokens.dynamicPrimary.withValues(alpha: 0.1) + : (isDark ? DarkDesignTokens.cardAlpha : DesignTokens.cardAlpha), + borderRadius: BorderRadius.circular(8), + ), + child: Icon( + CupertinoIcons.arrow_down_doc, + size: 18, + color: count > 0 + ? DesignTokens.dynamicPrimary + : (isDark ? DarkDesignTokens.text3 : DesignTokens.text3), + ), + ), + ), + const SizedBox(width: 8), + GestureDetector( + onTap: count > 0 + ? () => _exportService.shareExport(source.key, selectedFormat) + : null, + child: Container( + padding: const EdgeInsets.all(6), + decoration: BoxDecoration( + color: count > 0 + ? DesignTokens.dynamicPrimary.withValues(alpha: 0.1) + : (isDark ? DarkDesignTokens.cardAlpha : DesignTokens.cardAlpha), + borderRadius: BorderRadius.circular(8), + ), + child: Icon( + CupertinoIcons.share, + size: 18, + color: count > 0 + ? DesignTokens.dynamicPrimary + : (isDark ? DarkDesignTokens.text3 : DesignTokens.text3), + ), + ), + ), + ], + ), + ), + ); + } + + Widget _buildFormatSelector( + DataSourceInfo source, + ExportFormat selected, + bool isDark, + ) { + if (source.supportedFormats.length == 1) { + return Container( + padding: const EdgeInsets.symmetric(horizontal: 6, vertical: 2), + decoration: BoxDecoration( + color: DesignTokens.dynamicPrimary.withValues(alpha: 0.08), + borderRadius: BorderRadius.circular(4), + ), + child: Text( + selected.label, + style: TextStyle( + fontSize: 10, + fontWeight: FontWeight.w600, + color: DesignTokens.dynamicPrimary, + ), + ), + ); + } + + return CupertinoSlidingSegmentedControl( + groupValue: selected, + children: { + for (final fmt in source.supportedFormats) + fmt: Padding( + padding: const EdgeInsets.symmetric(horizontal: 4), + child: Text(fmt.label, style: const TextStyle(fontSize: 10)), + ), + }, + onValueChanged: (value) { + if (value != null) { + setState(() { + _selectedFormats[source.key] = value; + }); + } + }, + ); + } + + Widget _buildExportAllButton(bool isDark, ThemeService theme) { + return Padding( + padding: const EdgeInsets.symmetric(horizontal: 16), + child: SizedBox( + width: double.infinity, + child: CupertinoButton( + color: DesignTokens.dynamicPrimary, + borderRadius: BorderRadius.circular(DesignTokens.radiusMd), + padding: const EdgeInsets.symmetric(vertical: 14), + onPressed: _exportAll, + child: const Row( + mainAxisAlignment: MainAxisAlignment.center, + children: [ + Icon(CupertinoIcons.square_and_arrow_down, size: 18, color: CupertinoColors.white), + SizedBox(width: 8), + Text('导出全部数据 (JSON)', style: TextStyle(fontSize: 15, fontWeight: FontWeight.w600, color: CupertinoColors.white)), + ], + ), + ), + ), + ); + } + + Future _exportAll() async { + final buffer = StringBuffer(); + buffer.writeln('{'); + + final sources = DataExportService.dataSources; + for (var i = 0; i < sources.length; i++) { + final source = sources[i]; + final data = _exportService._getExportData(source.key, ExportFormat.json); + buffer.writeln(' "${source.key}": ${data ?? 'null'}${i < sources.length - 1 ? ',' : ''}'); + } + + buffer.writeln('}'); + + final exportService = DataExportService.to; + await exportService.exportToFile( + 'favorites', + ExportFormat.json, + customFileName: 'mom_kitchen_all_data_${DateTime.now().millisecondsSinceEpoch}.json', + ); + } +} +``` + +- [ ] **Step 2: 添加路由** + +在 `app_routes.dart` 的 `AppRoutes` 类中添加: +```dart +static const String dataExport = '/data-export'; +``` + +在路由表的 `GetPage` 列表中添加: +```dart +GetPage( + name: AppRoutes.dataExport, + page: () => const DataExportPage(), +), +``` + +添加 import: +```dart +import 'package:mom_kitchen/src/pages/profile/data/data_export_page.dart'; +``` + +- [ ] **Step 3: 在 profile_settings.dart 添加入口** + +在 `profile_settings.dart` 的"👣 足迹"section 中,在"缓存管理"之后添加: + +```dart +_buildTile( + icon: CupertinoIcons.square_and_arrow_up, + title: '数据导出', + isDark: isDark, + onTap: () => Get.toNamed(AppRoutes.dataExport), +), +``` + +添加 import: +```dart +import 'package:mom_kitchen/src/config/app_routes.dart'; +``` + +- [ ] **Step 4: 验证编译通过** + +Run: `cd e:\project\flutter\f\mom_kitchen && flutter analyze lib/src/pages/profile/data/ lib/src/config/app_routes.dart lib/src/pages/profile/profile_settings.dart` + +--- + +## Task 6: 更新 CHANGELOG.md + +**Files:** +- Modify: `CHANGELOG.md` + +- [ ] **Step 1: 更新 CHANGELOG** + +在 CHANGELOG.md 顶部添加新版本记录(如果当前版本是 0.96.0,新版本为 0.97.0): + +```markdown +## [0.97.0] - 2026-04-18 + +### ✨ 新增 +- 📵 离线模式增强:新增 OfflineService 统一管理离线状态、操作守卫、动作队列 +- 📵 增强离线指示器:显示离线持续时间、排队操作数、可用/不可用功能列表 +- 📵 网络恢复自动执行排队操作 +- 📦 数据导出功能:新增 DataExportService 统一导出服务 +- 📦 数据导出页面:支持8种数据源、3种格式(JSON/CSV/Markdown) +- 📦 各数据 Controller 新增 exportToJson/exportToCsv/exportToMarkdown 方法 +- 📦 支持保存到本地和系统分享两种导出方式 + +### 🔧 优化 +- ConnectivityService 新增网络恢复回调机制 +- OfflineBanner 使用 OfflineService 作为数据源 +``` + +- [ ] **Step 2: 最终全量编译验证** + +Run: `cd e:\project\flutter\f\mom_kitchen && flutter analyze` + +Expected: 0 errors, 0 warnings diff --git a/docs/superpowers/specs/2026-04-18-farm-game-design.md b/docs/superpowers/specs/2026-04-18-farm-game-design.md new file mode 100644 index 0000000..8bf112d --- /dev/null +++ b/docs/superpowers/specs/2026-04-18-farm-game-design.md @@ -0,0 +1,1078 @@ +# 小妈菜园 - 游戏设计文档 + +> **文档类型:** 游戏架构设计 + 功能规范 +> **创建时间:** 2026-04-18 +> **最后更新:** 2026-04-18 +> **版本:** 1.0 +> **状态:** 待确认 + +--- + +## 一、项目概述 + +### 1.1 游戏简介 +"小妈菜园"是一款类似 QQ 农场的模拟经营小游戏,集成在" mom_kitchen" App 的工具中心中。玩家可以开垦土地、种植作物、浇水施肥、收获果实,并分享给好友。 + +### 1.2 核心特性 +- 🌱 **种植系统**:播种、浇水、施肥、除草、除虫 +- ⏰ **实时生长**:作物按时间自动生长(本地计时器) +- 🎒 **背包系统**:种子、果实、肥料存储 +- 🏪 **商店系统**:购买种子、道具 +- 📤 **分享功能**:将收获成果生成图片分享到社交平台 +- 🏆 **成就系统**:解锁成就、获得奖励 +- 📈 **等级经验**:玩家等级提升、解锁新作物 + +### 1.3 当前阶段范围 +**第一阶段(本地优先)**: +- ✅ 所有游戏逻辑本地运行 +- ✅ 数据存储在 Hive 本地数据库 +- ✅ 无需联网即可完整游玩 +- ✅ 云端仅用于"分享收获"功能(可选) + +**未来扩展(不在当前范围)**: +- ❌ 真实好友系统(偷菜功能) +- ❌ 实时多人互动 +- ❌ 云端存档同步 + +--- + +## 二、系统架构 + +### 2.1 技术栈 + +#### 2.1.1 已有依赖库(无需新增) + +| 库 | 版本 | 用途 | 说明 | +|----|------|------|------| +| `get` | git | 状态管理、路由、依赖注入 | 项目核心框架 | +| `hive_ce` | ^2.11.0 | 本地数据持久化 | 存储游戏数据 | +| `share_plus` | git | 分享功能 | 分享收获到社交平台 | +| `animations` | git (v2.0.11) | 高级过渡动画 | FadeThrough/FadeScale 等页面动画 | +| `cupertino_icons` | ^1.0.8 | iOS 图标库 | CupertinoIcons | +| `path_provider` | git | 文件路径获取 | 获取存储目录 | +| `uuid` | ^4.5.1 | 生成唯一 ID | 玩家 ID、土地 ID | +| `logger` | ^2.7.0 | 日志记录 | 游戏日志调试 | +| `intl` | ^0.20.2 | 国际化 | 日期格式化 | +| `badges` | local | 徽章组件 | 工具中心 NEW 标记 | +| `fl_chart` | local | 图表组件 | 成就进度统计 | + +#### 2.1.2 需要新增的依赖库 + +**dev_dependencies(开发依赖)**: + +```yaml +dev_dependencies: + build_runner: ^2.4.13 # 代码生成工具 + hive_ce_generator: ^1.8.0 # Hive 适配器代码生成 +``` + +**用途说明**: +- `build_runner`: Flutter 官方推荐的代码生成构建工具 +- `hive_ce_generator`: 为 Hive 模型自动生成 `.g.dart` 适配器文件 + +**安装命令**: +```bash +flutter pub add --dev build_runner +flutter pub add --dev hive_ce_generator +``` + +**代码生成命令**: +```bash +flutter pub run build_runner build --delete-conflicting-outputs +``` + +> **注意**: 每次修改 Hive 模型(添加 `@HiveType` 或 `@HiveField`)后,都需要重新运行代码生成命令。 + +### 2.2 与现有服务集成 + +#### 2.2.1 复用现有的 HiveService + +项目已有完善的 `HiveService`(位于 `lib/src/services/data/hive_service.dart`),需要扩展而非重写: + +**需要修改的地方**: + +```dart +// 在 HiveService 类中添加 + +// 1. 新增 Box 常量 +static const String _farmPlayerBox = 'farmPlayerBox'; +static const String _farmLandsBox = 'farmLandsBox'; +static const String _farmInventoryBox = 'farmInventoryBox'; + +// 2. 在 _registerAdapters() 中注册新适配器 +void _registerAdapters() { + // ... 现有代码 ... + if (!Hive.isAdapterRegistered(100)) { + Hive.registerAdapter(FarmPlayerAdapter()); + } + if (!Hive.isAdapterRegistered(101)) { + Hive.registerAdapter(FarmLandAdapter()); + } + if (!Hive.isAdapterRegistered(102)) { + Hive.registerAdapter(InventoryItemAdapter()); + } +} + +// 3. 在 _openBoxes() 中打开新 Box +Future _openBoxes() async { + // ... 现有代码 ... + _farmPlayer = await _openBoxSafe(_farmPlayerBox); + _farmLands = await _openBoxSafe(_farmLandsBox); + _farmInventory = await _openBoxSafe(_farmInventoryBox); +} +``` + +#### 2.2.2 复用现有的 AnimationService + +项目已有丰富的动画服务(`lib/src/services/ui/animation_service.dart`),可直接使用: + +**可用的动画组件**: +- `FadeInWidget` - 页面淡入动画 +- `SlideInWidget` - 滑动进入动画 +- `ScaleInWidget` - 缩放进入动画 +- `AnimatedButton` - 按钮点击缩放效果 +- `AnimatedCard` - 卡片交互反馈 +- `StaggeredAnimation` - 交错列表动画 + +**使用示例**: +```dart +// 种植作物时的缩放动画 +ScaleInWidget( + child: Text('🌱'), + duration: AnimationService.instance.shortDuration, +) + +// 收获完成时的淡入动画 +FadeInWidget( + child: Text('🎉 收获成功!'), + offset: const Offset(0, 20), +) +``` + +### 2.3 架构图 + +``` +┌─────────────────────────────────────────┐ +│ UI 层 (Pages) │ +│ ┌──────────────┐ ┌──────────────────┐ │ +│ │ FarmGamePage │ │ FarmShopPage │ │ +│ │ (主游戏页面) │ │ (商店页面) │ │ +│ └──────────────┘ └──────────────────┘ │ +│ ┌──────────────┐ ┌──────────────────┐ │ +│ │FarmAchievePage│ │ FarmInventoryPage│ │ +│ │ (成就页面) │ │ (背包页面) │ │ +│ └──────────────┘ └──────────────────┘ │ +└──────────────────┬──────────────────────┘ + │ +┌──────────────────▼──────────────────────┐ +│ 业务逻辑层 (Controllers) │ +│ ┌────────────────────────────────────┐ │ +│ │ FarmGameController (核心控制器) │ │ +│ │ - 土地管理、作物生长、交互逻辑 │ │ +│ │ - 计时器、动画控制 │ │ +│ └────────────────────────────────────┘ │ +│ ┌────────────────────────────────────┐ │ +│ │ FarmShopController │ │ +│ │ FarmInventoryController │ │ +│ │ FarmAchievementController │ │ +│ └────────────────────────────────────┘ │ +└──────────────────┬──────────────────────┘ + │ +┌──────────────────▼──────────────────────┐ +│ 数据层 (Models + Repositories) │ +│ ┌────────────────────────────────────┐ │ +│ │ FarmLand, Crop, Item, Player │ │ +│ │ 数据模型定义 │ │ +│ └────────────────────────────────────┘ │ +│ ┌────────────────────────────────────┐ │ +│ │ FarmRepository (Hive 操作封装) │ │ +│ └────────────────────────────────────┘ │ +└──────────────────┬──────────────────────┘ + │ +┌──────────────────▼──────────────────────┐ +│ 存储层 (Hive + SharedPreferences) │ +│ ┌────────────────────────────────────┐ │ +│ │ farm_lands.hive (土地数据) │ │ +│ │ farm_inventory.hive (背包数据) │ │ +│ │ farm_player.hive (玩家数据) │ │ +│ └────────────────────────────────────┘ │ +└─────────────────────────────────────────┘ +``` + +--- + +## 三、数据模型设计 + +### 3.1 玩家模型 (FarmPlayer) + +```dart +@HiveType(typeId: 100) +class FarmPlayer extends HiveObject { + @HiveField(0) + String playerId; // 玩家唯一标识(设备 ID) + + @HiveField(1) + String playerName; // 玩家昵称 + + @HiveField(2) + int level; // 当前等级 + + @HiveField(3) + int experience; // 当前经验值 + + @HiveField(4) + int gold; // 金币数量 + + @HiveField(5) + int diamond; // 钻石数量(高级货币) + + @HiveField(6) + DateTime createTime; // 创建时间 + + @HiveField(7) + int totalHarvest; // 总收获次数 + + @HiveField(8) + int totalPlant; // 总种植次数 + + @HiveField(9) + List unlockedCrops; // 已解锁作物 ID 列表 + + @HiveField(10) + List achievements; // 已达成成就 ID 列表 + + /// 计算升级到下一级所需经验 + int get expToNextLevel => level * 100; + + /// 经验进度(0.0 - 1.0) + double get expProgress => experience / expToNextLevel; +} +``` + +### 3.2 作物配置 (CropConfig) + +```dart +class CropConfig { + final String id; // 作物 ID + final String name; // 作物名称 + final String emoji; // 作物 Emoji 图标 + final int growthTime; // 总生长时间(分钟) + final int seedPrice; // 种子价格(金币) + final int harvestPrice; // 收获价格(金币) + final int harvestExp; // 收获经验 + final int unlockLevel; // 解锁等级 + final List stages; // 生长阶段配置 + + const CropConfig({ + required this.id, + required this.name, + required this.emoji, + required this.growthTime, + required this.seedPrice, + required this.harvestPrice, + required this.harvestExp, + required this.unlockLevel, + required this.stages, + }); +} + +class StageInfo { + final int stage; // 阶段编号(0-3) + final String emoji; // 该阶段显示的 Emoji + final double durationPercent; // 该阶段占总时间的百分比 + final bool needWater; // 是否需要浇水 + + const StageInfo({ + required this.stage, + required this.emoji, + required this.durationPercent, + this.needWater = false, + }); +} +``` + +### 3.3 土地模型 (FarmLand) + +```dart +@HiveType(typeId: 101) +class FarmLand extends HiveObject { + @HiveField(0) + int landId; // 土地编号(0-11,最多 12 块土地) + + @HiveField(1) + bool isUnlocked; // 是否已解锁 + + @HiveField(2) + String? cropId; // 当前种植的作物 ID + + @HiveField(3) + DateTime? plantTime; // 种植时间 + + @HiveField(4) + int growthStage; // 当前生长阶段(0-3) + + @HiveField(5) + bool needWater; // 是否需要浇水 + + @HiveField(6) + bool needFertilizer; // 是否需要施肥 + + @HiveField(7) + DateTime? lastWaterTime; // 上次浇水时间 + + @HiveField(8) + bool isWithered; // 是否枯萎(长时间未浇水) + + @HiveField(9) + bool isReady; // 是否成熟可收获 + + /// 计算当前生长进度(0.0 - 1.0) + double get growthProgress { + if (plantTime == null || cropId == null) return 0.0; + final crop = CropRegistry.getById(cropId!); + if (crop == null) return 0.0; + + final elapsed = DateTime.now().difference(plantTime!).inMinutes; + return (elapsed / crop.growthTime).clamp(0.0, 1.0); + } + + /// 获取当前阶段应显示的 Emoji + String get currentEmoji { + if (cropId == null) return '🟫'; // 空地 + final crop = CropRegistry.getById(cropId!); + if (crop == null) return '🟫'; + + if (isWithered) return '🥀'; // 枯萎 + if (isReady) return crop.stages.last.emoji; // 成熟 + + final stageIndex = growthStage.clamp(0, crop.stages.length - 1); + return crop.stages[stageIndex].emoji; + } +} +``` + +### 3.4 背包物品 (InventoryItem) + +```dart +@HiveType(typeId: 102) +class InventoryItem extends HiveObject { + @HiveField(0) + String itemId; // 物品 ID + + @HiveField(1) + String itemName; // 物品名称 + + @HiveField(2) + String itemType; // 物品类型:seed, fruit, fertilizer, tool + + @HiveField(3) + int quantity; // 数量 + + @HiveField(4) + String emoji; // 物品 Emoji + + @HiveField(5) + int price; // 单价(金币) +} +``` + +### 3.5 成就配置 (AchievementConfig) + +```dart +class AchievementConfig { + final String id; // 成就 ID + final String name; // 成就名称 + final String description; // 成就描述 + final String emoji; // 成就图标 + final int rewardGold; // 奖励金币 + final int rewardExp; // 奖励经验 + final String conditionType; // 条件类型 + final int conditionValue; // 条件数值 + + const AchievementConfig({ + required this.id, + required this.name, + required this.description, + required this.emoji, + required this.rewardGold, + required this.rewardExp, + required this.conditionType, + required this.conditionValue, + }); +} +``` + +--- + +## 四、核心游戏逻辑 + +### 4.1 作物生长流程 + +``` +种植 → 阶段0(种子) → 阶段1(幼苗) → 阶段2(生长中) → 阶段3(成熟) → 收获 + ↓ ↓ ↓ ↓ ↓ ↓ +播种 5% 时间 30% 时间 60% 时间 95% 时间 100% + 需浇水 需浇水 需浇水 可收获 获得奖励 +``` + +**生长规则**: +- 每个作物有固定的生长时间(如:萝卜 30 分钟,西红柿 60 分钟) +- 生长分为 4 个阶段,每个阶段有不同的 Emoji 显示 +- 浇水可以加快 20% 生长速度 +- 超过 2 小时未浇水,作物会枯萎 +- 成熟后 24 小时内可收获,超过时间自动枯萎 + +### 4.2 浇水系统 + +```dart +void waterLand(int landId) { + final land = lands.firstWhere((l) => l.landId == landId); + if (!land.needWater) return; + + land.needWater = false; + land.lastWaterTime = DateTime.now(); + + // 浇水加速生长(减少 20% 剩余时间) + // 实现:记录浇水次数,计算时应用加速系数 + + inventory.updateItem('water_can', -1); // 消耗浇水壶 +} +``` + +### 4.3 收获系统 + +```dart +void harvestLand(int landId) { + final land = lands.firstWhere((l) => l.landId == landId); + if (!land.isReady) return; + + final crop = CropRegistry.getById(land.cropId!); + + // 增加金币和经验 + player.gold += crop.harvestPrice; + player.experience += crop.harvestExp; + player.totalHarvest++; + + // 添加果实到背包 + inventory.addItem(InventoryItem( + itemId: land.cropId!, + itemName: crop.name, + itemType: 'fruit', + quantity: 1, + emoji: crop.emoji, + price: crop.harvestPrice, + )); + + // 重置土地 + land.cropId = null; + land.plantTime = null; + land.growthStage = 0; + land.isReady = false; + + // 检查升级 + checkLevelUp(); + + // 检查成就 + checkAchievements(); +} +``` + +### 4.4 等级系统 + +```dart +void checkLevelUp() { + while (player.experience >= player.expToNextLevel) { + player.experience -= player.expToNextLevel; + player.level++; + + // 升级奖励 + player.gold += player.level * 50; + player.diamond += 5; + + // 解锁新作物 + final newCrops = CropRegistry.getUnlockedCrops(player.level); + player.unlockedCrops.addAll(newCrops.map((c) => c.id)); + + showToast('🎉 升级!当前等级:${player.level}'); + } +} +``` + +### 4.5 成就系统 + +**预设成就列表**: + +| 成就 ID | 名称 | 描述 | 条件 | 奖励 | +|---------|------|------|------|------| +| first_harvest | 初次收获 | 完成第一次收获 | totalHarvest >= 1 | 50 金币 | +| harvest_10 | 小有成就 | 收获 10 次 | totalHarvest >= 10 | 100 金币 | +| harvest_50 | 丰收达人 | 收获 50 次 | totalHarvest >= 50 | 300 金币 + 10 钻石 | +| level_5 | 初出茅庐 | 达到 5 级 | level >= 5 | 200 金币 | +| level_10 | 农场老手 | 达到 10 级 | level >= 10 | 500 金币 + 20 钻石 | +| unlock_all | 丰收大师 | 解锁所有作物 | unlockedCrops.length >= 12 | 1000 金币 + 50 钻石 | + +--- + +## 五、UI/UX 设计 + +### 5.1 设计风格 +遵循项目现有的 **iOS 26 Liquid Glass** 设计系统: +- 颜色:使用 `DesignTokens` 和 `DarkDesignTokens` +- 圆角:`DesignTokens.radiusLg` (20px) 主要容器 +- 阴影:`DesignTokens.shadowsMd` +- 毛玻璃效果:`GlassContainer` 组件 +- 动画:`DesignTokens.durationNormal` (250ms) + +### 5.2 页面结构 + +#### 5.2.1 主游戏页面 (FarmGamePage) + +``` +┌─────────────────────────────────────┐ +│ [← 返回] 小妈菜园 [🎒 背包] │ <- AppBar +├─────────────────────────────────────┤ +│ 👤 Lv.5 ━━━━━━━━ 60% 💰 1250 │ <- 玩家状态栏 +├─────────────────────────────────────┤ +│ │ +│ 🌱 🌿 🌾 🍅 🌱 🟫 │ +│ 🌾 🍅 🟫 🌿 🌱 🌾 │ <- 菜园网格(4x3) +│ 🟫 🟫 🌱 🌿 🌾 🍅 │ +│ │ +├─────────────────────────────────────┤ +│ [播种] [浇水] [施肥] [收获] │ <- 底部操作栏 +└─────────────────────────────────────┘ +``` + +#### 5.2.2 商店页面 (FarmShopPage) + +``` +┌─────────────────────────────────────┐ +│ [← 返回] 种子商店 💰 1250 │ +├─────────────────────────────────────┤ +│ │ +│ 🥕 萝卜种子 ⏱️ 30 分钟 💰 10 │ +│ ┌──────────────────────────────┐ │ +│ │ [购买] │ │ +│ └──────────────────────────────┘ │ +│ │ +│ 🍅 西红柿种子 ⏱️ 60 分钟 💰 25 │ +│ 🌽 玉米种子 ⏱️ 90 分钟 💰 40 │ +│ 🍓 草莓种子 ⏱️ 120 分钟 💰 60 │ +│ │ +└─────────────────────────────────────┘ +``` + +#### 5.2.3 背包页面 (FarmInventoryPage) + +``` +┌─────────────────────────────────────┐ +│ [← 返回] 我的背包 │ +├─────────────────────────────────────┤ +│ [种子] [果实] [道具] │ <- 分类标签 +├─────────────────────────────────────┤ +│ │ +│ 🥕x5 🍅x3 🌽x2 │ +│ 🍓x1 💧x10 🧪x5 │ +│ │ +└─────────────────────────────────────┘ +``` + +#### 5.2.4 成就页面 (FarmAchievementPage) + +``` +┌─────────────────────────────────────┐ +│ [← 返回] 成就中心 │ +├─────────────────────────────────────┤ +│ ✅ 初次收获 50 金币 [已领取] │ +│ ✅ 小有成就 100 金币 [已领取] │ +│ 🔒 丰收达人 300 金币 [未达成] │ +│ 🔒 丰收大师 1000 金币 [未达成] │ +└─────────────────────────────────────┘ +``` + +### 5.3 交互动画 + +1. **种植动画**:点击土地 → 种子掉落动画(0.3s) +2. **生长动画**:作物微微摇摆(无限循环) +3. **浇水动画**:水壶倾倒动画(0.5s) +4. **收获动画**:作物弹跳 + 金币飞入动画(0.8s) +5. **升级动画**:全屏庆祝粒子效果(1.5s) + +--- + +## 六、作物配置表 + +### 6.1 初始作物列表 + +| ID | 名称 | Emoji | 生长时间 | 种子价格 | 收获价格 | 收获经验 | 解锁等级 | +|----|------|-------|----------|----------|----------|----------|----------| +| radish | 萝卜 | 🥕 | 30 分钟 | 10 | 25 | 15 | 1 | +| tomato | 西红柿 | 🍅 | 60 分钟 | 25 | 60 | 35 | 2 | +| corn | 玉米 | 🌽 | 90 分钟 | 40 | 95 | 55 | 3 | +| strawberry | 草莓 | 🍓 | 120 分钟 | 60 | 140 | 80 | 5 | +| potato | 土豆 | 🥔 | 45 分钟 | 15 | 40 | 25 | 1 | +| carrot | 胡萝卜 | 🥕 | 50 分钟 | 20 | 50 | 30 | 2 | +| pepper | 辣椒 | 🌶️ | 70 分钟 | 30 | 75 | 45 | 3 | +| eggplant | 茄子 | 🍆 | 80 分钟 | 35 | 85 | 50 | 4 | +| cabbage | 卷心菜 | 🥬 | 40 分钟 | 12 | 35 | 20 | 1 | +| pumpkin | 南瓜 | 🎃 | 150 分钟 | 80 | 180 | 100 | 6 | +| watermelon | 西瓜 | 🍉 | 180 分钟 | 100 | 220 | 120 | 8 | +| grape | 葡萄 | 🍇 | 200 分钟 | 120 | 260 | 140 | 10 | + +### 6.2 生长阶段配置示例(以萝卜为例) + +```dart +const CropConfig( + id: 'radish', + name: '萝卜', + emoji: '🥕', + growthTime: 30, // 30 分钟 + seedPrice: 10, + harvestPrice: 25, + harvestExp: 15, + unlockLevel: 1, + stages: [ + StageInfo(stage: 0, emoji: '🌱', durationPercent: 0.05, needWater: true), // 0-1.5 分钟 + StageInfo(stage: 1, emoji: '🌿', durationPercent: 0.25, needWater: true), // 1.5-9 分钟 + StageInfo(stage: 2, emoji: '☘️', durationPercent: 0.55, needWater: true), // 9-25.5 分钟 + StageInfo(stage: 3, emoji: '🥕', durationPercent: 0.15, needWater: false), // 25.5-30 分钟 + ], +) +``` + +--- + +## 七、分享功能设计 + +### 7.1 分享流程 + +``` +玩家点击"分享收获" + ↓ +生成分享图片(包含玩家信息、菜园截图、成就) + ↓ +调用 share_plus 包 + ↓ +选择分享目标(微信、QQ、微博、保存相册) +``` + +### 7.2 分享图片设计 + +``` +┌─────────────────────────────────┐ +│ │ +│ 🌾 小妈菜园 🌾 │ +│ │ +│ 👤 玩家:小厨神 │ +│ 🏆 等级:Lv.5 │ +│ 💰 金币:1250 │ +│ │ +│ ┌───┬───┬───┬───┐ │ +│ │🥕 │🍅 │🌽 │🍓 │ │ +│ ├───┼───┼───┼───┤ │ +│ │🥔 │🌶️ │🍆 │🥬 │ │ +│ └───┴───┴───┴───┘ │ +│ │ +│ 今日收获:8 次 │ +│ 获得金币:+350 🎉 │ +│ │ +│ 📅 2026-04-18 │ +│ 小妈厨房 · 工具中心 │ +│ │ +└─────────────────────────────────┘ +``` + +### 7.3 技术实现 + +```dart +Future shareHarvest() async { + // 1. 生成分享图片 + final recorder = PictureRecorder(); + final canvas = Canvas(recorder); + final painter = FarmSharePainter( + player: player, + lands: lands, + todayHarvest: todayHarvestCount, + ); + painter.paint(canvas, Size(800, 1000)); + final picture = recorder.endRecording(); + final image = await picture.toImage(800, 1000); + final byteData = await image.toByteData(format: ImageByteFormat.png); + + // 2. 保存到临时文件 + final tempDir = await getTemporaryDirectory(); + final file = File('${tempDir.path}/farm_share_${DateTime.now().millisecondsSinceEpoch}.png'); + await file.writeAsBytes(byteData!.buffer.asUint8List()); + + // 3. 分享 + await Share.shareXFiles([XFile(file.path)], text: '我在小妈菜园的收获!🌾'); +} +``` + +--- + +## 八、Hive 数据持久化 + +### 8.1 Hive 集成方案 + +**重要**: 复用项目现有的 `HiveService`(单例模式),不创建新的数据服务类。 + +#### 扩展 HiveService + +在 `lib/src/services/data/hive_service.dart` 中添加: + +```dart +// 1. 新增 Box 变量 +late Box _farmPlayer; +late Box _farmLands; +late Box _farmInventory; + +// 2. 添加访问器 +Box get farmPlayer => _farmPlayer; +Box get farmLands => _farmLands; +Box get farmInventory => _farmInventory; + +// 3. 在 _registerAdapters() 中注册 +void _registerAdapters() { + // ... 现有代码 ... + if (!Hive.isAdapterRegistered(100)) { + Hive.registerAdapter(FarmPlayerAdapter()); + } + if (!Hive.isAdapterRegistered(101)) { + Hive.registerAdapter(FarmLandAdapter()); + } + if (!Hive.isAdapterRegistered(102)) { + Hive.registerAdapter(InventoryItemAdapter()); + } +} + +// 4. 在 _openBoxes() 中打开 +Future _openBoxes() async { + // ... 现有代码 ... + _farmPlayer = await _openBoxSafe('farmPlayer'); + _farmLands = await _openBoxSafe('farmLands'); + _farmInventory = await _openBoxSafe('farmInventory'); +} +``` + +### 8.2 Hive 代码生成 + +#### 生成 .g.dart 文件 + +每次创建或修改 Hive 模型后,运行: + +```bash +# 生成所有适配器 +dart run build_runner build --delete-conflicting-outputs + +# 或使用缩写 +dart run build_runner build -d +``` + +#### 生成的文件 + +``` +lib/src/models/farm/ +├── farm_player.dart ← 手动编写 +├── farm_player.g.dart ← 自动生成 +├── farm_land.dart ← 手动编写 +├── farm_land.g.dart ← 自动生成 +├── inventory_item.dart ← 手动编写 +└── inventory_item.g.dart ← 自动生成 +``` + +**注意**: `.g.dart` 文件必须提交到 Git,确保其他开发者可以直接编译项目。 + +### 8.3 数据初始化 + +```dart +// 在 HiveService 的 _initializeDefaultData() 方法中添加 +Future _initializeFarmData() async { + if (farmPlayer.isEmpty) { + final deviceId = await getDeviceId(); // 使用设备 ID + final player = FarmPlayer.createDefault(deviceId); + await farmPlayer.put('player', player); + + // 初始化 12 块土地 + for (int i = 0; i < FarmConfig.totalLands; i++) { + final land = FarmLand.initial(i, unlocked: i < 6); + await farmLands.put(i, land); + } + + // 初始种子 + await farmInventory.put( + 'radish_seed', + InventoryItem.seed( + cropId: 'radish', + name: '萝卜', + emoji: '🥕', + price: 10, + quantity: 5, + ), + ); + } +} +``` +``` + +--- + +## 九、路由配置 + +### 9.1 新增路由 + +```dart +// app_routes.dart 中新增 +static const String farmGame = '/farm-game'; +static const String farmShop = '/farm-shop'; +static const String farmInventory = '/farm-inventory'; +static const String farmAchievement = '/farm-achievement'; +``` + +### 9.2 工具中心添加入口 + +在 `ToolRegistry.defaultTools` 中新增: + +```dart +ToolItem( + id: 'farm_game', + name: '小妈菜园', + icon: '🌾', + needsNetwork: false, + category: 'planning', // 或新增 'game' 分类 + route: '/farm-game', + description: '种菜收菜,体验农场乐趣', + waterfallSlot: WaterfallSlotConfig(show: true, priority: 10, badge: 'NEW'), +), +``` + +--- + +## 十、文件清单 + +### 10.1 新增文件 + +| 文件路径 | 说明 | +|----------|------| +| `lib/src/models/farm/farm_player.dart` | 玩家模型 | +| `lib/src/models/farm/crop_config.dart` | 作物配置 | +| `lib/src/models/farm/farm_land.dart` | 土地模型 | +| `lib/src/models/farm/inventory_item.dart` | 背包物品模型 | +| `lib/src/models/farm/achievement_config.dart` | 成就配置 | +| `lib/src/models/farm/crop_registry.dart` | 作物注册表 | +| `lib/src/models/farm/achievement_registry.dart` | 成就注册表 | +| `lib/src/controllers/farm/farm_game_controller.dart` | 游戏核心控制器 | +| `lib/src/controllers/farm/farm_shop_controller.dart` | 商店控制器 | +| `lib/src/controllers/farm/farm_inventory_controller.dart` | 背包控制器 | +| `lib/src/controllers/farm/farm_achievement_controller.dart` | 成就控制器 | +| `lib/src/pages/tools/farm/farm_game_page.dart` | 主游戏页面 | +| `lib/src/pages/tools/farm/farm_shop_page.dart` | 商店页面 | +| `lib/src/pages/tools/farm/farm_inventory_page.dart` | 背包页面 | +| `lib/src/pages/tools/farm/farm_achievement_page.dart` | 成就页面 | +| `lib/src/pages/tools/farm/widgets/farm_grid_painter.dart` | 菜园 Canvas 绘制器 | +| `lib/src/pages/tools/farm/widgets/crop_widget.dart` | 作物 Widget | +| `lib/src/pages/tools/farm/widgets/land_widget.dart` | 土地 Widget | +| `lib/src/pages/tools/farm/widgets/farm_share_painter.dart` | 分享图片绘制器 | +| `lib/src/services/data/farm_data_service.dart` | 游戏数据服务 | + +### 10.2 修改文件 + +| 文件路径 | 修改内容 | +|----------|----------| +| `lib/src/config/app_routes.dart` | 新增路由定义 | +| `lib/src/models/tool_item_model.dart` | 新增小妈菜园工具项 | +| `lib/src/app_binding.dart` | 注册 FarmGameController | +| `lib/src/services/data/hive_service.dart` | 新增 Hive Box 定义 | + +--- + +## 十一、性能优化策略 + +### 11.1 Canvas 绘制优化 +- 使用 `RepaintBoundary` 隔离静态和动态元素 +- 缓存作物图像,避免重复绘制 +- 仅在数据变化时重绘(GetX 响应式触发) + +### 11.2 存储优化 +- 自动保存:每次操作后立即写入 Hive +- 定期清理:枯萎作物自动清除 +- 压缩数据:仅存储必要字段 + +### 11.3 内存优化 +- 使用 `const` 构造函数减少重建 +- 懒加载作物配置 +- 及时 dispose 动画控制器 + +--- + +## 十二、调试功能 + +### 12.1 调试面板 + +开发阶段提供调试按钮(生产环境隐藏): + +```dart +// 调试功能 +- [加速时间]:将所有作物生长时间缩短 10 倍 +- [添加金币]:+1000 金币 +- [解锁全部]:解锁所有作物和土地 +- [重置游戏]:清空所有数据重新开始 +- [导出日志]:导出游戏日志用于调试 +``` + +### 12.2 日志记录 + +```dart +// 使用项目现有的 logger_service +AppLogger.info('FarmGame: 种植作物 ${crop.name} 在土地 $landId'); +AppLogger.info('FarmGame: 收获作物 ${crop.name},获得 ${crop.harvestPrice} 金币'); +AppLogger.warning('FarmGame: 作物枯萎 - 土地 $landId 超过 2 小时未浇水'); +``` + +--- + +## 十三、未来扩展规划 + +### 13.1 第二阶段:社交功能(预留接口) + +```dart +// 预留 API 接口定义 +class FarmApiService { + // 同步游戏数据到服务器 + Future syncGameData(FarmGameData data); + + // 获取好友列表 + Future> getFriends(); + + // 访问好友菜园 + Future visitFriendFarm(String friendId); + + // 偷菜 + Future stealCrop(String friendId, int landId); + + // 排行榜 + Future> getLeaderboard(); +} +``` + +### 13.2 第三阶段:高级功能 + +- 🌦️ 天气系统(影响生长速度) +- 🐛 随机事件(害虫来袭、丰收 bonus) +- 🎨 装饰系统(装饰菜园) +- 🏠 建筑系统(仓库、工具房扩建) +- 🎁 每日签到奖励 + +--- + +## 十四、测试计划 + +### 14.1 单元测试 + +```dart +// test/farm/farm_game_controller_test.dart +test('种植作物后土地状态正确更新', () { + final controller = FarmGameController(); + controller.plantCrop(landId: 0, cropId: 'radish'); + + expect(controller.lands[0].cropId, 'radish'); + expect(controller.lands[0].plantTime, isNotNull); + expect(controller.lands[0].growthStage, 0); +}); + +test('收获作物后金币和经验增加', () { + final controller = FarmGameController(); + // 模拟作物成熟 + controller.lands[0].isReady = true; + controller.lands[0].cropId = 'radish'; + + final initialGold = controller.player.gold; + controller.harvestCrop(landId: 0); + + expect(controller.player.gold, initialGold + 25); + expect(controller.player.experience, greaterThan(0)); +}); +``` + +### 14.2 集成测试 + +```dart +// test/farm/farm_game_integration_test.dart +testWidgets('完整种植收获流程', (tester) async { + await tester.pumpWidget(FarmGamePage()); + + // 点击土地 + await tester.tap(find.byKey(ValueKey('land_0'))); + await tester.pumpAndSettle(); + + // 选择作物 + await tester.tap(find.text('萝卜种子')); + await tester.pumpAndSettle(); + + // 验证种植成功 + expect(find.text('🌱'), findsOneWidget); +}); +``` + +--- + +## 十五、开发规范 + +### 15.1 代码规范 +- 遵循项目现有的代码风格 +- 每个文件头部添加标准注释 +- 使用 iOS Cupertino 组件优先 +- 所有颜色使用 `DesignTokens` + +### 15.2 命名规范 +- 模型类:`FarmXxx` +- 控制器:`FarmXxxController` +- 页面:`FarmXxxPage` +- Widget:`FarmXxxWidget` + +### 15.3 提交规范 +- `feat(farm): 添加种植系统` +- `feat(farm): 实现浇水功能` +- `fix(farm): 修复收获后数据不同步问题` + +--- + +## 十六、CHANGELOG.md 更新 + +开发完成后需要在项目根目录 `CHANGELOG.md` 中添加: + +```markdown +## [1.0.0] - 2026-04-XX + +### 新增 +- 🌾 小妈菜园游戏系统 + - 种植、浇水、施肥、收获完整流程 + - 12 种作物,4 个生长阶段 + - 等级经验系统 + - 成就系统(6 个初始成就) + - 本地商店系统 + - 背包管理系统 + - 分享收获功能 + - Canvas 绘制游戏场景 + - Hive 本地数据持久化 + - iOS 26 Liquid Glass 风格 UI +``` + +--- + +## 十七、风险评估 + +| 风险 | 影响 | 概率 | 应对措施 | +|------|------|------|----------| +| Canvas 绘制性能差 | 高 | 低 | 使用 RepaintBoundary,优化绘制逻辑 | +| Hive 数据损坏 | 高 | 低 | 添加数据校验和恢复机制 | +| 生长计时器不准确 | 中 | 中 | 使用 DateTime 差值计算,而非定时器累加 | +| 内存泄漏 | 中 | 低 | 严格 dispose 动画控制器和监听器 | + +--- + +**文档结束** + +> 本文档定义了"小妈菜园"游戏的完整架构和实现方案。 +> 开发前请仔细阅读各章节,确保理解设计意图。 +> 如有疑问,请及时沟通确认。 diff --git a/ios/Runner/Info.plist b/ios/Runner/Info.plist index d6bfcab..53f7269 100644 --- a/ios/Runner/Info.plist +++ b/ios/Runner/Info.plist @@ -45,5 +45,49 @@ UIApplicationSupportsIndirectInputEvents + NSAppTransportSecurity + + NSAllowsArbitraryLoads + + + CFBundleDocumentTypes + + + CFBundleTypeName + JSON File + CFBundleTypeRole + Viewer + LSHandlerRank + Alternate + LSItemContentTypes + + public.json + + + + UTImportedTypeDeclarations + + + UTTypeIdentifier + public.json + UTTypeConformsTo + + public.text + + UTTypeDescription + JSON File + UTTypeTagSpecification + + public.filename-extension + + json + + public.mime-type + + application/json + + + + diff --git a/lib/src/app_binding.dart b/lib/src/app_binding.dart index 11fc3b3..5b3e04d 100644 --- a/lib/src/app_binding.dart +++ b/lib/src/app_binding.dart @@ -25,6 +25,8 @@ import 'package:mom_kitchen/src/controllers/data/weekly_menu_controller.dart'; import 'package:mom_kitchen/src/controllers/tools/bedtime_reminder_controller.dart'; import 'package:mom_kitchen/src/services/core/app_service.dart'; import 'package:mom_kitchen/src/services/ui/theme_service.dart'; +import 'package:mom_kitchen/src/services/data/offline_service.dart'; +import 'package:mom_kitchen/src/services/data/data_export_service.dart'; /// 全局Binding - 应用启动时注册所有全局控制器和服务 /// 所有 permanent:true 的控制器在此统一管理,路由级Binding禁止重复注册 @@ -47,6 +49,12 @@ class AppBinding extends Bindings { Get.lazyPut(() => AppService.instance.appInfo, fenix: true); Get.lazyPut(() => AppService.instance.toast, fenix: true); + // --- 离线服务 --- + Get.put(OfflineService(), permanent: true); + + // --- 数据导出服务 --- + Get.put(DataExportService(), permanent: true); + // --- 主题与个性化(首屏必需) --- if (!Get.isRegistered()) { Get.put(ThemeService.instance, permanent: true); diff --git a/lib/src/config/app_routes.dart b/lib/src/config/app_routes.dart index 78a255f..60d86a7 100644 --- a/lib/src/config/app_routes.dart +++ b/lib/src/config/app_routes.dart @@ -9,6 +9,7 @@ import 'package:mom_kitchen/src/pages/profile/settings/personalization_page.dart import 'package:mom_kitchen/src/pages/profile/social/chat_page.dart' show FeedbackPage; import 'package:mom_kitchen/src/pages/profile/about_page.dart'; +import 'package:mom_kitchen/src/pages/profile/data_export_page.dart'; import 'package:mom_kitchen/src/widgets/navigation_widgets.dart'; import 'package:mom_kitchen/src/standards/page_validator.dart'; import 'package:mom_kitchen/src/standards/route_middleware.dart'; @@ -60,6 +61,10 @@ import 'package:mom_kitchen/src/pages/tools/ranking/dish_ranking_page.dart'; import 'package:mom_kitchen/src/pages/tools/ingredient_manage_page.dart'; import 'package:mom_kitchen/src/pages/tools/tool_detail_page.dart'; import 'package:mom_kitchen/src/pages/tools/cooking/order_assistant_page.dart'; +import 'package:mom_kitchen/src/pages/tools/farm/farm_game_page.dart'; +import 'package:mom_kitchen/src/pages/tools/farm/farm_shop_page.dart'; +import 'package:mom_kitchen/src/pages/tools/farm/farm_inventory_page.dart'; +import 'package:mom_kitchen/src/pages/tools/farm/farm_achievement_page.dart'; import 'package:mom_kitchen/src/app_binding.dart'; class AppRoutes { @@ -125,6 +130,13 @@ class AppRoutes { static const String toolsIngredientManage = '/tools/ingredient-manage'; static const String toolDetail = '/tool-detail'; static const String toolsOrderAssistant = '/tools/order-assistant'; + static const String dataExport = '/data-export'; + + // 农场游戏路由 + static const String farmGame = '/farm-game'; + static const String farmShop = '/farm-shop'; + static const String farmInventory = '/farm-inventory'; + static const String farmAchievement = '/farm-achievement'; static final List pages = [ GetPage( @@ -172,6 +184,32 @@ class AppRoutes { page: () => const PrivacyPolicyPage(), middlewares: [PageStandardsMiddleware()], ), + GetPage( + name: dataExport, + page: () => const DataExportPage(), + middlewares: [PageStandardsMiddleware()], + ), + // 农场游戏路由 + GetPage( + name: farmGame, + page: () => const FarmGamePage(), + middlewares: [PageStandardsMiddleware()], + ), + GetPage( + name: farmShop, + page: () => const FarmShopPage(), + middlewares: [PageStandardsMiddleware()], + ), + GetPage( + name: farmInventory, + page: () => const FarmInventoryPage(), + middlewares: [PageStandardsMiddleware()], + ), + GetPage( + name: farmAchievement, + page: () => const FarmAchievementPage(), + middlewares: [PageStandardsMiddleware()], + ), GetPage( name: guide, page: () => const GuidePage(), @@ -518,7 +556,7 @@ class AppRoutes { PageInfo( route: home, name: 'Home Page', - description: '��ҳ��', + description: '首页页面', requiredStandards: const [ StandardCheck.themeColors, StandardCheck.textColors, @@ -531,7 +569,7 @@ class AppRoutes { PageInfo( route: theme, name: 'Theme Demo Page', - description: '������ʾҳ��', + description: '主题演示页面', requiredStandards: const [ StandardCheck.themeColors, StandardCheck.textColors, @@ -548,7 +586,7 @@ class AppRoutes { PageInfo( route: favorites, name: 'Favorites Page', - description: '�ղ�ҳ��', + description: '收藏页面', requiredStandards: const [ StandardCheck.themeColors, StandardCheck.textColors, @@ -560,7 +598,7 @@ class AppRoutes { PageInfo( route: discover, name: 'Discover Page', - description: '����ҳ��', + description: '发现页面', requiredStandards: const [ StandardCheck.themeColors, StandardCheck.textColors, @@ -572,7 +610,7 @@ class AppRoutes { PageInfo( route: profile, name: 'Profile Page', - description: '��������ҳ��', + description: '个人中心页面', requiredStandards: const [ StandardCheck.themeColors, StandardCheck.textColors, @@ -584,7 +622,7 @@ class AppRoutes { PageInfo( route: personalization, name: 'Personalization Page', - description: '���Ի�����ҳ��', + description: '个性化设置页面', requiredStandards: const [ StandardCheck.themeColors, StandardCheck.textColors, @@ -599,7 +637,7 @@ class AppRoutes { PageInfo( route: chat, name: 'Feedback Page', - description: '�������ҳ��', + description: '意见反馈页面', requiredStandards: const [ StandardCheck.themeColors, StandardCheck.textColors, @@ -611,7 +649,7 @@ class AppRoutes { PageInfo( route: main, name: 'Main Tab View', - description: '����ǩҳ', + description: '主标签页', requiredStandards: const [ StandardCheck.themeColors, StandardCheck.textColors, @@ -623,7 +661,7 @@ class AppRoutes { PageInfo( route: whatToEat, name: 'What To Eat Page', - description: '�����ʲôҳ��', + description: '今天吃什么页面', requiredStandards: const [ StandardCheck.themeColors, StandardCheck.textColors, @@ -635,7 +673,7 @@ class AppRoutes { PageInfo( route: hot, name: 'Hot Page', - description: '��������ҳ��', + description: '热门推荐页面', requiredStandards: const [ StandardCheck.themeColors, StandardCheck.textColors, @@ -647,7 +685,7 @@ class AppRoutes { PageInfo( route: nutrition, name: 'Nutrition Center Page', - description: 'Ӫ������ҳ��', + description: '营养中心页面', requiredStandards: const [ StandardCheck.themeColors, StandardCheck.textColors, @@ -659,7 +697,7 @@ class AppRoutes { PageInfo( route: goalSetting, name: 'Goal Setting Page', - description: 'Ŀ������ҳ��', + description: '目标设置页面', requiredStandards: const [ StandardCheck.themeColors, StandardCheck.textColors, @@ -671,7 +709,7 @@ class AppRoutes { PageInfo( route: shoppingList, name: 'Shopping List Page', - description: '�����嵥ҳ��', + description: '购物清单页面', requiredStandards: const [ StandardCheck.themeColors, StandardCheck.textColors, @@ -683,7 +721,7 @@ class AppRoutes { PageInfo( route: search, name: 'Search Page', - description: '����ҳ��', + description: '搜索页面', requiredStandards: const [ StandardCheck.themeColors, StandardCheck.textColors, @@ -695,7 +733,7 @@ class AppRoutes { PageInfo( route: recipeDetail, name: 'Recipe Detail Page', - description: '��������ҳ��', + description: '菜谱详情页面', requiredStandards: const [ StandardCheck.themeColors, StandardCheck.textColors, @@ -707,7 +745,7 @@ class AppRoutes { PageInfo( route: nutritionReport, name: 'Nutrition Report Page', - description: 'Ӫ������ҳ��', + description: '营养报告页面', requiredStandards: const [ StandardCheck.themeColors, StandardCheck.textColors, @@ -719,7 +757,7 @@ class AppRoutes { PageInfo( route: cookingTimer, name: 'Cooking Timer Page', - description: '��⿼�ʱ��ҳ��', + description: '烹饪计时器页面', requiredStandards: const [ StandardCheck.themeColors, StandardCheck.textColors, @@ -731,7 +769,7 @@ class AppRoutes { PageInfo( route: unitConverter, name: 'Unit Converter Page', - description: '�������㹤��ҳ��', + description: '单位换算工具页面', requiredStandards: const [ StandardCheck.themeColors, StandardCheck.textColors, @@ -743,7 +781,7 @@ class AppRoutes { PageInfo( route: bmiCalculator, name: 'BMI Calculator Page', - description: 'BMI������ҳ��', + description: 'BMI计算器页面', requiredStandards: const [ StandardCheck.themeColors, StandardCheck.textColors, @@ -755,7 +793,7 @@ class AppRoutes { PageInfo( route: servingScaler, name: 'Serving Scaler Page', - description: '�������Ź���ҳ��', + description: '份量缩放工具页面', requiredStandards: const [ StandardCheck.themeColors, StandardCheck.textColors, @@ -767,7 +805,7 @@ class AppRoutes { PageInfo( route: toolsIngredient, name: 'Ingredient Detail Page', - description: 'ʳ������ҳ��', + description: '食材详情页面', requiredStandards: const [ StandardCheck.themeColors, StandardCheck.textColors, @@ -779,7 +817,7 @@ class AppRoutes { PageInfo( route: toolsIngredientRecommend, name: 'Ingredient Recommend Page', - description: 'ʳ���Ƽ�ҳ��', + description: '食材推荐页面', requiredStandards: const [ StandardCheck.themeColors, StandardCheck.textColors, @@ -791,7 +829,7 @@ class AppRoutes { PageInfo( route: toolsIngredientRecipes, name: 'Ingredient Recipe List Page', - description: 'ʳ�IJ�Ʒ�б�ҳ��', + description: '食材菜谱列表页面', requiredStandards: const [ StandardCheck.themeColors, StandardCheck.textColors, @@ -804,7 +842,7 @@ class AppRoutes { PageInfo( route: toolsCenter, name: 'Tools Center Page', - description: '��������ҳ��', + description: '工具中心页面', requiredStandards: const [ StandardCheck.themeColors, StandardCheck.textColors, @@ -816,7 +854,7 @@ class AppRoutes { PageInfo( route: cookingNote, name: 'Cooking Note Page', - description: '��⿱ʼ�ҳ��', + description: '烹饪笔记页面', requiredStandards: const [ StandardCheck.themeColors, StandardCheck.textColors, @@ -828,7 +866,7 @@ class AppRoutes { PageInfo( route: dataCenter, name: 'Data Center Page', - description: '���ݹ�������ҳ��', + description: '数据管理中心页面', requiredStandards: const [ StandardCheck.themeColors, StandardCheck.textColors, @@ -1162,6 +1200,54 @@ class AppRoutes { ], builder: () => const IngredientManagePage(), ), + PageInfo( + route: farmGame, + name: 'Farm Game Page', + description: '小妈菜园主游戏页面', + requiredStandards: const [ + StandardCheck.themeColors, + StandardCheck.textColors, + StandardCheck.fontSize, + StandardCheck.darkMode, + ], + builder: () => const FarmGamePage(), + ), + PageInfo( + route: farmShop, + name: 'Farm Shop Page', + description: '农场种子商店页面', + requiredStandards: const [ + StandardCheck.themeColors, + StandardCheck.textColors, + StandardCheck.fontSize, + StandardCheck.darkMode, + ], + builder: () => const FarmShopPage(), + ), + PageInfo( + route: farmInventory, + name: 'Farm Inventory Page', + description: '农场背包页面', + requiredStandards: const [ + StandardCheck.themeColors, + StandardCheck.textColors, + StandardCheck.fontSize, + StandardCheck.darkMode, + ], + builder: () => const FarmInventoryPage(), + ), + PageInfo( + route: farmAchievement, + name: 'Farm Achievement Page', + description: '农场成就页面', + requiredStandards: const [ + StandardCheck.themeColors, + StandardCheck.textColors, + StandardCheck.fontSize, + StandardCheck.darkMode, + ], + builder: () => const FarmAchievementPage(), + ), ]); } } diff --git a/lib/src/config/farm_config.dart b/lib/src/config/farm_config.dart new file mode 100644 index 0000000..d1e422f --- /dev/null +++ b/lib/src/config/farm_config.dart @@ -0,0 +1,42 @@ +/// 农场游戏全局配置常量 +/// 定义游戏平衡性参数和默认值 +class FarmConfig { + FarmConfig._(); + + /// 土地总数 + static const int totalLands = 12; + + /// 初始解锁土地数 + static const int initialUnlockedLands = 6; + + /// 解锁新土地所需金币 + static const int unlockLandCost = 200; + + /// 浇水加速比例(%) + static const double waterSpeedBoost = 0.2; + + /// 枯萎时间(分钟) + static const int witherTimeMinutes = 120; + + /// 成熟后过期时间(分钟) + static const int matureExpireMinutes = 1440; // 24 小时 + + /// 初始金币 + static const int initialGold = 100; + + /// 初始钻石 + static const int initialDiamond = 10; + + /// 初始种子数量 + static const int initialSeedQuantity = 5; + + /// 分享图片尺寸 + static const double shareImageWidth = 800; + static const double shareImageHeight = 1000; + + /// 调试模式(发布时设为 false) + static const bool debugMode = true; + + /// 生长状态刷新间隔(秒) + static const int growthCheckIntervalSeconds = 30; +} diff --git a/lib/src/controllers/data/browse_history_controller.dart b/lib/src/controllers/data/browse_history_controller.dart index b4c7ecb..6603a69 100644 --- a/lib/src/controllers/data/browse_history_controller.dart +++ b/lib/src/controllers/data/browse_history_controller.dart @@ -98,9 +98,7 @@ class BrowseHistoryController extends GetxController { Future _saveHistory() async { try { - if (_prefs == null) { - _prefs = await SharedPreferences.getInstance(); - } + _prefs ??= await SharedPreferences.getInstance(); final data = json.encode(_history.map((h) => h.toJson()).toList()); await _prefs!.setString(_storageKey, data); } catch (e) { @@ -127,4 +125,40 @@ class BrowseHistoryController extends GetxController { List getHistoryByDate(String date) { return _history.where((h) => h.viewedAt.startsWith(date)).toList(); } + + String exportToJson() { + final data = _history.map((e) => e.toJson()).toList(); + return const JsonEncoder.withIndent(' ').convert(data); + } + + String exportToCsv() { + final buffer = StringBuffer(); + buffer.writeln('ID,菜谱ID,标题,封面,分类,浏览时间,浏览次数'); + for (final item in _history) { + buffer.writeln( + [ + item.id, + item.recipeId, + '"${item.title.replaceAll('"', '""')}"', + item.coverImage ?? '', + item.category ?? '', + item.viewedAt, + item.viewCount, + ].join(','), + ); + } + return buffer.toString(); + } + + Future importFromJson(Map json) async { + try { + final item = BrowseHistoryModel.fromJson(json); + if (!_history.any((h) => h.recipeId == item.recipeId)) { + _history.add(item); + await _saveHistory(); + } + } catch (e) { + debugPrint('BrowseHistoryController: importFromJson failed: $e'); + } + } } diff --git a/lib/src/controllers/data/cooking_note_controller.dart b/lib/src/controllers/data/cooking_note_controller.dart index d5bcd8c..6ebb8e7 100644 --- a/lib/src/controllers/data/cooking_note_controller.dart +++ b/lib/src/controllers/data/cooking_note_controller.dart @@ -166,4 +166,57 @@ class CookingNoteController extends GetxController { List getNotesByTag(String tag) { return _notes.where((note) => note.tags.contains(tag)).toList(); } + + String exportToJson() { + final data = _notes.map((e) => e.toJson()).toList(); + return const JsonEncoder.withIndent(' ').convert(data); + } + + String exportToCsv() { + final buffer = StringBuffer(); + buffer.writeln('ID,菜谱ID,标题,内容,标签,创建时间'); + for (final note in _notes) { + buffer.writeln( + [ + note.id, + note.recipeId, + '"${(note.title ?? '').replaceAll('"', '""')}"', + '"${note.content.replaceAll('"', '""').replaceAll('\n', ' ')}"', + '"${note.tags.join('; ')}"', + note.createdAt, + ].join(','), + ); + } + return buffer.toString(); + } + + String exportToMarkdown() { + final buffer = StringBuffer(); + buffer.writeln('# 📝 烹饪笔记'); + buffer.writeln(); + for (final note in _notes) { + buffer.writeln('## ${note.displayTitle}'); + if (note.hasTags) { + buffer.writeln('标签: ${note.tags.map((t) => '`$t`').join(' ')}'); + } + buffer.writeln(); + buffer.writeln(note.content); + buffer.writeln(); + buffer.writeln('*${note.displayDate}*'); + buffer.writeln('---'); + buffer.writeln(); + } + return buffer.toString(); + } + + void importFromJson(Map json) { + try { + final note = CookingNoteModel.fromJson(json); + if (!_notes.any((n) => n.id == note.id)) { + addNote(note); + } + } catch (e) { + debugPrint('CookingNoteController: importFromJson failed: $e'); + } + } } diff --git a/lib/src/controllers/data/favorites_controller.dart b/lib/src/controllers/data/favorites_controller.dart index 3f1850d..e144688 100644 --- a/lib/src/controllers/data/favorites_controller.dart +++ b/lib/src/controllers/data/favorites_controller.dart @@ -2,6 +2,7 @@ // 2026-04-09 | 新增排序、分类筛选、批量删除功能 // 2026-04-16 | 新增搜索功能、统计信息、导出功能 import 'dart:convert'; +import 'package:flutter/foundation.dart'; import 'package:get/get.dart'; import 'package:mom_kitchen/src/controllers/base_controller.dart'; import 'package:mom_kitchen/src/models/feed_item_model.dart'; @@ -82,6 +83,18 @@ class FavoritesController extends BaseController { } } + void addFavoriteFromJson(Map json) { + try { + final id = json['id'] as int?; + if (id == null || _favorites.containsKey(id)) return; + final item = FeedItemModel.fromJson(json); + _favorites[item.id] = item; + _saveToHive(item); + } catch (e) { + debugPrint('FavoritesController: addFavoriteFromJson failed: $e'); + } + } + void removeFavorite(int id) { if (_favorites.containsKey(id)) { _favorites.remove(id); @@ -199,6 +212,31 @@ class FavoritesController extends BaseController { return buffer.toString(); } + String exportToMarkdown() { + final buffer = StringBuffer(); + buffer.writeln('# ❤️ 我的收藏'); + buffer.writeln(); + + final byCategory = >{}; + for (final item in _favorites.values) { + final cat = item.categoryName ?? '未分类'; + byCategory.putIfAbsent(cat, () => []).add(item); + } + + for (final entry in byCategory.entries) { + buffer.writeln('## $entry.key'); + buffer.writeln(); + for (final item in entry.value) { + buffer.writeln('- **${item.title}** ${item.intro ?? ''}'); + } + buffer.writeln(); + } + + buffer.writeln('---'); + buffer.writeln('共 ${_favorites.length} 个收藏'); + return buffer.toString(); + } + void setSortMode(FavoritesSortMode mode) { sortMode.value = mode; _favorites.refresh(); diff --git a/lib/src/controllers/data/meal_record_controller.dart b/lib/src/controllers/data/meal_record_controller.dart index 17b568d..31ff4e2 100644 --- a/lib/src/controllers/data/meal_record_controller.dart +++ b/lib/src/controllers/data/meal_record_controller.dart @@ -1,5 +1,7 @@ // 2026-04-09 | MealRecordController | 饮食记录控制器 | 管理每日饮食记录的增删查改及营养汇总 // 2026-04-09 | 增加周/月营养聚合方法,支持营养报告页 +// 2026-04-18 | 新增导出功能:exportToJson/exportToCsv/exportToMarkdown +import 'dart:convert'; import 'package:flutter/foundation.dart'; import 'package:get/get.dart'; import 'package:mom_kitchen/src/controllers/base_controller.dart'; @@ -236,4 +238,92 @@ class MealRecordController extends BaseController { final daysWithData = monthlyCalories.values.where((v) => v > 0).length; return daysWithData > 0 ? total / daysWithData : 0; } + + String exportToJson() { + final hive = HiveService(); + if (!hive.isInitialized) return '[]'; + final allRecords = hive.getAllMealRecords(); + return const JsonEncoder.withIndent(' ').convert(allRecords); + } + + String exportToCsv() { + final hive = HiveService(); + if (!hive.isInitialized) return ''; + final allRecords = hive.getAllMealRecords(); + final buffer = StringBuffer(); + buffer.writeln('日期,餐次,菜谱,热量(kcal),蛋白质(g),脂肪(g),碳水(g),纤维(g),备注'); + for (final record in allRecords) { + buffer.writeln( + [ + record.date, + record.mealTypeLabel, + '"${record.recipeTitle.replaceAll('"', '""')}"', + record.calories.toStringAsFixed(1), + record.protein.toStringAsFixed(1), + record.fat.toStringAsFixed(1), + record.carbs.toStringAsFixed(1), + record.fiber.toStringAsFixed(1), + '"${(record.note ?? '').replaceAll('"', '""')}"', + ].join(','), + ); + } + return buffer.toString(); + } + + String exportToMarkdown() { + final hive = HiveService(); + if (!hive.isInitialized) return ''; + final allRecords = hive.getAllMealRecords(); + final buffer = StringBuffer(); + buffer.writeln('# 🍽️ 饮食记录'); + buffer.writeln(); + + final byDate = >{}; + for (final r in allRecords) { + byDate.putIfAbsent(r.date, () => []).add(r); + } + + for (final entry in byDate.entries) { + buffer.writeln('## ${entry.key}'); + buffer.writeln(); + for (final r in entry.value) { + buffer.writeln( + '- ${r.mealTypeEmoji} **${r.recipeTitle}** ' + '${r.calories.toStringAsFixed(0)}kcal ' + '(蛋白${r.protein.toStringAsFixed(0)}g / ' + '脂肪${r.fat.toStringAsFixed(0)}g / ' + '碳水${r.carbs.toStringAsFixed(0)}g)', + ); + } + buffer.writeln(); + } + + buffer.writeln('---'); + buffer.writeln('共 ${allRecords.length} 条记录'); + return buffer.toString(); + } + + void importFromJson(Map json) { + try { + final record = MealRecordModel( + date: + json['date'] as String? ?? + DateTime.now().toIso8601String().substring(0, 10), + mealType: json['mealType'] as String? ?? 'lunch', + recipeId: json['recipeId'] as int?, + recipeTitle: json['recipeTitle'] as String? ?? '', + calories: (json['calories'] as num?)?.toDouble() ?? 0, + protein: (json['protein'] as num?)?.toDouble() ?? 0, + fat: (json['fat'] as num?)?.toDouble() ?? 0, + carbs: (json['carbs'] as num?)?.toDouble() ?? 0, + fiber: (json['fiber'] as num?)?.toDouble() ?? 0, + note: json['note'] as String?, + createdAt: + json['createdAt'] as String? ?? DateTime.now().toIso8601String(), + ); + addRecord(record); + } catch (e) { + debugPrint('MealRecordController: importFromJson failed: $e'); + } + } } diff --git a/lib/src/controllers/data/shopping_list_controller.dart b/lib/src/controllers/data/shopping_list_controller.dart index 0f98b10..08a7ebe 100644 --- a/lib/src/controllers/data/shopping_list_controller.dart +++ b/lib/src/controllers/data/shopping_list_controller.dart @@ -1,5 +1,8 @@ // 2026-04-09 | ShoppingListController | 购物清单控制器 | 管理购物清单的增删改查及分类展示 // 2026-04-09 | 初始创建,支持添加/删除/勾选/清空已购功能 +// 2026-04-18 | 新增导出功能:exportToJson/exportToCsv/exportToMarkdown +import 'dart:convert'; +import 'package:flutter/foundation.dart'; import 'package:get/get.dart'; import 'package:mom_kitchen/src/controllers/base_controller.dart'; import 'package:mom_kitchen/src/models/data/shopping_item_model.dart'; @@ -122,4 +125,85 @@ class ShoppingListController extends BaseController { void refresh() { _loadItems(); } + + String exportToJson() { + final data = items.map((e) => _itemToMap(e)).toList(); + return const JsonEncoder.withIndent(' ').convert(data); + } + + String exportToCsv() { + final buffer = StringBuffer(); + buffer.writeln('名称,数量,单位,分类,已购买,关联菜谱ID,创建时间'); + for (final item in items) { + buffer.writeln( + [ + '"${item.name.replaceAll('"', '""')}"', + item.amount ?? '', + item.unit ?? '', + item.categoryLabel, + item.isChecked ? '是' : '否', + item.recipeId ?? '', + item.createdAt, + ].join(','), + ); + } + return buffer.toString(); + } + + String exportToMarkdown() { + final buffer = StringBuffer(); + buffer.writeln('# 🛒 购物清单'); + buffer.writeln(); + + final grouped = groupedItemsWithKeys; + for (final entry in grouped.entries) { + buffer.writeln('## ${entry.key.emoji} ${entry.key.label}'); + buffer.writeln(); + for (final item in entry.value) { + final check = item.value.isChecked ? 'x' : ' '; + buffer.writeln( + '- [$check] ${item.value.name} ${item.value.displayAmount}', + ); + } + buffer.writeln(); + } + + buffer.writeln('---'); + buffer.writeln( + '总计: $totalCount项 | 未购: $uncheckedCount项 | 已购: $checkedCount项', + ); + return buffer.toString(); + } + + Map _itemToMap(ShoppingItemModel item) { + return { + 'name': item.name, + 'amount': item.amount, + 'unit': item.unit, + 'category': item.category, + 'isChecked': item.isChecked, + 'recipeId': item.recipeId, + 'createdAt': item.createdAt, + }; + } + + void importFromJson(Map json) { + try { + final item = ShoppingItemModel( + name: json['name'] as String? ?? '', + amount: json['amount'] as String?, + unit: json['unit'] as String?, + category: json['category'] as String?, + isChecked: json['isChecked'] as bool? ?? false, + recipeId: json['recipeId'] as int?, + createdAt: + json['createdAt'] as String? ?? DateTime.now().toIso8601String(), + ); + if (item.name.isNotEmpty) { + addItem(item); + } + } catch (e) { + debugPrint('ShoppingListController: importFromJson failed: $e'); + } + } } diff --git a/lib/src/controllers/data/weekly_menu_controller.dart b/lib/src/controllers/data/weekly_menu_controller.dart index 291d65a..0ac667c 100644 --- a/lib/src/controllers/data/weekly_menu_controller.dart +++ b/lib/src/controllers/data/weekly_menu_controller.dart @@ -4,8 +4,10 @@ * 作用: 管理每周菜单规划数据 * 创建: 2026-04-11 * 更新: 2026-04-11 初始实现 + * 更新: 2026-04-18 新增导出功能:exportToJson/exportToCsv/exportToMarkdown */ +import 'dart:convert'; import 'package:flutter/foundation.dart'; import 'package:get/get.dart'; import 'package:mom_kitchen/src/controllers/base_controller.dart'; @@ -342,4 +344,128 @@ class WeeklyMenuController extends BaseController { } int get totalPossibleMeals => 21; // 7天 x 3餐 + + String exportToJson() { + if (currentMenu.value == null) return '{}'; + return const JsonEncoder.withIndent( + ' ', + ).convert(currentMenu.value!.toJson()); + } + + String exportToCsv() { + if (currentMenu.value == null) return ''; + final buffer = StringBuffer(); + buffer.writeln('日期,餐次,菜谱ID,菜谱名称'); + for (final entry in currentMenu.value!.dailyMenus.entries) { + final day = entry.value; + if (day.breakfast != null) { + buffer.writeln( + [ + entry.key, + '早餐', + day.breakfast!.recipeId, + '"${day.breakfast!.recipeTitle.replaceAll('"', '""')}"', + ].join(','), + ); + } + if (day.lunch != null) { + buffer.writeln( + [ + entry.key, + '午餐', + day.lunch!.recipeId, + '"${day.lunch!.recipeTitle.replaceAll('"', '""')}"', + ].join(','), + ); + } + if (day.dinner != null) { + buffer.writeln( + [ + entry.key, + '晚餐', + day.dinner!.recipeId, + '"${day.dinner!.recipeTitle.replaceAll('"', '""')}"', + ].join(','), + ); + } + } + return buffer.toString(); + } + + String exportToMarkdown() { + if (currentMenu.value == null) return ''; + final buffer = StringBuffer(); + buffer.writeln('# 📅 每周菜单'); + buffer.writeln('**${currentMenu.value!.weekLabel}**'); + buffer.writeln(); + + final dayNames = ['周一', '周二', '周三', '周四', '周五', '周六', '周日']; + var dayIndex = 0; + for (final entry in currentMenu.value!.dailyMenus.entries) { + final day = entry.value; + buffer.writeln('## ${dayNames[dayIndex % 7]} (${entry.key})'); + buffer.writeln(); + if (day.breakfast != null) { + buffer.writeln('- 🌅 早餐: **${day.breakfast!.recipeTitle}**'); + } + if (day.lunch != null) { + buffer.writeln('- ☀️ 午餐: **${day.lunch!.recipeTitle}**'); + } + if (day.dinner != null) { + buffer.writeln('- 🌙 晚餐: **${day.dinner!.recipeTitle}**'); + } + if (day.breakfast == null && day.lunch == null && day.dinner == null) { + buffer.writeln('- *未规划*'); + } + buffer.writeln(); + dayIndex++; + } + + buffer.writeln('---'); + buffer.writeln('已完成 $completedMeals/$totalPossibleMeals 餐'); + return buffer.toString(); + } + + void importFromJson(Map json) { + try { + final menu = currentMenu.value; + if (menu == null) return; + + final dateKey = json['dateKey'] as String? ?? ''; + if (dateKey.isEmpty) return; + + final dailyMenus = Map.from(json['dailyMenus'] ?? {}); + + for (final entry in dailyMenus.entries) { + final dayData = entry.value; + if (dayData is! Map) continue; + + final dayMenu = menu.dailyMenus[entry.key]; + if (dayMenu == null) continue; + + if (dayData['breakfast'] != null) { + final item = MealItem.fromJson( + Map.from(dayData['breakfast']), + ); + menu.dailyMenus[entry.key] = dayMenu.copyWith(breakfast: item); + } + if (dayData['lunch'] != null) { + final item = MealItem.fromJson( + Map.from(dayData['lunch']), + ); + menu.dailyMenus[entry.key] = dayMenu.copyWith(lunch: item); + } + if (dayData['dinner'] != null) { + final item = MealItem.fromJson( + Map.from(dayData['dinner']), + ); + menu.dailyMenus[entry.key] = dayMenu.copyWith(dinner: item); + } + } + currentMenu.refresh(); + _saveToHive(); + } catch (e) { + debugPrint('WeeklyMenuController: importFromJson failed: $e'); + } + } } diff --git a/lib/src/controllers/farm/farm_achievement_controller.dart b/lib/src/controllers/farm/farm_achievement_controller.dart new file mode 100644 index 0000000..d0cad3a --- /dev/null +++ b/lib/src/controllers/farm/farm_achievement_controller.dart @@ -0,0 +1,43 @@ +// 农场成就控制器 +// 管理成就显示和进度 +import 'package:get/get.dart'; +import 'package:mom_kitchen/src/models/farm/achievement_config.dart'; +import 'package:mom_kitchen/src/models/farm/achievement_registry.dart'; +import 'package:mom_kitchen/src/controllers/farm/farm_game_controller.dart'; + +class FarmAchievementController extends GetxController { + final _gameController = Get.find(); + + List get allAchievements => + AchievementRegistry.getAll().values.toList(); + + List get completedAchievements => allAchievements + .where((a) => _gameController.player.value.achievements.contains(a.id)) + .toList(); + + List get pendingAchievements => allAchievements + .where((a) => + !_gameController.player.value.achievements.contains(a.id)) + .toList(); + + double getProgress(AchievementConfig achievement) { + final player = _gameController.player.value; + + switch (achievement.conditionType) { + case 'totalHarvest': + return (player.totalHarvest / achievement.conditionValue) + .clamp(0.0, 1.0); + case 'level': + return (player.level / achievement.conditionValue).clamp(0.0, 1.0); + case 'unlockedCrops': + return (player.unlockedCrops.length / achievement.conditionValue) + .clamp(0.0, 1.0); + default: + return 0.0; + } + } + + bool isCompleted(AchievementConfig achievement) { + return _gameController.player.value.achievements.contains(achievement.id); + } +} diff --git a/lib/src/controllers/farm/farm_game_controller.dart b/lib/src/controllers/farm/farm_game_controller.dart new file mode 100644 index 0000000..f87791e --- /dev/null +++ b/lib/src/controllers/farm/farm_game_controller.dart @@ -0,0 +1,476 @@ +// 农场游戏核心控制器 +// 管理游戏逻辑:种植、生长、浇水、收获、升级、成就 +// 2026-04-18 | 优化:生长计时器间隔改为30秒;添加应用前后台生命周期管理 +import 'dart:async'; +import 'package:collection/collection.dart'; +import 'package:flutter/widgets.dart'; +import 'package:get/get.dart'; +import 'package:mom_kitchen/src/models/farm/farm_player.dart'; +import 'package:mom_kitchen/src/models/farm/farm_land.dart'; +import 'package:mom_kitchen/src/models/farm/inventory_item.dart'; +import 'package:mom_kitchen/src/models/farm/crop_registry.dart'; +import 'package:mom_kitchen/src/models/farm/achievement_registry.dart'; +import 'package:mom_kitchen/src/services/data/hive_service.dart'; +import 'package:mom_kitchen/src/config/farm_config.dart'; +import 'package:mom_kitchen/src/services/log/logger_service.dart'; + +class FarmGameController extends GetxController with WidgetsBindingObserver { + final Rx player = FarmPlayer.createDefault('').obs; + final RxList lands = [].obs; + final RxList inventory = [].obs; + + Timer? _growthTimer; + bool _isInitialized = false; + DateTime? _pausedTime; + + @override + void onInit() { + super.onInit(); + WidgetsBinding.instance.addObserver(this); + _loadData(); + _startGrowthTimer(); + } + + @override + void onClose() { + WidgetsBinding.instance.removeObserver(this); + _growthTimer?.cancel(); + super.onClose(); + } + + @override + void didChangeAppLifecycleState(AppLifecycleState state) { + switch (state) { + case AppLifecycleState.paused: + _pausedTime = DateTime.now(); + _growthTimer?.cancel(); + _growthTimer = null; + LoggerService().info('FarmGame: App paused, timer cancelled'); + break; + case AppLifecycleState.resumed: + if (_pausedTime != null && _isInitialized) { + _updateGrowthStages(); + } + _pausedTime = null; + _startGrowthTimer(); + LoggerService().info('FarmGame: App resumed, timer restarted'); + break; + case AppLifecycleState.inactive: + case AppLifecycleState.detached: + case AppLifecycleState.hidden: + break; + } + } + + // ==================== 数据加载 ==================== + + void _loadData() { + final hiveService = HiveService(); + if (!hiveService.isInitialized) { + LoggerService().warning('HiveService not initialized'); + return; + } + + // 加载玩家数据 + final playerBox = hiveService.farmPlayer; + if (playerBox != null && playerBox.isNotEmpty) { + player.value = playerBox.get('player') ?? FarmPlayer.createDefault(''); + } + + // 加载土地数据 + final landsBox = hiveService.farmLands; + if (landsBox != null) { + lands.assignAll(landsBox.values.toList()); + } + + // 加载背包数据 + final inventoryBox = hiveService.farmInventory; + if (inventoryBox != null) { + inventory.assignAll(inventoryBox.values.toList()); + } + + _updateGrowthStages(); + _isInitialized = true; + LoggerService().info('Farm game data loaded'); + } + + void _startGrowthTimer() { + _growthTimer = Timer.periodic( + Duration(seconds: FarmConfig.growthCheckIntervalSeconds), + (_) => _updateGrowthStages(), + ); + } + + // ==================== 生长更新 ==================== + + void _updateGrowthStages() { + if (!_isInitialized || lands.isEmpty) return; + + bool changed = false; + final now = DateTime.now(); + + for (final land in lands) { + if (land.cropId == null || land.isWithered || land.isReady) continue; + + final crop = CropRegistry.getById(land.cropId!); + if (crop == null) continue; + + final elapsed = now.difference(land.plantTime!).inMinutes.toDouble(); + final progress = elapsed / crop.growthTime; + + // 检查是否枯萎(浇水后 2 小时未再浇水) + if (land.needWater && land.lastWaterTime != null) { + final sinceWater = now.difference(land.lastWaterTime!).inMinutes; + if (sinceWater > FarmConfig.witherTimeMinutes) { + land.isWithered = true; + changed = true; + _saveLand(land); + continue; + } + } + + // 更新生长阶段 + double accumulated = 0; + for (final stage in crop.stages) { + accumulated += crop.growthTime * stage.durationPercent; + if (elapsed >= accumulated) { + final newStage = stage.stage; + if (newStage != land.growthStage) { + land.growthStage = newStage; + land.needWater = stage.needWater; + changed = true; + } + } + } + + // 检查是否成熟 + if (progress >= 1.0 && !land.isReady) { + land.isReady = true; + changed = true; + } + } + + if (changed) { + lands.refresh(); + } + } + + // ==================== 核心操作 ==================== + + /// 播种 + Future plantCrop({required int landId, required String cropId}) async { + final land = lands.firstWhereOrNull((l) => l.landId == landId); + if (land == null) { + Get.snackbar('错误', '土地不存在'); + return; + } + if (land.cropId != null) { + Get.snackbar('提示', '这块土地已经种植了作物'); + return; + } + + if (!land.isUnlocked) { + Get.snackbar('提示', '这块土地还未解锁'); + return; + } + + final seedItemId = '${cropId}_seed'; + final seedItem = inventory + .where((item) => item.itemId == seedItemId) + .firstOrNull; + + if (seedItem == null || seedItem.quantity <= 0) { + Get.snackbar('提示', '种子数量不足,请前往商店购买'); + return; + } + + // 消耗种子 + seedItem.quantity--; + if (seedItem.quantity == 0) { + inventory.remove(seedItem); + _removeInventoryItem(seedItemId); + } else { + _saveInventoryItem(seedItem); + } + + // 种植 + land.cropId = cropId; + land.plantTime = DateTime.now(); + land.growthStage = 0; + land.needWater = true; + land.isWithered = false; + land.isReady = false; + land.lastWaterTime = null; + + player.value.totalPlant++; + await savePlayer(); + await _saveLand(land); + lands.refresh(); + + final crop = CropRegistry.getById(cropId); + Get.snackbar('🌱 种植成功', '已开始种植${crop?.name}'); + LoggerService().info('Planted ${crop?.name} on land $landId'); + } + + /// 浇水 + Future waterLand(int landId) async { + final land = lands.firstWhereOrNull((l) => l.landId == landId); + if (land == null) { + Get.snackbar('错误', '土地不存在'); + return; + } + if (!land.needWater || land.isWithered || land.isReady) { + Get.snackbar('提示', '这块土地不需要浇水'); + return; + } + + land.needWater = false; + land.lastWaterTime = DateTime.now(); + await _saveLand(land); + lands.refresh(); + + Get.snackbar('💧 浇水成功', '作物生长速度已提升'); + LoggerService().info('Watered land $landId'); + } + + /// 收获 + Future harvestCrop(int landId) async { + final land = lands.firstWhereOrNull((l) => l.landId == landId); + if (land == null) { + Get.snackbar('错误', '土地不存在'); + return; + } + if (!land.isReady) { + Get.snackbar('提示', '作物还未成熟'); + return; + } + + final crop = CropRegistry.getById(land.cropId!); + if (crop == null) return; + + // 增加金币和经验 + player.value.gold += crop.harvestPrice; + player.value.experience += crop.harvestExp; + player.value.totalHarvest++; + + // 添加果实到背包 + final fruitItem = InventoryItem.fruit( + cropId: crop.id, + name: crop.name, + emoji: crop.emoji, + price: crop.harvestPrice, + quantity: 1, + ); + + final existingFruit = inventory + .where((item) => item.itemId == fruitItem.itemId) + .firstOrNull; + + if (existingFruit != null) { + existingFruit.quantity++; + _saveInventoryItem(existingFruit); + } else { + inventory.add(fruitItem); + _saveInventoryItem(fruitItem); + } + + // 重置土地 + land.cropId = null; + land.plantTime = null; + land.growthStage = 0; + land.isReady = false; + land.isWithered = false; + land.needWater = false; + land.lastWaterTime = null; + + await savePlayer(); + await _saveLand(land); + lands.refresh(); + + // 检查升级 + _checkLevelUp(); + + // 检查成就 + _checkAchievements('totalHarvest', player.value.totalHarvest); + + Get.snackbar( + '🎉 收获成功', + '获得 ${crop.harvestPrice} 金币,+${crop.harvestExp} 经验', + ); + LoggerService().info('Harvested ${crop.name} from land $landId'); + } + + /// 清理枯萎作物 + Future clearWitheredLand(int landId) async { + final land = lands.firstWhereOrNull((l) => l.landId == landId); + if (land == null) { + Get.snackbar('错误', '土地不存在'); + return; + } + if (!land.isWithered) return; + + land.cropId = null; + land.plantTime = null; + land.growthStage = 0; + land.isWithered = false; + land.isReady = false; + land.needWater = false; + + await _saveLand(land); + lands.refresh(); + + Get.snackbar('🧹 清理完成', '土地已恢复'); + } + + // ==================== 升级和成就 ==================== + + void _checkLevelUp() { + while (player.value.experience >= player.value.expToNextLevel) { + player.value.experience -= player.value.expToNextLevel; + player.value.level++; + + // 升级奖励 + final goldReward = player.value.level * 50; + player.value.gold += goldReward; + player.value.diamond += 5; + + // 解锁新作物 + final newCrops = CropRegistry.getAvailableForLevel(player.value.level); + for (final crop in newCrops) { + if (!player.value.unlockedCrops.contains(crop.id)) { + player.value.unlockedCrops.add(crop.id); + } + } + + savePlayer(); + + Get.snackbar( + '🎊 升级!', + '当前等级:Lv.${player.value.level}\n奖励:$goldReward 金币 + 5 钻石', + duration: const Duration(seconds: 3), + ); + + // 检查解锁作物的成就 + _checkAchievements('unlockedCrops', player.value.unlockedCrops.length); + + LoggerService().info('Player leveled up to ${player.value.level}'); + } + } + + void _checkAchievements(String conditionType, int currentValue) { + final newOnes = AchievementRegistry.checkNewAchievements( + conditionType: conditionType, + currentValue: currentValue, + completedIds: player.value.achievements, + ); + + for (final achievement in newOnes) { + if (!player.value.achievements.contains(achievement.id)) { + player.value.achievements.add(achievement.id); + player.value.gold += achievement.rewardGold; + player.value.experience += achievement.rewardExp; + player.value.diamond += achievement.rewardDiamond; + + savePlayer(); + + Get.snackbar( + '🏆 成就解锁!', + '${achievement.emoji} ${achievement.name}\n${achievement.description}', + duration: const Duration(seconds: 3), + ); + + LoggerService().info('Achievement unlocked: ${achievement.name}'); + } + } + } + + // ==================== 解锁土地 ==================== + + Future unlockLand(int landId) async { + final land = lands.firstWhereOrNull((l) => l.landId == landId); + if (land == null) { + Get.snackbar('错误', '土地不存在'); + return; + } + if (land.isUnlocked) return; + + if (player.value.gold < FarmConfig.unlockLandCost) { + Get.snackbar('金币不足', '解锁土地需要 ${FarmConfig.unlockLandCost} 金币'); + return; + } + + player.value.gold -= FarmConfig.unlockLandCost; + land.isUnlocked = true; + + savePlayer(); + await _saveLand(land); + lands.refresh(); + + Get.snackbar('🔓 解锁成功', '土地 ${landId + 1} 已解锁'); + } + + // ==================== 数据保存 ==================== + + Future savePlayer() async { + final hiveService = HiveService(); + await hiveService.farmPlayer?.put('player', player.value); + } + + Future _saveLand(FarmLand land) async { + final hiveService = HiveService(); + await hiveService.farmLands?.put(land.landId, land); + } + + Future _saveInventoryItem(InventoryItem item) async { + final hiveService = HiveService(); + await hiveService.farmInventory?.put(item.itemId, item); + } + + Future _removeInventoryItem(String itemId) async { + final hiveService = HiveService(); + await hiveService.farmInventory?.delete(itemId); + } + + // ==================== 调试功能 ==================== + + Future debugAddGold() async { + player.value.gold += 1000; + savePlayer(); + Get.snackbar('🐛 调试', '已添加 1000 金币'); + } + + Future debugSpeedUp() async { + for (final land in lands) { + if (land.cropId != null && !land.isReady) { + land.plantTime = DateTime.now().subtract( + Duration(minutes: CropRegistry.getById(land.cropId!)!.growthTime), + ); + await _saveLand(land); + } + } + lands.refresh(); + Get.snackbar('🐛 调试', '所有作物已加速成熟'); + } + + Future debugUnlockAll() async { + for (final land in lands) { + land.isUnlocked = true; + await _saveLand(land); + } + player.value.level = 10; + player.value.unlockedCrops.clear(); + player.value.unlockedCrops.addAll(CropRegistry.getAll().map((c) => c.id)); + savePlayer(); + lands.refresh(); + Get.snackbar('🐛 调试', '已解锁所有内容'); + } + + Future debugReset() async { + final hiveService = HiveService(); + await hiveService.farmPlayer?.clear(); + await hiveService.farmLands?.clear(); + await hiveService.farmInventory?.clear(); + _loadData(); + Get.snackbar('🐛 调试', '游戏数据已重置'); + } +} diff --git a/lib/src/controllers/farm/farm_inventory_controller.dart b/lib/src/controllers/farm/farm_inventory_controller.dart new file mode 100644 index 0000000..5c8e68c --- /dev/null +++ b/lib/src/controllers/farm/farm_inventory_controller.dart @@ -0,0 +1,42 @@ +// 农场背包控制器 +// 管理背包物品显示和分类 +import 'package:get/get.dart'; +import 'package:mom_kitchen/src/models/farm/inventory_item.dart'; +import 'package:mom_kitchen/src/controllers/farm/farm_game_controller.dart'; + +class FarmInventoryController extends GetxController { + final _gameController = Get.find(); + + final RxString selectedTab = 'seed'.obs; + + List get seeds => + _gameController.inventory.where((i) => i.isSeed).toList(); + + List get fruits => + _gameController.inventory.where((i) => i.isFruit).toList(); + + List get tools => + _gameController.inventory.where((i) => i.itemType == 'tool').toList(); + + List get filteredItems { + switch (selectedTab.value) { + case 'seed': + return seeds; + case 'fruit': + return fruits; + case 'tool': + return tools; + default: + return _gameController.inventory; + } + } + + void selectTab(String tab) { + selectedTab.value = tab; + } + + int get totalItems => _gameController.inventory.fold( + 0, + (sum, item) => sum + item.quantity, + ); +} diff --git a/lib/src/controllers/farm/farm_shop_controller.dart b/lib/src/controllers/farm/farm_shop_controller.dart new file mode 100644 index 0000000..946a8fc --- /dev/null +++ b/lib/src/controllers/farm/farm_shop_controller.dart @@ -0,0 +1,72 @@ +// 农场商店控制器 +// 管理种子购买逻辑 +import 'package:get/get.dart'; +import 'package:mom_kitchen/src/models/farm/crop_config.dart'; +import 'package:mom_kitchen/src/models/farm/crop_registry.dart'; +import 'package:mom_kitchen/src/models/farm/inventory_item.dart'; +import 'package:mom_kitchen/src/services/data/hive_service.dart'; +import 'package:mom_kitchen/src/controllers/farm/farm_game_controller.dart'; +import 'package:mom_kitchen/src/services/log/logger_service.dart'; + +class FarmShopController extends GetxController { + final _gameController = Get.find(); + + RxList availableCrops = [].obs; + + @override + void onInit() { + super.onInit(); + _loadAvailableCrops(); + } + + void _loadAvailableCrops() { + availableCrops.assignAll(CropRegistry.getAll()); + } + + /// 购买种子 + Future buySeed(String cropId) async { + final crop = CropRegistry.getById(cropId); + if (crop == null) return; + + final player = _gameController.player.value; + if (player.gold < crop.seedPrice) { + Get.snackbar('金币不足', '需要 ${crop.seedPrice} 金币,当前 ${player.gold} 金币'); + return; + } + + // 扣除金币 + player.gold -= crop.seedPrice; + _gameController.player.value = player; + await _gameController.savePlayer(); + + // 添加种子到背包 + final seedItem = InventoryItem.seed( + cropId: crop.id, + name: crop.name, + emoji: crop.emoji, + price: crop.seedPrice, + quantity: 1, + ); + + final inventory = _gameController.inventory; + final existing = inventory + .where((item) => item.itemId == seedItem.itemId) + .firstOrNull; + + if (existing != null) { + existing.quantity++; + await _saveInventoryItem(existing); + } else { + inventory.add(seedItem); + await _saveInventoryItem(seedItem); + } + + Get.snackbar('🛒 购买成功', '已购买 ${crop.name}种子'); + LoggerService().info('Bought ${crop.name} seed for ${crop.seedPrice} gold'); + } + + Future _saveInventoryItem(InventoryItem item) async { + final hiveService = HiveService(); + await hiveService.farmInventory?.put(item.itemId, item); + } +} diff --git a/lib/src/controllers/recipe/search_controller.dart b/lib/src/controllers/recipe/search_controller.dart index 48a9772..07bcf11 100644 --- a/lib/src/controllers/recipe/search_controller.dart +++ b/lib/src/controllers/recipe/search_controller.dart @@ -6,7 +6,6 @@ * 更新: 2026-04-13 切换到global_search接口,新增多Tab搜索结果+高级筛选 */ -import 'package:flutter/foundation.dart'; import 'package:flutter/widgets.dart'; import 'package:get/get.dart'; import 'package:mom_kitchen/src/controllers/base_controller.dart'; diff --git a/lib/src/models/data/weekly_menu_model.dart b/lib/src/models/data/weekly_menu_model.dart index b47f2f6..ed2d8fd 100644 --- a/lib/src/models/data/weekly_menu_model.dart +++ b/lib/src/models/data/weekly_menu_model.dart @@ -119,6 +119,23 @@ class DayMenu { bool get hasAnyMeal => breakfast != null || lunch != null || dinner != null; int get mealCount => [breakfast, lunch, dinner].where((m) => m != null).length; + + DayMenu copyWith({ + String? dateKey, + MealItem? breakfast, + MealItem? lunch, + MealItem? dinner, + bool clearBreakfast = false, + bool clearLunch = false, + bool clearDinner = false, + }) { + return DayMenu( + dateKey: dateKey ?? this.dateKey, + breakfast: clearBreakfast ? null : (breakfast ?? this.breakfast), + lunch: clearLunch ? null : (lunch ?? this.lunch), + dinner: clearDinner ? null : (dinner ?? this.dinner), + ); + } } class MealItem { diff --git a/lib/src/models/discover_model.dart b/lib/src/models/discover_model.dart index 0a0ca3f..7df4b58 100644 --- a/lib/src/models/discover_model.dart +++ b/lib/src/models/discover_model.dart @@ -341,8 +341,9 @@ class DiscoverRecipe { String get resolvedCoverUrl { if (cover.isEmpty) return ''; if (cover.startsWith('https://')) return cover; - if (cover.startsWith('http://')) + if (cover.startsWith('http://')) { return cover.replaceFirst('http://', 'https://'); + } if (cover.startsWith('/')) return 'https://eat.wktyl.com$cover'; return 'https://eat.wktyl.com/$cover'; } diff --git a/lib/src/models/farm/achievement_config.dart b/lib/src/models/farm/achievement_config.dart new file mode 100644 index 0000000..cf059b8 --- /dev/null +++ b/lib/src/models/farm/achievement_config.dart @@ -0,0 +1,42 @@ +/// 成就配置类 +/// 定义成就条件、奖励等信息 +class AchievementConfig { + /// 成就 ID + final String id; + + /// 成就名称 + final String name; + + /// 成就描述 + final String description; + + /// 成就 Emoji + final String emoji; + + /// 奖励金币 + final int rewardGold; + + /// 奖励经验 + final int rewardExp; + + /// 奖励钻石 + final int rewardDiamond; + + /// 条件类型(totalHarvest/level/unlockedCrops) + final String conditionType; + + /// 条件数值 + final int conditionValue; + + const AchievementConfig({ + required this.id, + required this.name, + required this.description, + required this.emoji, + this.rewardGold = 0, + this.rewardExp = 0, + this.rewardDiamond = 0, + required this.conditionType, + required this.conditionValue, + }); +} diff --git a/lib/src/models/farm/achievement_registry.dart b/lib/src/models/farm/achievement_registry.dart new file mode 100644 index 0000000..35f07db --- /dev/null +++ b/lib/src/models/farm/achievement_registry.dart @@ -0,0 +1,90 @@ +// 成就注册表 +// 提供所有成就配置的查询和验证接口 +import 'package:mom_kitchen/src/models/farm/achievement_config.dart'; + +class AchievementRegistry { + AchievementRegistry._(); + + static const Map _achievements = { + 'first_harvest': AchievementConfig( + id: 'first_harvest', + name: '初次收获', + description: '完成第一次收获', + emoji: '🌾', + rewardGold: 50, + rewardExp: 20, + conditionType: 'totalHarvest', + conditionValue: 1, + ), + 'harvest_10': AchievementConfig( + id: 'harvest_10', + name: '小有成就', + description: '收获 10 次', + emoji: '🏅', + rewardGold: 100, + rewardExp: 50, + conditionType: 'totalHarvest', + conditionValue: 10, + ), + 'harvest_50': AchievementConfig( + id: 'harvest_50', + name: '丰收达人', + description: '收获 50 次', + emoji: '🏆', + rewardGold: 300, + rewardExp: 100, + rewardDiamond: 10, + conditionType: 'totalHarvest', + conditionValue: 50, + ), + 'level_5': AchievementConfig( + id: 'level_5', + name: '初出茅庐', + description: '达到 5 级', + emoji: '⭐', + rewardGold: 200, + rewardExp: 0, + conditionType: 'level', + conditionValue: 5, + ), + 'level_10': AchievementConfig( + id: 'level_10', + name: '农场老手', + description: '达到 10 级', + emoji: '🌟', + rewardGold: 500, + rewardExp: 0, + rewardDiamond: 20, + conditionType: 'level', + conditionValue: 10, + ), + 'unlock_all': AchievementConfig( + id: 'unlock_all', + name: '丰收大师', + description: '解锁所有作物', + emoji: '👑', + rewardGold: 1000, + rewardExp: 0, + rewardDiamond: 50, + conditionType: 'unlockedCrops', + conditionValue: 12, + ), + }; + + static AchievementConfig? getById(String id) => _achievements[id]; + + static Map getAll() => _achievements; + + static List checkNewAchievements({ + required String conditionType, + required int currentValue, + required List completedIds, + }) { + return _achievements.values + .where((a) => + !completedIds.contains(a.id) && + a.conditionType == conditionType && + currentValue >= a.conditionValue) + .toList(); + } +} diff --git a/lib/src/models/farm/crop_config.dart b/lib/src/models/farm/crop_config.dart new file mode 100644 index 0000000..fac8f63 --- /dev/null +++ b/lib/src/models/farm/crop_config.dart @@ -0,0 +1,65 @@ +/// 作物生长阶段信息 +/// 定义每个阶段的显示和持续时间 +class StageInfo { + /// 阶段编号(0-3) + final int stage; + + /// 该阶段显示的 Emoji + final String emoji; + + /// 该阶段占总时间的百分比 + final double durationPercent; + + /// 该阶段是否需要浇水 + final bool needWater; + + const StageInfo({ + required this.stage, + required this.emoji, + required this.durationPercent, + this.needWater = false, + }); +} + +/// 作物配置类 +/// 定义作物的所有属性,作为静态配置数据 +class CropConfig { + /// 作物唯一 ID + final String id; + + /// 作物名称 + final String name; + + /// 作物 Emoji + final String emoji; + + /// 总生长时间(分钟) + final int growthTime; + + /// 种子价格(金币) + final int seedPrice; + + /// 收获价格(金币) + final int harvestPrice; + + /// 收获经验 + final int harvestExp; + + /// 解锁等级 + final int unlockLevel; + + /// 生长阶段列表 + final List stages; + + const CropConfig({ + required this.id, + required this.name, + required this.emoji, + required this.growthTime, + required this.seedPrice, + required this.harvestPrice, + required this.harvestExp, + required this.unlockLevel, + required this.stages, + }); +} diff --git a/lib/src/models/farm/crop_registry.dart b/lib/src/models/farm/crop_registry.dart new file mode 100644 index 0000000..dc65c60 --- /dev/null +++ b/lib/src/models/farm/crop_registry.dart @@ -0,0 +1,198 @@ +// 作物注册表 +// 提供所有作物配置的静态查询接口 +import 'package:mom_kitchen/src/models/farm/crop_config.dart'; + +class CropRegistry { + CropRegistry._(); + + static const Map _crops = { + 'radish': CropConfig( + id: 'radish', + name: '萝卜', + emoji: '🥕', + growthTime: 30, + seedPrice: 10, + harvestPrice: 25, + harvestExp: 15, + unlockLevel: 1, + stages: [ + StageInfo(stage: 0, emoji: '🌱', durationPercent: 0.05, needWater: true), + StageInfo(stage: 1, emoji: '🌿', durationPercent: 0.25, needWater: true), + StageInfo(stage: 2, emoji: '☘️', durationPercent: 0.55, needWater: true), + StageInfo(stage: 3, emoji: '🥕', durationPercent: 0.15, needWater: false), + ], + ), + 'potato': CropConfig( + id: 'potato', + name: '土豆', + emoji: '🥔', + growthTime: 45, + seedPrice: 15, + harvestPrice: 40, + harvestExp: 25, + unlockLevel: 1, + stages: [ + StageInfo(stage: 0, emoji: '🌱', durationPercent: 0.05, needWater: true), + StageInfo(stage: 1, emoji: '🌿', durationPercent: 0.25, needWater: true), + StageInfo(stage: 2, emoji: '🪴', durationPercent: 0.55, needWater: true), + StageInfo(stage: 3, emoji: '🥔', durationPercent: 0.15, needWater: false), + ], + ), + 'cabbage': CropConfig( + id: 'cabbage', + name: '卷心菜', + emoji: '🥬', + growthTime: 40, + seedPrice: 12, + harvestPrice: 35, + harvestExp: 20, + unlockLevel: 1, + stages: [ + StageInfo(stage: 0, emoji: '🌱', durationPercent: 0.05, needWater: true), + StageInfo(stage: 1, emoji: '🌿', durationPercent: 0.25, needWater: true), + StageInfo(stage: 2, emoji: '☘️', durationPercent: 0.55, needWater: true), + StageInfo(stage: 3, emoji: '🥬', durationPercent: 0.15, needWater: false), + ], + ), + 'tomato': CropConfig( + id: 'tomato', + name: '西红柿', + emoji: '🍅', + growthTime: 60, + seedPrice: 25, + harvestPrice: 60, + harvestExp: 35, + unlockLevel: 2, + stages: [ + StageInfo(stage: 0, emoji: '🌱', durationPercent: 0.05, needWater: true), + StageInfo(stage: 1, emoji: '🌿', durationPercent: 0.25, needWater: true), + StageInfo(stage: 2, emoji: '🪴', durationPercent: 0.55, needWater: true), + StageInfo(stage: 3, emoji: '🍅', durationPercent: 0.15, needWater: false), + ], + ), + 'corn': CropConfig( + id: 'corn', + name: '玉米', + emoji: '🌽', + growthTime: 90, + seedPrice: 40, + harvestPrice: 95, + harvestExp: 55, + unlockLevel: 3, + stages: [ + StageInfo(stage: 0, emoji: '🌱', durationPercent: 0.05, needWater: true), + StageInfo(stage: 1, emoji: '🌿', durationPercent: 0.25, needWater: true), + StageInfo(stage: 2, emoji: '🪴', durationPercent: 0.55, needWater: true), + StageInfo(stage: 3, emoji: '🌽', durationPercent: 0.15, needWater: false), + ], + ), + 'pepper': CropConfig( + id: 'pepper', + name: '辣椒', + emoji: '🌶️', + growthTime: 70, + seedPrice: 30, + harvestPrice: 75, + harvestExp: 45, + unlockLevel: 3, + stages: [ + StageInfo(stage: 0, emoji: '🌱', durationPercent: 0.05, needWater: true), + StageInfo(stage: 1, emoji: '🌿', durationPercent: 0.25, needWater: true), + StageInfo(stage: 2, emoji: '🪴', durationPercent: 0.55, needWater: true), + StageInfo(stage: 3, emoji: '🌶️', durationPercent: 0.15, needWater: false), + ], + ), + 'eggplant': CropConfig( + id: 'eggplant', + name: '茄子', + emoji: '🍆', + growthTime: 80, + seedPrice: 35, + harvestPrice: 85, + harvestExp: 50, + unlockLevel: 4, + stages: [ + StageInfo(stage: 0, emoji: '🌱', durationPercent: 0.05, needWater: true), + StageInfo(stage: 1, emoji: '🌿', durationPercent: 0.25, needWater: true), + StageInfo(stage: 2, emoji: '🪴', durationPercent: 0.55, needWater: true), + StageInfo(stage: 3, emoji: '🍆', durationPercent: 0.15, needWater: false), + ], + ), + 'strawberry': CropConfig( + id: 'strawberry', + name: '草莓', + emoji: '🍓', + growthTime: 120, + seedPrice: 60, + harvestPrice: 140, + harvestExp: 80, + unlockLevel: 5, + stages: [ + StageInfo(stage: 0, emoji: '🌱', durationPercent: 0.05, needWater: true), + StageInfo(stage: 1, emoji: '🌿', durationPercent: 0.25, needWater: true), + StageInfo(stage: 2, emoji: '🌸', durationPercent: 0.55, needWater: true), + StageInfo(stage: 3, emoji: '🍓', durationPercent: 0.15, needWater: false), + ], + ), + 'pumpkin': CropConfig( + id: 'pumpkin', + name: '南瓜', + emoji: '🎃', + growthTime: 150, + seedPrice: 80, + harvestPrice: 180, + harvestExp: 100, + unlockLevel: 6, + stages: [ + StageInfo(stage: 0, emoji: '🌱', durationPercent: 0.05, needWater: true), + StageInfo(stage: 1, emoji: '🌿', durationPercent: 0.25, needWater: true), + StageInfo(stage: 2, emoji: '🪴', durationPercent: 0.55, needWater: true), + StageInfo(stage: 3, emoji: '🎃', durationPercent: 0.15, needWater: false), + ], + ), + 'watermelon': CropConfig( + id: 'watermelon', + name: '西瓜', + emoji: '🍉', + growthTime: 180, + seedPrice: 100, + harvestPrice: 220, + harvestExp: 120, + unlockLevel: 8, + stages: [ + StageInfo(stage: 0, emoji: '🌱', durationPercent: 0.05, needWater: true), + StageInfo(stage: 1, emoji: '🌿', durationPercent: 0.25, needWater: true), + StageInfo(stage: 2, emoji: '🪴', durationPercent: 0.55, needWater: true), + StageInfo(stage: 3, emoji: '🍉', durationPercent: 0.15, needWater: false), + ], + ), + 'grape': CropConfig( + id: 'grape', + name: '葡萄', + emoji: '🍇', + growthTime: 200, + seedPrice: 120, + harvestPrice: 260, + harvestExp: 140, + unlockLevel: 10, + stages: [ + StageInfo(stage: 0, emoji: '🌱', durationPercent: 0.05, needWater: true), + StageInfo(stage: 1, emoji: '🌿', durationPercent: 0.25, needWater: true), + StageInfo(stage: 2, emoji: '🌸', durationPercent: 0.55, needWater: true), + StageInfo(stage: 3, emoji: '🍇', durationPercent: 0.15, needWater: false), + ], + ), + }; + + static CropConfig? getById(String id) => _crops[id]; + + static List getAll() => _crops.values.toList(); + + static List getUnlockedCrops(int level) { + return _crops.values.where((c) => c.unlockLevel <= level).toList(); + } + + static List getAvailableForLevel(int level) { + return _crops.values.where((c) => c.unlockLevel == level).toList(); + } +} diff --git a/lib/src/models/farm/farm_land.dart b/lib/src/models/farm/farm_land.dart new file mode 100644 index 0000000..1dd89e0 --- /dev/null +++ b/lib/src/models/farm/farm_land.dart @@ -0,0 +1,82 @@ +/// 农场土地数据模型 +/// 存储每块土地的种植状态、作物生长信息 +library; + +import 'package:hive_ce/hive.dart'; +import 'package:mom_kitchen/src/models/farm/crop_registry.dart'; + +part 'farm_land.g.dart'; + +@HiveType(typeId: 101) +class FarmLand extends HiveObject { + /// 土地编号 + @HiveField(0) + int landId = 0; + + /// 是否已解锁 + @HiveField(1) + bool isUnlocked = false; + + /// 当前种植的作物 ID(null 表示空地) + @HiveField(2) + String? cropId; + + /// 种植时间 + @HiveField(3) + DateTime? plantTime; + + /// 当前生长阶段(0-3) + @HiveField(4) + int growthStage = 0; + + /// 是否需要浇水 + @HiveField(5) + bool needWater = false; + + /// 是否需要施肥 + @HiveField(6) + bool needFertilizer = false; + + /// 上次浇水时间 + @HiveField(7) + DateTime? lastWaterTime; + + /// 是否枯萎(长时间未浇水) + @HiveField(8) + bool isWithered = false; + + /// 是否成熟可收获 + @HiveField(9) + bool isReady = false; + + FarmLand(); + + /// 计算当前生长进度(0.0 - 1.0) + double get growthProgress { + if (plantTime == null || cropId == null) return 0.0; + final crop = CropRegistry.getById(cropId!); + if (crop == null) return 0.0; + + final elapsed = DateTime.now().difference(plantTime!).inMinutes; + return (elapsed / crop.growthTime).clamp(0.0, 1.0); + } + + /// 获取当前阶段应显示的 Emoji + String get currentDisplayEmoji { + if (cropId == null) return '🟫'; + final crop = CropRegistry.getById(cropId!); + if (crop == null) return '🟫'; + + if (isWithered) return '🥀'; + if (isReady) return crop.stages.last.emoji; + + final index = growthStage.clamp(0, crop.stages.length - 1); + return crop.stages[index].emoji; + } + + factory FarmLand.initial(int id, {bool unlocked = false}) { + return FarmLand() + ..landId = id + ..isUnlocked = unlocked; + } +} diff --git a/lib/src/models/farm/farm_land.g.dart b/lib/src/models/farm/farm_land.g.dart new file mode 100644 index 0000000..5dace9d --- /dev/null +++ b/lib/src/models/farm/farm_land.g.dart @@ -0,0 +1,64 @@ +/// 手动编写的 Hive 适配器 - FarmLand +// ignore_for_file: type=lint + +part of 'farm_land.dart'; + +class FarmLandAdapter extends TypeAdapter { + @override + final int typeId = 101; + + @override + FarmLand read(BinaryReader reader) { + final numOfFields = reader.readByte(); + final fields = { + for (int i = 0; i < numOfFields; i++) reader.readByte(): reader.read(), + }; + return FarmLand() + ..landId = fields[0] as int + ..isUnlocked = fields[1] as bool + ..cropId = fields[2] as String? + ..plantTime = fields[3] as DateTime? + ..growthStage = fields[4] as int + ..needWater = fields[5] as bool + ..needFertilizer = fields[6] as bool + ..lastWaterTime = fields[7] as DateTime? + ..isWithered = fields[8] as bool + ..isReady = fields[9] as bool; + } + + @override + void write(BinaryWriter writer, FarmLand obj) { + writer + ..writeByte(10) + ..writeByte(0) + ..write(obj.landId) + ..writeByte(1) + ..write(obj.isUnlocked) + ..writeByte(2) + ..write(obj.cropId) + ..writeByte(3) + ..write(obj.plantTime) + ..writeByte(4) + ..write(obj.growthStage) + ..writeByte(5) + ..write(obj.needWater) + ..writeByte(6) + ..write(obj.needFertilizer) + ..writeByte(7) + ..write(obj.lastWaterTime) + ..writeByte(8) + ..write(obj.isWithered) + ..writeByte(9) + ..write(obj.isReady); + } + + @override + int get hashCode => typeId.hashCode; + + @override + bool operator ==(Object other) => + identical(this, other) && + other is FarmLandAdapter && + runtimeType == other.runtimeType && + typeId == other.typeId; +} diff --git a/lib/src/models/farm/farm_player.dart b/lib/src/models/farm/farm_player.dart new file mode 100644 index 0000000..c27ce1d --- /dev/null +++ b/lib/src/models/farm/farm_player.dart @@ -0,0 +1,64 @@ +/// 农场玩家数据模型 +/// 存储玩家等级、经验、金币、解锁状态等 +library; + +import 'package:hive_ce/hive.dart'; + +part 'farm_player.g.dart'; + +@HiveType(typeId: 100) +class FarmPlayer extends HiveObject { + @HiveField(0) + String playerId = ''; + + @HiveField(1) + String playerName = '小厨神'; + + @HiveField(2) + int level = 1; + + @HiveField(3) + int experience = 0; + + @HiveField(4) + int gold = 100; + + @HiveField(5) + int diamond = 10; + + @HiveField(6) + DateTime createTime = DateTime.now(); + + @HiveField(7) + int totalHarvest = 0; + + @HiveField(8) + int totalPlant = 0; + + @HiveField(9) + List unlockedCrops = []; + + @HiveField(10) + List achievements = []; + + FarmPlayer(); + + /// 计算升级到下一级所需经验 + int get expToNextLevel => level * 100; + + /// 经验进度(0.0 - 1.0) + double get expProgress => level > 0 ? experience / expToNextLevel : 0.0; + + factory FarmPlayer.createDefault(String deviceId) { + return FarmPlayer() + ..playerId = deviceId + ..playerName = '小厨神' + ..level = 1 + ..experience = 0 + ..gold = 100 + ..diamond = 10 + ..createTime = DateTime.now() + ..unlockedCrops = ['radish', 'potato', 'cabbage'] + ..achievements = []; + } +} diff --git a/lib/src/models/farm/farm_player.g.dart b/lib/src/models/farm/farm_player.g.dart new file mode 100644 index 0000000..519ade9 --- /dev/null +++ b/lib/src/models/farm/farm_player.g.dart @@ -0,0 +1,68 @@ +/// 手动编写的 Hive 适配器 - FarmPlayer +/// 用于序列化 FarmPlayer 对象到 Hive 数据库 +// ignore_for_file: type=lint + +part of 'farm_player.dart'; + +class FarmPlayerAdapter extends TypeAdapter { + @override + final int typeId = 100; + + @override + FarmPlayer read(BinaryReader reader) { + final numOfFields = reader.readByte(); + final fields = { + for (int i = 0; i < numOfFields; i++) reader.readByte(): reader.read(), + }; + return FarmPlayer() + ..playerId = fields[0] as String + ..playerName = fields[1] as String + ..level = fields[2] as int + ..experience = fields[3] as int + ..gold = fields[4] as int + ..diamond = fields[5] as int + ..createTime = fields[6] as DateTime + ..totalHarvest = fields[7] as int + ..totalPlant = fields[8] as int + ..unlockedCrops = (fields[9] as List).cast() + ..achievements = (fields[10] as List).cast(); + } + + @override + void write(BinaryWriter writer, FarmPlayer obj) { + writer + ..writeByte(11) + ..writeByte(0) + ..write(obj.playerId) + ..writeByte(1) + ..write(obj.playerName) + ..writeByte(2) + ..write(obj.level) + ..writeByte(3) + ..write(obj.experience) + ..writeByte(4) + ..write(obj.gold) + ..writeByte(5) + ..write(obj.diamond) + ..writeByte(6) + ..write(obj.createTime) + ..writeByte(7) + ..write(obj.totalHarvest) + ..writeByte(8) + ..write(obj.totalPlant) + ..writeByte(9) + ..write(obj.unlockedCrops) + ..writeByte(10) + ..write(obj.achievements); + } + + @override + int get hashCode => typeId.hashCode; + + @override + bool operator ==(Object other) => + identical(this, other) && + other is FarmPlayerAdapter && + runtimeType == other.runtimeType && + typeId == other.typeId; +} diff --git a/lib/src/models/farm/inventory_item.dart b/lib/src/models/farm/inventory_item.dart new file mode 100644 index 0000000..4909638 --- /dev/null +++ b/lib/src/models/farm/inventory_item.dart @@ -0,0 +1,72 @@ +/// 背包物品模型 +/// 存储种子、果实、道具等背包物品 +library; + +import 'package:hive_ce/hive.dart'; + +part 'inventory_item.g.dart'; + +@HiveType(typeId: 102) +class InventoryItem extends HiveObject { + /// 物品 ID + @HiveField(0) + String itemId = ''; + + /// 物品名称 + @HiveField(1) + String itemName = ''; + + /// 物品类型:seed/fruit/fertilizer/tool + @HiveField(2) + String itemType = 'seed'; + + /// 数量 + @HiveField(3) + int quantity = 0; + + /// 物品 Emoji + @HiveField(4) + String emoji = '📦'; + + /// 单价(金币) + @HiveField(5) + int price = 0; + + InventoryItem(); + + factory InventoryItem.seed({ + required String cropId, + required String name, + required String emoji, + required int price, + int quantity = 1, + }) { + return InventoryItem() + ..itemId = '${cropId}_seed' + ..itemName = '$name种子' + ..itemType = 'seed' + ..quantity = quantity + ..emoji = emoji + ..price = price; + } + + factory InventoryItem.fruit({ + required String cropId, + required String name, + required String emoji, + required int price, + int quantity = 1, + }) { + return InventoryItem() + ..itemId = '${cropId}_fruit' + ..itemName = name + ..itemType = 'fruit' + ..quantity = quantity + ..emoji = emoji + ..price = price; + } + + bool get isSeed => itemType == 'seed'; + bool get isFruit => itemType == 'fruit'; + bool get isTool => itemType == 'tool'; +} diff --git a/lib/src/models/farm/inventory_item.g.dart b/lib/src/models/farm/inventory_item.g.dart new file mode 100644 index 0000000..af090d5 --- /dev/null +++ b/lib/src/models/farm/inventory_item.g.dart @@ -0,0 +1,52 @@ +/// 手动编写的 Hive 适配器 - InventoryItem +// ignore_for_file: type=lint + +part of 'inventory_item.dart'; + +class InventoryItemAdapter extends TypeAdapter { + @override + final int typeId = 102; + + @override + InventoryItem read(BinaryReader reader) { + final numOfFields = reader.readByte(); + final fields = { + for (int i = 0; i < numOfFields; i++) reader.readByte(): reader.read(), + }; + return InventoryItem() + ..itemId = fields[0] as String + ..itemName = fields[1] as String + ..itemType = fields[2] as String + ..quantity = fields[3] as int + ..emoji = fields[4] as String + ..price = fields[5] as int; + } + + @override + void write(BinaryWriter writer, InventoryItem obj) { + writer + ..writeByte(6) + ..writeByte(0) + ..write(obj.itemId) + ..writeByte(1) + ..write(obj.itemName) + ..writeByte(2) + ..write(obj.itemType) + ..writeByte(3) + ..write(obj.quantity) + ..writeByte(4) + ..write(obj.emoji) + ..writeByte(5) + ..write(obj.price); + } + + @override + int get hashCode => typeId.hashCode; + + @override + bool operator ==(Object other) => + identical(this, other) && + other is InventoryItemAdapter && + runtimeType == other.runtimeType && + typeId == other.typeId; +} diff --git a/lib/src/models/tool_item_model.dart b/lib/src/models/tool_item_model.dart index 3772041..cad8ee0 100644 --- a/lib/src/models/tool_item_model.dart +++ b/lib/src/models/tool_item_model.dart @@ -334,5 +334,15 @@ class ToolRegistry { description: '点餐推单,二维码分享', waterfallSlot: WaterfallSlotConfig(show: true, priority: 9), ), + ToolItem( + id: 'farm_game', + name: '小妈菜园', + icon: '🌾', + needsNetwork: false, + category: 'planning', + route: '/farm-game', + description: '种菜收菜,体验农场乐趣', + waterfallSlot: WaterfallSlotConfig(show: true, priority: 10, badge: 'NEW'), + ), ]; } diff --git a/lib/src/pages/discover/components/browse_history_section.dart b/lib/src/pages/discover/components/browse_history_section.dart index a98f4f5..2660bd0 100644 --- a/lib/src/pages/discover/components/browse_history_section.dart +++ b/lib/src/pages/discover/components/browse_history_section.dart @@ -175,7 +175,9 @@ class BrowseHistorySection extends StatelessWidget { style: TextStyle( fontSize: DesignTokens.fontXs, fontWeight: FontWeight.w600, - color: isDark ? DarkDesignTokens.text1 : DesignTokens.text1, + color: isDark + ? DarkDesignTokens.text1 + : DesignTokens.text1, ), maxLines: 1, overflow: TextOverflow.ellipsis, diff --git a/lib/src/pages/discover/components/tool_detail_sheet.dart b/lib/src/pages/discover/components/tool_detail_sheet.dart index d9fe0f8..f7882fe 100644 --- a/lib/src/pages/discover/components/tool_detail_sheet.dart +++ b/lib/src/pages/discover/components/tool_detail_sheet.dart @@ -7,11 +7,7 @@ class ToolDetailSheet extends StatelessWidget { final ToolItem tool; final bool isDark; - const ToolDetailSheet({ - super.key, - required this.tool, - required this.isDark, - }); + const ToolDetailSheet({super.key, required this.tool, required this.isDark}); @override Widget build(BuildContext context) { @@ -73,9 +69,7 @@ class ToolDetailSheet extends StatelessWidget { ), borderRadius: DesignTokens.borderRadiusXl, ), - child: Center( - child: Text(tool.icon, style: TextStyle(fontSize: 36)), - ), + child: Center(child: Text(tool.icon, style: TextStyle(fontSize: 36))), ); } diff --git a/lib/src/pages/discover/components/tools_panel_widget.dart b/lib/src/pages/discover/components/tools_panel_widget.dart index 8d25f7c..f53af64 100644 --- a/lib/src/pages/discover/components/tools_panel_widget.dart +++ b/lib/src/pages/discover/components/tools_panel_widget.dart @@ -7,8 +7,6 @@ */ 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'; @@ -36,8 +34,6 @@ class ToolsPanelWidget extends StatefulWidget { 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(); @@ -114,50 +110,6 @@ class _ToolsPanelWidgetState extends State { 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( @@ -422,8 +374,9 @@ class _ToolsPanelWidgetState extends State { Widget _buildPanelFrequentTools() { if (widget.toolsController == null) return const SizedBox.shrink(); // 搜索框有焦点时隐藏常用工具 - if (_isSearchFocused || _searchQuery.isNotEmpty) + if (_isSearchFocused || _searchQuery.isNotEmpty) { return const SizedBox.shrink(); + } return Padding( padding: const EdgeInsets.fromLTRB( diff --git a/lib/src/pages/home/advanced_search_page.dart b/lib/src/pages/home/advanced_search_page.dart index 90cf666..c470280 100644 --- a/lib/src/pages/home/advanced_search_page.dart +++ b/lib/src/pages/home/advanced_search_page.dart @@ -29,7 +29,7 @@ class _AdvancedSearchPageState extends State { List _tasteTags = []; List _cookingTags = []; List<_MealTimeItem> _mealTimes = []; - List _commonAllergens = ['花生', '虾', '蟹', '蛋', '奶', '豆', '麦', '鱼']; + final List _commonAllergens = ['花生', '虾', '蟹', '蛋', '奶', '豆', '麦', '鱼']; CategoryModel? _selectedMainCategory; TagModel? _selectedTaste; diff --git a/lib/src/pages/home/home_page.dart b/lib/src/pages/home/home_page.dart index ced4f8f..027a24f 100644 --- a/lib/src/pages/home/home_page.dart +++ b/lib/src/pages/home/home_page.dart @@ -314,15 +314,14 @@ class _HomePageState extends State { void _precacheDiscoverImages(DiscoverData data) { try { - final ctx = context; - if (!mounted || ctx == null) return; + if (!mounted) 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) { - precacheImage(NetworkImage(u), ctx).catchError((_) {}); + precacheImage(NetworkImage(u), context).catchError((_) {}); } } catch (_) {} } diff --git a/lib/src/pages/profile/data/cache_manage_page.dart b/lib/src/pages/profile/data/cache_manage_page.dart index 01b58b9..4e9c6bb 100644 --- a/lib/src/pages/profile/data/cache_manage_page.dart +++ b/lib/src/pages/profile/data/cache_manage_page.dart @@ -10,7 +10,6 @@ import 'dart:convert'; import 'package:flutter/cupertino.dart'; -import 'package:flutter/material.dart'; import 'package:flutter_cache_manager/flutter_cache_manager.dart'; import 'package:get/get.dart'; import 'package:shared_preferences/shared_preferences.dart'; @@ -45,6 +44,8 @@ class _CacheManagePageState extends State { int _miniCardImageCacheSize = 0; List<_CachedRecipeItem> _cachedRecipes = []; List _cachedIngredients = []; + bool _recipesExpanded = false; + bool _ingredientsExpanded = false; final IngredientCacheService _ingredientCacheService = IngredientCacheService(); @@ -713,6 +714,11 @@ class _CacheManagePageState extends State { // ─── 菜品缓存列表 ─── Widget _buildCachedRecipesList(bool isDark) { + const int previewCount = 3; + final showExpand = _cachedRecipes.length > previewCount; + final displayItems = + _recipesExpanded ? _cachedRecipes : _cachedRecipes.take(previewCount).toList(); + return Container( decoration: BoxDecoration( color: isDark ? DarkDesignTokens.card : DesignTokens.card, @@ -726,24 +732,55 @@ class _CacheManagePageState extends State { child: Column( crossAxisAlignment: CrossAxisAlignment.start, children: [ - Padding( - padding: const EdgeInsets.all(DesignTokens.space3), - child: Row( - children: [ - const Text('🍳', style: TextStyle(fontSize: 18)), - const SizedBox(width: DesignTokens.space2), - Text( - '已缓存的菜谱 (${_cachedRecipes.length})', - style: TextStyle( - fontSize: DesignTokens.fontMd, - fontWeight: FontWeight.w600, - color: isDark ? DarkDesignTokens.text1 : DesignTokens.text1, + GestureDetector( + onTap: showExpand + ? () => setState(() => _recipesExpanded = !_recipesExpanded) + : null, + behavior: HitTestBehavior.opaque, + child: Padding( + padding: const EdgeInsets.all(DesignTokens.space3), + child: Row( + children: [ + const Text('🍳', style: TextStyle(fontSize: 18)), + const SizedBox(width: DesignTokens.space2), + Text( + '已缓存的菜谱 (${_cachedRecipes.length})', + style: TextStyle( + fontSize: DesignTokens.fontMd, + fontWeight: FontWeight.w600, + color: isDark ? DarkDesignTokens.text1 : DesignTokens.text1, + ), ), - ), - ], + const Spacer(), + if (showExpand) + Icon( + _recipesExpanded + ? CupertinoIcons.chevron_up + : CupertinoIcons.chevron_down, + size: 16, + color: isDark ? DarkDesignTokens.text3 : DesignTokens.text3, + ), + ], + ), ), ), - ..._cachedRecipes.map((item) => _buildCachedRecipeItem(item, isDark)), + ...displayItems.map((item) => _buildCachedRecipeItem(item, isDark)), + if (showExpand && !_recipesExpanded) + GestureDetector( + onTap: () => setState(() => _recipesExpanded = true), + child: Container( + width: double.infinity, + padding: const EdgeInsets.symmetric(vertical: DesignTokens.space2), + alignment: Alignment.center, + child: Text( + '还有 ${_cachedRecipes.length - previewCount} 项,点击展开全部', + style: TextStyle( + fontSize: DesignTokens.fontXs, + color: DesignTokens.dynamicPrimary, + ), + ), + ), + ), ], ), ); @@ -765,7 +802,7 @@ class _CacheManagePageState extends State { onPressed: () { Get.toNamed( AppRoutes.recipeDetail, - arguments: {'id': item.id}, + arguments: item.id, preventDuplicates: false, ); }, @@ -992,6 +1029,12 @@ class _CacheManagePageState extends State { // ─── 食材缓存列表 ─── Widget _buildCachedIngredientsList(bool isDark) { + const int previewCount = 3; + final showExpand = _cachedIngredients.length > previewCount; + final displayItems = _ingredientsExpanded + ? _cachedIngredients + : _cachedIngredients.take(previewCount).toList(); + return Container( decoration: BoxDecoration( color: isDark ? DarkDesignTokens.card : DesignTokens.card, @@ -1005,26 +1048,57 @@ class _CacheManagePageState extends State { child: Column( crossAxisAlignment: CrossAxisAlignment.start, children: [ - Padding( - padding: const EdgeInsets.all(DesignTokens.space3), - child: Row( - children: [ - const Text('🥬', style: TextStyle(fontSize: 18)), - const SizedBox(width: DesignTokens.space2), - Text( - '已缓存的食材 (${_cachedIngredients.length})', - style: TextStyle( - fontSize: DesignTokens.fontMd, - fontWeight: FontWeight.w600, - color: isDark ? DarkDesignTokens.text1 : DesignTokens.text1, + GestureDetector( + onTap: showExpand + ? () => setState(() => _ingredientsExpanded = !_ingredientsExpanded) + : null, + behavior: HitTestBehavior.opaque, + child: Padding( + padding: const EdgeInsets.all(DesignTokens.space3), + child: Row( + children: [ + const Text('🥬', style: TextStyle(fontSize: 18)), + const SizedBox(width: DesignTokens.space2), + Text( + '已缓存的食材 (${_cachedIngredients.length})', + style: TextStyle( + fontSize: DesignTokens.fontMd, + fontWeight: FontWeight.w600, + color: isDark ? DarkDesignTokens.text1 : DesignTokens.text1, + ), ), - ), - ], + const Spacer(), + if (showExpand) + Icon( + _ingredientsExpanded + ? CupertinoIcons.chevron_up + : CupertinoIcons.chevron_down, + size: 16, + color: isDark ? DarkDesignTokens.text3 : DesignTokens.text3, + ), + ], + ), ), ), - ..._cachedIngredients.map( + ...displayItems.map( (item) => _buildCachedIngredientItem(item, isDark), ), + if (showExpand && !_ingredientsExpanded) + GestureDetector( + onTap: () => setState(() => _ingredientsExpanded = true), + child: Container( + width: double.infinity, + padding: const EdgeInsets.symmetric(vertical: DesignTokens.space2), + alignment: Alignment.center, + child: Text( + '还有 ${_cachedIngredients.length - previewCount} 项,点击展开全部', + style: TextStyle( + fontSize: DesignTokens.fontXs, + color: DesignTokens.dynamicPrimary, + ), + ), + ), + ), ], ), ); diff --git a/lib/src/pages/profile/data_export_page.dart b/lib/src/pages/profile/data_export_page.dart new file mode 100644 index 0000000..2ca72c3 --- /dev/null +++ b/lib/src/pages/profile/data_export_page.dart @@ -0,0 +1,740 @@ +// 2026-04-18 | DataExportPage | 数据导出/导入页面 | 支持6种数据源、3种格式导出、JSON导入、分享导入 +// 2026-04-18 | 初始创建:iOS风格UI,支持单源/全量导出 +// 2026-04-18 | 新增数据导入功能:支持JSON文件选择导入和receive_sharing_intent分享导入 + +import 'dart:async'; +import 'dart:io'; +import 'package:flutter/cupertino.dart'; +import 'package:flutter/foundation.dart'; +import 'package:flutter/material.dart'; +import 'package:get/get.dart'; +import 'package:receive_sharing_intent/receive_sharing_intent.dart'; +import 'package:mom_kitchen/src/services/data/data_export_service.dart'; +import 'package:mom_kitchen/src/services/ui/toast_service.dart'; +import 'package:mom_kitchen/src/utils/platform_utils.dart'; + +bool get _supportsSharingIntent => !kIsWeb && (PlatformUtils().isMobile); + +class DataExportPage extends StatefulWidget { + const DataExportPage({super.key}); + + @override + State createState() => _DataExportPageState(); +} + +class _DataExportPageState extends State { + StreamSubscription? _intentSub; + final Rx _importPreview = Rx(null); + final RxString _importFileName = ''.obs; + + @override + void initState() { + super.initState(); + _initReceiveSharingIntent(); + } + + @override + void dispose() { + _intentSub?.cancel(); + super.dispose(); + } + + void _initReceiveSharingIntent() { + if (!_supportsSharingIntent) return; + + _intentSub = ReceiveSharingIntent.instance.getMediaStream().listen( + (values) => _handleSharedFiles(values), + onError: (err) => debugPrint('ReceiveSharingIntent stream error: $err'), + ); + + ReceiveSharingIntent.instance.getInitialMedia().then((values) { + if (values.isNotEmpty) { + _handleSharedFiles(values); + } + ReceiveSharingIntent.instance.reset(); + }); + } + + void _handleSharedFiles(List files) { + for (final file in files) { + final path = file.path; + if (path.endsWith('.json')) { + _importJsonFile(path); + return; + } + } + ToastService.show(message: '⚠️ 仅支持 JSON 格式文件导入'); + } + + Future _importJsonFile(String filePath) async { + try { + final file = File(filePath); + if (!await file.exists()) { + ToastService.show(message: '❌ 文件不存在'); + return; + } + final content = await file.readAsString(); + _previewImportContent(content, filePath.split('/').last); + } catch (e) { + ToastService.show(message: '❌ 读取文件失败: $e'); + } + } + + void _previewImportContent(String content, String fileName) { + final preview = DataExportService.to.previewImport(content); + if (!preview.hasData) { + ToastService.show(message: '❌ 无法识别的导入数据格式'); + return; + } + _importPreview.value = preview; + _importFileName.value = fileName; + } + + @override + Widget build(BuildContext context) { + final exportService = DataExportService.to; + + return CupertinoPageScaffold( + navigationBar: const CupertinoNavigationBar(middle: Text('📦 数据管理')), + child: SafeArea( + child: ListView( + padding: const EdgeInsets.symmetric(vertical: 16), + children: [ + _buildExportFormatSection(exportService), + const SizedBox(height: 24), + _buildExportSourceSection(exportService), + const SizedBox(height: 24), + _buildExportActionSection(exportService), + const SizedBox(height: 32), + _buildImportSection(), + const SizedBox(height: 32), + _buildInfoSection(), + ], + ), + ), + ); + } + + // ─── 导出部分 ─── + + Widget _buildExportFormatSection(DataExportService service) { + return Obx(() { + final selected = service.selectedFormat.value; + return _SectionCard( + title: '📄 导出格式', + child: CupertinoSegmentedControl( + groupValue: selected, + children: { + for (final format in ExportFormat.values) + format: Padding( + padding: const EdgeInsets.symmetric( + horizontal: 12, + vertical: 8, + ), + child: Text(service.getFormatLabel(format)), + ), + }, + onValueChanged: (value) { + service.selectedFormat.value = value; + }, + ), + ); + }); + } + + Widget _buildExportSourceSection(DataExportService service) { + return Obx(() { + final selected = service.selectedSources; + return _SectionCard( + title: '📊 选择数据源', + child: Column( + children: [ + for (final source in DataSource.values) + _SourceTile( + source: source, + count: service.getDataCount(source), + isSelected: selected.contains(source), + onTap: () { + if (selected.contains(source)) { + selected.remove(source); + } else { + selected.add(source); + } + }, + ), + ], + ), + ); + }); + } + + Widget _buildExportActionSection(DataExportService service) { + return Obx(() { + final isExporting = service.isExporting.value; + final hasSelection = service.selectedSources.isNotEmpty; + + return Padding( + padding: const EdgeInsets.symmetric(horizontal: 16), + child: Column( + children: [ + SizedBox( + width: double.infinity, + child: CupertinoButton.filled( + onPressed: isExporting || !hasSelection + ? null + : () => _exportSelected(service), + child: isExporting + ? const CupertinoActivityIndicator( + color: CupertinoColors.white, + ) + : Text( + hasSelection + ? '导出已选 (${service.selectedSources.length})' + : '请选择数据源', + ), + ), + ), + const SizedBox(height: 12), + SizedBox( + width: double.infinity, + child: CupertinoButton( + onPressed: isExporting ? null : () => _exportAll(service), + child: const Text('一键导出全部'), + ), + ), + if (isExporting) ...[ + const SizedBox(height: 12), + Obx( + () => CupertinoProgressIndicator( + progress: service.exportProgress.value, + ), + ), + ], + ], + ), + ); + }); + } + + // ─── 导入部分 ─── + + Widget _buildImportSection() { + return _SectionCard( + title: '📥 数据导入', + child: Column( + children: [ + GestureDetector( + onTap: _pickImportFile, + behavior: HitTestBehavior.opaque, + child: Container( + padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 14), + child: const Row( + children: [ + Icon( + CupertinoIcons.doc_text_viewfinder, + size: 22, + color: CupertinoColors.activeBlue, + ), + SizedBox(width: 10), + Expanded( + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text( + '选择 JSON 文件导入', + style: TextStyle( + fontSize: 16, + fontWeight: FontWeight.w500, + ), + ), + SizedBox(height: 2), + Text( + '仅支持本应用导出的 JSON 格式', + style: TextStyle( + fontSize: 12, + color: CupertinoColors.systemGrey, + ), + ), + ], + ), + ), + Icon( + CupertinoIcons.chevron_right, + size: 18, + color: CupertinoColors.systemGrey3, + ), + ], + ), + ), + ), + const Divider(height: 1, indent: 16, endIndent: 16), + GestureDetector( + onTap: _showShareImportTip, + behavior: HitTestBehavior.opaque, + child: Container( + padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 14), + child: const Row( + children: [ + Icon( + CupertinoIcons.share, + size: 22, + color: CupertinoColors.activeGreen, + ), + SizedBox(width: 10), + Expanded( + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text( + '从其他应用分享导入', + style: TextStyle( + fontSize: 16, + fontWeight: FontWeight.w500, + ), + ), + SizedBox(height: 2), + Text( + '在其他应用中分享 JSON 文件到本应用', + style: TextStyle( + fontSize: 12, + color: CupertinoColors.systemGrey, + ), + ), + ], + ), + ), + Icon( + CupertinoIcons.chevron_right, + size: 18, + color: CupertinoColors.systemGrey3, + ), + ], + ), + ), + ), + Obx(() { + final preview = _importPreview.value; + if (preview == null || !preview.hasData) { + return const SizedBox.shrink(); + } + return _buildImportPreview(preview); + }), + ], + ), + ); + } + + Widget _buildImportPreview(ImportPreview preview) { + return Column( + children: [ + const Divider(height: 1, indent: 16, endIndent: 16), + Container( + padding: const EdgeInsets.all(16), + color: CupertinoColors.systemGreen.withValues(alpha: 0.05), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Row( + children: [ + const Icon( + CupertinoIcons.doc_checkmark, + size: 18, + color: CupertinoColors.activeGreen, + ), + const SizedBox(width: 6), + Obx( + () => Expanded( + child: Text( + '已识别: ${_importFileName.value}', + style: const TextStyle( + fontSize: 13, + fontWeight: FontWeight.w600, + ), + overflow: TextOverflow.ellipsis, + ), + ), + ), + ], + ), + const SizedBox(height: 8), + ...preview.sourceCounts.entries.map( + (entry) => Padding( + padding: const EdgeInsets.only(bottom: 4), + child: Row( + children: [ + Text( + entry.key.label, + style: const TextStyle(fontSize: 13), + ), + const Spacer(), + Text( + '${entry.value} 条', + style: const TextStyle( + fontSize: 13, + color: CupertinoColors.activeGreen, + fontWeight: FontWeight.w500, + ), + ), + ], + ), + ), + ), + const SizedBox(height: 8), + Row( + children: [ + Text( + '共 ${preview.totalItems} 条数据', + style: const TextStyle( + fontSize: 12, + color: CupertinoColors.systemGrey, + ), + ), + const Spacer(), + SizedBox( + height: 32, + child: CupertinoButton.filled( + padding: const EdgeInsets.symmetric(horizontal: 16), + onPressed: () => _executeImport(preview), + child: const Text('确认导入', style: TextStyle(fontSize: 13)), + ), + ), + ], + ), + ], + ), + ), + ], + ); + } + + Future _pickImportFile() async { + try { + ToastService.show(message: '📋 请将 JSON 文件分享到本应用,或通过文件管理器打开'); + } catch (e) { + ToastService.show(message: '❌ 选择文件失败: $e'); + } + } + + void _showShareImportTip() { + showCupertinoDialog( + context: context, + builder: (ctx) => CupertinoAlertDialog( + title: const Text('📥 分享导入说明'), + content: const Text( + '1. 在文件管理器或其他应用中找到导出的 JSON 文件\n' + '2. 点击分享按钮,选择"妈妈厨房"\n' + '3. 应用会自动识别并预览导入数据\n' + '4. 确认后即可完成导入', + ), + actions: [ + CupertinoDialogAction( + onPressed: () => Navigator.pop(ctx), + child: const Text('知道了'), + ), + ], + ), + ); + } + + Future _executeImport(ImportPreview preview) async { + final confirmed = await showCupertinoDialog( + context: context, + builder: (ctx) => CupertinoAlertDialog( + title: const Text('⚠️ 确认导入'), + content: Text( + '即将导入 ${preview.totalItems} 条数据到以下数据源:\n' + '${preview.sourceCounts.entries.map((e) => '${e.key.label}: ${e.value}条').join('\n')}\n\n' + '重复数据将被跳过,是否继续?', + ), + actions: [ + CupertinoDialogAction( + onPressed: () => Navigator.pop(ctx, false), + child: const Text('取消'), + ), + CupertinoDialogAction( + isDefaultAction: true, + onPressed: () => Navigator.pop(ctx, true), + child: const Text('确认导入'), + ), + ], + ), + ); + + if (confirmed != true) return; + + try { + final service = DataExportService.to; + final count = await service.importFromJson( + preview.jsonContent, + sources: preview.availableSources, + ); + _importPreview.value = null; + _importFileName.value = ''; + ToastService.show(message: '✅ 成功导入 $count 条数据'); + } catch (e) { + ToastService.show(message: '❌ 导入失败: $e'); + } + } + + // ─── 导出操作 ─── + + Future _exportSelected(DataExportService service) async { + try { + final sources = service.selectedSources.toList(); + final format = service.selectedFormat.value; + + if (sources.length == 1) { + final path = await service.exportSingle(sources.first, format); + _showSuccess(path); + } else { + final paths = []; + for (final source in sources) { + try { + final path = await service.exportSingle(source, format); + paths.add(path); + } catch (e) { + debugPrint('导出 ${source.label} 失败: $e'); + } + } + if (paths.isNotEmpty) { + _showSuccess(paths.first); + } + } + } catch (e) { + ToastService.show(message: '❌ 导出失败: $e'); + } + } + + Future _exportAll(DataExportService service) async { + try { + final format = service.selectedFormat.value; + final path = await service.exportAll(format); + _showSuccess(path); + } catch (e) { + ToastService.show(message: '❌ 导出失败: $e'); + } + } + + void _showSuccess(String path) { + showCupertinoModalPopup( + context: Get.context!, + builder: (ctx) => CupertinoActionSheet( + title: const Text('📦 导出完成'), + message: const Text('文件已保存到临时目录'), + actions: [ + CupertinoActionSheetAction( + onPressed: () { + Navigator.pop(ctx); + DataExportService.to.shareExportedData(path); + }, + child: const Text('📤 分享文件'), + ), + ], + cancelButton: CupertinoActionSheetAction( + onPressed: () { + Navigator.pop(ctx); + ToastService.show(message: '✅ 导出成功!'); + }, + child: const Text('完成'), + ), + ), + ); + } + + // ─── 说明 ─── + + Widget _buildInfoSection() { + return Padding( + padding: const EdgeInsets.symmetric(horizontal: 16), + child: Container( + padding: const EdgeInsets.all(16), + decoration: BoxDecoration( + color: CupertinoColors.systemGrey6, + borderRadius: BorderRadius.circular(12), + ), + child: const Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Row( + children: [ + Icon( + CupertinoIcons.info_circle, + size: 16, + color: CupertinoColors.systemGrey, + ), + SizedBox(width: 6), + Text( + '说明', + style: TextStyle(fontWeight: FontWeight.w600, fontSize: 13), + ), + ], + ), + SizedBox(height: 8), + Text( + '• JSON 格式适合数据备份和程序读取\n' + '• CSV 格式可用 Excel 打开编辑\n' + '• Markdown 格式适合阅读和分享\n' + '• 导出文件保存在临时目录,可通过分享发送\n' + '• 导入仅支持 JSON 格式,与导出格式一致\n' + '• 重复数据会自动跳过,不会覆盖', + style: TextStyle(fontSize: 12, color: CupertinoColors.systemGrey), + ), + ], + ), + ), + ); + } +} + +class _SectionCard extends StatelessWidget { + final String title; + final Widget child; + + const _SectionCard({required this.title, required this.child}); + + @override + Widget build(BuildContext context) { + return Padding( + padding: const EdgeInsets.symmetric(horizontal: 16), + child: Container( + decoration: BoxDecoration( + color: CupertinoColors.systemBackground, + borderRadius: BorderRadius.circular(12), + boxShadow: [ + BoxShadow( + color: CupertinoColors.systemGrey4.withValues(alpha: 0.3), + blurRadius: 8, + offset: const Offset(0, 2), + ), + ], + ), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Padding( + padding: const EdgeInsets.fromLTRB(16, 14, 16, 8), + child: Text( + title, + style: const TextStyle( + fontSize: 13, + fontWeight: FontWeight.w600, + color: CupertinoColors.systemGrey, + ), + ), + ), + child, + ], + ), + ), + ); + } +} + +class _SourceTile extends StatelessWidget { + final DataSource source; + final int count; + final bool isSelected; + final VoidCallback onTap; + + const _SourceTile({ + required this.source, + required this.count, + required this.isSelected, + required this.onTap, + }); + + @override + Widget build(BuildContext context) { + return GestureDetector( + onTap: onTap, + behavior: HitTestBehavior.opaque, + child: Container( + padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 12), + decoration: const BoxDecoration( + border: Border( + bottom: BorderSide(color: CupertinoColors.separator, width: 0.5), + ), + ), + child: Row( + children: [ + Expanded( + child: Row( + children: [ + Text(source.label, style: const TextStyle(fontSize: 16)), + const SizedBox(width: 8), + Container( + padding: const EdgeInsets.symmetric( + horizontal: 8, + vertical: 2, + ), + decoration: BoxDecoration( + color: count > 0 + ? CupertinoColors.activeBlue.withValues(alpha: 0.1) + : CupertinoColors.systemGrey5, + borderRadius: BorderRadius.circular(8), + ), + child: Text( + '$count', + style: TextStyle( + fontSize: 12, + fontWeight: FontWeight.w500, + color: count > 0 + ? CupertinoColors.activeBlue + : CupertinoColors.systemGrey, + ), + ), + ), + ], + ), + ), + if (isSelected) + const Icon( + CupertinoIcons.checkmark_circle_fill, + color: CupertinoColors.activeBlue, + size: 22, + ) + else + const Icon( + CupertinoIcons.circle, + color: CupertinoColors.systemGrey4, + size: 22, + ), + ], + ), + ), + ); + } +} + +class CupertinoProgressIndicator extends StatelessWidget { + final double progress; + + const CupertinoProgressIndicator({super.key, required this.progress}); + + @override + Widget build(BuildContext context) { + return Column( + children: [ + ClipRRect( + borderRadius: BorderRadius.circular(4), + child: LinearProgressIndicator( + value: progress, + minHeight: 6, + backgroundColor: CupertinoColors.systemGrey5, + valueColor: const AlwaysStoppedAnimation( + CupertinoColors.activeBlue, + ), + ), + ), + const SizedBox(height: 4), + Text( + '${(progress * 100).toStringAsFixed(0)}%', + style: const TextStyle( + fontSize: 12, + color: CupertinoColors.systemGrey, + ), + ), + ], + ); + } +} diff --git a/lib/src/pages/profile/guide_page.dart b/lib/src/pages/profile/guide_page.dart index 8e3426a..0130dc2 100644 --- a/lib/src/pages/profile/guide_page.dart +++ b/lib/src/pages/profile/guide_page.dart @@ -520,15 +520,6 @@ class _GuidePageState extends State { ); } - void _acceptAndFinish() { - _acceptAgreement(); - if (widget.fromSettings) { - Get.back(); - } else { - Get.offAllNamed(AppRoutes.main); - } - } - Widget _buildNavButton({ required IconData icon, required String label, diff --git a/lib/src/pages/profile/profile_settings.dart b/lib/src/pages/profile/profile_settings.dart index f23e56e..d2aebaa 100644 --- a/lib/src/pages/profile/profile_settings.dart +++ b/lib/src/pages/profile/profile_settings.dart @@ -1,4 +1,4 @@ -/* +/* * 文件: profile_settings.dart * 名称: 个人中心设置标签 * 作用: iOS 26 风格的设置选项,使用 DesignTokens 和 GlassSettingsTile @@ -19,6 +19,7 @@ import 'package:mom_kitchen/src/pages/discover/hot_page.dart'; import 'package:mom_kitchen/src/pages/tools/cooking/cooking_note_page.dart'; import 'package:mom_kitchen/src/pages/profile/social/footprints_page.dart'; import 'package:mom_kitchen/src/pages/profile/data/cache_manage_page.dart'; +import 'package:mom_kitchen/src/pages/profile/data_export_page.dart'; class ProfileSettingsTab extends StatelessWidget { const ProfileSettingsTab({super.key}); @@ -78,6 +79,12 @@ class ProfileSettingsTab extends StatelessWidget { isDark: isDark, onTap: () => Get.to(() => const CacheManagePage()), ), + _buildTile( + icon: CupertinoIcons.square_arrow_up, + title: '数据导出 📦', + isDark: isDark, + onTap: () => Get.to(() => const DataExportPage()), + ), ], ), const SizedBox(height: DesignTokens.space3), diff --git a/lib/src/pages/profile/social/favorites_page.dart b/lib/src/pages/profile/social/favorites_page.dart index 76e4679..20e0bfc 100644 --- a/lib/src/pages/profile/social/favorites_page.dart +++ b/lib/src/pages/profile/social/favorites_page.dart @@ -9,6 +9,7 @@ * 更新: 2026-04-16 修复空指针安全、AnimationListener泄漏、参数命名、布局比例计算 * 更新: 2026-04-16 修复RenderFlex溢出:Column重构为CustomScrollView+Slivers;TextField替换为CupertinoTextField * 更新: 2026-04-16 移除下拉工具中心功能至发现页;新增顶部4个快捷功能按钮 + * 更新: 2026-04-18 恢复排序功能:将排序按钮集成到类型标签栏右侧,显示当前排序方式,优化排序弹窗选中标识 */ import 'package:flutter/cupertino.dart'; @@ -107,60 +108,55 @@ class _FavoritesPageState extends State ? DarkDesignTokens.background : DesignTokens.background, child: SafeArea( - child: Column( + child: Stack( children: [ - Expanded( - child: CustomScrollView( - controller: _scrollController, - physics: const ClampingScrollPhysics( - parent: AlwaysScrollableScrollPhysics(), - ), - slivers: [ - SliverToBoxAdapter(child: _buildHeader(isDark)), - SliverToBoxAdapter( - child: _buildSearchBar(isDark, favController), - ), - 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(), - ), - ), - ], + CustomScrollView( + controller: _scrollController, + physics: const ClampingScrollPhysics( + parent: AlwaysScrollableScrollPhysics(), ), + slivers: [ + SliverToBoxAdapter(child: _buildHeader(isDark)), + SliverToBoxAdapter( + child: _buildSearchBar(isDark, favController), + ), + SliverToBoxAdapter(child: _buildQuickActions(isDark)), + SliverToBoxAdapter( + child: _buildStatisticsBar(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: 100) + : const SizedBox.shrink(), + ), + ), + ], ), Obx( () => favController.isEditMode.value - ? _buildEditBottomBar(isDark, favController) + ? _buildFloatingSideBar(isDark, favController) : const SizedBox.shrink(), ), ], @@ -558,129 +554,60 @@ class _FavoritesPageState extends State ); } - Widget _buildToolbar(bool isDark, FavoritesController favController) { + Widget _buildSortButton(bool isDark, FavoritesController favController) { return Obx(() { - final stats = favController.statistics; - final total = stats['total'] ?? 0; - if (total == 0) return const SizedBox.shrink(); + final sortMode = favController.sortMode.value; + final sortLabel = _getSortLabel(sortMode); - return Container( - height: 44, - margin: const EdgeInsets.symmetric( - horizontal: DesignTokens.space4, - vertical: DesignTokens.space2, - ), - child: Row( - children: [ - _buildToolbarButton( - icon: CupertinoIcons.square_grid_2x2_fill, - label: '全部', - isSelected: true, - isDark: isDark, - onTap: () {}, + return GestureDetector( + onTap: () => _showSortOptions(isDark, favController), + child: Container( + padding: const EdgeInsets.symmetric( + horizontal: DesignTokens.space3, + vertical: DesignTokens.space2, + ), + decoration: BoxDecoration( + color: DesignTokens.dynamicPrimary.withValues(alpha: 0.1), + borderRadius: DesignTokens.borderRadiusFull, + border: Border.all( + color: DesignTokens.dynamicPrimary.withValues(alpha: 0.3), ), - const SizedBox(width: DesignTokens.space2), - _buildToolbarButton( - icon: CupertinoIcons.flame_fill, - label: '菜品 ${stats['recipe'] ?? 0}', - isSelected: false, - isDark: isDark, - onTap: () {}, - ), - const Spacer(), - _buildSortButton(isDark, favController), - ], + ), + child: Row( + mainAxisSize: MainAxisSize.min, + children: [ + Icon( + CupertinoIcons.arrow_up_arrow_down, + size: 14, + color: DesignTokens.dynamicPrimary, + ), + const SizedBox(width: 4), + Text( + sortLabel, + style: TextStyle( + fontSize: DesignTokens.fontXs, + color: DesignTokens.dynamicPrimary, + fontWeight: FontWeight.w600, + ), + ), + ], + ), ), ); }); } - Widget _buildToolbarButton({ - required IconData icon, - required String label, - required bool isSelected, - required bool isDark, - required VoidCallback onTap, - }) { - return GestureDetector( - onTap: onTap, - child: Container( - padding: const EdgeInsets.symmetric( - horizontal: DesignTokens.space3, - vertical: DesignTokens.space2, - ), - decoration: BoxDecoration( - color: isSelected - ? DesignTokens.dynamicPrimary.withValues(alpha: 0.12) - : Colors.transparent, - borderRadius: DesignTokens.borderRadiusFull, - border: Border.all( - color: isSelected - ? DesignTokens.dynamicPrimary.withValues(alpha: 0.4) - : Colors.transparent, - ), - ), - child: Row( - mainAxisSize: MainAxisSize.min, - children: [ - Icon( - icon, - size: 14, - color: isSelected - ? DesignTokens.dynamicPrimary - : (isDark ? DarkDesignTokens.text2 : DesignTokens.text2), - ), - const SizedBox(width: 4), - Text( - label, - style: TextStyle( - fontSize: DesignTokens.fontSm, - fontWeight: isSelected ? FontWeight.w600 : FontWeight.w500, - color: isSelected - ? DesignTokens.dynamicPrimary - : (isDark ? DarkDesignTokens.text1 : DesignTokens.text1), - ), - ), - ], - ), - ), - ); - } - - Widget _buildSortButton(bool isDark, FavoritesController favController) { - return GestureDetector( - 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, - ), - const SizedBox(width: 4), - Text( - '排序', - style: TextStyle( - fontSize: DesignTokens.fontSm, - color: isDark ? DarkDesignTokens.text1 : DesignTokens.text1, - ), - ), - ], - ), - ), - ); + 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'; + } } Widget _buildFavoriteTypeTabs( @@ -694,109 +621,125 @@ class _FavoritesPageState extends State 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, + child: Row( + children: [ + Expanded( + 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 + ? DesignTokens.dynamicPrimary.withValues( + alpha: 0.12, + ) : (isDark - ? DarkDesignTokens.text1 - : DesignTokens.text1), - fontWeight: isSelected - ? FontWeight.w600 - : FontWeight.w500, + ? DarkDesignTokens.glass + : DesignTokens.card.withValues(alpha: 0.6)), + borderRadius: DesignTokens.borderRadiusFull, + border: Border.all( + color: isSelected + ? DesignTokens.dynamicPrimary.withValues( + alpha: 0.4, + ) + : Colors.transparent, + ), + ), + child: Row( + mainAxisSize: MainAxisSize.min, + children: [ + const Text('📋', style: TextStyle(fontSize: 14)), + const SizedBox(width: 4), + Text( + '全部', + style: TextStyle( + fontSize: DesignTokens.fontSm, + color: isSelected + ? DesignTokens.dynamicPrimary + : (isDark + ? DarkDesignTokens.text1 + : DesignTokens.text1), + fontWeight: isSelected + ? FontWeight.w600 + : FontWeight.w500, + ), + ), + ], ), ), - ], - ), - ), - ); - } + ); + } - final type = types[index - 1]; - final isSelected = type == currentType; + 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, + return GestureDetector( + onTap: () => favController.setFavoriteType(type), + child: Container( + padding: const EdgeInsets.symmetric( + horizontal: DesignTokens.space3, + vertical: DesignTokens.space2, + ), + decoration: BoxDecoration( color: isSelected - ? DesignTokens.dynamicPrimary + ? DesignTokens.dynamicPrimary.withValues( + alpha: 0.12, + ) : (isDark - ? DarkDesignTokens.text1 - : DesignTokens.text1), - fontWeight: isSelected - ? FontWeight.w600 - : FontWeight.w500, + ? 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, + ), + ), + ], ), ), - ], - ), + ); + }, ), - ); - }, + ), + const SizedBox(width: DesignTokens.space2), + _buildSortButton(isDark, favController), + ], ), ); }); @@ -911,54 +854,118 @@ class _FavoritesPageState extends State ); } - Widget _buildEditBottomBar(bool isDark, FavoritesController favController) { + Widget _buildFloatingSideBar(bool isDark, FavoritesController favController) { return Positioned( - left: 0, - right: 0, - bottom: 0, + right: DesignTokens.space3, + bottom: 120, + child: Column( + mainAxisSize: MainAxisSize.min, + children: [ + _buildFloatingButton( + isDark: isDark, + icon: CupertinoIcons.xmark_circle_fill, + label: '取消', + color: isDark ? DarkDesignTokens.glass : DesignTokens.card, + onTap: () => favController.toggleEditMode(), + ), + const SizedBox(height: DesignTokens.space2), + Obx(() { + final count = favController.selectedIds.length; + return _buildFloatingButton( + isDark: isDark, + icon: CupertinoIcons.trash_fill, + label: '删除($count)', + color: count > 0 + ? DesignTokens.red + : (isDark ? DarkDesignTokens.glass : DesignTokens.card), + onTap: count > 0 + ? () { + favController.deleteSelected(); + ToastService.show(message: '已删除 $count 项收藏'); + } + : () {}, + ); + }), + const SizedBox(height: DesignTokens.space2), + Obx(() { + final allSelected = + favController.selectedIds.length == favController.count; + return _buildFloatingButton( + isDark: isDark, + icon: allSelected + ? CupertinoIcons.checkmark_square_fill + : CupertinoIcons.square_fill, + label: allSelected ? '取消全选' : '全选', + color: DesignTokens.dynamicPrimary, + onTap: () { + if (allSelected) { + favController.deselectAll(); + } else { + favController.selectAll(); + } + }, + ); + }), + ], + ), + ); + } + + Widget _buildFloatingButton({ + required bool isDark, + required IconData icon, + required String label, + required Color color, + required VoidCallback onTap, + }) { + return GestureDetector( + onTap: onTap, child: Container( - padding: const EdgeInsets.symmetric( - horizontal: DesignTokens.space4, - vertical: DesignTokens.space3, - ), + width: 56, decoration: BoxDecoration( - color: isDark ? DarkDesignTokens.background : DesignTokens.background, + color: color, + borderRadius: DesignTokens.borderRadiusLg, boxShadow: [ BoxShadow( - color: Colors.black.withValues(alpha: 0.08), - blurRadius: 10, - offset: const Offset(0, -2), + color: Colors.black.withValues(alpha: 0.15), + blurRadius: 12, + offset: const Offset(0, 4), ), ], ), - child: Row( + child: Column( children: [ - Expanded( - child: CupertinoButton( - borderRadius: DesignTokens.borderRadiusLg, - color: isDark ? DarkDesignTokens.glass : DesignTokens.card, - onPressed: () => favController.toggleEditMode(), - child: const Text('取消'), + Padding( + padding: const EdgeInsets.only(top: DesignTokens.space2), + child: Icon( + icon, + size: 22, + color: + color == DesignTokens.red || + color == DesignTokens.dynamicPrimary + ? CupertinoColors.white + : (isDark ? DarkDesignTokens.text1 : DesignTokens.text1), ), ), - 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})')), - ], + Padding( + padding: const EdgeInsets.symmetric( + horizontal: DesignTokens.space1, + vertical: 2, + ), + child: Text( + label, + style: TextStyle( + fontSize: 10, + fontWeight: FontWeight.w500, + color: + color == DesignTokens.red || + color == DesignTokens.dynamicPrimary + ? CupertinoColors.white + : (isDark ? DarkDesignTokens.text1 : DesignTokens.text1), ), + textAlign: TextAlign.center, + maxLines: 1, + overflow: TextOverflow.ellipsis, ), ), ], @@ -1026,6 +1033,8 @@ class _FavoritesPageState extends State } void _showSortOptions(bool isDark, FavoritesController favController) { + final currentSort = favController.sortMode.value; + showCupertinoModalPopup( context: context, builder: (BuildContext context) => CupertinoActionSheet( @@ -1036,29 +1045,84 @@ class _FavoritesPageState extends State Navigator.pop(context); favController.setSortMode(FavoritesSortMode.newest); }, - isDefaultAction: true, - child: const Text('最新收藏'), + isDefaultAction: currentSort == FavoritesSortMode.newest, + child: Row( + mainAxisAlignment: MainAxisAlignment.center, + children: [ + const Text('🕐 最新收藏'), + if (currentSort == FavoritesSortMode.newest) ...[ + const SizedBox(width: 8), + Icon( + CupertinoIcons.checkmark_alt, + size: 16, + color: DesignTokens.dynamicPrimary, + ), + ], + ], + ), ), CupertinoActionSheetAction( onPressed: () { Navigator.pop(context); favController.setSortMode(FavoritesSortMode.oldest); }, - child: const Text('最早收藏'), + isDefaultAction: currentSort == FavoritesSortMode.oldest, + child: Row( + mainAxisAlignment: MainAxisAlignment.center, + children: [ + const Text('⏳ 最早收藏'), + if (currentSort == FavoritesSortMode.oldest) ...[ + const SizedBox(width: 8), + Icon( + CupertinoIcons.checkmark_alt, + size: 16, + color: DesignTokens.dynamicPrimary, + ), + ], + ], + ), ), CupertinoActionSheetAction( onPressed: () { Navigator.pop(context); favController.setSortMode(FavoritesSortMode.nameAsc); }, - child: const Text('名称 A-Z'), + isDefaultAction: currentSort == FavoritesSortMode.nameAsc, + child: Row( + mainAxisAlignment: MainAxisAlignment.center, + children: [ + const Text('🔤 名称 A-Z'), + if (currentSort == FavoritesSortMode.nameAsc) ...[ + const SizedBox(width: 8), + Icon( + CupertinoIcons.checkmark_alt, + size: 16, + color: DesignTokens.dynamicPrimary, + ), + ], + ], + ), ), CupertinoActionSheetAction( onPressed: () { Navigator.pop(context); favController.setSortMode(FavoritesSortMode.nameDesc); }, - child: const Text('名称 Z-A'), + isDefaultAction: currentSort == FavoritesSortMode.nameDesc, + child: Row( + mainAxisAlignment: MainAxisAlignment.center, + children: [ + const Text('🔤 名称 Z-A'), + if (currentSort == FavoritesSortMode.nameDesc) ...[ + const SizedBox(width: 8), + Icon( + CupertinoIcons.checkmark_alt, + size: 16, + color: DesignTokens.dynamicPrimary, + ), + ], + ], + ), ), ], cancelButton: CupertinoActionSheetAction( diff --git a/lib/src/pages/tools/cooking/cooking_note_page.dart b/lib/src/pages/tools/cooking/cooking_note_page.dart index 6530b69..e7c56bc 100644 --- a/lib/src/pages/tools/cooking/cooking_note_page.dart +++ b/lib/src/pages/tools/cooking/cooking_note_page.dart @@ -126,16 +126,15 @@ class _CookingNotePageState extends State { trailing: Row( mainAxisSize: MainAxisSize.min, children: [ - if (widget.recipeId.isNotEmpty) - CupertinoButton( - padding: EdgeInsets.zero, - onPressed: () => _showAddDialog(isDark), - child: Icon( - CupertinoIcons.add, - color: DesignTokens.dynamicPrimary, - size: 28, - ), + CupertinoButton( + padding: EdgeInsets.zero, + onPressed: () => _showAddDialog(isDark), + child: Icon( + CupertinoIcons.add, + color: DesignTokens.dynamicPrimary, + size: 28, ), + ), ], ), backgroundColor: isDark diff --git a/lib/src/pages/tools/cooking/order_assistant_page.dart b/lib/src/pages/tools/cooking/order_assistant_page.dart index d48e20e..9b9eb5b 100644 --- a/lib/src/pages/tools/cooking/order_assistant_page.dart +++ b/lib/src/pages/tools/cooking/order_assistant_page.dart @@ -1,4 +1,4 @@ -/* +/* * 文件: order_assistant_page.dart * 名称: 点餐助手主页面 * 作用: 点餐/推单主界面,支持菜品管理、账单生成、二维码分享、文本/图片分享 @@ -1309,6 +1309,7 @@ class _OrderAssistantPageState extends State { final filePath = '${tempDir.path}/order_${order.orderNo}.png'; await File(filePath).writeAsBytes(buffer.asUint8List()); final xFile = XFile(filePath); + if (!mounted) return; final box = context.findRenderObject() as RenderBox?; await Share.shareXFiles( [xFile], diff --git a/lib/src/pages/tools/farm/farm_achievement_page.dart b/lib/src/pages/tools/farm/farm_achievement_page.dart new file mode 100644 index 0000000..b1abebc --- /dev/null +++ b/lib/src/pages/tools/farm/farm_achievement_page.dart @@ -0,0 +1,227 @@ +// 农场成就页面 +// 显示成就列表和进度 +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/farm/farm_achievement_controller.dart'; +import 'package:mom_kitchen/src/controllers/farm/farm_game_controller.dart'; +import 'package:mom_kitchen/src/models/farm/achievement_config.dart'; + +class FarmAchievementPage extends StatefulWidget { + const FarmAchievementPage({super.key}); + + @override + State createState() => _FarmAchievementPageState(); +} + +class _FarmAchievementPageState extends State { + late FarmAchievementController _achievementController; + bool _isInitialized = false; + + @override + void didChangeDependencies() { + super.didChangeDependencies(); + if (!_isInitialized) { + _isInitialized = true; + _achievementController = Get.put(FarmAchievementController()); + Get.put(FarmGameController()); + } + } + + @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), + Expanded(child: _buildAchievementList(isDark)), + ], + ), + ), + ); + } + + Widget _buildHeader(bool isDark) { + return Padding( + padding: const EdgeInsets.all(DesignTokens.space4), + child: Row( + children: [ + GestureDetector( + onTap: () => Get.back(), + child: Container( + width: 40, + height: 40, + decoration: BoxDecoration( + color: isDark + ? DarkDesignTokens.card.withValues(alpha: 0.3) + : DesignTokens.text3.withValues(alpha: 0.08), + borderRadius: DesignTokens.borderRadiusMd, + ), + child: Icon( + CupertinoIcons.back, + size: 20, + color: isDark ? DarkDesignTokens.text1 : DesignTokens.text1, + ), + ), + ), + const SizedBox(width: DesignTokens.space3), + Expanded( + child: Text( + '🏆 成就中心', + style: TextStyle( + fontSize: DesignTokens.fontXl, + fontWeight: FontWeight.w700, + color: isDark ? DarkDesignTokens.text1 : DesignTokens.text1, + ), + ), + ), + Obx( + () => Text( + '${_achievementController.completedAchievements.length}/${_achievementController.allAchievements.length}', + style: TextStyle( + fontSize: DesignTokens.fontMd, + color: isDark ? DarkDesignTokens.text3 : DesignTokens.text3, + ), + ), + ), + ], + ), + ); + } + + Widget _buildAchievementList(bool isDark) { + return ListView.separated( + padding: const EdgeInsets.all(DesignTokens.space4), + itemCount: _achievementController.allAchievements.length, + separatorBuilder: (context, index) => const SizedBox(height: 12), + itemBuilder: (context, index) { + final achievement = _achievementController.allAchievements[index]; + final isCompleted = _achievementController.isCompleted(achievement); + return _buildAchievementCard(achievement, isCompleted, isDark); + }, + ); + } + + Widget _buildAchievementCard( + AchievementConfig achievement, + bool isCompleted, + bool isDark, + ) { + final progress = _achievementController.getProgress(achievement); + + return Container( + padding: const EdgeInsets.all(DesignTokens.space4), + decoration: BoxDecoration( + color: isCompleted + ? (DesignTokens.green.withValues(alpha: isDark ? 0.1 : 0.05)) + : (isDark ? DarkDesignTokens.card : DesignTokens.card), + borderRadius: DesignTokens.borderRadiusLg, + border: Border.all( + color: isCompleted + ? DesignTokens.green.withValues(alpha: 0.3) + : (isDark + ? DarkDesignTokens.glassBorder.withValues(alpha: 0.3) + : DesignTokens.text3.withValues(alpha: 0.08)), + ), + boxShadow: DesignTokens.shadowsSm, + ), + child: Row( + children: [ + Container( + width: 56, + height: 56, + decoration: BoxDecoration( + color: isCompleted + ? DesignTokens.green.withValues(alpha: 0.2) + : (isDark + ? DarkDesignTokens.card.withValues(alpha: 0.3) + : DesignTokens.text3.withValues(alpha: 0.06)), + borderRadius: DesignTokens.borderRadiusMd, + ), + child: Center( + child: Opacity( + opacity: isCompleted ? 1.0 : 0.4, + child: Text( + achievement.emoji, + style: const TextStyle(fontSize: 28), + ), + ), + ), + ), + const SizedBox(width: DesignTokens.space4), + Expanded( + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Row( + children: [ + Text( + achievement.name, + style: TextStyle( + fontSize: DesignTokens.fontLg, + fontWeight: FontWeight.w600, + color: isCompleted + ? DesignTokens.green + : (isDark + ? DarkDesignTokens.text1 + : DesignTokens.text1), + ), + ), + const SizedBox(width: 8), + if (isCompleted) + const Icon( + CupertinoIcons.checkmark_circle_fill, + size: 16, + color: Colors.green, + ), + ], + ), + const SizedBox(height: 4), + Text( + achievement.description, + style: TextStyle( + fontSize: DesignTokens.fontSm, + color: isDark ? DarkDesignTokens.text2 : DesignTokens.text2, + ), + ), + const SizedBox(height: 8), + LinearProgressIndicator( + value: progress, + backgroundColor: isDark + ? DarkDesignTokens.glassBorder.withValues(alpha: 0.2) + : DesignTokens.text3.withValues(alpha: 0.1), + valueColor: AlwaysStoppedAnimation( + isCompleted + ? DesignTokens.green + : DesignTokens.dynamicPrimary, + ), + ), + const SizedBox(height: 4), + Row( + children: [ + Text( + '奖励:${achievement.rewardGold}💰 ${achievement.rewardExp}⭐', + style: TextStyle( + fontSize: DesignTokens.fontXs, + color: isDark + ? DarkDesignTokens.text3 + : DesignTokens.text3, + ), + ), + ], + ), + ], + ), + ), + ], + ), + ); + } +} diff --git a/lib/src/pages/tools/farm/farm_game_page.dart b/lib/src/pages/tools/farm/farm_game_page.dart new file mode 100644 index 0000000..644c8d4 --- /dev/null +++ b/lib/src/pages/tools/farm/farm_game_page.dart @@ -0,0 +1,659 @@ +// 农场主游戏页面 +// 展示菜园网格,提供种植、浇水、收获等操作 +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/farm_config.dart'; +import 'package:mom_kitchen/src/controllers/farm/farm_game_controller.dart'; +import 'package:mom_kitchen/src/utils/farm_share_util.dart'; + +class FarmGamePage extends StatefulWidget { + const FarmGamePage({super.key}); + + @override + State createState() => _FarmGamePageState(); +} + +class _FarmGamePageState extends State { + late FarmGameController _controller; + bool _isInitialized = false; + + @override + void didChangeDependencies() { + super.didChangeDependencies(); + if (!_isInitialized) { + _isInitialized = true; + _controller = Get.put(FarmGameController()); + } + } + + @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), + _buildStatusBar(isDark), + Expanded(child: _buildGardenGrid(isDark)), + _buildActionBar(isDark), + ], + ), + ), + ); + } + + // ==================== 头部导航 ==================== + + Widget _buildHeader(bool isDark) { + return Padding( + padding: const EdgeInsets.fromLTRB( + DesignTokens.space4, + DesignTokens.space3, + DesignTokens.space4, + DesignTokens.space2, + ), + child: Row( + children: [ + _buildBackButton(isDark), + const SizedBox(width: DesignTokens.space3), + Expanded( + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text( + '🌾 小妈菜园', + style: TextStyle( + fontSize: DesignTokens.fontXl, + fontWeight: FontWeight.w700, + color: isDark ? DarkDesignTokens.text1 : DesignTokens.text1, + ), + ), + Text( + '种菜收菜,体验农场乐趣', + style: TextStyle( + fontSize: DesignTokens.fontXs, + color: isDark ? DarkDesignTokens.text3 : DesignTokens.text3, + ), + ), + ], + ), + ), + Row( + children: [ + _buildNavButton( + icon: CupertinoIcons.bag_fill, + onTap: () => Get.toNamed('/farm-inventory'), + isDark: isDark, + ), + const SizedBox(width: 8), + _buildNavButton( + icon: CupertinoIcons.cart_fill, + onTap: () => Get.toNamed('/farm-shop'), + isDark: isDark, + ), + const SizedBox(width: 8), + _buildNavButton( + icon: Icons.emoji_events, + onTap: () => Get.toNamed('/farm-achievement'), + isDark: isDark, + ), + ], + ), + ], + ), + ); + } + + Widget _buildBackButton(bool isDark) { + return GestureDetector( + onTap: () => Get.back(), + child: Container( + width: 40, + height: 40, + decoration: BoxDecoration( + color: isDark + ? DarkDesignTokens.card.withValues(alpha: 0.3) + : DesignTokens.text3.withValues(alpha: 0.08), + borderRadius: DesignTokens.borderRadiusMd, + ), + child: Icon( + CupertinoIcons.back, + size: 20, + color: isDark ? DarkDesignTokens.text1 : DesignTokens.text1, + ), + ), + ); + } + + Widget _buildNavButton({ + required IconData icon, + required VoidCallback onTap, + required bool isDark, + }) { + return GestureDetector( + onTap: onTap, + child: Container( + width: 40, + height: 40, + decoration: BoxDecoration( + color: DesignTokens.dynamicPrimary.withValues(alpha: 0.1), + borderRadius: DesignTokens.borderRadiusMd, + ), + child: Icon(icon, size: 20, color: DesignTokens.dynamicPrimary), + ), + ); + } + + // ==================== 状态栏 ==================== + + Widget _buildStatusBar(bool isDark) { + return Obx(() { + final player = _controller.player.value; + return Container( + padding: const EdgeInsets.symmetric( + horizontal: DesignTokens.space4, + vertical: DesignTokens.space2, + ), + margin: const EdgeInsets.symmetric(horizontal: DesignTokens.space4), + decoration: BoxDecoration( + color: isDark ? DarkDesignTokens.card : DesignTokens.card, + borderRadius: DesignTokens.borderRadiusMd, + border: Border.all( + color: isDark + ? DarkDesignTokens.glassBorder.withValues(alpha: 0.3) + : DesignTokens.text3.withValues(alpha: 0.08), + ), + ), + child: Row( + children: [ + _buildStatusItem( + icon: '⭐', + value: 'Lv.${player.level}', + progress: player.expProgress, + isDark: isDark, + ), + const SizedBox(width: DesignTokens.space3), + _buildStatusItem( + icon: '💰', + value: '${player.gold}', + isDark: isDark, + ), + const SizedBox(width: DesignTokens.space3), + _buildStatusItem( + icon: '💎', + value: '${player.diamond}', + isDark: isDark, + ), + const Spacer(), + _buildStatusItem( + icon: '🌾', + value: '${player.totalHarvest}', + label: '收获', + isDark: isDark, + ), + ], + ), + ); + }); + } + + Widget _buildStatusItem({ + required String icon, + required String value, + double? progress, + String? label, + required bool isDark, + }) { + return Row( + mainAxisSize: MainAxisSize.min, + children: [ + Text(icon, style: const TextStyle(fontSize: 16)), + const SizedBox(width: 4), + Text( + value, + style: TextStyle( + fontSize: DesignTokens.fontMd, + fontWeight: FontWeight.w600, + color: isDark ? DarkDesignTokens.text1 : DesignTokens.text1, + ), + ), + if (progress != null) ...[ + const SizedBox(width: 4), + SizedBox( + width: 40, + child: LinearProgressIndicator( + value: progress, + backgroundColor: isDark + ? DarkDesignTokens.glassBorder.withValues(alpha: 0.2) + : DesignTokens.text3.withValues(alpha: 0.1), + valueColor: AlwaysStoppedAnimation(DesignTokens.dynamicPrimary), + ), + ), + ], + if (label != null) ...[ + const SizedBox(width: 4), + Text( + label, + style: TextStyle( + fontSize: DesignTokens.fontXs, + color: isDark ? DarkDesignTokens.text3 : DesignTokens.text3, + ), + ), + ], + ], + ); + } + + // ==================== 菜园网格 ==================== + + Widget _buildGardenGrid(bool isDark) { + return Padding( + padding: const EdgeInsets.all(DesignTokens.space4), + child: LayoutBuilder( + builder: (context, constraints) { + final crossAxisCount = constraints.maxWidth > 600 ? 4 : 3; + + return Obx(() { + final landList = _controller.lands; + final landCount = landList.length; + + if (landCount == 0) { + 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.text3 + : DesignTokens.text3, + ), + ), + ], + ), + ); + } + + return GridView.builder( + physics: const BouncingScrollPhysics(), + gridDelegate: SliverGridDelegateWithFixedCrossAxisCount( + crossAxisCount: crossAxisCount, + mainAxisSpacing: 12, + crossAxisSpacing: 12, + childAspectRatio: 1, + ), + itemCount: landCount, + itemBuilder: (context, index) { + final land = landList[index]; + return GestureDetector( + onTap: () => _showLandActionSheet(land, isDark), + child: _buildLandWidget(land, isDark), + ); + }, + ); + }); + }, + ), + ); + } + + Widget _buildLandWidget(dynamic land, bool isDark) { + return AnimatedContainer( + duration: DesignTokens.durationNormal, + decoration: BoxDecoration( + color: land.isUnlocked + ? (isDark ? const Color(0xFF3D2B1F) : const Color(0xFFDEB887)) + : (isDark + ? DarkDesignTokens.card + : DesignTokens.text3.withValues(alpha: 0.1)), + borderRadius: DesignTokens.borderRadiusMd, + border: Border.all( + color: land.isUnlocked + ? (isDark ? const Color(0xFF5C4033) : const Color(0xFFCD853F)) + : (isDark + ? DarkDesignTokens.glassBorder + : DesignTokens.text3.withValues(alpha: 0.2)), + width: 2, + ), + boxShadow: [ + BoxShadow( + color: isDark + ? Colors.black.withValues(alpha: 0.3) + : Colors.black.withValues(alpha: 0.1), + blurRadius: 8, + offset: const Offset(0, 4), + ), + ], + ), + child: land.isUnlocked + ? _buildLandContent(land, isDark) + : _buildLockedOverlay(isDark), + ); + } + + Widget _buildLandContent(dynamic land, bool isDark) { + if (land.cropId == null) { + return const Center(child: Text('🟫', style: TextStyle(fontSize: 32))); + } + + return Stack( + children: [ + Center( + child: AnimatedSwitcher( + duration: DesignTokens.durationNormal, + child: Text( + land.currentDisplayEmoji, + key: ValueKey(land.currentDisplayEmoji), + style: const TextStyle(fontSize: 36), + ), + ), + ), + if (land.needWater && !land.isWithered) + Positioned( + top: 4, + right: 4, + child: Container( + padding: const EdgeInsets.all(2), + decoration: BoxDecoration( + color: Colors.blue.withValues(alpha: 0.8), + borderRadius: DesignTokens.borderRadiusFull, + ), + child: const Text('💧', style: TextStyle(fontSize: 10)), + ), + ), + if (land.isReady) + Positioned( + top: 4, + right: 4, + child: Container( + padding: const EdgeInsets.all(2), + decoration: BoxDecoration( + color: Colors.green.withValues(alpha: 0.8), + borderRadius: DesignTokens.borderRadiusFull, + ), + child: const Text('✨', style: TextStyle(fontSize: 10)), + ), + ), + if (land.isWithered) + Positioned.fill( + child: Container( + decoration: BoxDecoration( + color: Colors.black.withValues(alpha: 0.3), + borderRadius: DesignTokens.borderRadiusMd, + ), + child: const Center( + child: Text('🥀', style: TextStyle(fontSize: 24)), + ), + ), + ), + ], + ); + } + + Widget _buildLockedOverlay(bool isDark) { + return Center( + child: Column( + mainAxisAlignment: MainAxisAlignment.center, + children: [ + const Text('🔒', style: TextStyle(fontSize: 24)), + const SizedBox(height: 4), + Text( + '未解锁', + style: TextStyle( + fontSize: DesignTokens.fontXs, + color: isDark ? DarkDesignTokens.text3 : DesignTokens.text3, + ), + ), + ], + ), + ); + } + + // ==================== 操作面板 ==================== + + void _showLandActionSheet(dynamic land, bool isDark) { + showCupertinoModalPopup( + context: context, + builder: (context) { + return CupertinoActionSheet( + title: Text('土地 ${land.landId + 1}'), + actions: [ + if (land.cropId == null && land.isUnlocked) + CupertinoActionSheetAction( + onPressed: () { + Get.back(); + _showPlantSelector(land.landId); + }, + child: const Text('🌱 播种'), + ), + if (land.needWater && !land.isWithered) + CupertinoActionSheetAction( + onPressed: () { + Get.back(); + _controller.waterLand(land.landId); + }, + child: const Text('💧 浇水'), + ), + if (land.isReady) + CupertinoActionSheetAction( + onPressed: () { + Get.back(); + _controller.harvestCrop(land.landId); + }, + child: const Text('🎉 收获'), + ), + if (land.isWithered) + CupertinoActionSheetAction( + onPressed: () { + Get.back(); + _controller.clearWitheredLand(land.landId); + }, + child: const Text('🧹 清理'), + ), + if (!land.isUnlocked) + CupertinoActionSheetAction( + onPressed: () { + Get.back(); + _confirmUnlockLand(land.landId); + }, + child: const Text('🔓 解锁土地'), + ), + ], + cancelButton: CupertinoActionSheetAction( + isDefaultAction: true, + onPressed: () => Get.back(), + child: const Text('取消'), + ), + ); + }, + ); + } + + void _showPlantSelector(int landId) { + final seeds = _controller.inventory + .where((i) => i.isSeed && i.quantity > 0) + .toList(); + + if (seeds.isEmpty) { + Get.snackbar('提示', '背包中没有种子,请前往商店购买'); + return; + } + + showCupertinoModalPopup( + context: context, + builder: (context) { + return CupertinoActionSheet( + title: const Text('选择要播种的种子'), + actions: seeds.map((seed) { + return CupertinoActionSheetAction( + onPressed: () { + Get.back(); + final cropId = seed.itemId.replaceAll('_seed', ''); + _controller.plantCrop(landId: landId, cropId: cropId); + }, + child: Text('${seed.emoji} ${seed.itemName} x${seed.quantity}'), + ); + }).toList(), + cancelButton: CupertinoActionSheetAction( + isDefaultAction: true, + onPressed: () => Get.back(), + child: const Text('取消'), + ), + ); + }, + ); + } + + void _confirmUnlockLand(int landId) { + showCupertinoDialog( + context: context, + builder: (context) { + return CupertinoAlertDialog( + title: const Text('🔓 解锁土地'), + content: Text('解锁这块土地需要 ${FarmConfig.unlockLandCost} 金币,是否确认解锁?'), + actions: [ + CupertinoDialogAction( + child: const Text('取消'), + onPressed: () => Get.back(), + ), + CupertinoDialogAction( + isDefaultAction: true, + child: const Text('确认'), + onPressed: () { + Get.back(); + _controller.unlockLand(landId); + }, + ), + ], + ); + }, + ); + } + + // ==================== 底部操作栏 ==================== + + Widget _buildActionBar(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.withValues(alpha: 0.3) + : DesignTokens.text3.withValues(alpha: 0.08), + ), + ), + ), + child: Row( + children: [ + if (FarmConfig.debugMode) _buildDebugButton(isDark), + const Spacer(), + ElevatedButton.icon( + onPressed: () => _showShareSheet(), + icon: const Icon(CupertinoIcons.share), + label: const Text('分享收获'), + style: ElevatedButton.styleFrom( + backgroundColor: DesignTokens.dynamicPrimary, + foregroundColor: CupertinoColors.white, + padding: const EdgeInsets.symmetric( + horizontal: DesignTokens.space4, + vertical: DesignTokens.space3, + ), + shape: RoundedRectangleBorder( + borderRadius: DesignTokens.borderRadiusMd, + ), + ), + ), + ], + ), + ); + } + + Widget _buildDebugButton(bool isDark) { + return CupertinoButton( + padding: EdgeInsets.zero, + onPressed: () => _showDebugSheet(), + child: Icon( + CupertinoIcons.hammer, + color: isDark ? DarkDesignTokens.text3 : DesignTokens.text3, + ), + ); + } + + void _showDebugSheet() { + showCupertinoModalPopup( + context: context, + builder: (context) { + return CupertinoActionSheet( + title: const Text('🐛 调试功能'), + message: const Text('以下功能仅用于开发测试'), + actions: [ + CupertinoActionSheetAction( + onPressed: () { + Get.back(); + _controller.debugAddGold(); + }, + child: const Text('💰 添加 1000 金币'), + ), + CupertinoActionSheetAction( + onPressed: () { + Get.back(); + _controller.debugSpeedUp(); + }, + child: const Text('⚡ 加速所有作物'), + ), + CupertinoActionSheetAction( + onPressed: () { + Get.back(); + _controller.debugUnlockAll(); + }, + child: const Text('🔓 解锁所有内容'), + ), + CupertinoActionSheetAction( + isDestructiveAction: true, + onPressed: () { + Get.back(); + _controller.debugReset(); + }, + child: const Text('🗑️ 重置游戏数据'), + ), + ], + cancelButton: CupertinoActionSheetAction( + isDefaultAction: true, + onPressed: () => Get.back(), + child: const Text('关闭'), + ), + ); + }, + ); + } + + void _showShareSheet() { + final player = _controller.player.value; + final cropEmojis = _controller.lands + .where((l) => l.cropId != null) + .map((l) => l.currentDisplayEmoji) + .take(8) + .toList(); + + FarmShareUtil().shareFarmProgress( + playerName: player.playerName, + level: player.level, + gold: player.gold, + totalHarvest: player.totalHarvest, + cropEmojis: cropEmojis, + ); + } +} diff --git a/lib/src/pages/tools/farm/farm_inventory_page.dart b/lib/src/pages/tools/farm/farm_inventory_page.dart new file mode 100644 index 0000000..83d5629 --- /dev/null +++ b/lib/src/pages/tools/farm/farm_inventory_page.dart @@ -0,0 +1,232 @@ +// 农场背包页面 +// 显示背包物品,支持分类查看 +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/farm/farm_inventory_controller.dart'; +import 'package:mom_kitchen/src/controllers/farm/farm_game_controller.dart'; +import 'package:mom_kitchen/src/models/farm/inventory_item.dart'; + +class FarmInventoryPage extends StatefulWidget { + const FarmInventoryPage({super.key}); + + @override + State createState() => _FarmInventoryPageState(); +} + +class _FarmInventoryPageState extends State { + late FarmInventoryController _controller; + bool _isInitialized = false; + + @override + void didChangeDependencies() { + super.didChangeDependencies(); + if (!_isInitialized) { + _isInitialized = true; + _controller = Get.put(FarmInventoryController()); + Get.put(FarmGameController()); + } + } + + @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), + _buildTabs(isDark), + Expanded(child: _buildInventoryList(isDark)), + ], + ), + ), + ); + } + + Widget _buildHeader(bool isDark) { + return Padding( + padding: const EdgeInsets.all(DesignTokens.space4), + child: Row( + children: [ + GestureDetector( + onTap: () => Get.back(), + child: Container( + width: 40, + height: 40, + decoration: BoxDecoration( + color: isDark + ? DarkDesignTokens.card.withValues(alpha: 0.3) + : DesignTokens.text3.withValues(alpha: 0.08), + borderRadius: DesignTokens.borderRadiusMd, + ), + child: Icon(CupertinoIcons.back, size: 20, + color: isDark ? DarkDesignTokens.text1 : DesignTokens.text1), + ), + ), + const SizedBox(width: DesignTokens.space3), + Expanded( + child: Text( + '🎒 我的背包', + style: TextStyle( + fontSize: DesignTokens.fontXl, + fontWeight: FontWeight.w700, + color: isDark ? DarkDesignTokens.text1 : DesignTokens.text1, + ), + ), + ), + Obx(() => Text( + '共 ${_controller.totalItems} 件', + style: TextStyle( + fontSize: DesignTokens.fontMd, + color: isDark ? DarkDesignTokens.text3 : DesignTokens.text3, + ), + )), + ], + ), + ); + } + + Widget _buildTabs(bool isDark) { + return Obx(() { + final tabs = [ + {'id': 'seed', 'label': '🌱 种子', 'count': _controller.seeds.length}, + {'id': 'fruit', 'label': '🍎 果实', 'count': _controller.fruits.length}, + {'id': 'tool', 'label': '🔧 道具', 'count': _controller.tools.length}, + ]; + + return Container( + margin: const EdgeInsets.symmetric(horizontal: DesignTokens.space4), + padding: const EdgeInsets.all(4), + decoration: BoxDecoration( + color: isDark ? DarkDesignTokens.segmentedBg : DesignTokens.segmentedBg, + borderRadius: DesignTokens.borderRadiusMd, + ), + child: Row( + children: tabs.map((tab) { + final isSelected = _controller.selectedTab.value == tab['id']; + return Expanded( + child: GestureDetector( + onTap: () => _controller.selectTab(tab['id'] as String), + child: Container( + padding: const EdgeInsets.symmetric(vertical: 10), + decoration: BoxDecoration( + color: isSelected + ? (isDark ? DarkDesignTokens.card : DesignTokens.card) + : Colors.transparent, + borderRadius: DesignTokens.borderRadiusSm, + boxShadow: isSelected ? DesignTokens.shadowsSm : null, + ), + child: Center( + child: Text( + '${tab['label']} (${tab['count']})', + style: TextStyle( + fontSize: DesignTokens.fontSm, + fontWeight: isSelected ? FontWeight.w600 : FontWeight.normal, + color: isDark ? DarkDesignTokens.text1 : DesignTokens.text1, + ), + ), + ), + ), + ), + ); + }).toList(), + ), + ); + }); + } + + Widget _buildInventoryList(bool isDark) { + return Obx(() { + final items = _controller.filteredItems; + + if (items.isEmpty) { + return Center( + child: Column( + mainAxisAlignment: MainAxisAlignment.center, + children: [ + const Text('📭', style: TextStyle(fontSize: 64)), + 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 GridView.builder( + padding: const EdgeInsets.all(DesignTokens.space4), + gridDelegate: const SliverGridDelegateWithFixedCrossAxisCount( + crossAxisCount: 3, + mainAxisSpacing: 12, + crossAxisSpacing: 12, + childAspectRatio: 0.8, + ), + itemCount: items.length, + itemBuilder: (context, index) { + return _buildItemCard(items[index], isDark); + }, + ); + }); + } + + Widget _buildItemCard(InventoryItem item, bool isDark) { + return Container( + padding: const EdgeInsets.all(DesignTokens.space3), + decoration: BoxDecoration( + color: isDark ? DarkDesignTokens.card : DesignTokens.card, + borderRadius: DesignTokens.borderRadiusLg, + border: Border.all( + color: isDark + ? DarkDesignTokens.glassBorder.withValues(alpha: 0.3) + : DesignTokens.text3.withValues(alpha: 0.08), + ), + boxShadow: DesignTokens.shadowsSm, + ), + child: Column( + mainAxisAlignment: MainAxisAlignment.center, + children: [ + Text(item.emoji, style: const TextStyle(fontSize: 40)), + const SizedBox(height: 8), + Text( + item.itemName, + style: TextStyle( + fontSize: DesignTokens.fontSm, + fontWeight: FontWeight.w600, + color: isDark ? DarkDesignTokens.text1 : DesignTokens.text1, + ), + textAlign: TextAlign.center, + maxLines: 2, + overflow: TextOverflow.ellipsis, + ), + const SizedBox(height: 4), + Text( + 'x${item.quantity}', + style: TextStyle( + fontSize: DesignTokens.fontLg, + fontWeight: FontWeight.w700, + color: DesignTokens.dynamicPrimary, + ), + ), + ], + ), + ); + } +} diff --git a/lib/src/pages/tools/farm/farm_shop_page.dart b/lib/src/pages/tools/farm/farm_shop_page.dart new file mode 100644 index 0000000..cba82e0 --- /dev/null +++ b/lib/src/pages/tools/farm/farm_shop_page.dart @@ -0,0 +1,228 @@ +// 农场商店页面 +// 显示可购买的种子列表,支持购买操作 +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/farm/farm_shop_controller.dart'; +import 'package:mom_kitchen/src/controllers/farm/farm_game_controller.dart'; +import 'package:mom_kitchen/src/models/farm/crop_config.dart'; + +class FarmShopPage extends StatefulWidget { + const FarmShopPage({super.key}); + + @override + State createState() => _FarmShopPageState(); +} + +class _FarmShopPageState extends State { + late FarmShopController _shopController; + late FarmGameController _gameController; + bool _isInitialized = false; + + @override + void didChangeDependencies() { + super.didChangeDependencies(); + if (!_isInitialized) { + _isInitialized = true; + _shopController = Get.put(FarmShopController()); + _gameController = Get.find(); + } + } + + @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), + Expanded(child: _buildShopList(isDark)), + ], + ), + ), + ); + } + + Widget _buildHeader(bool isDark) { + return Padding( + padding: const EdgeInsets.all(DesignTokens.space4), + child: Row( + children: [ + GestureDetector( + onTap: () => Get.back(), + child: Container( + width: 40, + height: 40, + decoration: BoxDecoration( + color: isDark + ? DarkDesignTokens.card.withValues(alpha: 0.3) + : DesignTokens.text3.withValues(alpha: 0.08), + borderRadius: DesignTokens.borderRadiusMd, + ), + child: Icon(CupertinoIcons.back, size: 20, + color: isDark ? DarkDesignTokens.text1 : DesignTokens.text1), + ), + ), + const SizedBox(width: DesignTokens.space3), + Expanded( + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text( + '🏪 种子商店', + style: TextStyle( + fontSize: DesignTokens.fontXl, + fontWeight: FontWeight.w700, + color: isDark ? DarkDesignTokens.text1 : DesignTokens.text1, + ), + ), + Obx(() => Text( + '💰 ${_gameController.player.value.gold} 金币', + style: TextStyle( + fontSize: DesignTokens.fontSm, + color: isDark ? DarkDesignTokens.text3 : DesignTokens.text3, + ), + )), + ], + ), + ), + ], + ), + ); + } + + Widget _buildShopList(bool isDark) { + return Obx(() { + final crops = _shopController.availableCrops; + return ListView.separated( + padding: const EdgeInsets.all(DesignTokens.space4), + itemCount: crops.length, + separatorBuilder: (context, index) => const SizedBox(height: 12), + itemBuilder: (context, index) { + final crop = crops[index]; + final isUnlocked = _gameController.player.value.unlockedCrops.contains(crop.id); + return _buildCropCard(crop, isUnlocked, isDark); + }, + ); + }); + } + + Widget _buildCropCard(CropConfig crop, bool isUnlocked, bool isDark) { + return Container( + padding: const EdgeInsets.all(DesignTokens.space4), + decoration: BoxDecoration( + color: isDark ? DarkDesignTokens.card : DesignTokens.card, + borderRadius: DesignTokens.borderRadiusLg, + border: Border.all( + color: isDark + ? DarkDesignTokens.glassBorder.withValues(alpha: 0.3) + : DesignTokens.text3.withValues(alpha: 0.08), + ), + boxShadow: DesignTokens.shadowsMd, + ), + child: Row( + children: [ + Container( + width: 64, + height: 64, + decoration: BoxDecoration( + color: DesignTokens.dynamicPrimary.withValues(alpha: 0.1), + borderRadius: DesignTokens.borderRadiusMd, + ), + child: Center( + child: Text(crop.emoji, style: const TextStyle(fontSize: 36)), + ), + ), + const SizedBox(width: DesignTokens.space4), + Expanded( + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Row( + children: [ + Text( + crop.name, + style: TextStyle( + fontSize: DesignTokens.fontLg, + fontWeight: FontWeight.w600, + color: isDark ? DarkDesignTokens.text1 : DesignTokens.text1, + ), + ), + if (!isUnlocked) ...[ + const SizedBox(width: 8), + Container( + padding: const EdgeInsets.symmetric( + horizontal: 8, + vertical: 2, + ), + decoration: BoxDecoration( + color: DesignTokens.orange.withValues(alpha: 0.2), + borderRadius: DesignTokens.borderRadiusSm, + ), + child: Text( + 'Lv.${crop.unlockLevel} 解锁', + style: TextStyle( + fontSize: DesignTokens.fontXs, + color: DesignTokens.orange, + ), + ), + ), + ], + ], + ), + const SizedBox(height: 8), + Row( + children: [ + _buildInfoTag('⏱️', '${crop.growthTime} 分钟', isDark), + const SizedBox(width: 8), + _buildInfoTag('💰', '收获 ${crop.harvestPrice}', isDark), + const SizedBox(width: 8), + _buildInfoTag('⭐', '+${crop.harvestExp} EXP', isDark), + ], + ), + ], + ), + ), + const SizedBox(width: DesignTokens.space3), + SizedBox( + width: 80, + child: CupertinoButton( + padding: EdgeInsets.zero, + color: isUnlocked ? DesignTokens.dynamicPrimary : DesignTokens.text3, + borderRadius: DesignTokens.borderRadiusMd, + onPressed: isUnlocked ? () => _shopController.buySeed(crop.id) : null, + child: Text( + '${crop.seedPrice}💰', + style: const TextStyle( + fontSize: DesignTokens.fontMd, + fontWeight: FontWeight.w600, + color: CupertinoColors.white, + ), + ), + ), + ), + ], + ), + ); + } + + Widget _buildInfoTag(String icon, String text, bool isDark) { + return Row( + mainAxisSize: MainAxisSize.min, + children: [ + Text(icon, style: const TextStyle(fontSize: 12)), + const SizedBox(width: 4), + Text( + text, + style: TextStyle( + fontSize: DesignTokens.fontSm, + color: isDark ? DarkDesignTokens.text2 : DesignTokens.text2, + ), + ), + ], + ); + } +} diff --git a/lib/src/pages/tools/health/safe_period_calculator_page.dart b/lib/src/pages/tools/health/safe_period_calculator_page.dart index 297b821..30b991b 100644 --- a/lib/src/pages/tools/health/safe_period_calculator_page.dart +++ b/lib/src/pages/tools/health/safe_period_calculator_page.dart @@ -118,9 +118,7 @@ class _SafePeriodCalculatorPageState extends State { buffer.writeln( '📅 上次月经:${_lastPeriodDate.year}年${_lastPeriodDate.month}月${_lastPeriodDate.day}日', ); - buffer.writeln( - '🔄 月经周期:${_shortestCycle}~${_longestCycle}天(平均${_avgCycle}天)', - ); + buffer.writeln('🔄 月经周期:$_shortestCycle~$_longestCycle天(平均$_avgCycle天)'); buffer.writeln(); buffer.writeln('📊 计算结果:'); @@ -1011,7 +1009,7 @@ class _SafePeriodCalculatorPageState extends State { horizontal: DesignTokens.space3, vertical: DesignTokens.space1, ), - minSize: 0, + minimumSize: Size.zero, borderRadius: BorderRadius.circular(DesignTokens.radiusSm), color: DesignTokens.dynamicPrimary.withValues(alpha: 0.1), onPressed: () { diff --git a/lib/src/pages/tools/ingredient_manage_page.dart b/lib/src/pages/tools/ingredient_manage_page.dart index 57fb70d..3e7086d 100644 --- a/lib/src/pages/tools/ingredient_manage_page.dart +++ b/lib/src/pages/tools/ingredient_manage_page.dart @@ -745,7 +745,7 @@ class _BottleDetailSheetState extends State<_BottleDetailSheet> { child: Column( children: [ Text( - '${_bottle.currentAmount.toStringAsFixed(1)}', + _bottle.currentAmount.toStringAsFixed(1), style: TextStyle( fontSize: DesignTokens.fontXl, fontWeight: FontWeight.w700, diff --git a/lib/src/pages/tools/ranking/dish_ranking_page.dart b/lib/src/pages/tools/ranking/dish_ranking_page.dart index 810df63..584d25e 100644 --- a/lib/src/pages/tools/ranking/dish_ranking_page.dart +++ b/lib/src/pages/tools/ranking/dish_ranking_page.dart @@ -430,9 +430,9 @@ class _DishRankingPageState extends State content: const Text('将清除所有层级中的菜品,此操作不可撤销。'), actions: [ CupertinoDialogAction( - child: const Text('取消'), isDefaultAction: true, onPressed: () => Navigator.pop(ctx), + child: const Text('取消'), ), CupertinoDialogAction( isDestructiveAction: true, diff --git a/lib/src/repositories/recipe_repository.dart b/lib/src/repositories/recipe_repository.dart index 3b7c8dd..b85e0f2 100644 --- a/lib/src/repositories/recipe_repository.dart +++ b/lib/src/repositories/recipe_repository.dart @@ -873,8 +873,9 @@ class RecipeRepository { 'limit': limit, }, ); - if (response.data == null) + if (response.data == null) { return {'list': [], 'total': 0, 'has_more': false}; + } final raw = response.data as Map; if (raw['code'] != 200 || raw['data'] == null) { return {'list': [], 'total': 0, 'has_more': false}; @@ -974,15 +975,21 @@ class RecipeRepository { if (excludeCookings != null && excludeCookings.isNotEmpty) { params['exclude_cooking'] = excludeCookings.join(','); } - if (excludeCategoryName != null) + if (excludeCategoryName != null) { params['exclude_category_name'] = excludeCategoryName; - if (excludeTasteName != null) + } + if (excludeTasteName != null) { params['exclude_taste_name'] = excludeTasteName; - if (excludeCookingName != null) + } + if (excludeCookingName != null) { params['exclude_cooking_name'] = excludeCookingName; - if (excludeIngredient != null) + } + if (excludeIngredient != null) { params['exclude_ingredient'] = excludeIngredient; - if (excludeAllergen != null) params['exclude_allergen'] = excludeAllergen; + } + if (excludeAllergen != null) { + params['exclude_allergen'] = excludeAllergen; + } if (excludeAuthors != null && excludeAuthors.isNotEmpty) { params['exclude_author'] = excludeAuthors.join(','); } diff --git a/lib/src/services/api/api_service.dart b/lib/src/services/api/api_service.dart index ec9e555..e583638 100644 --- a/lib/src/services/api/api_service.dart +++ b/lib/src/services/api/api_service.dart @@ -46,6 +46,16 @@ class ApiService { bool get isDnsReachable => _dnsReachable; + /// 重置DNS预检状态,供后台恢复时调用 + /// 重置后下次请求会重新执行DNS预检 + void resetDnsCheck() { + _dnsChecked = false; + _dnsReachable = true; + _lastDnsCheckTime = null; + _dnsReadyCompleter = null; + debugPrint('ApiService: DNS预检状态已重置,下次请求将重新检查'); + } + Future waitForDnsReady() async { if (_dnsChecked) return; _dnsReadyCompleter ??= Completer(); @@ -572,8 +582,9 @@ class ApiService { if (e.type == DioExceptionType.connectionError) return true; if (e.type == DioExceptionType.connectionTimeout) return true; if (e.type == DioExceptionType.unknown && - e.message?.contains('onerror') == true) + e.message?.contains('onerror') == true) { return true; + } final response = e.response; if (response != null) { final statusCode = response.statusCode; diff --git a/lib/src/services/connectivity_service.dart b/lib/src/services/connectivity_service.dart index 59de68d..0efba0f 100644 --- a/lib/src/services/connectivity_service.dart +++ b/lib/src/services/connectivity_service.dart @@ -1,12 +1,17 @@ // 2026-04-09 | ConnectivityService | 网络连接状态服务 | Web端默认在线 +// 2026-04-18 | 新增网络恢复回调机制,支持OfflineService等订阅恢复事件 +// 2026-04-18 | 修复后台恢复假离线:监听App生命周期,resumed时重新检查网络+重置DNS预检 import 'dart:async'; import 'package:connectivity_plus/connectivity_plus.dart'; import 'package:flutter/foundation.dart'; +import 'package:flutter/widgets.dart'; import 'package:get/get.dart'; enum ConnectivityStatus { online, offline, unknown } -class ConnectivityService extends GetxService { +typedef OnNetworkRestoredCallback = Future Function(); + +class ConnectivityService extends GetxService with WidgetsBindingObserver { final Connectivity _connectivity = Connectivity(); StreamSubscription? _subscription; @@ -14,8 +19,27 @@ class ConnectivityService extends GetxService { ConnectivityStatus.unknown, ); + final List _onRestoredCallbacks = []; + final List _onNetworkRestoredAsyncCallbacks = []; + static ConnectivityService get to => Get.find(); + void addOnRestoredCallback(VoidCallback callback) { + _onRestoredCallbacks.add(callback); + } + + void removeOnRestoredCallback(VoidCallback callback) { + _onRestoredCallbacks.remove(callback); + } + + void addOnNetworkRestoredAsync(OnNetworkRestoredCallback callback) { + _onNetworkRestoredAsyncCallbacks.add(callback); + } + + void removeOnNetworkRestoredAsync(OnNetworkRestoredCallback callback) { + _onNetworkRestoredAsyncCallbacks.remove(callback); + } + @override void onInit() { super.onInit(); @@ -23,10 +47,44 @@ class ConnectivityService extends GetxService { status.value = ConnectivityStatus.online; return; } + WidgetsBinding.instance.addObserver(this); _subscription = _connectivity.onConnectivityChanged.listen(_onChanged); _checkInitial(); } + @override + void didChangeAppLifecycleState(AppLifecycleState state) { + if (state == AppLifecycleState.resumed) { + _onAppResumed(); + } + } + + Future _onAppResumed() async { + if (kIsWeb) return; + + final wasOffline = status.value == ConnectivityStatus.offline; + + try { + final result = await _connectivity.checkConnectivity().timeout( + const Duration(seconds: 3), + ); + _onChanged(result); + } catch (e) { + debugPrint('ConnectivityService: resumed check failed: $e'); + } + + if (wasOffline && status.value == ConnectivityStatus.online) { + debugPrint('ConnectivityService: 后台恢复后网络已恢复,触发异步恢复回调'); + for (final cb in _onNetworkRestoredAsyncCallbacks) { + try { + await cb(); + } catch (e) { + debugPrint('ConnectivityService: 异步恢复回调执行失败: $e'); + } + } + } + } + Future _checkInitial() async { if (kIsWeb) return; final result = await _connectivity.checkConnectivity(); @@ -54,6 +112,9 @@ class ConnectivityService extends GetxService { snackPosition: SnackPosition.TOP, duration: const Duration(seconds: 2), ); + for (final cb in _onRestoredCallbacks) { + cb(); + } } bool get isOnline => kIsWeb || status.value == ConnectivityStatus.online; @@ -67,6 +128,7 @@ class ConnectivityService extends GetxService { @override void onClose() { + WidgetsBinding.instance.removeObserver(this); _subscription?.cancel(); super.onClose(); } diff --git a/lib/src/services/core/app_service.dart b/lib/src/services/core/app_service.dart index 4196ac1..7081171 100644 --- a/lib/src/services/core/app_service.dart +++ b/lib/src/services/core/app_service.dart @@ -86,6 +86,10 @@ class AppService { if (!kIsWeb) { debugPrint('🌐 初始化网络连接服务...'); Get.put(connectivity!); + connectivity!.addOnNetworkRestoredAsync(() async { + api.resetDnsCheck(); + await api.preCheckDns(); + }); debugPrint('✅ 网络连接服务初始化完成'); } } diff --git a/lib/src/services/data/data_export_service.dart b/lib/src/services/data/data_export_service.dart new file mode 100644 index 0000000..cddd2b8 --- /dev/null +++ b/lib/src/services/data/data_export_service.dart @@ -0,0 +1,465 @@ +// 2026-04-18 | DataExportService | 数据导出/导入服务 | 统一管理6种数据源导出导入,支持JSON/CSV/Markdown格式 +// 2026-04-18 | 初始创建:支持收藏/购物清单/饮食记录/烹饪笔记/每周菜单/浏览记录导出 +// 2026-04-18 | 新增数据导入功能:支持JSON格式导入,与导出格式一致 +import 'dart:convert'; +import 'dart:io'; +import 'package:flutter/foundation.dart'; +import 'package:get/get.dart'; +import 'package:path_provider/path_provider.dart'; +import 'package:share_plus/share_plus.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/controllers/data/meal_record_controller.dart'; +import 'package:mom_kitchen/src/controllers/data/cooking_note_controller.dart'; +import 'package:mom_kitchen/src/controllers/data/weekly_menu_controller.dart'; +import 'package:mom_kitchen/src/controllers/data/browse_history_controller.dart'; + +enum ExportFormat { json, csv, markdown } + +enum DataSource { + favorites('❤️ 收藏', 'favorites'), + shoppingList('🛒 购物清单', 'shopping_list'), + mealRecords('🍽️ 饮食记录', 'meal_records'), + cookingNotes('📝 烹饪笔记', 'cooking_notes'), + weeklyMenu('📅 每周菜单', 'weekly_menu'), + browseHistory('👀 浏览记录', 'browse_history'); + + final String label; + final String fileName; + const DataSource(this.label, this.fileName); +} + +class DataExportService extends GetxService { + static DataExportService get to => Get.find(); + + final Rx selectedFormat = ExportFormat.json.obs; + final RxSet selectedSources = {}.obs; + final RxBool isExporting = false.obs; + final RxDouble exportProgress = 0.0.obs; + + String getFormatExtension(ExportFormat format) { + switch (format) { + case ExportFormat.json: + return '.json'; + case ExportFormat.csv: + return '.csv'; + case ExportFormat.markdown: + return '.md'; + } + } + + String getFormatLabel(ExportFormat format) { + switch (format) { + case ExportFormat.json: + return 'JSON'; + case ExportFormat.csv: + return 'CSV'; + case ExportFormat.markdown: + return 'Markdown'; + } + } + + int getDataCount(DataSource source) { + try { + switch (source) { + case DataSource.favorites: + if (!Get.isRegistered()) return 0; + return Get.find().count; + case DataSource.shoppingList: + if (!Get.isRegistered()) return 0; + return Get.find().totalCount; + case DataSource.mealRecords: + if (!Get.isRegistered()) return 0; + return Get.find().recordedDates.length; + case DataSource.cookingNotes: + if (!Get.isRegistered()) return 0; + return CookingNoteController.to.notes.length; + case DataSource.weeklyMenu: + if (!Get.isRegistered()) return 0; + return Get.find().completedMeals; + case DataSource.browseHistory: + if (!Get.isRegistered()) return 0; + return BrowseHistoryController.to.history.length; + } + } catch (e) { + debugPrint('DataExportService: 获取数据数量失败: $e'); + return 0; + } + } + + String _exportSource(DataSource source, ExportFormat format) { + try { + switch (source) { + case DataSource.favorites: + if (!Get.isRegistered()) return ''; + final ctrl = Get.find(); + switch (format) { + case ExportFormat.json: + return ctrl.exportToJson(); + case ExportFormat.csv: + return ctrl.exportToCsv(); + case ExportFormat.markdown: + return ctrl.exportToMarkdown(); + } + case DataSource.shoppingList: + if (!Get.isRegistered()) return ''; + final ctrl = Get.find(); + switch (format) { + case ExportFormat.json: + return ctrl.exportToJson(); + case ExportFormat.csv: + return ctrl.exportToCsv(); + case ExportFormat.markdown: + return ctrl.exportToMarkdown(); + } + case DataSource.mealRecords: + if (!Get.isRegistered()) return ''; + final ctrl = Get.find(); + switch (format) { + case ExportFormat.json: + return ctrl.exportToJson(); + case ExportFormat.csv: + return ctrl.exportToCsv(); + case ExportFormat.markdown: + return ctrl.exportToMarkdown(); + } + case DataSource.cookingNotes: + if (!Get.isRegistered()) return ''; + final ctrl = CookingNoteController.to; + switch (format) { + case ExportFormat.json: + return ctrl.exportToJson(); + case ExportFormat.csv: + return ctrl.exportToCsv(); + case ExportFormat.markdown: + return ctrl.exportToMarkdown(); + } + case DataSource.weeklyMenu: + if (!Get.isRegistered()) return ''; + final ctrl = Get.find(); + switch (format) { + case ExportFormat.json: + return ctrl.exportToJson(); + case ExportFormat.csv: + return ctrl.exportToCsv(); + case ExportFormat.markdown: + return ctrl.exportToMarkdown(); + } + case DataSource.browseHistory: + if (!Get.isRegistered()) return ''; + final ctrl = BrowseHistoryController.to; + switch (format) { + case ExportFormat.json: + return ctrl.exportToJson(); + case ExportFormat.csv: + return ctrl.exportToCsv(); + case ExportFormat.markdown: + return _historyToMarkdown(ctrl); + } + } + } catch (e) { + debugPrint('DataExportService: 导出 $source 失败: $e'); + return ''; + } + } + + String _historyToMarkdown(BrowseHistoryController ctrl) { + final buffer = StringBuffer(); + buffer.writeln('# 👀 浏览记录'); + buffer.writeln(); + for (final item in ctrl.history) { + buffer.writeln( + '- **${item.title}** ${item.category ?? ""} ' + '浏览${item.viewCount}次 *${item.viewedAt}*', + ); + } + buffer.writeln(); + buffer.writeln('---'); + buffer.writeln('共 ${ctrl.history.length} 条记录'); + return buffer.toString(); + } + + Future exportSingle(DataSource source, ExportFormat format) async { + final content = _exportSource(source, format); + if (content.isEmpty) throw Exception('${source.label} 无数据可导出'); + + final fileName = + '${source.fileName}_${_dateStamp()}${getFormatExtension(format)}'; + + if (kIsWeb) { + return content; + } + + final file = await _writeFile(fileName, content); + return file.path; + } + + Future exportAll(ExportFormat format) async { + isExporting.value = true; + exportProgress.value = 0.0; + + try { + final sources = DataSource.values; + final buffer = StringBuffer(); + final ext = getFormatExtension(format); + + for (var i = 0; i < sources.length; i++) { + final content = _exportSource(sources[i], format); + if (content.isNotEmpty) { + if (format == ExportFormat.markdown) { + buffer.writeln(content); + buffer.writeln(); + } else if (format == ExportFormat.json) { + if (i == 0) buffer.writeln('{'); + buffer.writeln(' "${sources[i].fileName}": $content'); + if (i < sources.length - 1) buffer.writeln(','); + } else { + buffer.writeln(content); + buffer.writeln(); + } + } + exportProgress.value = (i + 1) / sources.length; + } + + if (format == ExportFormat.json) buffer.writeln('}'); + + final fileName = 'mom_kitchen_export_${_dateStamp()}$ext'; + + if (kIsWeb) return buffer.toString(); + + final file = await _writeFile(fileName, buffer.toString()); + return file.path; + } finally { + isExporting.value = false; + exportProgress.value = 1.0; + } + } + + Future shareExportedData(String filePath) async { + if (kIsWeb) return; + await Share.shareXFiles( + [XFile(filePath)], + subject: '妈妈厨房 - 数据导出', + text: '从妈妈厨房导出的数据', + ); + } + + Future _writeFile(String fileName, String content) async { + final directory = await getTemporaryDirectory(); + final file = File('${directory.path}/$fileName'); + await file.writeAsString(content); + return file; + } + + String _dateStamp() { + final now = DateTime.now(); + return '${now.year}${now.month.toString().padLeft(2, '0')}${now.day.toString().padLeft(2, '0')}' + '_${now.hour.toString().padLeft(2, '0')}${now.minute.toString().padLeft(2, '0')}'; + } + + // ─── 数据导入 ─── + + final RxBool isImporting = false.obs; + final RxDouble importProgress = 0.0.obs; + + ImportPreview previewImport(String jsonContent) { + final result = {}; + try { + final decoded = jsonDecode(jsonContent); + if (decoded is Map) { + for (final source in DataSource.values) { + if (decoded.containsKey(source.fileName)) { + final data = decoded[source.fileName]; + if (data is List) { + result[source] = data.length; + } else if (data is Map && source == DataSource.weeklyMenu) { + result[source] = data.length; + } + } + } + } else if (decoded is List) { + if (selectedSources.length == 1) { + result[selectedSources.first] = decoded.length; + } + } + } catch (e) { + debugPrint('DataExportService: 预览导入失败: $e'); + } + return ImportPreview(jsonContent, result); + } + + Future importFromJson( + String jsonContent, { + Set? sources, + }) async { + isImporting.value = true; + importProgress.value = 0.0; + var totalImported = 0; + + try { + final decoded = jsonDecode(jsonContent); + final targetSources = sources ?? DataSource.values.toSet(); + final sourceList = targetSources.toList(); + + for (var i = 0; i < sourceList.length; i++) { + final source = sourceList[i]; + List? data; + + if (decoded is Map) { + final raw = decoded[source.fileName]; + if (raw is List) { + data = raw; + } else if (raw is Map && source == DataSource.weeklyMenu) { + data = [raw]; + } + } else if (decoded is List && targetSources.length == 1) { + data = decoded; + } + + if (data != null && data.isNotEmpty) { + totalImported += await _importSource(source, data); + } + importProgress.value = (i + 1) / sourceList.length; + } + + return totalImported; + } finally { + isImporting.value = false; + importProgress.value = 1.0; + } + } + + Future _importSource(DataSource source, List data) async { + try { + switch (source) { + case DataSource.favorites: + return _importFavorites(data); + case DataSource.shoppingList: + return _importShoppingList(data); + case DataSource.mealRecords: + return _importMealRecords(data); + case DataSource.cookingNotes: + return _importCookingNotes(data); + case DataSource.weeklyMenu: + return _importWeeklyMenu(data); + case DataSource.browseHistory: + return _importBrowseHistory(data); + } + } catch (e) { + debugPrint('DataExportService: 导入 $source 失败: $e'); + return 0; + } + } + + int _importFavorites(List data) { + if (!Get.isRegistered()) return 0; + final ctrl = Get.find(); + var count = 0; + for (final item in data) { + try { + if (item is Map) { + ctrl.addFavoriteFromJson(item); + count++; + } + } catch (e) { + debugPrint('DataExportService: 导入收藏项失败: $e'); + } + } + return count; + } + + int _importShoppingList(List data) { + if (!Get.isRegistered()) return 0; + final ctrl = Get.find(); + var count = 0; + for (final item in data) { + try { + if (item is Map) { + ctrl.importFromJson(item); + count++; + } + } catch (e) { + debugPrint('DataExportService: 导入购物清单项失败: $e'); + } + } + return count; + } + + int _importMealRecords(List data) { + if (!Get.isRegistered()) return 0; + final ctrl = Get.find(); + var count = 0; + for (final item in data) { + try { + if (item is Map) { + ctrl.importFromJson(item); + count++; + } + } catch (e) { + debugPrint('DataExportService: 导入饮食记录项失败: $e'); + } + } + return count; + } + + int _importCookingNotes(List data) { + if (!Get.isRegistered()) return 0; + final ctrl = CookingNoteController.to; + var count = 0; + for (final item in data) { + try { + if (item is Map) { + ctrl.importFromJson(item); + count++; + } + } catch (e) { + debugPrint('DataExportService: 导入烹饪笔记项失败: $e'); + } + } + return count; + } + + int _importWeeklyMenu(List data) { + if (!Get.isRegistered()) return 0; + final ctrl = Get.find(); + var count = 0; + for (final entry in data) { + try { + if (entry is Map) { + ctrl.importFromJson(entry); + count++; + } + } catch (e) { + debugPrint('DataExportService: 导入每周菜单项失败: $e'); + } + } + return count; + } + + int _importBrowseHistory(List data) { + if (!Get.isRegistered()) return 0; + final ctrl = BrowseHistoryController.to; + var count = 0; + for (final item in data) { + try { + if (item is Map) { + ctrl.importFromJson(item); + count++; + } + } catch (e) { + debugPrint('DataExportService: 导入浏览记录项失败: $e'); + } + } + return count; + } +} + +class ImportPreview { + final String jsonContent; + final Map sourceCounts; + const ImportPreview(this.jsonContent, this.sourceCounts); + + int get totalItems => sourceCounts.values.fold(0, (a, b) => a + b); + bool get hasData => sourceCounts.isNotEmpty; + Set get availableSources => sourceCounts.keys.toSet(); +} diff --git a/lib/src/services/data/hive_service.dart b/lib/src/services/data/hive_service.dart index 02d4f12..da696e1 100644 --- a/lib/src/services/data/hive_service.dart +++ b/lib/src/services/data/hive_service.dart @@ -8,6 +8,9 @@ import 'package:mom_kitchen/src/models/data/meal_record_model.dart'; import 'package:mom_kitchen/src/models/data/shopping_item_model.dart'; import 'package:mom_kitchen/src/models/user/user_goal_model.dart'; import 'package:mom_kitchen/src/models/data/cooking_note_model.dart'; +import 'package:mom_kitchen/src/models/farm/farm_player.dart'; +import 'package:mom_kitchen/src/models/farm/farm_land.dart'; +import 'package:mom_kitchen/src/models/farm/inventory_item.dart'; import 'package:mom_kitchen/src/services/log/logger_service.dart'; class HiveService { @@ -34,9 +37,19 @@ class HiveService { Box? _searchHistory; Box? _versionBoxInstance; + // 农场游戏 Box + Box? _farmPlayer; + Box? _farmLands; + Box? _farmInventory; + // 动态 box 缓存,用于通用 get/put 方法 final Map> _dynamicBoxCache = {}; + // 农场游戏 Box 访问器 + Box? get farmPlayer => _farmPlayer; + Box? get farmLands => _farmLands; + Box? get farmInventory => _farmInventory; + bool _initialized = false; bool get isInitialized => _initialized; @@ -83,6 +96,16 @@ class HiveService { if (!Hive.isAdapterRegistered(3)) { Hive.registerAdapter(CookingNoteAdapter()); } + // 农场游戏适配器 + if (!Hive.isAdapterRegistered(100)) { + Hive.registerAdapter(FarmPlayerAdapter()); + } + if (!Hive.isAdapterRegistered(101)) { + Hive.registerAdapter(FarmLandAdapter()); + } + if (!Hive.isAdapterRegistered(102)) { + Hive.registerAdapter(InventoryItemAdapter()); + } } Future _openBoxes() async { @@ -94,6 +117,11 @@ class HiveService { _favorites = await _openBoxSafe(_favoriteBox); _searchHistory = await _openBoxSafe(_searchHistoryBox); + // 农场游戏 Boxes + _farmPlayer = await _openBoxSafe('farmPlayerBox'); + _farmLands = await _openBoxSafe('farmLandsBox'); + _farmInventory = await _openBoxSafe('farmInventoryBox'); + // 打开动态 boxes(用于通用 get/put 方法) final bedtimeBox = await _openBoxSafe('bedtime_reminder'); if (bedtimeBox != null) { @@ -139,19 +167,21 @@ class HiveService { LoggerService().info( 'Hive schema is up to date (v$_currentSchemaVersion)', ); - return; + } else { + LoggerService().info( + 'Migrating Hive from v$storedVersion to v$_currentSchemaVersion', + ); + + for (var v = storedVersion + 1; v <= _currentSchemaVersion; v++) { + await _migrateToVersion(v); + } + + await _versionBoxInstance?.put(_versionKey, _currentSchemaVersion); + LoggerService().info('Hive migration completed to v$_currentSchemaVersion'); } - LoggerService().info( - 'Migrating Hive from v$storedVersion to v$_currentSchemaVersion', - ); - - for (var v = storedVersion + 1; v <= _currentSchemaVersion; v++) { - await _migrateToVersion(v); - } - - await _versionBoxInstance?.put(_versionKey, _currentSchemaVersion); - LoggerService().info('Hive migration completed to v$_currentSchemaVersion'); + // 初始化农场游戏数据(无论版本,仅在首次运行时) + await _initializeFarmData(); } Future _migrateToVersion(int version) async { @@ -175,6 +205,37 @@ class HiveService { } } + /// 初始化农场游戏数据 + Future _initializeFarmData() async { + if (_farmPlayer == null || _farmPlayer!.isEmpty) { + LoggerService().info('Initializing farm game data...'); + + final deviceId = 'device_${DateTime.now().millisecondsSinceEpoch}'; + final player = FarmPlayer.createDefault(deviceId); + await _farmPlayer?.put('player', player); + + // 初始化 12 块土地(前 6 块解锁) + for (int i = 0; i < 12; i++) { + final land = FarmLand.initial(i, unlocked: i < 6); + await _farmLands?.put(i, land); + } + + // 初始种子 + await _farmInventory?.put( + 'radish_seed', + InventoryItem.seed( + cropId: 'radish', + name: '萝卜', + emoji: '🥕', + price: 10, + quantity: 5, + ), + ); + + LoggerService().info('Farm game data initialized'); + } + } + Future migrateBox(Box box, T Function(T) migrator) async { final keys = box.keys.toList(); for (final key in keys) { @@ -270,6 +331,11 @@ class HiveService { return _mealRecords!.values.map((r) => r.date).toSet(); } + List getAllMealRecords() { + if (!_initialized || _mealRecords == null) return []; + return _mealRecords!.values.toList(); + } + Future removeMealRecord(String key) async { if (!_initialized || _mealRecords == null) return; await _mealRecords!.delete(key); diff --git a/lib/src/services/data/offline_service.dart b/lib/src/services/data/offline_service.dart new file mode 100644 index 0000000..91b7c18 --- /dev/null +++ b/lib/src/services/data/offline_service.dart @@ -0,0 +1,143 @@ +// 2026-04-18 | OfflineService | 离线模式服务 | 统一管理离线状态、操作守卫、动作队列、网络恢复通知 + +import 'package:flutter/cupertino.dart'; +import 'package:get/get.dart'; +import 'package:mom_kitchen/src/services/connectivity_service.dart'; +import 'package:mom_kitchen/src/services/ui/toast_service.dart'; + +typedef OfflineAction = Future Function(); + +class _QueuedAction { + final String name; + final OfflineAction action; + final DateTime queuedAt; + + _QueuedAction({ + required this.name, + required this.action, + required this.queuedAt, + }); +} + +class OfflineService extends GetxService { + static OfflineService get to => Get.find(); + + final RxBool isOffline = false.obs; + final Rx lastOnlineTime = Rx(null); + final Rx wentOfflineAt = Rx(null); + + final List<_QueuedAction> _actionQueue = []; + + final RxList offlineFeatures = [].obs; + final RxList unavailableFeatures = [].obs; + + static const List _availableOffline = [ + '浏览缓存菜谱', + '查看收藏', + '查看购物清单', + '查看饮食记录', + '查看烹饪笔记', + '查看每周菜单', + '查看浏览记录', + '数据导出', + ]; + + static const List _offlineUnavailable = [ + '搜索菜谱', + '发现页刷新', + '热门排行刷新', + '菜谱详情加载', + '分享到邮箱', + '数据同步', + ]; + + @override + void onInit() { + super.onInit(); + _initConnectivityListener(); + _updateFeatureLists(); + } + + void _initConnectivityListener() { + if (!Get.isRegistered()) return; + final connectivity = ConnectivityService.to; + isOffline.value = connectivity.isOffline; + if (!isOffline.value) { + lastOnlineTime.value = DateTime.now(); + } else { + wentOfflineAt.value = DateTime.now(); + } + + ever(connectivity.status, (status) { + final wasOffline = isOffline.value; + isOffline.value = status == ConnectivityStatus.offline; + + if (isOffline.value && !wasOffline) { + wentOfflineAt.value = DateTime.now(); + _updateFeatureLists(); + ToastService.show(message: '网络已断开,部分功能不可用 📵'); + } else if (!isOffline.value && wasOffline) { + lastOnlineTime.value = DateTime.now(); + _updateFeatureLists(); + _executeQueuedActions(); + } + }); + } + + void _updateFeatureLists() { + if (isOffline.value) { + offlineFeatures.value = List.from(_availableOffline); + unavailableFeatures.value = List.from(_offlineUnavailable); + } else { + offlineFeatures.clear(); + unavailableFeatures.clear(); + } + } + + bool guard(String operationName, {OfflineAction? onRetry}) { + if (!isOffline.value) return true; + + if (onRetry != null) { + _actionQueue.add(_QueuedAction( + name: operationName, + action: onRetry, + queuedAt: DateTime.now(), + )); + ToastService.show(message: '📵 网络不可用,"$operationName"将在恢复后自动执行'); + } else { + ToastService.show(message: '📵 网络不可用,无法$operationName'); + } + return false; + } + + Future _executeQueuedActions() async { + if (_actionQueue.isEmpty) return; + + final actions = List<_QueuedAction>.from(_actionQueue); + _actionQueue.clear(); + + ToastService.show(message: '📡 网络已恢复,正在执行 ${actions.length} 个待办操作...'); + + for (final item in actions) { + try { + await item.action(); + } catch (e) { + debugPrint('OfflineService: 执行排队操作"${item.name}"失败: $e'); + } + } + } + + int get queuedActionCount => _actionQueue.length; + + void clearQueue() { + _actionQueue.clear(); + } + + String get offlineDuration { + if (wentOfflineAt.value == null) return ''; + final duration = DateTime.now().difference(wentOfflineAt.value!); + if (duration.inMinutes < 1) return '刚刚断开'; + if (duration.inHours < 1) return '已离线 ${duration.inMinutes} 分钟'; + return '已离线 ${duration.inHours} 小时 ${duration.inMinutes % 60} 分钟'; + } +} diff --git a/lib/src/utils/farm_share_util.dart b/lib/src/utils/farm_share_util.dart new file mode 100644 index 0000000..aa16e63 --- /dev/null +++ b/lib/src/utils/farm_share_util.dart @@ -0,0 +1,202 @@ +// 农场游戏分享工具类 +// 生成分享图片并调用系统分享 +import 'dart:io'; +import 'dart:ui' as ui; +import 'package:flutter/material.dart'; +import 'package:flutter/rendering.dart'; +import 'package:share_plus/share_plus.dart'; +import 'package:path_provider/path_provider.dart'; +import 'package:mom_kitchen/src/config/farm_config.dart'; + +/// 农场分享工具类 +class FarmShareUtil { + static final FarmShareUtil _instance = FarmShareUtil._internal(); + factory FarmShareUtil() => _instance; + FarmShareUtil._internal(); + + /// 生成并分享农场收获图片 + Future shareFarmProgress({ + required String playerName, + required int level, + required int gold, + required int totalHarvest, + required List cropEmojis, + }) async { + try { + // 创建分享图片 + final recorder = ui.PictureRecorder(); + final canvas = Canvas(recorder); + const size = Size( + FarmConfig.shareImageWidth, + FarmConfig.shareImageHeight, + ); + + // 绘制背景 + _drawBackground(canvas, size); + + // 绘制标题 + _drawTitle(canvas, size); + + // 绘制玩家信息 + _drawPlayerInfo(canvas, size, playerName, level, gold, totalHarvest); + + // 绘制作物网格 + _drawCropGrid(canvas, size, cropEmojis); + + // 绘制底部信息 + _drawFooter(canvas, size); + + // 转换为图片 + final picture = recorder.endRecording(); + final image = await picture.toImage( + size.width.toInt(), + size.height.toInt(), + ); + final byteData = await image.toByteData(format: ui.ImageByteFormat.png); + final bytes = byteData!.buffer.asUint8List(); + + // 保存到临时文件 + final tempDir = await getTemporaryDirectory(); + final file = File( + '${tempDir.path}/farm_share_${DateTime.now().millisecondsSinceEpoch}.png', + ); + await file.writeAsBytes(bytes); + + // 分享 + await Share.shareXFiles( + [XFile(file.path)], + text: + '🌾 我在小妈菜园的收获!\n等级: Lv.$level | 收获: $totalHarvest 次 | 金币: $gold 💰', + ); + + // 清理临时文件 + await file.delete(); + } catch (e) { + debugPrint('分享失败: $e'); + rethrow; + } + } + + void _drawBackground(Canvas canvas, Size size) { + final paint = Paint() + ..shader = LinearGradient( + begin: Alignment.topCenter, + end: Alignment.bottomCenter, + colors: [ + const Color(0xFFFFF9C4), + const Color(0xFFFFECB3), + const Color(0xFFFFE082), + ], + ).createShader(Rect.fromLTWH(0, 0, size.width, size.height)); + + canvas.drawRect(Rect.fromLTWH(0, 0, size.width, size.height), paint); + } + + void _drawTitle(Canvas canvas, Size size) { + final titleStyle = const TextStyle( + fontSize: 48, + fontWeight: FontWeight.bold, + color: Color(0xFF5D4037), + ); + + final titleText = '🌾 小妈菜园 🌾'; + final titleSpan = TextSpan(text: titleText, style: titleStyle); + final titlePainter = TextPainter( + text: titleSpan, + textDirection: TextDirection.ltr, + ); + titlePainter.layout(); + titlePainter.paint( + canvas, + Offset((size.width - titlePainter.width) / 2, 80), + ); + } + + void _drawPlayerInfo( + Canvas canvas, + Size size, + String playerName, + int level, + int gold, + int totalHarvest, + ) { + final infoStyle = const TextStyle(fontSize: 28, color: Color(0xFF5D4037)); + + final infoTexts = [ + '👤 玩家:$playerName', + '🏆 等级:Lv.$level', + '💰 金币:$gold', + '🌾 收获:$totalHarvest 次', + ]; + + double y = 180; + for (final text in infoTexts) { + final span = TextSpan(text: text, style: infoStyle); + final painter = TextPainter(text: span, textDirection: TextDirection.ltr); + painter.layout(); + painter.paint(canvas, Offset((size.width - painter.width) / 2, y)); + y += 45; + } + } + + void _drawCropGrid(Canvas canvas, Size size, List cropEmojis) { + final emojiStyle = const TextStyle(fontSize: 48); + final startX = (size.width - (cropEmojis.length.clamp(1, 4) * 80)) / 2; + double x = startX; + double y = 380; + + for (int i = 0; i < cropEmojis.length; i++) { + if (i > 0 && i % 4 == 0) { + x = startX; + y += 80; + } + if (i < 8) { + // 最多显示 8 个 + final span = TextSpan(text: cropEmojis[i], style: emojiStyle); + final painter = TextPainter( + text: span, + textDirection: TextDirection.ltr, + ); + painter.layout(); + painter.paint(canvas, Offset(x, y)); + x += 80; + } + } + } + + void _drawFooter(Canvas canvas, Size size) { + // 日期 + final dateStyle = const TextStyle(fontSize: 24, color: Color(0xFF795548)); + final now = DateTime.now(); + final dateText = + '📅 ${now.year}-${now.month.toString().padLeft(2, '0')}-${now.day.toString().padLeft(2, '0')}'; + final dateSpan = TextSpan(text: dateText, style: dateStyle); + final datePainter = TextPainter( + text: dateSpan, + textDirection: TextDirection.ltr, + ); + datePainter.layout(); + datePainter.paint( + canvas, + Offset((size.width - datePainter.width) / 2, size.height - 150), + ); + + // 底部品牌 + final brandStyle = const TextStyle( + fontSize: 20, + color: Color(0xFF8D6E63), + fontWeight: FontWeight.w500, + ); + final brandText = '小妈厨房 · 工具中心'; + final brandSpan = TextSpan(text: brandText, style: brandStyle); + final brandPainter = TextPainter( + text: brandSpan, + textDirection: TextDirection.ltr, + ); + brandPainter.layout(); + brandPainter.paint( + canvas, + Offset((size.width - brandPainter.width) / 2, size.height - 100), + ); + } +} diff --git a/lib/src/widgets/base/app_page_scaffold.dart b/lib/src/widgets/base/app_page_scaffold.dart index d5cea37..ab48768 100644 --- a/lib/src/widgets/base/app_page_scaffold.dart +++ b/lib/src/widgets/base/app_page_scaffold.dart @@ -259,7 +259,7 @@ class AppStatItem extends StatelessWidget { final IconData? icon; final String? emoji; - AppStatItem({ + const AppStatItem({ super.key, required this.label, required this.value, diff --git a/lib/src/widgets/discover/category_discover_card.dart b/lib/src/widgets/discover/category_discover_card.dart index f9f14a9..e203a9f 100644 --- a/lib/src/widgets/discover/category_discover_card.dart +++ b/lib/src/widgets/discover/category_discover_card.dart @@ -13,7 +13,6 @@ 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'; diff --git a/lib/src/widgets/discover/tag_discover_card.dart b/lib/src/widgets/discover/tag_discover_card.dart index 6f637ed..33ebbae 100644 --- a/lib/src/widgets/discover/tag_discover_card.dart +++ b/lib/src/widgets/discover/tag_discover_card.dart @@ -152,7 +152,6 @@ 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, 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 eb724db..a4b759a 100644 --- a/lib/src/widgets/recipe_detail/info/recipe_nutrition_section.dart +++ b/lib/src/widgets/recipe_detail/info/recipe_nutrition_section.dart @@ -12,9 +12,6 @@ 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'; diff --git a/lib/src/widgets/recipe_detail/interaction/recipe_email_button.dart b/lib/src/widgets/recipe_detail/interaction/recipe_email_button.dart index 2ebcd34..6544d02 100644 --- a/lib/src/widgets/recipe_detail/interaction/recipe_email_button.dart +++ b/lib/src/widgets/recipe_detail/interaction/recipe_email_button.dart @@ -177,6 +177,7 @@ class _EmailDialogState extends State<_EmailDialog> { ), CupertinoButton( padding: EdgeInsets.zero, + onPressed: _isSending ? null : _handleSend, child: _isSending ? const CupertinoActivityIndicator() : Text( @@ -186,7 +187,6 @@ class _EmailDialogState extends State<_EmailDialog> { fontWeight: FontWeight.w600, ), ), - onPressed: _isSending ? null : _handleSend, ), ], ), diff --git a/lib/src/widgets/states/offline_banner.dart b/lib/src/widgets/states/offline_banner.dart index e3355d4..ba711ce 100644 --- a/lib/src/widgets/states/offline_banner.dart +++ b/lib/src/widgets/states/offline_banner.dart @@ -1,7 +1,9 @@ -// 2026-04-09 | OfflineBanner | 离线状态横幅 | Web端不显示离线横幅 +// 2026-04-09 | OfflineBanner | 离线状态横幅 | 增强版:优先使用OfflineService数据源 +// 2026-04-18 | 重构:优先使用OfflineService,降级使用ConnectivityService import 'package:flutter/cupertino.dart'; import 'package:flutter/foundation.dart'; import 'package:get/get.dart'; +import 'package:mom_kitchen/src/services/data/offline_service.dart'; import 'package:mom_kitchen/src/services/connectivity_service.dart'; class OfflineBanner extends StatelessWidget { @@ -11,6 +13,44 @@ class OfflineBanner extends StatelessWidget { Widget build(BuildContext context) { if (kIsWeb) return const SizedBox.shrink(); + if (Get.isRegistered()) { + final offline = OfflineService.to; + return Obx(() { + if (!offline.isOffline.value) return const SizedBox.shrink(); + return Container( + padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 6), + decoration: BoxDecoration( + color: CupertinoColors.systemOrange.withValues(alpha: 0.15), + border: Border( + bottom: BorderSide( + color: CupertinoColors.systemOrange.withValues(alpha: 0.3), + width: 0.5, + ), + ), + ), + child: Row( + mainAxisAlignment: MainAxisAlignment.center, + children: [ + Icon( + CupertinoIcons.wifi_slash, + size: 14, + color: CupertinoColors.systemOrange.darkColor, + ), + const SizedBox(width: 6), + Text( + '网络已断开,显示缓存数据', + style: TextStyle( + fontSize: 12, + fontWeight: FontWeight.w500, + color: CupertinoColors.systemOrange.darkColor, + ), + ), + ], + ), + ); + }); + } + if (!Get.isRegistered()) { return const SizedBox.shrink(); } diff --git a/lib/src/widgets/states/offline_indicator.dart b/lib/src/widgets/states/offline_indicator.dart new file mode 100644 index 0000000..86535af --- /dev/null +++ b/lib/src/widgets/states/offline_indicator.dart @@ -0,0 +1,132 @@ +// 2026-04-18 | OfflineIndicator | 增强版离线指示器 | 显示离线状态、持续时间、排队操作数、可用功能详情 + +import 'package:flutter/cupertino.dart'; +import 'package:get/get.dart'; +import 'package:mom_kitchen/src/services/data/offline_service.dart'; + +class OfflineIndicator extends StatelessWidget { + const OfflineIndicator({super.key}); + + @override + Widget build(BuildContext context) { + if (!Get.isRegistered()) { + return const SizedBox.shrink(); + } + + final offlineService = OfflineService.to; + + return Obx(() { + if (!offlineService.isOffline.value) return const SizedBox.shrink(); + + final duration = offlineService.offlineDuration; + final queuedCount = offlineService.queuedActionCount; + + return Container( + padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 8), + decoration: BoxDecoration( + color: CupertinoColors.systemOrange.withValues(alpha: 0.12), + border: Border( + bottom: BorderSide( + color: CupertinoColors.systemOrange.withValues(alpha: 0.25), + width: 0.5, + ), + ), + ), + child: GestureDetector( + onTap: () => _showOfflineDetail(context, offlineService), + child: Row( + mainAxisAlignment: MainAxisAlignment.center, + children: [ + Icon( + CupertinoIcons.wifi_slash, + size: 14, + color: CupertinoColors.systemOrange.darkColor, + ), + const SizedBox(width: 6), + Text( + '离线模式', + style: TextStyle( + fontSize: 12, + fontWeight: FontWeight.w600, + color: CupertinoColors.systemOrange.darkColor, + ), + ), + if (duration.isNotEmpty) ...[ + const SizedBox(width: 4), + Text( + '· $duration', + style: TextStyle( + fontSize: 11, + color: CupertinoColors.systemOrange.darkColor + .withValues(alpha: 0.7), + ), + ), + ], + if (queuedCount > 0) ...[ + const SizedBox(width: 6), + Container( + padding: + const EdgeInsets.symmetric(horizontal: 6, vertical: 1), + decoration: BoxDecoration( + color: CupertinoColors.systemOrange.darkColor, + borderRadius: BorderRadius.circular(8), + ), + child: Text( + '$queuedCount', + style: const TextStyle( + fontSize: 10, color: CupertinoColors.white), + ), + ), + ], + const SizedBox(width: 4), + Icon( + CupertinoIcons.chevron_down, + size: 10, + color: + CupertinoColors.systemOrange.darkColor.withValues(alpha: 0.5), + ), + ], + ), + ), + ); + }); + } + + void _showOfflineDetail( + BuildContext context, + OfflineService offlineService, + ) { + showCupertinoModalPopup( + context: context, + builder: (ctx) => CupertinoActionSheet( + title: const Text('📵 离线模式'), + message: Column( + children: [ + Text(offlineService.offlineDuration), + const SizedBox(height: 8), + const Text('✅ 可用功能:', + style: TextStyle(fontWeight: FontWeight.w600)), + ...offlineService.offlineFeatures.take(4).map( + (f) => Text(' · $f', style: const TextStyle(fontSize: 13)), + ), + const SizedBox(height: 8), + const Text('❌ 不可用:', + style: TextStyle(fontWeight: FontWeight.w600)), + ...offlineService.unavailableFeatures.take(3).map( + (f) => Text(' · $f', style: const TextStyle(fontSize: 13)), + ), + if (offlineService.queuedActionCount > 0) ...[ + const SizedBox(height: 8), + Text( + '⏳ 恢复后自动执行 ${offlineService.queuedActionCount} 个操作'), + ], + ], + ), + cancelButton: CupertinoActionSheetAction( + onPressed: () => Navigator.pop(ctx), + child: const Text('知道了'), + ), + ), + ); + } +} diff --git a/ohos/entry/src/main/module.json5 b/ohos/entry/src/main/module.json5 index 7ef8a68..395c103 100644 --- a/ohos/entry/src/main/module.json5 +++ b/ohos/entry/src/main/module.json5 @@ -29,7 +29,20 @@ "entity.system.home" ], "actions": [ - "action.system.home" + "action.system.home", + "ohos.want.action.sendData" + ], + "uris": [ + { + "scheme": "file", + "utd": "general.entity", + "maxFileSupported": 15 + }, + { + "scheme": "file", + "utd": "general.object", + "maxFileSupported": 15 + } ] } ] @@ -51,6 +64,14 @@ "abilities": ["EntryAbility"], "when": "inuse" } + }, + { + "name": "ohos.permission.READ_MEDIA", + "reason": "$string:permission_read_media_reason", + "usedScene": { + "abilities": ["EntryAbility"], + "when": "inuse" + } } ] } diff --git a/ohos/entry/src/main/resources/base/element/string.json b/ohos/entry/src/main/resources/base/element/string.json index 7241ae5..b550114 100644 --- a/ohos/entry/src/main/resources/base/element/string.json +++ b/ohos/entry/src/main/resources/base/element/string.json @@ -23,6 +23,10 @@ { "name": "permission_clipboard_reason", "value": "Access to clipboard for paste operations" + }, + { + "name": "permission_read_media_reason", + "value": "Read media files for data import" } ] } \ No newline at end of file diff --git a/ohos/entry/src/main/resources/en_US/element/string.json b/ohos/entry/src/main/resources/en_US/element/string.json index 3d8c275..0bc2364 100644 --- a/ohos/entry/src/main/resources/en_US/element/string.json +++ b/ohos/entry/src/main/resources/en_US/element/string.json @@ -11,6 +11,10 @@ { "name": "EntryAbility_label", "value": "ohos" + }, + { + "name": "permission_read_media_reason", + "value": "Read media files for data import" } ] } \ No newline at end of file diff --git a/ohos/entry/src/main/resources/zh_CN/element/string.json b/ohos/entry/src/main/resources/zh_CN/element/string.json index 64436fa..62485cc 100644 --- a/ohos/entry/src/main/resources/zh_CN/element/string.json +++ b/ohos/entry/src/main/resources/zh_CN/element/string.json @@ -11,6 +11,10 @@ { "name": "EntryAbility_label", "value": "ohos" + }, + { + "name": "permission_read_media_reason", + "value": "读取媒体文件用于数据导入" } ] } \ No newline at end of file diff --git a/pubspec.lock b/pubspec.lock index f34b2c8..4230c36 100644 --- a/pubspec.lock +++ b/pubspec.lock @@ -57,6 +57,70 @@ packages: url: "https://pub.flutter-io.cn" source: hosted version: "2.1.2" + build: + dependency: transitive + description: + name: build + sha256: "80184af8b6cb3e5c1c4ec6d8544d27711700bc3e6d2efad04238c7b5290889f0" + url: "https://pub.flutter-io.cn" + source: hosted + version: "2.4.1" + build_config: + dependency: transitive + description: + name: build_config + sha256: "4ae2de3e1e67ea270081eaee972e1bd8f027d459f249e0f1186730784c2e7e33" + url: "https://pub.flutter-io.cn" + source: hosted + version: "1.1.2" + build_daemon: + dependency: transitive + description: + name: build_daemon + sha256: bf05f6e12cfea92d3c09308d7bcdab1906cd8a179b023269eed00c071004b957 + url: "https://pub.flutter-io.cn" + source: hosted + version: "4.1.1" + build_resolvers: + dependency: transitive + description: + name: build_resolvers + sha256: "339086358431fa15d7eca8b6a36e5d783728cf025e559b834f4609a1fcfb7b0a" + url: "https://pub.flutter-io.cn" + source: hosted + version: "2.4.2" + build_runner: + dependency: "direct dev" + description: + name: build_runner + sha256: "028819cfb90051c6b5440c7e574d1896f8037e3c96cf17aaeb054c9311cfbf4d" + url: "https://pub.flutter-io.cn" + source: hosted + version: "2.4.13" + build_runner_core: + dependency: transitive + description: + name: build_runner_core + sha256: f8126682b87a7282a339b871298cc12009cb67109cfa1614d6436fb0289193e0 + url: "https://pub.flutter-io.cn" + source: hosted + version: "7.3.2" + built_collection: + dependency: transitive + description: + name: built_collection + sha256: "376e3dd27b51ea877c28d525560790aee2e6fbb5f20e2f85d5081027d94e2100" + url: "https://pub.flutter-io.cn" + source: hosted + version: "5.1.1" + built_value: + dependency: transitive + description: + name: built_value + sha256: "0730c18c770d05636a8f945c32a4d7d81cb6e0f0148c8db4ad12e7748f7e49af" + url: "https://pub.flutter-io.cn" + source: hosted + version: "8.12.5" cached_network_image: dependency: "direct main" description: @@ -96,6 +160,14 @@ packages: url: "https://pub.flutter-io.cn" source: hosted version: "1.4.0" + checked_yaml: + dependency: transitive + description: + name: checked_yaml + sha256: "959525d3162f249993882720d52b7e0c833978df229be20702b33d48d91de70f" + url: "https://pub.flutter-io.cn" + source: hosted + version: "2.0.4" clock: dependency: transitive description: @@ -104,8 +176,16 @@ packages: url: "https://pub.flutter-io.cn" source: hosted version: "1.1.2" - collection: + code_builder: dependency: transitive + description: + name: code_builder + sha256: "6a6cab2ba4680d6423f34a9b972a4c9a94ebe1b62ecec4e1a1f2cba91fd1319d" + url: "https://pub.flutter-io.cn" + source: hosted + version: "4.11.1" + collection: + dependency: "direct main" description: name: collection sha256: "2f5709ae4d3d59dd8f7cd309b4e023046b57d8a6c82130785d2b0e5868084e76" @@ -161,6 +241,14 @@ packages: url: "https://pub.flutter-io.cn" source: hosted version: "1.0.9" + dart_style: + dependency: transitive + description: + name: dart_style + sha256: "1efa911ca7086affd35f463ca2fc1799584fb6aa89883cf0af8e3664d6a02d55" + url: "https://pub.flutter-io.cn" + source: hosted + version: "2.3.2" dbus: dependency: transitive description: @@ -361,6 +449,14 @@ packages: relative: true source: path version: "1.0.0" + frontend_server_client: + dependency: transitive + description: + name: frontend_server_client + sha256: f64a0333a82f30b0cca061bc3d143813a486dc086b574bfb233b7c1372427694 + url: "https://pub.flutter-io.cn" + source: hosted + version: "4.0.0" get: dependency: "direct main" description: @@ -378,6 +474,14 @@ packages: url: "https://pub.flutter-io.cn" source: hosted version: "2.1.3" + graphs: + dependency: transitive + description: + name: graphs + sha256: "741bbf84165310a68ff28fe9e727332eef1407342fca52759cb21ad8177bb8d0" + url: "https://pub.flutter-io.cn" + source: hosted + version: "2.3.2" hive_ce: dependency: "direct main" description: @@ -394,6 +498,14 @@ packages: url: "https://pub.flutter-io.cn" source: hosted version: "1.6.0" + http_multi_server: + dependency: transitive + description: + name: http_multi_server + sha256: aa6199f908078bb1c5efb8d8638d4ae191aac11b311132c3ef48ce352fb52ef8 + url: "https://pub.flutter-io.cn" + source: hosted + version: "3.2.2" http_parser: dependency: transitive description: @@ -410,6 +522,14 @@ packages: url: "https://pub.flutter-io.cn" source: hosted version: "0.20.2" + io: + dependency: transitive + description: + name: io + sha256: dfd5a80599cf0165756e3181807ed3e77daf6dd4137caaad72d0b7931597650b + url: "https://pub.flutter-io.cn" + source: hosted + version: "1.0.5" isolate_channel: dependency: transitive description: @@ -752,6 +872,14 @@ packages: url: "https://pub.flutter-io.cn" source: hosted version: "2.1.8" + pool: + dependency: transitive + description: + name: pool + sha256: "978783255c543aa3586a1b3c21f6e9d720eb315376a915872c61ef8b5c20177d" + url: "https://pub.flutter-io.cn" + source: hosted + version: "1.5.2" pretty_dio_logger: dependency: "direct main" description: @@ -768,6 +896,14 @@ packages: url: "https://pub.flutter-io.cn" source: hosted version: "2.2.0" + pubspec_parse: + dependency: transitive + description: + name: pubspec_parse + sha256: "0560ba233314abbed0a48a2956f7f022cce7c3e1e73df540277da7544cad4082" + url: "https://pub.flutter-io.cn" + source: hosted + version: "1.5.0" punycoder: dependency: transitive description: @@ -784,6 +920,15 @@ packages: url: "https://pub.flutter-io.cn" source: hosted version: "3.0.2" + receive_sharing_intent: + dependency: "direct main" + description: + path: "." + ref: "br_v1.8.1_ohos" + resolved-ref: "59f4e7c741680a5ce121a7ddc686b89d4fe03182" + url: "https://gitcode.com/openharmony-sig/fluttertpc_receive_sharing_intent.git" + source: git + version: "1.8.1" rxdart: dependency: transitive description: @@ -884,6 +1029,22 @@ packages: url: "https://pub.flutter-io.cn" source: hosted version: "2.4.1" + shelf: + dependency: transitive + description: + name: shelf + sha256: e7dd780a7ffb623c57850b33f43309312fc863fb6aa3d276a754bb299839ef12 + url: "https://pub.flutter-io.cn" + source: hosted + version: "1.4.2" + shelf_web_socket: + dependency: transitive + description: + name: shelf_web_socket + sha256: cc36c297b52866d203dbf9332263c94becc2fe0ceaa9681d07b6ef9807023b67 + url: "https://pub.flutter-io.cn" + source: hosted + version: "2.0.1" sky_engine: dependency: transitive description: flutter @@ -953,6 +1114,14 @@ packages: url: "https://pub.flutter-io.cn" source: hosted version: "2.1.4" + stream_transform: + dependency: transitive + description: + name: stream_transform + sha256: ad47125e588cfd37a9a7f86c7d6356dde8dfe89d071d293f80ca9e9273a33871 + url: "https://pub.flutter-io.cn" + source: hosted + version: "2.1.1" string_scanner: dependency: transitive description: @@ -985,6 +1154,14 @@ packages: url: "https://pub.flutter-io.cn" source: hosted version: "0.7.6" + timing: + dependency: transitive + description: + name: timing + sha256: "62ee18aca144e4a9f29d212f5a4c6a053be252b895ab14b5821996cff4ed90fe" + url: "https://pub.flutter-io.cn" + source: hosted + version: "1.0.2" typed_data: dependency: transitive description: @@ -1123,6 +1300,22 @@ packages: url: "https://pub.flutter-io.cn" source: hosted version: "1.1.1" + web_socket: + dependency: transitive + description: + name: web_socket + sha256: "34d64019aa8e36bf9842ac014bb5d2f5586ca73df5e4d9bf5c936975cae6982c" + url: "https://pub.flutter-io.cn" + source: hosted + version: "1.0.1" + web_socket_channel: + dependency: transitive + description: + name: web_socket_channel + sha256: d645757fb0f4773d602444000a8131ff5d48c9e47adfe9772652dd1a4f2d45c8 + url: "https://pub.flutter-io.cn" + source: hosted + version: "3.0.3" win32: dependency: transitive description: diff --git a/pubspec.yaml b/pubspec.yaml index b96bb8d..ec35550 100644 --- a/pubspec.yaml +++ b/pubspec.yaml @@ -47,6 +47,7 @@ dependencies: pretty_dio_logger: ^1.4.0 logger: ^2.7.0 uuid: ^4.5.1 + collection: ^1.18.0 catcher_2: ^2.1.9 get: @@ -117,6 +118,12 @@ dependencies: git: url: https://gitcode.com/openharmony-sig/flutter_plus_plugins.git path: packages/device_info_plus/device_info_plus + receive_sharing_intent: + git: + url: "https://gitcode.com/openharmony-sig/fluttertpc_receive_sharing_intent.git" + ref: "br_v1.8.1_ohos" + + flutter_staggered_grid_view: path: packages/flutter_staggered_grid_view @@ -155,6 +162,7 @@ dev_dependencies: sdk: flutter flutter_lints: ^6.0.0 + build_runner: ^2.4.13 dependency_overrides: path_provider: