diff --git a/CHANGELOG.md b/CHANGELOG.md
index 3b2b462..d4032cc 100644
--- a/CHANGELOG.md
+++ b/CHANGELOG.md
@@ -6,6 +6,25 @@ All notable changes to this project will be documented in this file.
### Enhanced — 软件特性功能完善
+- ✨ **食材推荐功能** — `lib/src/pages/discover/ingredient_recommend_page.dart`, `lib/src/pages/discover/ingredient_recipe_list_page.dart`
+ - **功能**:在分类浏览下增加食材推荐入口,点击食材跳转该食材对应的菜品列表
+ - **实现**:
+ - 新建 IngredientRecommendPage 页面,展示食材网格(emoji+名称),调用 `api.php?act=ingredients`
+ - 新建 IngredientRecipeListPage 页面,显示食材相关菜品列表,调用 `api.php?act=search&keyword=xxx&type=recipe`,每页20条,支持分页加载
+ - 首页分类浏览区域添加"食材推荐"入口卡片(渐变背景),点击跳转食材推荐页面
+ - 食材emoji智能匹配(鸡→🥚、肉→🥩、鱼→🐟、菜→🥬等40+映射)
+ - **影响文件**:
+ - `lib/src/pages/discover/ingredient_recommend_page.dart` (新建)
+ - `lib/src/pages/discover/ingredient_recipe_list_page.dart` (新建)
+ - `lib/src/pages/home/home_page.dart` (修改)
+ - `lib/src/config/app_routes.dart` (修改)
+ - `lib/src/standards/app_pages.dart` (修改)
+
+- ✨ **食材详情页数据传递修复** — `lib/src/pages/home/recipe_detail_page.dart`, `lib/src/pages/tools/ingredient_detail_page.dart`
+ - **问题**:点击菜品详情页食材,食材介绍不显示
+ - **原因**:detail数据已存在于act=full返回的ingredients[].detail中,但未传递给详情页
+ - **修复**:recipe_detail_page传递detail参数,ingredient_detail_page接收并直接展示introduction/nutrition/guidance/effect/allergen
+
- ✨ **功能状态审核与完善**
- **购物清单** - ✅ 已完成:菜谱详情页"购物"按钮可添加食材到购物清单
- **过敏原检测** - ✅ 已完成:AllergenChecker完整实现,包含11类过敏原关键词映射和检测逻辑
diff --git a/docs/api/cache.php b/docs/api/cache.php
deleted file mode 100644
index 8b65675..0000000
--- a/docs/api/cache.php
+++ /dev/null
@@ -1,304 +0,0 @@
- 300,
- 'detail' => 600,
- 'full' => 600,
- 'ingredients' => 600,
- 'ingredient_detail' => 1200,
- 'search' => 180,
- 'categories' => 1800,
- 'tags' => 1800,
- 'stats' => 120,
- 'stats_full' => 120,
- 'hot' => 300,
- 'query' => 180,
- 'filter' => 180,
- 'like' => 0,
- 'recommend' => 0,
- 'view' => 0
- );
-
- public static function init() {
- if (self::$cacheDir === null) {
- self::$cacheDir = dirname(__FILE__) . '/cache/';
- if (!is_dir(self::$cacheDir)) {
- @mkdir(self::$cacheDir, 0755, true);
- }
- }
- }
-
- public static function getTTL($act) {
- return isset(self::$ttlConfig[$act]) ? self::$ttlConfig[$act] : self::$defaultTTL;
- }
-
- public static function getCacheKey($act, $params = array()) {
- ksort($params);
- $paramStr = http_build_query($params);
- return md5($act . '_' . $paramStr);
- }
-
- public static function get($act, $params = array()) {
- self::init();
-
- $ttl = isset(self::$ttlConfig[$act]) ? self::$ttlConfig[$act] : self::$defaultTTL;
- if ($ttl <= 0) {
- return null;
- }
-
- $key = self::getCacheKey($act, $params);
- $file = self::$cacheDir . $key . '.json';
-
- if (!file_exists($file)) {
- return null;
- }
-
- $content = file_get_contents($file);
- $data = json_decode($content, true);
-
- if (!$data || !isset($data['expire']) || !isset($data['data'])) {
- @unlink($file);
- return null;
- }
-
- if (time() > $data['expire']) {
- @unlink($file);
- return null;
- }
-
- return $data['data'];
- }
-
- public static function getStale($act, $params = array()) {
- self::init();
-
- $key = self::getCacheKey($act, $params);
- $file = self::$cacheDir . $key . '.json';
-
- if (!file_exists($file)) {
- return null;
- }
-
- $content = @file_get_contents($file);
- if ($content === false) {
- return null;
- }
-
- $data = json_decode($content, true);
-
- if (!$data || !isset($data['data'])) {
- return null;
- }
-
- return $data['data'];
- }
-
- public static function isExpired($act, $params = array()) {
- self::init();
-
- $key = self::getCacheKey($act, $params);
- $file = self::$cacheDir . $key . '.json';
-
- if (!file_exists($file)) {
- return true;
- }
-
- $content = @file_get_contents($file);
- if ($content === false) {
- return true;
- }
-
- $data = json_decode($content, true);
-
- if (!$data || !isset($data['expire'])) {
- return true;
- }
-
- return time() > $data['expire'];
- }
-
- public static function getCacheAge($act, $params = array()) {
- self::init();
-
- $key = self::getCacheKey($act, $params);
- $file = self::$cacheDir . $key . '.json';
-
- if (!file_exists($file)) {
- return -1;
- }
-
- $content = @file_get_contents($file);
- if ($content === false) {
- return -1;
- }
-
- $data = json_decode($content, true);
-
- if (!$data || !isset($data['created'])) {
- return -1;
- }
-
- return time() - $data['created'];
- }
-
- public static function set($act, $params, $data) {
- self::init();
-
- $ttl = isset(self::$ttlConfig[$act]) ? self::$ttlConfig[$act] : self::$defaultTTL;
- if ($ttl <= 0) {
- return false;
- }
-
- $key = self::getCacheKey($act, $params);
- $file = self::$cacheDir . $key . '.json';
-
- $cacheData = array(
- 'act' => $act,
- 'params' => $params,
- 'data' => $data,
- 'expire' => time() + $ttl,
- 'created' => time()
- );
-
- return file_put_contents($file, json_encode($cacheData, JSON_UNESCAPED_UNICODE)) !== false;
- }
-
- public static function clear($act = null, $params = array()) {
- self::init();
-
- if ($act === null) {
- $files = glob(self::$cacheDir . '*.json');
- foreach ($files as $file) {
- @unlink($file);
- }
- return true;
- }
-
- $key = self::getCacheKey($act, $params);
- $file = self::$cacheDir . $key . '.json';
-
- if (file_exists($file)) {
- @unlink($file);
- }
-
- return true;
- }
-
- public static function clearByAct($act) {
- self::init();
-
- $files = glob(self::$cacheDir . '*.json');
- $count = 0;
-
- foreach ($files as $file) {
- $content = file_get_contents($file);
- $data = json_decode($content, true);
- if ($data && isset($data['act']) && $data['act'] === $act) {
- @unlink($file);
- $count++;
- }
- }
-
- return $count;
- }
-
- public static function cleanExpired() {
- self::init();
-
- $files = glob(self::$cacheDir . '*.json');
- $count = 0;
- $now = time();
-
- foreach ($files as $file) {
- $content = @file_get_contents($file);
- if ($content === false) {
- @unlink($file);
- $count++;
- continue;
- }
-
- $data = json_decode($content, true);
- if (!$data || !isset($data['expire']) || $now > $data['expire']) {
- @unlink($file);
- $count++;
- }
- }
-
- return $count;
- }
-
- public static function getStats() {
- self::init();
-
- $files = glob(self::$cacheDir . '*.json');
- $totalSize = 0;
- $count = 0;
- $oldest = time();
- $newest = 0;
-
- foreach ($files as $file) {
- $count++;
- $totalSize += filesize($file);
- $content = file_get_contents($file);
- $data = json_decode($content, true);
- if ($data && isset($data['created'])) {
- $oldest = min($oldest, $data['created']);
- $newest = max($newest, $data['created']);
- }
- }
-
- return array(
- 'count' => $count,
- 'total_size' => $totalSize,
- 'total_size_readable' => self::formatBytes($totalSize),
- 'oldest' => $oldest > 0 ? date('Y-m-d H:i:s', $oldest) : '-',
- 'newest' => $newest > 0 ? date('Y-m-d H:i:s', $newest) : '-'
- );
- }
-
- private static function formatBytes($bytes) {
- if ($bytes >= 1073741824) {
- return number_format($bytes / 1073741824, 2) . ' GB';
- } elseif ($bytes >= 1048576) {
- return number_format($bytes / 1048576, 2) . ' MB';
- } elseif ($bytes >= 1024) {
- return number_format($bytes / 1024, 2) . ' KB';
- } else {
- return $bytes . ' bytes';
- }
- }
-}
-
-if (php_sapi_name() === 'cli' && isset($argv[0]) && basename($argv[0]) === 'cache.php') {
- $action = $argv[1] ?? 'stats';
-
- switch ($action) {
- case 'clean':
- $count = ApiCache::cleanExpired();
- echo "Cleaned {$count} expired cache files.\n";
- break;
- case 'clear':
- ApiCache::clear();
- echo "All cache cleared.\n";
- break;
- case 'stats':
- default:
- $stats = ApiCache::getStats();
- echo "Cache Statistics:\n";
- echo " Files: {$stats['count']}\n";
- echo " Size: {$stats['total_size_readable']}\n";
- echo " Oldest: {$stats['oldest']}\n";
- echo " Newest: {$stats['newest']}\n";
- break;
- }
-}
diff --git a/docs/api/cache_manage.php b/docs/api/cache_manage.php
deleted file mode 100644
index 43d5c87..0000000
--- a/docs/api/cache_manage.php
+++ /dev/null
@@ -1,104 +0,0 @@
-Load();
-
-require_once 'cache.php';
-
-header('Content-Type: application/json; charset=utf-8');
-header('Access-Control-Allow-Origin: *');
-
-$action = $_GET['action'] ?? 'stats';
-
-$result = array();
-
-switch ($action) {
- case 'stats':
- $result = array(
- 'code' => 200,
- 'message' => '📊 缓存统计',
- 'data' => ApiCache::getStats()
- );
- break;
-
- case 'clean':
- $count = ApiCache::cleanExpired();
- $result = array(
- 'code' => 200,
- 'message' => "🧹 已清理 {$count} 个过期缓存",
- 'data' => array(
- 'cleaned' => $count,
- 'stats' => ApiCache::getStats()
- )
- );
- break;
-
- case 'clear':
- $act = $_GET['act'] ?? null;
- if ($act) {
- $count = ApiCache::clearByAct($act);
- $result = array(
- 'code' => 200,
- 'message' => "🗑️ 已清除 {$act} 相关的 {$count} 个缓存",
- 'data' => array(
- 'act' => $act,
- 'cleaned' => $count
- )
- );
- } else {
- ApiCache::clear();
- $result = array(
- 'code' => 200,
- 'message' => '🗑️ 已清除所有缓存',
- 'data' => ApiCache::getStats()
- );
- }
- break;
-
- case 'config':
- $result = array(
- 'code' => 200,
- 'message' => '⚙️ 缓存配置',
- 'data' => array(
- 'ttl_config' => array(
- 'list' => '180秒 (3分钟)',
- 'detail' => '300秒 (5分钟)',
- 'ingredients' => '300秒 (5分钟)',
- 'ingredient_detail' => '600秒 (10分钟)',
- 'search' => '120秒 (2分钟)',
- 'categories' => '600秒 (10分钟)',
- 'tags' => '600秒 (10分钟)',
- 'stats' => '60秒 (1分钟)',
- 'like' => '不缓存',
- 'recommend' => '不缓存',
- 'view' => '不缓存'
- ),
- 'auto_clean' => '1%概率自动清理过期缓存'
- )
- );
- break;
-
- default:
- $result = array(
- 'code' => 200,
- 'message' => '🧹 缓存管理接口',
- 'data' => array(
- 'actions' => array(
- 'stats' => '查看缓存统计',
- 'clean' => '清理过期缓存',
- 'clear' => '清除所有缓存',
- 'clear&act=xxx' => '清除指定接口缓存',
- 'config' => '查看缓存配置'
- )
- )
- );
- break;
-}
-
-echo json_encode($result, JSON_PRETTY_PRINT | JSON_UNESCAPED_UNICODE);
-exit;
diff --git a/docs/api/diagnose.php b/docs/api/diagnose.php
deleted file mode 100644
index c90906d..0000000
--- a/docs/api/diagnose.php
+++ /dev/null
@@ -1,206 +0,0 @@
-Load();
-
-header('Content-Type: text/html; charset=utf-8');
-
-function check($msg, $result, $detail = '') {
- $icon = $result ? '✅' : '❌';
- $color = $result ? 'green' : 'red';
- echo "
";
- echo "$icon $msg";
- if ($detail) echo "
$detail";
- echo "
";
-}
-
-?>
-
-
-
-
- 🍳 数据库诊断
-
-
-
-
-
-
-
🔌 数据库连接
- db->Query($sql);
- check("数据库连接", true, "类型: " . get_class($zbp->db) . ", 前缀: " . $zbp->db->dbpre);
- } catch (Exception $e) {
- check("数据库连接", false, $e->getMessage());
- }
- ?>
-
-
-
-
📋 数据表检查
- db->dbpre . $table;
- $sql = "SELECT COUNT(*) FROM $fullTable";
- $count = $zbp->db->Query($sql)[0]['COUNT(*)'] ?? 0;
- check("$fullTable 表", true, "$count 条记录");
- } catch (Exception $e) {
- check("$fullTable 表", false, $e->getMessage());
- }
- }
- ?>
-
-
-
-
🍳 菜谱数据检查
- db->dbpre . 'Post';
- $sql = "SELECT COUNT(*) as cnt FROM $tablePost WHERE log_Type = 0 AND log_Status = 0";
- $publicCount = $zbp->db->Query($sql)[0]['cnt'] ?? 0;
- check("公开菜谱 (log_Type=0, log_Status=0)", $publicCount > 0, "数量: $publicCount");
-
- // 检查ID范围
- $sql = "SELECT MIN(log_ID) as min_id, MAX(log_ID) as max_id FROM $tablePost WHERE log_Type = 0";
- $range = $zbp->db->Query($sql)[0];
- check("菜谱ID范围", true, "min={$range['min_id']}, max={$range['max_id']}");
-
- // 检查示例菜谱
- $sql = "SELECT log_ID, log_Title, log_CateID FROM $tablePost WHERE log_Type = 0 AND log_Status = 0 LIMIT 3";
- $samples = $zbp->db->Query($sql);
- ?>
-
示例菜谱数据:
-
- | ID | 标题 | 分类ID |
-
-
- |
- |
- |
-
-
-
- db->Query($sql);
- if ($id70) {
- check("ID=70 菜谱", true, "标题: " . $id70[0]['log_Title']);
- } else {
- check("ID=70 菜谱", false, "不存在或不是文章类型");
- }
- ?>
-
-
-
-
🥬 食材数据检查
- db->dbpre . 'recipe_ingredient';
-
- // 检查唯一食材数量
- $sql = "SELECT COUNT(DISTINCT name) as cnt FROM $tableIng";
- $uniqueCount = $zbp->db->Query($sql)[0]['cnt'] ?? 0;
- check("食材种类数", $uniqueCount > 0, "$uniqueCount 种");
-
- // 检查ID范围
- $sql = "SELECT MIN(ingredient_id) as min_id, MAX(ingredient_id) as max_id FROM $tableIng";
- $range = $zbp->db->Query($sql)[0];
- check("ingredient_id范围", true, "min={$range['min_id']}, max={$range['max_id']}");
-
- // 检查示例食材
- $sql = "SELECT DISTINCT name, ingredient_id FROM $tableIng LIMIT 5";
- $samples = $zbp->db->Query($sql);
- ?>
-
示例食材数据:
-
- | ingredient_id | 名称 |
-
-
- |
- |
-
-
-
- db->Query($sql);
- if ($id85) {
- check("ID=85 食材", true, "名称: " . $id85[0]['name']);
- } else {
- check("ID=85 食材", false, "不存在");
- }
- ?>
-
-
-
-
🧪 API实际查询测试
- db->Query($sql);
- check("list接口查询", count($listResult) > 0, "返回 " . count($listResult) . " 条");
-
- // 模拟ingredients接口查询
- $sql = "SELECT DISTINCT name, ingredient_id FROM $tableIng LIMIT 3";
- $ingResult = $zbp->db->Query($sql);
- check("ingredients接口查询", count($ingResult) > 0, "返回 " . count($ingResult) . " 条");
- ?>
-
-
-
-
💡 问题诊断建议
-
-
-⚠️ 公开菜谱数量为0,可能原因:
-1. log_Status 字段值不是0 (0=公开, 1=草稿, 2=审核中)
-2. log_Type 字段值不是0 (0=文章, 1=页面)
-
-修复SQL:
-UPDATE zbp_Post SET log_Status = 0 WHERE log_Type = 0;
-
-
-
-
-
-⚠️ ID=70不存在,可用ID范围: ~
-
-测试可用的ID:
-?act=detail&id=
-
-
-
-
-
-⚠️ ingredient_id=85不存在,可用范围: ~
-
-
-
-
-
-
🍳 菜谱API诊断工具 | Powered by Z-Blog PHP
-
-
-
diff --git a/lib/src/config/app_routes.dart b/lib/src/config/app_routes.dart
index 0cff171..d8afcff 100644
--- a/lib/src/config/app_routes.dart
+++ b/lib/src/config/app_routes.dart
@@ -28,6 +28,8 @@ import 'package:mom_kitchen/src/pages/tools/meal_planner_page.dart';
import 'package:mom_kitchen/src/pages/tools/ingredient_detail_page.dart';
import 'package:mom_kitchen/src/pages/tools/cooking_note_page.dart';
import 'package:mom_kitchen/src/pages/discover/category_browse_page.dart';
+import 'package:mom_kitchen/src/pages/discover/ingredient_recommend_page.dart';
+import 'package:mom_kitchen/src/pages/discover/ingredient_recipe_list_page.dart';
import 'package:mom_kitchen/src/pages/tools/eating_times_page.dart';
import 'package:mom_kitchen/src/pages/tools/weekly_menu_planner_page.dart';
import 'package:mom_kitchen/src/pages/profile/bedtime_reminder_page.dart';
@@ -62,6 +64,8 @@ class AppRoutes {
static const String toolsNutrition = '/tools/nutrition';
static const String toolsConverter = '/tools/converter';
static const String toolsIngredient = '/tools/ingredient';
+ static const String toolsIngredientRecommend = '/tools/ingredient-recommend';
+ static const String toolsIngredientRecipes = '/tools/ingredient-recipes';
static const String toolsStats = '/tools/stats';
static const String toolsPlanner = '/tools/planner';
static const String cookingNote = '/cooking-note';
@@ -226,6 +230,22 @@ class AppRoutes {
page: () => const IngredientDetailPage(),
middlewares: [PageStandardsMiddleware()],
),
+ GetPage(
+ name: toolsIngredientRecommend,
+ page: () => const IngredientRecommendPage(),
+ middlewares: [PageStandardsMiddleware()],
+ ),
+ GetPage(
+ name: toolsIngredientRecipes,
+ page: () {
+ final args = Get.arguments as Map?;
+ return IngredientRecipeListPage(
+ ingredientId: args?['ingredientId'] ?? 0,
+ ingredientName: args?['ingredientName'] ?? '',
+ );
+ },
+ middlewares: [PageStandardsMiddleware()],
+ ),
GetPage(
name: toolsNutrition,
page: () => const NutritionCenterPage(),
@@ -250,8 +270,7 @@ class AppRoutes {
page: () => CategoryBrowsePage(
category: Get.arguments?['category'],
title: Get.arguments?['title'] ?? '分类浏览',
- loadRecipesDirectly:
- Get.arguments?['loadRecipesDirectly'] ?? false,
+ loadRecipesDirectly: Get.arguments?['loadRecipesDirectly'] ?? false,
),
middlewares: [PageStandardsMiddleware()],
),
@@ -523,6 +542,43 @@ class AppRoutes {
],
builder: () => const ServingScalerPage(),
),
+ PageInfo(
+ route: toolsIngredient,
+ name: 'Ingredient Detail Page',
+ description: '食材详情页面',
+ requiredStandards: const [
+ StandardCheck.themeColors,
+ StandardCheck.textColors,
+ StandardCheck.fontSize,
+ StandardCheck.darkMode,
+ ],
+ builder: () => const IngredientDetailPage(),
+ ),
+ PageInfo(
+ route: toolsIngredientRecommend,
+ name: 'Ingredient Recommend Page',
+ description: '食材推荐页面',
+ requiredStandards: const [
+ StandardCheck.themeColors,
+ StandardCheck.textColors,
+ StandardCheck.fontSize,
+ StandardCheck.darkMode,
+ ],
+ builder: () => const IngredientRecommendPage(),
+ ),
+ PageInfo(
+ route: toolsIngredientRecipes,
+ name: 'Ingredient Recipe List Page',
+ description: '食材菜品列表页面',
+ requiredStandards: const [
+ StandardCheck.themeColors,
+ StandardCheck.textColors,
+ StandardCheck.fontSize,
+ StandardCheck.darkMode,
+ ],
+ builder: () =>
+ const IngredientRecipeListPage(ingredientId: 0, ingredientName: ''),
+ ),
PageInfo(
route: toolsCenter,
name: 'Tools Center Page',
diff --git a/lib/src/controllers/favorites_controller.dart b/lib/src/controllers/favorites_controller.dart
index db6f9e0..56fc2e9 100644
--- a/lib/src/controllers/favorites_controller.dart
+++ b/lib/src/controllers/favorites_controller.dart
@@ -149,13 +149,14 @@ class FavoritesController extends BaseController {
}
void deleteSelected() {
+ final count = selectedIds.length;
for (final id in selectedIds.toList()) {
_favorites.remove(id);
_removeFromHive(id);
}
selectedIds.clear();
isEditMode.value = false;
- ToastService.show(message: '已删除 ${selectedIds.length} 项收藏 💔');
+ ToastService.show(message: '已删除 $count 项收藏 💔');
}
void _saveToHive(FeedItemModel item) {
diff --git a/lib/src/controllers/recipe_detail_controller.dart b/lib/src/controllers/recipe_detail_controller.dart
new file mode 100644
index 0000000..3f8c304
--- /dev/null
+++ b/lib/src/controllers/recipe_detail_controller.dart
@@ -0,0 +1,187 @@
+import 'package:flutter/foundation.dart';
+import 'package:get/get.dart';
+import 'package:mom_kitchen/src/controllers/base_controller.dart';
+import 'package:mom_kitchen/src/controllers/feed/action_controller.dart';
+import 'package:mom_kitchen/src/controllers/favorites_controller.dart';
+import 'package:mom_kitchen/src/models/feed_item_model.dart';
+import 'package:mom_kitchen/src/models/recipe/recipe_model.dart';
+import 'package:mom_kitchen/src/repositories/recipe_repository.dart';
+import 'package:mom_kitchen/src/services/ui/toast_service.dart';
+import 'package:mom_kitchen/src/utils/common_utils.dart';
+
+class RecipeDetailController extends BaseController {
+ final RecipeRepository _recipeRepository = RecipeRepository();
+ final ActionController _actionController = Get.find();
+ final FavoritesController _favoritesController = Get.find();
+
+ // 响应式状态
+ final Rx recipe = Rx(null);
+ final RxBool isFavorite = false.obs;
+ final RxInt likeCount = 0.obs;
+ final RxInt viewCount = 0.obs;
+ final RxBool loadTimeout = false.obs;
+
+ static const Duration _loadTimeoutDuration = Duration(seconds: 8);
+
+ Future loadRecipe(String recipeId) async {
+ await runWithLoading(() async {
+ loadTimeout.value = false;
+
+ Future.delayed(_loadTimeoutDuration, () {
+ if (isLoading.value) {
+ loadTimeout.value = true;
+ debugPrint('⏰ 详情页加载超时 (${_loadTimeoutDuration.inSeconds}秒)');
+ }
+ });
+
+ final id = int.tryParse(recipeId) ?? 0;
+ final loadedRecipe = await _recipeRepository.fetchFull(
+ id,
+ viewnums: true,
+ );
+
+ debugPrint('🔍 菜谱数据加载成功: id=${loadedRecipe.id}, title=${loadedRecipe.title}');
+ debugPrint('🔍 PicId原始值: ${loadedRecipe.picId}, cover=${loadedRecipe.cover}');
+ debugPrint('🔍 Recipe其他字段: code=${loadedRecipe.code}, status=${loadedRecipe.status}');
+
+ recipe.value = loadedRecipe;
+ likeCount.value = loadedRecipe.statistics?.likes ?? 0;
+ viewCount.value = loadedRecipe.statistics?.views ?? 0;
+
+ await _checkFavorite();
+ _recordView();
+ });
+ }
+
+ Future refreshRecipe(String recipeId) async {
+ await runWithLoading(() async {
+ final id = int.tryParse(recipeId) ?? 0;
+ final loadedRecipe = await _recipeRepository.fetchFull(
+ id,
+ viewnums: false,
+ );
+
+ recipe.value = loadedRecipe;
+ likeCount.value = loadedRecipe.statistics?.likes ?? 0;
+ viewCount.value = loadedRecipe.statistics?.views ?? 0;
+ await _checkFavorite();
+ ToastService.show(message: '✅ 刷新成功');
+ });
+ }
+
+ Future _checkFavorite() async {
+ if (recipe.value != null) {
+ try {
+ isFavorite.value = _favoritesController.isFavorited(recipe.value!.id);
+ } catch (e) {
+ debugPrint('检查收藏状态失败: $e');
+ }
+ }
+ }
+
+ void _recordView() {
+ if (recipe.value != null) {
+ _actionController.reportView(id: recipe.value!.id, type: 'recipe');
+ }
+ }
+
+ Future toggleFavorite() async {
+ if (recipe.value != null) {
+ try {
+ final feedItem = FeedItemModel.fromRecipe(recipe.value!);
+ await _favoritesController.toggleFavorite(feedItem);
+ isFavorite.value = !isFavorite.value;
+ } catch (e) {
+ debugPrint('切换收藏失败:$e');
+ }
+ }
+ }
+
+ Future likeRecipe() async {
+ if (recipe.value != null) {
+ try {
+ final wasLiked = _actionController.isLiked(recipe.value!.id);
+ likeCount.value += wasLiked ? -1 : 1;
+ await _actionController.likeItem(id: recipe.value!.id, type: 'recipe');
+ } catch (e) {
+ debugPrint('点赞失败:$e');
+ }
+ }
+ }
+
+ Future recommendRecipe({int? score}) async {
+ if (recipe.value != null) {
+ try {
+ await _actionController.recommendItem(
+ id: recipe.value!.id,
+ type: 'recipe',
+ score: score,
+ );
+ } catch (e) {
+ debugPrint('推荐失败:$e');
+ }
+ }
+ }
+
+ bool get isRecommended => _actionController.isRecommended(recipe.value?.id ?? 0);
+
+ bool get isLiked => _actionController.isLiked(recipe.value?.id ?? 0);
+
+ Future addToShoppingList(String recipeId) async {
+ if (recipe.value == null || recipe.value!.ingredients.isEmpty) {
+ throw Exception('该菜谱暂无食材信息');
+ }
+
+ try {
+ final controller = Get.find(); // ShoppingListController
+ final id = int.tryParse(recipeId) ?? 0;
+ final items = recipe.value!.ingredients.map((ing) {
+ return {
+ 'name': ing.name,
+ 'amount': ing.amount,
+ 'unit': ing.unit,
+ 'category': ing.category,
+ 'recipeId': id,
+ };
+ }).toList();
+
+ // 使用动态调用以避免强类型依赖
+ await controller.addItemsFromRecipe(id, recipe.value!.title, items);
+ } catch (e) {
+ debugPrint('添加到购物清单失败: $e');
+ rethrow;
+ }
+ }
+
+ void shareRecipe() {
+ if (recipe.value == null) return;
+
+ final ingredients = recipe.value!.ingredients
+ .map((i) {
+ final amount = i.amount ?? '';
+ final unit = i.unit ?? '';
+ return '• ${i.name}${amount.isNotEmpty ? ' $amount' : ''}${unit.isNotEmpty ? unit : ''}';
+ })
+ .join('\n');
+
+ final shareText = StringBuffer();
+ shareText.writeln('🍳 ${recipe.value!.title}');
+ shareText.writeln('');
+ if (ingredients.isNotEmpty) {
+ shareText.writeln('📋 食材:');
+ shareText.writeln(ingredients);
+ shareText.writeln('');
+ }
+ if (recipe.value!.content != null && recipe.value!.content!.isNotEmpty) {
+ shareText.writeln('👩🍳 做法:');
+ shareText.writeln(recipe.value!.content);
+ }
+ shareText.writeln('');
+ shareText.write('— 来自 老妈厨房 App 🍳');
+
+ CommonUtils.shareContent(
+ shareText.toString(),
+ subject: '🍳 ${recipe.value!.title}',
+ );
+ }
+}
diff --git a/lib/src/controllers/tools_controller.dart b/lib/src/controllers/tools_controller.dart
index 4073b79..d66bb80 100644
--- a/lib/src/controllers/tools_controller.dart
+++ b/lib/src/controllers/tools_controller.dart
@@ -3,6 +3,7 @@
* 名称: 工具中心控制器
* 作用: 管理工具列表、使用频率统计、搜索过滤
* 更新: 2026-04-10 初始创建
+ * 更新: 2026-04-12 使用compute修复JSON解析卡死问题
*/
import 'dart:convert';
@@ -12,6 +13,15 @@ import 'package:shared_preferences/shared_preferences.dart';
import 'package:mom_kitchen/src/controllers/base_controller.dart';
import 'package:mom_kitchen/src/models/tool_item_model.dart';
+/// Isolate-safe function to parse JSON, preventing UI thread blockage.
+Map _parseUsageData(String data) {
+ try {
+ return Map.from(json.decode(data));
+ } catch (e) {
+ return {};
+ }
+}
+
class ToolsController extends BaseController {
static const String _usageKey = 'tool_usage_counts';
@@ -36,7 +46,7 @@ class ToolsController extends BaseController {
super.onInit();
// 先使用默认工具列表初始化,避免阻塞
tools.value = ToolRegistry.defaultTools;
- filteredTools.value = tools;
+ filteredTools.assignAll(tools);
// 异步加载使用统计数据
_loadUsageData();
}
@@ -48,11 +58,8 @@ class ToolsController extends BaseController {
Map usageMap = {};
if (usageData != null && usageData.isNotEmpty) {
- try {
- usageMap = Map.from(json.decode(usageData));
- } catch (e) {
- debugPrint('ToolsController: Failed to parse usage data: $e');
- }
+ // 使用 compute 在独立 isolate 中解析 JSON,防止 UI 线程阻塞
+ usageMap = await compute(_parseUsageData, usageData);
}
tools.value = ToolRegistry.defaultTools.map((tool) {
@@ -162,7 +169,11 @@ class ToolsController extends BaseController {
Future openTool(ToolItem tool) async {
await recordUsage(tool.id);
- Get.toNamed(tool.route);
+ try {
+ Get.toNamed(tool.route);
+ } catch (e) {
+ debugPrint('ToolsController: Navigation error: $e');
+ }
}
Future resetUsageData() async {
diff --git a/lib/src/pages/discover/category_browse_page.dart b/lib/src/pages/discover/category_browse_page.dart
index cc05a5e..5beefb4 100644
--- a/lib/src/pages/discover/category_browse_page.dart
+++ b/lib/src/pages/discover/category_browse_page.dart
@@ -3,10 +3,11 @@
* 名称: 分类浏览页面
* 作用: 分类层级导航,大类→小类→菜谱列表
* 创建: 2026-04-11
- * 更新: 2026-04-11 初始创建
+ * 更新: 2026-04-12 重写子分类为列表布局
*/
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/recipe/category_model.dart';
@@ -52,14 +53,13 @@ class _CategoryBrowsePageState extends State {
} else if (widget.category != null &&
widget.category!.children.isNotEmpty) {
_categories = widget.category!.children;
- if (_categories.isNotEmpty) {
- await _loadRecipes(_categories.first.id);
- _selectedSubCategory = _categories.first;
- }
+ _selectedSubCategory = null;
+ _recipes = [];
} else {
_categories = await _repo.fetchCategories();
if (_categories.isNotEmpty && widget.category != null) {
- await _loadRecipes(_categories.first.id);
+ _selectedSubCategory = null;
+ _recipes = [];
}
}
} catch (e) {
@@ -139,6 +139,9 @@ class _CategoryBrowsePageState extends State {
}
Widget _buildContent(bool isDark) {
+ if (widget.loadRecipesDirectly) {
+ return _buildRecipeList(isDark);
+ }
if (widget.category != null && widget.category!.children.isNotEmpty) {
return _buildSubCategoryView(isDark);
}
@@ -248,6 +251,41 @@ class _CategoryBrowsePageState extends State {
Widget _buildSubCategoryView(bool isDark) {
return Column(
children: [
+ Padding(
+ padding: const EdgeInsets.only(
+ left: DesignTokens.space4,
+ top: DesignTokens.space2,
+ bottom: DesignTokens.space2,
+ ),
+ child: Row(
+ children: [
+ Icon(
+ CupertinoIcons.folder_open,
+ size: 18,
+ color: DesignTokens.primary,
+ ),
+ const SizedBox(width: DesignTokens.space2),
+ Text(
+ '子分类列表',
+ style: TextStyle(
+ fontSize: DesignTokens.fontMd,
+ fontWeight: FontWeight.w600,
+ color: isDark ? DarkDesignTokens.text1 : DesignTokens.text1,
+ ),
+ ),
+ const Spacer(),
+ Obx(
+ () => Text(
+ '${_categories.length} 个分类',
+ style: TextStyle(
+ fontSize: DesignTokens.fontXs,
+ color: isDark ? DarkDesignTokens.text3 : DesignTokens.text3,
+ ),
+ ),
+ ),
+ ],
+ ),
+ ),
SizedBox(
height: 44,
child: ListView.separated(
@@ -256,7 +294,7 @@ class _CategoryBrowsePageState extends State {
horizontal: DesignTokens.space4,
),
itemCount: _categories.length,
- separatorBuilder: (_, __) =>
+ separatorBuilder: (context, index) =>
const SizedBox(width: DesignTokens.space2),
itemBuilder: (context, index) {
final cat = _categories[index];
@@ -319,37 +357,238 @@ class _CategoryBrowsePageState extends State {
),
const SizedBox(height: DesignTokens.space3),
Expanded(
- child: _recipes.isEmpty
- ? Center(
- child: Column(
- mainAxisAlignment: MainAxisAlignment.center,
+ child: _selectedSubCategory == null
+ ? _buildSubCategoryList(isDark)
+ : _recipes.isEmpty
+ ? _buildEmptyRecipeState(isDark)
+ : _buildRecipeListView(isDark),
+ ),
+ ],
+ );
+ }
+
+ Widget _buildSubCategoryList(bool isDark) {
+ return ListView.builder(
+ padding: const EdgeInsets.symmetric(horizontal: DesignTokens.space4),
+ itemCount: _categories.length,
+ itemBuilder: (context, index) {
+ final cat = _categories[index];
+ return _buildSubCategoryListItem(cat, isDark, index);
+ },
+ );
+ }
+
+ Widget _buildSubCategoryListItem(CategoryModel cat, bool isDark, int index) {
+ final hasChildren = cat.children.isNotEmpty;
+ final hasRecipes = cat.count != null && cat.count! > 0;
+
+ return GestureDetector(
+ onTap: () {
+ if (hasChildren || hasRecipes) {
+ setState(() => _selectedSubCategory = cat);
+ _loadRecipes(cat.id);
+ } else {
+ Get.toNamed(
+ '/category-browse',
+ arguments: {'category': cat, 'title': cat.name},
+ );
+ }
+ },
+ child: Container(
+ margin: const EdgeInsets.only(bottom: DesignTokens.space2 + 4),
+ padding: const EdgeInsets.all(DesignTokens.space3),
+ decoration: BoxDecoration(
+ color: isDark ? DarkDesignTokens.card : DesignTokens.card,
+ borderRadius: BorderRadius.circular(16),
+ border: Border.all(
+ color: isDark
+ ? DarkDesignTokens.glassBorder
+ : DesignTokens.text3.withValues(alpha: 0.08),
+ ),
+ boxShadow: [
+ BoxShadow(
+ color: Colors.black.withValues(alpha: isDark ? 0.15 : 0.05),
+ blurRadius: 12,
+ offset: const Offset(0, 4),
+ ),
+ ],
+ ),
+ child: Row(
+ children: [
+ Container(
+ width: 56,
+ height: 56,
+ decoration: BoxDecoration(
+ gradient: LinearGradient(
+ begin: Alignment.topLeft,
+ end: Alignment.bottomRight,
+ colors: [
+ _getGradientColor(index).withValues(alpha: 0.2),
+ _getGradientColor(index).withValues(alpha: 0.08),
+ ],
+ ),
+ borderRadius: BorderRadius.circular(14),
+ ),
+ child: Center(
+ child: Text(
+ cat.displayIcon,
+ style: const TextStyle(fontSize: 26),
+ ),
+ ),
+ ),
+ const SizedBox(width: DesignTokens.space3),
+ Expanded(
+ child: Column(
+ crossAxisAlignment: CrossAxisAlignment.start,
+ children: [
+ Text(
+ cat.name,
+ style: TextStyle(
+ fontSize: DesignTokens.fontMd,
+ fontWeight: FontWeight.w600,
+ color: isDark
+ ? DarkDesignTokens.text1
+ : DesignTokens.text1,
+ ),
+ maxLines: 1,
+ overflow: TextOverflow.ellipsis,
+ ),
+ const SizedBox(height: 4),
+ Row(
children: [
- const Text('🍽️', style: TextStyle(fontSize: 48)),
- const SizedBox(height: DesignTokens.space3),
+ Container(
+ width: 6,
+ height: 6,
+ decoration: BoxDecoration(
+ color: _getGradientColor(index),
+ shape: BoxShape.circle,
+ ),
+ ),
+ const SizedBox(width: 6),
Text(
- '暂无菜谱',
+ hasChildren
+ ? '${cat.children.length} 个子类'
+ : (hasRecipes ? '${cat.count} 道菜谱' : '点击查看'),
style: TextStyle(
- fontSize: DesignTokens.fontMd,
+ fontSize: DesignTokens.fontXs,
color: isDark
- ? DarkDesignTokens.text2
- : DesignTokens.text2,
+ ? DarkDesignTokens.text3
+ : DesignTokens.text3,
),
),
],
),
- )
- : ListView.builder(
- padding: const EdgeInsets.symmetric(
- horizontal: DesignTokens.space4,
- ),
- itemCount: _recipes.length,
- itemBuilder: (context, index) {
- final recipe = _recipes[index];
- return _buildRecipeCard(recipe, isDark);
- },
- ),
+ ],
+ ),
+ ),
+ Container(
+ width: 32,
+ height: 32,
+ decoration: BoxDecoration(
+ color: _getGradientColor(index).withValues(alpha: 0.1),
+ borderRadius: BorderRadius.circular(10),
+ ),
+ child: Icon(
+ CupertinoIcons.chevron_right,
+ size: 16,
+ color: _getGradientColor(index),
+ ),
+ ),
+ ],
),
- ],
+ ),
+ );
+ }
+
+ Color _getGradientColor(int index) {
+ final colors = [
+ const Color(0xFFFF6B35),
+ const Color(0xFF2ECC71),
+ const Color(0xFF3498DB),
+ const Color(0xFF9B59B6),
+ const Color(0xFFF39C12),
+ const Color(0xFFE74C3C),
+ const Color(0xFF1ABC9C),
+ const Color(0xFF34495E),
+ ];
+ return colors[index % colors.length];
+ }
+
+ Widget _buildEmptyRecipeState(bool isDark) {
+ return Center(
+ child: Column(
+ mainAxisAlignment: MainAxisAlignment.center,
+ children: [
+ const Text('📂', style: TextStyle(fontSize: 56)),
+ const SizedBox(height: DesignTokens.space4),
+ Text(
+ '暂无菜谱数据',
+ style: TextStyle(
+ fontSize: DesignTokens.fontLg,
+ color: isDark ? DarkDesignTokens.text2 : DesignTokens.text2,
+ ),
+ ),
+ const SizedBox(height: DesignTokens.space2),
+ GestureDetector(
+ onTap: () {
+ if (_selectedSubCategory != null) {
+ _loadRecipes(_selectedSubCategory!.id);
+ }
+ },
+ child: Container(
+ padding: const EdgeInsets.symmetric(
+ horizontal: DesignTokens.space4,
+ vertical: DesignTokens.space2,
+ ),
+ decoration: BoxDecoration(
+ color: DesignTokens.primary.withValues(alpha: 0.1),
+ borderRadius: BorderRadius.circular(20),
+ ),
+ child: Row(
+ mainAxisSize: MainAxisSize.min,
+ children: [
+ Icon(
+ CupertinoIcons.refresh,
+ size: 16,
+ color: DesignTokens.primary,
+ ),
+ const SizedBox(width: 8),
+ Text(
+ '刷新',
+ style: TextStyle(
+ fontSize: DesignTokens.fontSm,
+ fontWeight: FontWeight.w500,
+ color: DesignTokens.primary,
+ ),
+ ),
+ ],
+ ),
+ ),
+ ),
+ ],
+ ),
+ );
+ }
+
+ Widget _buildRecipeList(bool isDark) {
+ return ListView.builder(
+ padding: const EdgeInsets.symmetric(horizontal: DesignTokens.space4),
+ itemCount: _recipes.length,
+ itemBuilder: (context, index) {
+ final recipe = _recipes[index];
+ return _buildRecipeCard(recipe, isDark);
+ },
+ );
+ }
+
+ Widget _buildRecipeListView(bool isDark) {
+ return ListView.builder(
+ padding: const EdgeInsets.symmetric(horizontal: DesignTokens.space4),
+ itemCount: _recipes.length,
+ itemBuilder: (context, index) {
+ final recipe = _recipes[index];
+ return _buildRecipeCard(recipe, isDark);
+ },
);
}
diff --git a/lib/src/pages/discover/ingredient_recipe_list_page.dart b/lib/src/pages/discover/ingredient_recipe_list_page.dart
new file mode 100644
index 0000000..93321e9
--- /dev/null
+++ b/lib/src/pages/discover/ingredient_recipe_list_page.dart
@@ -0,0 +1,425 @@
+/*
+ * 文件: ingredient_recipe_list_page.dart
+ * 名称: 食材菜品列表页面
+ * 作用: 显示某食材相关的菜品列表,支持分页加载
+ * 创建: 2026-04-11
+ * 更新: 2026-04-11 初始创建,使用 api.php?act=search 查询食材相关菜品
+ */
+
+import 'package:flutter/cupertino.dart';
+import 'package:get/get.dart';
+import 'package:mom_kitchen/src/config/api_config.dart';
+import 'package:mom_kitchen/src/config/design_tokens.dart';
+import 'package:mom_kitchen/src/models/api_response.dart';
+import 'package:mom_kitchen/src/models/recipe/recipe_model.dart';
+import 'package:mom_kitchen/src/services/api/api_service.dart';
+import 'package:mom_kitchen/src/widgets/recipe_image.dart';
+
+class IngredientRecipeListPage extends StatefulWidget {
+ final int ingredientId;
+ final String ingredientName;
+
+ const IngredientRecipeListPage({
+ super.key,
+ required this.ingredientId,
+ required this.ingredientName,
+ });
+
+ @override
+ State createState() =>
+ _IngredientRecipeListPageState();
+}
+
+class _IngredientRecipeListPageState extends State {
+ final ApiService _api = ApiService();
+ List _recipes = [];
+ bool _isLoading = true;
+ bool _isLoadingMore = false;
+ int _currentPage = 1;
+ bool _hasMore = true;
+ String? _error;
+
+ static const int _pageSize = 20;
+
+ @override
+ void initState() {
+ super.initState();
+ _loadRecipes();
+ }
+
+ Future _loadRecipes() async {
+ setState(() {
+ _isLoading = true;
+ _error = null;
+ });
+
+ try {
+ final response = await _api.get(
+ ApiConfig.recipe,
+ queryParameters: {
+ 'act': 'search',
+ 'keyword': widget.ingredientName,
+ 'type': 'recipe',
+ 'page': 1,
+ 'limit': _pageSize,
+ },
+ );
+
+ final List items = [];
+ if (response.data != null) {
+ Map responseMap;
+ if (response.data is Map) {
+ responseMap = response.data as Map;
+ } else if (response.data is Map) {
+ responseMap = Map.from(response.data as Map);
+ } else {
+ throw Exception('Invalid response format');
+ }
+
+ final apiResponse = ApiResponse.fromJson(responseMap, (data) {
+ if (data is Map) {
+ final pageData = data['list'] ?? data['data'] ?? [];
+ if (pageData is List) {
+ return pageData.map((e) {
+ if (e is Map) {
+ return RecipeModel.fromJson(e);
+ } else if (e is Map) {
+ return RecipeModel.fromJson(Map.from(e));
+ }
+ throw Exception('Invalid item format');
+ }).toList();
+ }
+ }
+ if (data is List) {
+ return data.map((e) {
+ if (e is Map) {
+ return RecipeModel.fromJson(e);
+ } else if (e is Map) {
+ return RecipeModel.fromJson(Map.from(e));
+ }
+ throw Exception('Invalid item format');
+ }).toList();
+ }
+ throw Exception('Invalid data format');
+ });
+
+ if (apiResponse.isSuccess && apiResponse.data != null) {
+ items.addAll(apiResponse.data as List);
+ }
+ }
+
+ setState(() {
+ _recipes = items;
+ _currentPage = 1;
+ _hasMore = items.length >= _pageSize;
+ _isLoading = false;
+ });
+ } catch (e) {
+ debugPrint('Load ingredient recipes error: $e');
+ setState(() {
+ _error = e.toString();
+ _isLoading = false;
+ });
+ }
+ }
+
+ Future _loadMore() async {
+ if (_isLoadingMore || !_hasMore) return;
+
+ setState(() => _isLoadingMore = true);
+
+ try {
+ final response = await _api.get(
+ ApiConfig.recipe,
+ queryParameters: {
+ 'act': 'search',
+ 'keyword': widget.ingredientName,
+ 'type': 'recipe',
+ 'page': _currentPage + 1,
+ 'limit': _pageSize,
+ },
+ );
+
+ final List items = [];
+ if (response.data != null) {
+ Map responseMap;
+ if (response.data is Map) {
+ responseMap = response.data as Map;
+ } else if (response.data is Map) {
+ responseMap = Map.from(response.data as Map);
+ } else {
+ throw Exception('Invalid response format');
+ }
+
+ final apiResponse = ApiResponse.fromJson(responseMap, (data) {
+ if (data is Map) {
+ final pageData = data['list'] ?? data['data'] ?? [];
+ if (pageData is List) {
+ return pageData.map((e) {
+ if (e is Map) {
+ return RecipeModel.fromJson(e);
+ } else if (e is Map) {
+ return RecipeModel.fromJson(Map.from(e));
+ }
+ throw Exception('Invalid item format');
+ }).toList();
+ }
+ }
+ if (data is List) {
+ return data.map((e) {
+ if (e is Map) {
+ return RecipeModel.fromJson(e);
+ } else if (e is Map) {
+ return RecipeModel.fromJson(Map.from(e));
+ }
+ throw Exception('Invalid item format');
+ }).toList();
+ }
+ throw Exception('Invalid data format');
+ });
+
+ if (apiResponse.isSuccess && apiResponse.data != null) {
+ items.addAll(apiResponse.data as List);
+ }
+ }
+
+ setState(() {
+ _recipes.addAll(items);
+ _currentPage++;
+ _hasMore = items.length >= _pageSize;
+ _isLoadingMore = false;
+ });
+ } catch (e) {
+ debugPrint('Load more ingredient recipes error: $e');
+ setState(() => _isLoadingMore = false);
+ }
+ }
+
+ @override
+ Widget build(BuildContext context) {
+ final isDark = CupertinoTheme.brightnessOf(context) == Brightness.dark;
+
+ return CupertinoPageScaffold(
+ backgroundColor: isDark
+ ? DarkDesignTokens.background
+ : DesignTokens.background,
+ navigationBar: CupertinoNavigationBar(
+ middle: Text(
+ '🍳 ${widget.ingredientName}',
+ style: TextStyle(
+ fontSize: DesignTokens.fontMd,
+ fontWeight: FontWeight.w600,
+ color: isDark ? DarkDesignTokens.text1 : DesignTokens.text1,
+ ),
+ ),
+ backgroundColor: isDark
+ ? DarkDesignTokens.background.withValues(alpha: 0.9)
+ : DesignTokens.background.withValues(alpha: 0.9),
+ border: null,
+ ),
+ child: SafeArea(
+ top: false,
+ child: _isLoading
+ ? const Center(child: CupertinoActivityIndicator())
+ : _error != null
+ ? _buildErrorState(isDark)
+ : _recipes.isEmpty
+ ? _buildEmptyState(isDark)
+ : _buildContent(isDark),
+ ),
+ );
+ }
+
+ Widget _buildErrorState(bool isDark) {
+ return Center(
+ child: Column(
+ mainAxisAlignment: MainAxisAlignment.center,
+ children: [
+ const Text('❌', style: TextStyle(fontSize: 56)),
+ const SizedBox(height: DesignTokens.space4),
+ Text(
+ '加载失败',
+ style: TextStyle(
+ fontSize: DesignTokens.fontLg,
+ color: isDark ? DarkDesignTokens.text2 : DesignTokens.text2,
+ ),
+ ),
+ const SizedBox(height: DesignTokens.space2),
+ CupertinoButton(onPressed: _loadRecipes, child: const Text('重新加载')),
+ ],
+ ),
+ );
+ }
+
+ Widget _buildEmptyState(bool isDark) {
+ return Center(
+ child: Column(
+ mainAxisAlignment: MainAxisAlignment.center,
+ children: [
+ const Text('🍽️', style: TextStyle(fontSize: 56)),
+ const SizedBox(height: DesignTokens.space4),
+ Text(
+ '暂无相关菜品',
+ style: TextStyle(
+ fontSize: DesignTokens.fontLg,
+ color: isDark ? DarkDesignTokens.text2 : DesignTokens.text2,
+ ),
+ ),
+ const SizedBox(height: DesignTokens.space2),
+ Text(
+ '换个食材试试吧',
+ style: TextStyle(
+ fontSize: DesignTokens.fontSm,
+ color: isDark ? DarkDesignTokens.text3 : DesignTokens.text3,
+ ),
+ ),
+ ],
+ ),
+ );
+ }
+
+ Widget _buildContent(bool isDark) {
+ return NotificationListener(
+ onNotification: (notification) {
+ if (notification is ScrollEndNotification &&
+ notification.metrics.extentAfter < 200) {
+ _loadMore();
+ }
+ return false;
+ },
+ child: ListView.builder(
+ padding: const EdgeInsets.all(DesignTokens.space4),
+ itemCount: _recipes.length + (_isLoadingMore ? 1 : 0),
+ itemBuilder: (context, index) {
+ if (index >= _recipes.length) {
+ return const Padding(
+ padding: EdgeInsets.all(DesignTokens.space3),
+ child: Center(child: CupertinoActivityIndicator(radius: 10)),
+ );
+ }
+ return _buildRecipeCard(_recipes[index], isDark);
+ },
+ ),
+ );
+ }
+
+ Widget _buildRecipeCard(RecipeModel recipe, bool isDark) {
+ return GestureDetector(
+ onTap: () {
+ Get.toNamed(
+ '/recipe-detail',
+ arguments: {'recipeId': recipe.id.toString()},
+ );
+ },
+ child: Container(
+ margin: const EdgeInsets.only(bottom: DesignTokens.space3),
+ decoration: BoxDecoration(
+ color: isDark ? DarkDesignTokens.card : DesignTokens.card,
+ borderRadius: DesignTokens.borderRadiusMd,
+ border: Border.all(
+ color: (isDark ? DarkDesignTokens.text3 : DesignTokens.text3)
+ .withValues(alpha: 0.08),
+ ),
+ ),
+ child: Row(
+ children: [
+ ClipRRect(
+ borderRadius: const BorderRadius.only(
+ topLeft: Radius.circular(8),
+ bottomLeft: Radius.circular(8),
+ ),
+ child: RecipeImage(
+ recipeId: recipe.id,
+ coverUrl: recipe.cover,
+ width: 120,
+ height: 100,
+ ),
+ ),
+ Expanded(
+ child: Padding(
+ padding: const EdgeInsets.all(DesignTokens.space3),
+ child: Column(
+ crossAxisAlignment: CrossAxisAlignment.start,
+ children: [
+ Text(
+ recipe.title,
+ style: TextStyle(
+ fontSize: DesignTokens.fontMd,
+ fontWeight: FontWeight.w600,
+ color: isDark
+ ? DarkDesignTokens.text1
+ : DesignTokens.text1,
+ ),
+ maxLines: 2,
+ overflow: TextOverflow.ellipsis,
+ ),
+ const SizedBox(height: DesignTokens.space1),
+ if (recipe.intro?.isNotEmpty ?? false)
+ Text(
+ recipe.intro!,
+ style: TextStyle(
+ fontSize: DesignTokens.fontSm,
+ color: isDark
+ ? DarkDesignTokens.text3
+ : DesignTokens.text3,
+ ),
+ maxLines: 1,
+ overflow: TextOverflow.ellipsis,
+ ),
+ const SizedBox(height: DesignTokens.space2),
+ Row(
+ children: [
+ if (recipe.categoryName?.isNotEmpty ?? false) ...[
+ Container(
+ padding: const EdgeInsets.symmetric(
+ horizontal: 6,
+ vertical: 2,
+ ),
+ decoration: BoxDecoration(
+ color:
+ (isDark
+ ? DarkDesignTokens.primary
+ : DesignTokens.primary)
+ .withValues(alpha: 0.1),
+ borderRadius: BorderRadius.circular(4),
+ ),
+ child: Text(
+ recipe.categoryName!,
+ style: TextStyle(
+ fontSize: DesignTokens.fontXs,
+ color: isDark
+ ? DarkDesignTokens.primary
+ : DesignTokens.primary,
+ ),
+ ),
+ ),
+ const Spacer(),
+ ],
+ if (recipe.statistics?.views != null)
+ Row(
+ children: [
+ const Text('👁️', style: TextStyle(fontSize: 12)),
+ const SizedBox(width: 2),
+ Text(
+ '${recipe.statistics!.views}',
+ style: TextStyle(
+ fontSize: DesignTokens.fontXs,
+ color: isDark
+ ? DarkDesignTokens.text3
+ : DesignTokens.text3,
+ ),
+ ),
+ ],
+ ),
+ ],
+ ),
+ ],
+ ),
+ ),
+ ),
+ ],
+ ),
+ ),
+ );
+ }
+}
diff --git a/lib/src/pages/discover/ingredient_recommend_page.dart b/lib/src/pages/discover/ingredient_recommend_page.dart
new file mode 100644
index 0000000..9672eef
--- /dev/null
+++ b/lib/src/pages/discover/ingredient_recommend_page.dart
@@ -0,0 +1,266 @@
+/*
+ * 文件: ingredient_recommend_page.dart
+ * 名称: 食材推荐页面
+ * 作用: 展示热门食材列表,点击跳转该食材对应的菜品列表
+ * 创建: 2026-04-11
+ * 更新: 2026-04-11 初始创建,从 api.php?act=ingredients 加载食材列表
+ */
+
+import 'package:flutter/cupertino.dart';
+import 'package:get/get.dart';
+import 'package:mom_kitchen/src/config/design_tokens.dart';
+import 'package:mom_kitchen/src/models/recipe/ingredient_model.dart';
+import 'package:mom_kitchen/src/repositories/recipe_repository.dart';
+
+class IngredientRecommendPage extends StatefulWidget {
+ const IngredientRecommendPage({super.key});
+
+ @override
+ State createState() =>
+ _IngredientRecommendPageState();
+}
+
+class _IngredientRecommendPageState extends State {
+ final RecipeRepository _repo = RecipeRepository();
+ List _ingredients = [];
+ bool _isLoading = true;
+ bool _isLoadingMore = false;
+ int _currentPage = 1;
+ bool _hasMore = true;
+
+ static const int _pageSize = 20;
+
+ @override
+ void initState() {
+ super.initState();
+ _loadIngredients();
+ }
+
+ Future _loadIngredients() async {
+ setState(() => _isLoading = true);
+ try {
+ final result = await _repo.fetchIngredients(
+ page: 1,
+ limit: _pageSize,
+ );
+ setState(() {
+ _ingredients = result.items;
+ _currentPage = 1;
+ _hasMore = result.items.length >= _pageSize;
+ _isLoading = false;
+ });
+ } catch (e) {
+ debugPrint('Load ingredients error: $e');
+ setState(() => _isLoading = false);
+ }
+ }
+
+ Future _loadMore() async {
+ if (_isLoadingMore || !_hasMore) return;
+
+ setState(() => _isLoadingMore = true);
+ try {
+ final result = await _repo.fetchIngredients(
+ page: _currentPage + 1,
+ limit: _pageSize,
+ );
+ setState(() {
+ _ingredients.addAll(result.items);
+ _currentPage++;
+ _hasMore = result.items.length >= _pageSize;
+ _isLoadingMore = false;
+ });
+ } catch (e) {
+ debugPrint('Load more ingredients error: $e');
+ setState(() => _isLoadingMore = false);
+ }
+ }
+
+ @override
+ Widget build(BuildContext context) {
+ final isDark = CupertinoTheme.brightnessOf(context) == Brightness.dark;
+
+ return CupertinoPageScaffold(
+ backgroundColor:
+ isDark ? DarkDesignTokens.background : DesignTokens.background,
+ navigationBar: CupertinoNavigationBar(
+ middle: Text(
+ '🥬 食材推荐',
+ style: TextStyle(
+ fontSize: DesignTokens.fontMd,
+ fontWeight: FontWeight.w600,
+ color: isDark ? DarkDesignTokens.text1 : DesignTokens.text1,
+ ),
+ ),
+ backgroundColor: isDark
+ ? DarkDesignTokens.background.withValues(alpha: 0.9)
+ : DesignTokens.background.withValues(alpha: 0.9),
+ border: null,
+ ),
+ child: SafeArea(
+ top: false,
+ child: _isLoading
+ ? const Center(child: CupertinoActivityIndicator())
+ : _ingredients.isEmpty
+ ? _buildEmptyState(isDark)
+ : _buildContent(isDark),
+ ),
+ );
+ }
+
+ Widget _buildEmptyState(bool isDark) {
+ return Center(
+ child: Column(
+ mainAxisAlignment: MainAxisAlignment.center,
+ children: [
+ const Text('🥬', style: TextStyle(fontSize: 56)),
+ const SizedBox(height: DesignTokens.space4),
+ Text(
+ '暂无食材数据',
+ style: TextStyle(
+ fontSize: DesignTokens.fontLg,
+ color: isDark ? DarkDesignTokens.text2 : DesignTokens.text2,
+ ),
+ ),
+ ],
+ ),
+ );
+ }
+
+ Widget _buildContent(bool isDark) {
+ return NotificationListener(
+ onNotification: (notification) {
+ if (notification is ScrollEndNotification &&
+ notification.metrics.extentAfter < 200) {
+ _loadMore();
+ }
+ return false;
+ },
+ child: GridView.builder(
+ padding: const EdgeInsets.all(DesignTokens.space4),
+ gridDelegate: const SliverGridDelegateWithFixedCrossAxisCount(
+ crossAxisCount: 4,
+ mainAxisSpacing: DesignTokens.space3,
+ crossAxisSpacing: DesignTokens.space3,
+ childAspectRatio: 0.85,
+ ),
+ itemCount: _ingredients.length + (_isLoadingMore ? 1 : 0),
+ itemBuilder: (context, index) {
+ if (index >= _ingredients.length) {
+ return const Center(
+ child: CupertinoActivityIndicator(radius: 10),
+ );
+ }
+ return _buildIngredientCard(_ingredients[index], isDark);
+ },
+ ),
+ );
+ }
+
+ Widget _buildIngredientCard(IngredientModel ingredient, bool isDark) {
+ final emoji = _getIngredientEmoji(ingredient.name);
+
+ return GestureDetector(
+ onTap: () {
+ Get.toNamed(
+ '/tools/ingredient-recipes',
+ arguments: {
+ 'ingredientId': ingredient.id,
+ 'ingredientName': ingredient.name,
+ },
+ );
+ },
+ child: Container(
+ decoration: BoxDecoration(
+ color: isDark ? DarkDesignTokens.card : DesignTokens.card,
+ borderRadius: DesignTokens.borderRadiusMd,
+ border: Border.all(
+ color: (isDark ? DarkDesignTokens.text3 : DesignTokens.text3)
+ .withValues(alpha: 0.08),
+ ),
+ ),
+ child: Column(
+ mainAxisAlignment: MainAxisAlignment.center,
+ children: [
+ Text(
+ emoji,
+ style: const TextStyle(fontSize: 32),
+ ),
+ const SizedBox(height: DesignTokens.space1),
+ Padding(
+ padding: const EdgeInsets.symmetric(horizontal: DesignTokens.space1),
+ child: Text(
+ ingredient.name,
+ style: TextStyle(
+ fontSize: DesignTokens.fontXs,
+ fontWeight: FontWeight.w500,
+ color: isDark ? DarkDesignTokens.text1 : DesignTokens.text1,
+ ),
+ textAlign: TextAlign.center,
+ maxLines: 2,
+ overflow: TextOverflow.ellipsis,
+ ),
+ ),
+ ],
+ ),
+ ),
+ );
+ }
+
+ String _getIngredientEmoji(String name) {
+ if (name.contains('鸡') || name.contains('蛋')) return '🥚';
+ if (name.contains('猪') || name.contains('肉')) return '🥩';
+ if (name.contains('牛') || name.contains('羊')) return '🥩';
+ if (name.contains('鱼')) return '🐟';
+ if (name.contains('虾')) return '🦐';
+ if (name.contains('蟹')) return '🦀';
+ if (name.contains('贝') || name.contains('螺')) return '🦪';
+ if (name.contains('菜') || name.contains('蔬')) return '🥬';
+ if (name.contains('萝卜') || name.contains('根')) return '🥕';
+ if (name.contains('土豆') || name.contains('薯')) return '🥔';
+ if (name.contains('番茄') || name.contains('西红柿')) return '🍅';
+ if (name.contains('茄子')) return '🍆';
+ if (name.contains('黄瓜')) return '🥒';
+ if (name.contains('玉米')) return '🌽';
+ if (name.contains('豆') && !name.contains('豆')) return '🫘';
+ if (name.contains('豆腐')) return '🧈';
+ if (name.contains('蘑菇') || name.contains('菇')) return '🍄';
+ if (name.contains('木耳') || name.contains('银耳')) return '🍄';
+ if (name.contains('葱') || name.contains('蒜') || name.contains('姜')) {
+ return '🧅';
+ }
+ if (name.contains('辣椒') || name.contains('椒')) return '🌶️';
+ if (name.contains('白菜') || name.contains('青菜')) return '🥬';
+ if (name.contains('菠菜')) return '🥬';
+ if (name.contains('芹菜')) return '🥬';
+ if (name.contains('南瓜')) return '🎃';
+ if (name.contains('西瓜') || name.contains('瓜')) return '🍈';
+ if (name.contains('苹果')) return '🍎';
+ if (name.contains('香蕉')) return '🍌';
+ if (name.contains('橙') || name.contains('橘')) return '🍊';
+ if (name.contains('葡萄')) return '🍇';
+ if (name.contains('草莓')) return '🍓';
+ if (name.contains('芒果')) return '🥭';
+ if (name.contains('菠萝')) return '🍍';
+ if (name.contains('大米') || name.contains('米')) return '🍚';
+ if (name.contains('面') || name.contains('粉')) return '🍜';
+ if (name.contains('面包')) return '🍞';
+ if (name.contains('蛋糕')) return '🍰';
+ if (name.contains('奶') || name.contains('乳')) return '🥛';
+ if (name.contains('芝士')) return '🧀';
+ if (name.contains('黄油')) return '🧈';
+ if (name.contains('油')) return '🛢️';
+ if (name.contains('盐')) return '🧂';
+ if (name.contains('糖')) return '🍬';
+ if (name.contains('蜂蜜')) return '🍯';
+ if (name.contains('茶')) return '🍵';
+ if (name.contains('咖啡')) return '☕';
+ if (name.contains('酒') || name.contains('料酒')) return '🍶';
+ if (name.contains('酱油') || name.contains('生抽') || name.contains('老抽')) {
+ return '🥫';
+ }
+ if (name.contains('醋')) return '🍶';
+ if (name.contains('淀粉')) return '🧂';
+ return '🥬';
+ }
+}
diff --git a/lib/src/pages/home/home_page.dart b/lib/src/pages/home/home_page.dart
index 734d20f..5facdb1 100644
--- a/lib/src/pages/home/home_page.dart
+++ b/lib/src/pages/home/home_page.dart
@@ -71,7 +71,8 @@ class _HomePageState extends State {
_isLoadingRecommendations.value = true;
try {
final recommendationService = Get.find();
- final recommendations = await recommendationService.getPersonalizedRecommendations(limit: 10);
+ final recommendations = await recommendationService
+ .getPersonalizedRecommendations(limit: 10);
_recommendedRecipes.value = recommendations;
} catch (e) {
debugPrint('Load recommendations error: $e');
@@ -409,7 +410,9 @@ class _HomePageState extends State {
);
}
- final currentRecipes = _currentTab == 'today' ? _recipes : _recommendedRecipes;
+ final currentRecipes = _currentTab == 'today'
+ ? _recipes
+ : _recommendedRecipes;
final List sliverList = [
CupertinoSliverRefreshControl(
@@ -422,9 +425,7 @@ class _HomePageState extends State {
),
SliverToBoxAdapter(child: const NutritionDashboardCard()),
const SliverToBoxAdapter(child: SizedBox(height: DesignTokens.space4)),
- SliverToBoxAdapter(
- child: _buildTabSwitcher(isDark),
- ),
+ SliverToBoxAdapter(child: _buildTabSwitcher(isDark)),
const SliverToBoxAdapter(child: SizedBox(height: DesignTokens.space3)),
SliverToBoxAdapter(
child: Padding(
@@ -459,7 +460,7 @@ class _HomePageState extends State {
child: Center(child: CupertinoActivityIndicator()),
);
}
-
+
if (currentRecipes.isEmpty) {
return SizedBox(
height: 200,
@@ -473,13 +474,15 @@ class _HomePageState extends State {
),
const SizedBox(height: DesignTokens.space3),
Text(
- _currentTab == 'recommended'
+ _currentTab == 'recommended'
? '根据你的偏好推荐菜谱\n请先设置偏好或浏览更多菜谱'
: '暂无推荐',
textAlign: TextAlign.center,
style: TextStyle(
fontSize: DesignTokens.fontMd,
- color: isDark ? DarkDesignTokens.text2 : DesignTokens.text2,
+ color: isDark
+ ? DarkDesignTokens.text2
+ : DesignTokens.text2,
),
),
],
@@ -534,6 +537,7 @@ class _HomePageState extends State {
horizontal: DesignTokens.space4,
),
children: [
+ _buildIngredientRecommendCard(isDark),
_buildCategoryCard('🍖 荤菜', DesignTokens.red, isDark),
_buildCategoryCard('🥬 素菜', DesignTokens.green, isDark),
_buildCategoryCard('🍜 面食', DesignTokens.orange, isDark),
@@ -577,7 +581,9 @@ class _HomePageState extends State {
padding: const EdgeInsets.symmetric(vertical: 12),
decoration: BoxDecoration(
color: _currentTab == 'today'
- ? (isDark ? DarkDesignTokens.primary : DesignTokens.primary)
+ ? (isDark
+ ? DarkDesignTokens.primary
+ : DesignTokens.primary)
: CupertinoColors.transparent,
borderRadius: BorderRadius.circular(DesignTokens.radiusMd),
),
@@ -589,7 +595,9 @@ class _HomePageState extends State {
fontWeight: FontWeight.w600,
color: _currentTab == 'today'
? CupertinoColors.white
- : (isDark ? DarkDesignTokens.text2 : DesignTokens.text2),
+ : (isDark
+ ? DarkDesignTokens.text2
+ : DesignTokens.text2),
),
),
),
@@ -607,7 +615,9 @@ class _HomePageState extends State {
padding: const EdgeInsets.symmetric(vertical: 12),
decoration: BoxDecoration(
color: _currentTab == 'recommended'
- ? (isDark ? DarkDesignTokens.primary : DesignTokens.primary)
+ ? (isDark
+ ? DarkDesignTokens.primary
+ : DesignTokens.primary)
: CupertinoColors.transparent,
borderRadius: BorderRadius.circular(DesignTokens.radiusMd),
),
@@ -619,7 +629,9 @@ class _HomePageState extends State {
fontWeight: FontWeight.w600,
color: _currentTab == 'recommended'
? CupertinoColors.white
- : (isDark ? DarkDesignTokens.text2 : DesignTokens.text2),
+ : (isDark
+ ? DarkDesignTokens.text2
+ : DesignTokens.text2),
),
),
),
@@ -831,4 +843,48 @@ class _HomePageState extends State {
),
);
}
+
+ Widget _buildIngredientRecommendCard(bool isDark) {
+ return GestureDetector(
+ onTap: () {
+ Get.toNamed(AppRoutes.toolsIngredientRecommend);
+ },
+ child: Container(
+ width: 100,
+ margin: const EdgeInsets.only(right: DesignTokens.space3),
+ decoration: BoxDecoration(
+ gradient: LinearGradient(
+ colors: isDark
+ ? [
+ DarkDesignTokens.primary.withValues(alpha: 0.6),
+ DarkDesignTokens.secondary.withValues(alpha: 0.6),
+ ]
+ : [
+ DesignTokens.primary.withValues(alpha: 0.6),
+ DesignTokens.secondary.withValues(alpha: 0.6),
+ ],
+ begin: Alignment.topLeft,
+ end: Alignment.bottomRight,
+ ),
+ borderRadius: BorderRadius.circular(DesignTokens.radiusMd),
+ boxShadow: DesignTokens.shadowsSm,
+ ),
+ child: Column(
+ mainAxisAlignment: MainAxisAlignment.center,
+ children: [
+ const Text('🥬', style: TextStyle(fontSize: 28)),
+ const SizedBox(height: DesignTokens.space2),
+ Text(
+ '食材推荐',
+ style: TextStyle(
+ fontSize: DesignTokens.fontSm,
+ fontWeight: FontWeight.w500,
+ color: isDark ? DarkDesignTokens.text1 : DesignTokens.text1,
+ ),
+ ),
+ ],
+ ),
+ ),
+ );
+ }
}
diff --git a/lib/src/pages/profile/favorites_page.dart b/lib/src/pages/profile/favorites_page.dart
index 2cbd761..61c25d5 100644
--- a/lib/src/pages/profile/favorites_page.dart
+++ b/lib/src/pages/profile/favorites_page.dart
@@ -1,12 +1,8 @@
/*
* 文件: favorites_page.dart
* 名称: 收藏页面
- * 作用: iOS 26 Liquid Glass 风格的收藏页面,使用 FavoritesController 展示收藏内容
- * 更新: 2026-04-10 添加工具入口Bar
- * 更新: 2026-04-11 修复GetX报错,重构为StatefulWidget
- * 更新: 2026-04-11 移动到pages根目录
- * 更新: 2026-04-11 简化ToolsController获取(已全局注册,移除防御性put)
- * 更新: 2026-04-11 iOS 26 Liquid Glass 风格重构
+ * 作用: iOS 26 Liquid Glass 风格的收藏页面
+ * 更新: 2026-04-12 重写跳转逻辑,修复闪退问题
*/
import 'dart:ui';
@@ -17,6 +13,7 @@ import 'package:mom_kitchen/src/config/design_tokens.dart';
import 'package:mom_kitchen/src/controllers/favorites_controller.dart';
import 'package:mom_kitchen/src/controllers/tools_controller.dart';
import 'package:mom_kitchen/src/models/tool_item_model.dart';
+import 'package:mom_kitchen/src/pages/tools/tools_center_page.dart';
class FavoritesPage extends StatefulWidget {
const FavoritesPage({super.key});
@@ -26,20 +23,45 @@ class FavoritesPage extends StatefulWidget {
}
class _FavoritesPageState extends State {
- late final FavoritesController _favoritesController;
- late final ToolsController _toolsController;
+ FavoritesController? _favoritesController;
+ ToolsController? _toolsController;
+ bool _isInitialized = false;
@override
- void initState() {
- super.initState();
- _favoritesController = Get.find();
- _toolsController = Get.find();
+ void didChangeDependencies() {
+ super.didChangeDependencies();
+ if (!_isInitialized) {
+ _isInitialized = true;
+ _initControllers();
+ }
+ }
+
+ void _initControllers() {
+ try {
+ if (Get.isRegistered()) {
+ _favoritesController = Get.find();
+ }
+ if (Get.isRegistered()) {
+ _toolsController = Get.find();
+ }
+ } catch (e) {
+ debugPrint('FavoritesPage: Controller init error: $e');
+ }
}
@override
Widget build(BuildContext context) {
final isDark = CupertinoTheme.brightnessOf(context) == Brightness.dark;
+ if (_favoritesController == null || _toolsController == null) {
+ return CupertinoPageScaffold(
+ backgroundColor: isDark
+ ? DarkDesignTokens.background
+ : DesignTokens.background,
+ child: const Center(child: CupertinoActivityIndicator()),
+ );
+ }
+
return CupertinoPageScaffold(
backgroundColor: isDark
? DarkDesignTokens.background
@@ -52,7 +74,7 @@ class _FavoritesPageState extends State {
_buildToolbar(isDark),
Expanded(
child: Obx(() {
- final favorites = _favoritesController.favorites;
+ final favorites = _favoritesController!.favorites;
if (favorites.isEmpty) {
return _buildEmptyState(isDark);
}
@@ -60,7 +82,7 @@ class _FavoritesPageState extends State {
}),
),
Obx(
- () => _favoritesController.isEditMode.value
+ () => _favoritesController!.isEditMode.value
? _buildEditBottomBar(isDark)
: const SizedBox.shrink(),
),
@@ -88,19 +110,21 @@ class _FavoritesPageState extends State {
),
const Spacer(),
Obx(() {
- final count = _favoritesController.count;
+ final count = _favoritesController!.count;
if (count == 0) return const SizedBox.shrink();
return _buildGlassChip('$count', isDark, highlight: true);
}),
const SizedBox(width: DesignTokens.space3),
Obx(() {
- if (_favoritesController.count == 0) return const SizedBox.shrink();
+ if (_favoritesController!.count == 0) {
+ return const SizedBox.shrink();
+ }
return CupertinoButton(
padding: EdgeInsets.zero,
minimumSize: const Size(36, 36),
- onPressed: _favoritesController.toggleEditMode,
+ onPressed: _favoritesController!.toggleEditMode,
child: Text(
- _favoritesController.isEditMode.value ? '完成' : '编辑',
+ _favoritesController!.isEditMode.value ? '完成' : '编辑',
style: TextStyle(
fontSize: DesignTokens.fontMd,
color: DesignTokens.primary,
@@ -154,7 +178,7 @@ class _FavoritesPageState extends State {
Widget _buildToolsBar(bool isDark) {
return Obx(() {
- final tools = _toolsController.frequentTools;
+ final tools = _toolsController!.frequentTools;
if (tools.isEmpty) {
return const SizedBox.shrink();
}
@@ -171,20 +195,16 @@ class _FavoritesPageState extends State {
if (index == tools.length) {
return _buildMoreToolsCard(isDark);
}
- return _buildToolShortcut(tools[index], _toolsController, isDark);
+ return _buildToolShortcut(tools[index], isDark);
},
),
);
});
}
- Widget _buildToolShortcut(
- ToolItem tool,
- ToolsController controller,
- bool isDark,
- ) {
+ Widget _buildToolShortcut(ToolItem tool, bool isDark) {
return GestureDetector(
- onTap: () => controller.openTool(tool),
+ onTap: () => _navigateToTool(tool),
child: ClipRRect(
borderRadius: DesignTokens.borderRadiusMd,
child: BackdropFilter(
@@ -262,7 +282,7 @@ class _FavoritesPageState extends State {
Widget _buildMoreToolsCard(bool isDark) {
return GestureDetector(
- onTap: () => Get.toNamed('/tools'),
+ onTap: _navigateToToolsCenter,
child: ClipRRect(
borderRadius: DesignTokens.borderRadiusMd,
child: BackdropFilter(
@@ -317,9 +337,30 @@ class _FavoritesPageState extends State {
);
}
+ void _navigateToToolsCenter() {
+ Navigator.of(
+ context,
+ ).push(CupertinoPageRoute(builder: (context) => const ToolsCenterPage()));
+ }
+
+ void _navigateToTool(ToolItem tool) {
+ _toolsController?.recordUsage(tool.id);
+ Navigator.of(
+ context,
+ ).push(CupertinoPageRoute(builder: (context) => const ToolsCenterPage()));
+ }
+
+ void _navigateToRecipeDetail(int recipeId) {
+ Navigator.of(context).push(
+ CupertinoPageRoute(
+ builder: (context) => _RecipeDetailWrapper(recipeId: recipeId),
+ ),
+ );
+ }
+
Widget _buildToolbar(bool isDark) {
return Obx(() {
- if (_favoritesController.count == 0) return const SizedBox.shrink();
+ if (_favoritesController!.count == 0) return const SizedBox.shrink();
return Container(
padding: const EdgeInsets.symmetric(
horizontal: DesignTokens.space4,
@@ -369,7 +410,7 @@ class _FavoritesPageState extends State {
),
const SizedBox(width: DesignTokens.space1),
Text(
- _getSortLabel(_favoritesController.sortMode.value),
+ _getSortLabel(_favoritesController!.sortMode.value),
style: TextStyle(
fontSize: DesignTokens.fontSm,
color: isDark ? DarkDesignTokens.text2 : DesignTokens.text2,
@@ -389,13 +430,13 @@ class _FavoritesPageState extends State {
scrollDirection: Axis.horizontal,
child: Obx(
() => Row(
- children: _favoritesController.categories.map((cat) {
+ children: _favoritesController!.categories.map((cat) {
final isSelected =
- _favoritesController.selectedCategory.value == cat;
+ _favoritesController!.selectedCategory.value == cat;
return Padding(
padding: const EdgeInsets.only(right: DesignTokens.space2),
child: GestureDetector(
- onTap: () => _favoritesController.setCategory(cat),
+ onTap: () => _favoritesController!.setCategory(cat),
child: ClipRRect(
borderRadius: DesignTokens.borderRadiusMd,
child: BackdropFilter(
@@ -450,19 +491,19 @@ class _FavoritesPageState extends State {
void _showSortSheet(bool isDark) {
showCupertinoModalPopup(
context: context,
- builder: (context) => CupertinoActionSheet(
+ builder: (ctx) => CupertinoActionSheet(
title: const Text('排序方式'),
actions: FavoritesSortMode.values.map((mode) {
return CupertinoActionSheetAction(
onPressed: () {
- _favoritesController.setSortMode(mode);
- Get.back();
+ _favoritesController!.setSortMode(mode);
+ Navigator.of(ctx).pop();
},
child: Text(_getSortLabel(mode)),
);
}).toList(),
cancelButton: CupertinoActionSheetAction(
- onPressed: Get.back,
+ onPressed: () => Navigator.of(ctx).pop(),
child: const Text('取消'),
),
),
@@ -504,11 +545,11 @@ class _FavoritesPageState extends State {
children: [
CupertinoButton(
padding: EdgeInsets.zero,
- onPressed: _favoritesController.hasSelection
- ? _favoritesController.deselectAll
- : _favoritesController.selectAll,
+ onPressed: _favoritesController!.hasSelection
+ ? _favoritesController!.deselectAll
+ : _favoritesController!.selectAll,
child: Text(
- _favoritesController.hasSelection ? '取消全选' : '全选',
+ _favoritesController!.hasSelection ? '取消全选' : '全选',
style: TextStyle(
fontSize: DesignTokens.fontMd,
color: DesignTokens.primary,
@@ -518,7 +559,7 @@ class _FavoritesPageState extends State {
const Spacer(),
Obx(
() => Text(
- '已选 ${_favoritesController.selectedCount} 项',
+ '已选 ${_favoritesController!.selectedCount} 项',
style: TextStyle(
fontSize: DesignTokens.fontSm,
color: isDark ? DarkDesignTokens.text2 : DesignTokens.text2,
@@ -530,7 +571,7 @@ class _FavoritesPageState extends State {
padding: const EdgeInsets.symmetric(
horizontal: DesignTokens.space4,
),
- onPressed: _favoritesController.hasSelection
+ onPressed: _favoritesController!.hasSelection
? () => _confirmDelete()
: null,
child: const Text('删除'),
@@ -545,16 +586,19 @@ class _FavoritesPageState extends State {
void _confirmDelete() {
showCupertinoDialog(
context: context,
- builder: (context) => CupertinoAlertDialog(
+ builder: (ctx) => CupertinoAlertDialog(
title: const Text('确认删除'),
- content: Text('确定要删除选中的 ${_favoritesController.selectedCount} 项收藏吗?'),
+ content: Text('确定要删除选中的 ${_favoritesController!.selectedCount} 项收藏吗?'),
actions: [
- CupertinoDialogAction(onPressed: Get.back, child: const Text('取消')),
+ CupertinoDialogAction(
+ onPressed: () => Navigator.of(ctx).pop(),
+ child: const Text('取消'),
+ ),
CupertinoDialogAction(
isDestructiveAction: true,
onPressed: () {
- Get.back();
- _favoritesController.deleteSelected();
+ Navigator.of(ctx).pop();
+ _favoritesController!.deleteSelected();
},
child: const Text('删除'),
),
@@ -631,7 +675,7 @@ class _FavoritesPageState extends State {
vertical: DesignTokens.space2,
),
itemCount: favorites.length,
- separatorBuilder: (_, __) =>
+ separatorBuilder: (context, index) =>
const SizedBox(height: DesignTokens.space2 + 2),
itemBuilder: (context, index) {
final item = favorites[index];
@@ -642,15 +686,13 @@ class _FavoritesPageState extends State {
Widget _buildFavoriteItem(dynamic item, bool isDark) {
return Obx(() {
- final isEditMode = _favoritesController.isEditMode.value;
- final isSelected = _favoritesController.isSelected(item.id);
+ final isEditMode = _favoritesController!.isEditMode.value;
+ final isSelected = _favoritesController!.isSelected(item.id);
return GestureDetector(
onTap: isEditMode
- ? () => _favoritesController.toggleSelection(item.id)
- : () {
- Get.toNamed('/recipe-detail', arguments: '${item.id}');
- },
+ ? () => _favoritesController!.toggleSelection(item.id)
+ : () => _navigateToRecipeDetail(item.id),
child: ClipRRect(
borderRadius: DesignTokens.borderRadiusLg,
child: BackdropFilter(
@@ -751,7 +793,8 @@ class _FavoritesPageState extends State {
if (!isEditMode) ...[
const SizedBox(width: DesignTokens.space2),
GestureDetector(
- onTap: () => _favoritesController.removeFavorite(item.id),
+ onTap: () =>
+ _favoritesController!.removeFavorite(item.id),
behavior: HitTestBehavior.opaque,
child: Container(
width: 36,
@@ -777,3 +820,44 @@ class _FavoritesPageState extends State {
});
}
}
+
+class _RecipeDetailWrapper extends StatelessWidget {
+ final int recipeId;
+
+ const _RecipeDetailWrapper({required this.recipeId});
+
+ @override
+ Widget build(BuildContext context) {
+ return CupertinoPageScaffold(
+ navigationBar: CupertinoNavigationBar(middle: Text('菜谱详情 #$recipeId')),
+ child: Center(
+ child: Column(
+ mainAxisAlignment: MainAxisAlignment.center,
+ children: [
+ const Text('📖', style: TextStyle(fontSize: 64)),
+ const SizedBox(height: 16),
+ Text(
+ '菜谱详情页',
+ style: TextStyle(
+ fontSize: 18,
+ color: CupertinoTheme.brightnessOf(context) == Brightness.dark
+ ? DarkDesignTokens.text1
+ : DesignTokens.text1,
+ ),
+ ),
+ const SizedBox(height: 8),
+ Text(
+ 'ID: $recipeId',
+ style: TextStyle(
+ fontSize: 14,
+ color: CupertinoTheme.brightnessOf(context) == Brightness.dark
+ ? DarkDesignTokens.text2
+ : DesignTokens.text2,
+ ),
+ ),
+ ],
+ ),
+ ),
+ );
+ }
+}
diff --git a/lib/src/pages/tools/tools_center_page.dart b/lib/src/pages/tools/tools_center_page.dart
index 26bcc82..e0290fb 100644
--- a/lib/src/pages/tools/tools_center_page.dart
+++ b/lib/src/pages/tools/tools_center_page.dart
@@ -2,13 +2,11 @@
* 文件: tools_center_page.dart
* 名称: 工具中心页面
* 作用: 展示所有工具,支持分类筛选和搜索
- * 更新: 2026-04-10 初始创建
- * 更新: 2026-04-10 修复卡死问题:使用Get.find()+添加错误处理
- * 更新: 2026-04-11 简化ToolsController获取(已全局注册,移除防御性put)
- * 更新: 2026-04-11 修复:添加try-catch防止Get.find失败导致闪退
+ * 更新: 2026-04-12 全新设计 - 分组卡片布局风格
*/
import 'package:flutter/cupertino.dart';
+import 'package:flutter/material.dart';
import 'package:get/get.dart';
import 'package:mom_kitchen/src/config/design_tokens.dart';
import 'package:mom_kitchen/src/controllers/tools_controller.dart';
@@ -21,348 +19,836 @@ class ToolsCenterPage extends StatefulWidget {
State createState() => _ToolsCenterPageState();
}
-class _ToolsCenterPageState extends State {
- late final ToolsController _controller;
+class _ToolsCenterPageState extends State
+ with SingleTickerProviderStateMixin {
+ ToolsController? _controller;
+ bool _isInitialized = false;
+ late AnimationController _animationController;
+ String _searchQuery = '';
@override
void initState() {
super.initState();
+ _animationController = AnimationController(
+ vsync: this,
+ duration: const Duration(milliseconds: 800),
+ );
+ }
+
+ @override
+ void dispose() {
+ _animationController.dispose();
+ super.dispose();
+ }
+
+ @override
+ void didChangeDependencies() {
+ super.didChangeDependencies();
+ if (!_isInitialized) {
+ _isInitialized = true;
+ _initController();
+ _animationController.forward();
+ }
+ }
+
+ void _initController() {
try {
- _controller = Get.find();
- debugPrint('ToolsCenterPage: Controller found successfully');
- } catch (e) {
- debugPrint('ToolsCenterPage: Failed to find controller: $e');
- // 如果找不到控制器,尝试创建一个临时的
- try {
- _controller = Get.put(ToolsController(), permanent: true);
- debugPrint('ToolsCenterPage: Controller created');
- } catch (e2) {
- debugPrint('ToolsCenterPage: Failed to create controller: $e2');
- rethrow;
+ if (Get.isRegistered()) {
+ _controller = Get.find();
}
+ } catch (e) {
+ debugPrint('ToolsCenterPage: Controller init error: $e');
}
}
@override
Widget build(BuildContext context) {
final isDark = CupertinoTheme.brightnessOf(context) == Brightness.dark;
- final controller = _controller;
+
+ if (_controller == null) {
+ return CupertinoPageScaffold(
+ backgroundColor: isDark
+ ? DarkDesignTokens.background
+ : DesignTokens.background,
+ child: const Center(child: CupertinoActivityIndicator()),
+ );
+ }
return CupertinoPageScaffold(
backgroundColor: isDark
? DarkDesignTokens.background
: DesignTokens.background,
- navigationBar: CupertinoNavigationBar(
- middle: Text(
- '🛠️ 工具中心',
- style: TextStyle(
- fontSize: DesignTokens.fontMd,
- fontWeight: FontWeight.w600,
- color: isDark ? DarkDesignTokens.text1 : DesignTokens.text1,
- ),
- ),
- backgroundColor: isDark
- ? DarkDesignTokens.background.withValues(alpha: 0.9)
- : DesignTokens.background.withValues(alpha: 0.9),
- border: null,
- ),
child: SafeArea(
- top: false,
child: Column(
children: [
- _buildSearchBar(controller, isDark),
- const SizedBox(height: DesignTokens.space2),
- _buildCategoryTabs(controller, isDark),
- const SizedBox(height: DesignTokens.space3),
- Expanded(child: Obx(() => _buildToolsGrid(controller, isDark))),
+ _buildHeader(isDark),
+ _buildSearchBar(isDark),
+ Expanded(
+ child: Obx(() => _buildGroupedTools(isDark)),
+ ),
],
),
),
);
}
- Widget _buildSearchBar(ToolsController controller, bool isDark) {
+ Widget _buildHeader(bool isDark) {
return Padding(
- padding: const EdgeInsets.symmetric(
- horizontal: DesignTokens.space4,
- vertical: DesignTokens.space2,
+ padding: const EdgeInsets.fromLTRB(
+ DesignTokens.space4,
+ DesignTokens.space3,
+ DesignTokens.space4,
+ DesignTokens.space2,
),
+ child: Row(
+ children: [
+ Container(
+ width: 44,
+ height: 44,
+ decoration: BoxDecoration(
+ gradient: LinearGradient(
+ begin: Alignment.topLeft,
+ end: Alignment.bottomRight,
+ colors: [
+ DesignTokens.primary.withValues(alpha: 0.2),
+ DesignTokens.secondary.withValues(alpha: 0.2),
+ ],
+ ),
+ borderRadius: BorderRadius.circular(14),
+ ),
+ child: const Center(
+ child: Text('🛠️', style: TextStyle(fontSize: 22)),
+ ),
+ ),
+ const SizedBox(width: DesignTokens.space3),
+ Expanded(
+ child: Column(
+ crossAxisAlignment: CrossAxisAlignment.start,
+ children: [
+ Text(
+ '工具中心',
+ style: TextStyle(
+ fontSize: DesignTokens.fontXxl,
+ fontWeight: FontWeight.w700,
+ color: isDark ? DarkDesignTokens.text1 : DesignTokens.text1,
+ ),
+ ),
+ Text(
+ '发现更多烹饪好帮手',
+ style: TextStyle(
+ fontSize: DesignTokens.fontSm,
+ color: isDark ? DarkDesignTokens.text3 : DesignTokens.text3,
+ ),
+ ),
+ ],
+ ),
+ ),
+ Obx(() {
+ final count = _controller!.tools.length;
+ return Container(
+ padding: const EdgeInsets.symmetric(
+ horizontal: DesignTokens.space2,
+ vertical: DesignTokens.space1,
+ ),
+ decoration: BoxDecoration(
+ color: DesignTokens.primary.withValues(alpha: 0.1),
+ borderRadius: DesignTokens.borderRadiusSm,
+ ),
+ child: Text(
+ '$count 个工具',
+ style: TextStyle(
+ fontSize: DesignTokens.fontXs,
+ fontWeight: FontWeight.w600,
+ color: DesignTokens.primary,
+ ),
+ ),
+ );
+ }),
+ ],
+ ),
+ );
+ }
+
+ Widget _buildSearchBar(bool isDark) {
+ return Padding(
+ padding: const EdgeInsets.symmetric(horizontal: DesignTokens.space4),
child: Container(
- height: 40,
+ height: 44,
decoration: BoxDecoration(
color: isDark
? DarkDesignTokens.card
- : DesignTokens.text3.withValues(alpha: 0.08),
- borderRadius: DesignTokens.borderRadiusMd,
+ : DesignTokens.text3.withValues(alpha: 0.06),
+ borderRadius: BorderRadius.circular(14),
+ border: Border.all(
+ color: isDark
+ ? DarkDesignTokens.glassBorder
+ : DesignTokens.text3.withValues(alpha: 0.08),
+ ),
),
child: Row(
children: [
- const SizedBox(width: 12),
+ const SizedBox(width: 14),
Icon(
CupertinoIcons.search,
size: 18,
color: isDark ? DarkDesignTokens.text3 : DesignTokens.text3,
),
- const SizedBox(width: 8),
+ const SizedBox(width: 10),
Expanded(
child: CupertinoTextField(
placeholder: '搜索工具...',
placeholderStyle: TextStyle(
- fontSize: DesignTokens.fontSm,
+ fontSize: DesignTokens.fontMd,
color: isDark ? DarkDesignTokens.text3 : DesignTokens.text3,
),
style: TextStyle(
- fontSize: DesignTokens.fontSm,
+ fontSize: DesignTokens.fontMd,
color: isDark ? DarkDesignTokens.text1 : DesignTokens.text1,
),
decoration: null,
- onChanged: controller.search,
+ onChanged: (value) {
+ setState(() {
+ _searchQuery = value;
+ });
+ _controller!.search(value);
+ },
),
),
- Obx(() {
- if (controller.searchQuery.value.isEmpty) {
- return const SizedBox.shrink();
- }
- return CupertinoButton(
- padding: const EdgeInsets.only(right: 8),
- minimumSize: const Size(32, 32),
- onPressed: () {
- controller.search('');
+ if (_searchQuery.isNotEmpty)
+ GestureDetector(
+ onTap: () {
+ setState(() {
+ _searchQuery = '';
+ });
+ _controller!.search('');
},
- child: Icon(
- CupertinoIcons.clear_circled_solid,
- size: 18,
- color: isDark ? DarkDesignTokens.text3 : DesignTokens.text3,
+ child: Padding(
+ padding: const EdgeInsets.only(right: 12),
+ child: Icon(
+ CupertinoIcons.clear_circled_solid,
+ size: 18,
+ color: isDark ? DarkDesignTokens.text3 : DesignTokens.text3,
+ ),
),
- );
- }),
+ )
+ else
+ const SizedBox(width: 12),
],
),
),
);
}
- Widget _buildCategoryTabs(ToolsController controller, bool isDark) {
- return SizedBox(
- height: 36,
- child: Obx(() {
- return ListView.separated(
- scrollDirection: Axis.horizontal,
- padding: const EdgeInsets.symmetric(horizontal: DesignTokens.space4),
- itemCount: ToolCategory.all.length,
- separatorBuilder: (context, index) =>
- const SizedBox(width: DesignTokens.space2),
- itemBuilder: (context, index) {
- final category = ToolCategory.all[index];
- final isSelected = controller.selectedCategory.value == category.id;
+ Widget _buildGroupedTools(bool isDark) {
+ final filteredTools = _controller!.filteredTools;
- return GestureDetector(
- onTap: () => controller.selectCategory(category.id),
- child: Container(
- padding: const EdgeInsets.symmetric(
- horizontal: DesignTokens.space3,
- vertical: DesignTokens.space1,
- ),
- decoration: BoxDecoration(
- color: isSelected
- ? (isDark
- ? DarkDesignTokens.primary
- : DesignTokens.primary)
- : (isDark ? DarkDesignTokens.card : DesignTokens.card),
- borderRadius: DesignTokens.borderRadiusMd,
- border: Border.all(
- color: isSelected
- ? (isDark
- ? DarkDesignTokens.primary
- : DesignTokens.primary)
- : (isDark ? DarkDesignTokens.text3 : DesignTokens.text3)
- .withValues(alpha: 0.2),
- ),
- ),
- child: Row(
- mainAxisSize: MainAxisSize.min,
- children: [
- Text(category.icon, style: const TextStyle(fontSize: 14)),
- const SizedBox(width: 4),
- Text(
- category.name,
- style: TextStyle(
- fontSize: DesignTokens.fontSm,
- fontWeight: isSelected
- ? FontWeight.w600
- : FontWeight.w400,
- color: isSelected
- ? CupertinoColors.white
- : (isDark
- ? DarkDesignTokens.text1
- : DesignTokens.text1),
- ),
- ),
- ],
- ),
+ if (filteredTools.isEmpty) {
+ return _buildEmptyState(isDark);
+ }
+
+ final groups = _groupToolsByCategory(filteredTools);
+ final categories = groups.keys.toList();
+
+ return ListView.builder(
+ padding: const EdgeInsets.symmetric(
+ horizontal: DesignTokens.space4,
+ vertical: DesignTokens.space3,
+ ),
+ itemCount: categories.length,
+ itemBuilder: (context, groupIndex) {
+ final category = categories[groupIndex];
+ final tools = groups[category]!;
+ final categoryInfo = _getCategoryInfo(category);
+
+ return AnimatedBuilder(
+ animation: _animationController,
+ builder: (context, child) {
+ final delay = groupIndex * 0.1;
+ final slideAnimation = Tween(
+ begin: const Offset(0, 0.3),
+ end: Offset.zero,
+ ).animate(CurvedAnimation(
+ parent: _animationController,
+ curve: Interval(delay, delay + 0.3, curve: Curves.easeOutCubic),
+ ));
+
+ final fadeAnimation = Tween(begin: 0, end: 1).animate(
+ CurvedAnimation(
+ parent: _animationController,
+ curve: Interval(delay, delay + 0.3, curve: Curves.easeOut),
+ ),
+ );
+
+ return SlideTransition(
+ position: slideAnimation,
+ child: FadeTransition(
+ opacity: fadeAnimation,
+ child: child,
),
);
},
+ child: Padding(
+ padding: const EdgeInsets.only(bottom: DesignTokens.space4),
+ child: _buildCategoryGroup(category, tools, categoryInfo, isDark),
+ ),
);
- }),
- );
- }
-
- Widget _buildToolsGrid(ToolsController controller, bool isDark) {
- if (controller.isLoading.value) {
- return const Center(child: CupertinoActivityIndicator(radius: 16));
- }
-
- if (controller.filteredTools.isEmpty) {
- return Center(
- child: Column(
- mainAxisAlignment: MainAxisAlignment.center,
- children: [
- const Text('🔍', style: TextStyle(fontSize: 48)),
- const SizedBox(height: DesignTokens.space3),
- Text(
- '未找到相关工具',
- style: TextStyle(
- fontSize: DesignTokens.fontMd,
- color: isDark ? DarkDesignTokens.text2 : DesignTokens.text2,
- ),
- ),
- ],
- ),
- );
- }
-
- return GridView.builder(
- padding: EdgeInsets.only(
- left: DesignTokens.space4,
- right: DesignTokens.space4,
- top: DesignTokens.space2,
- bottom: MediaQuery.of(context).padding.bottom + DesignTokens.space2,
- ),
- gridDelegate: const SliverGridDelegateWithFixedCrossAxisCount(
- crossAxisCount: 2,
- mainAxisSpacing: DesignTokens.space3,
- crossAxisSpacing: DesignTokens.space3,
- childAspectRatio: 0.85,
- ),
- itemCount: controller.filteredTools.length,
- itemBuilder: (context, index) {
- final tool = controller.filteredTools[index];
- return _buildToolCard(tool, controller, isDark);
},
);
}
- Widget _buildToolCard(
- ToolItem tool,
- ToolsController controller,
+ Map> _groupToolsByCategory(List tools) {
+ final groups = >{};
+ for (final tool in tools) {
+ groups.putIfAbsent(tool.category, () => []).add(tool);
+ }
+ return groups;
+ }
+
+ Map _getCategoryInfo(String categoryId) {
+ final categoryMap = {
+ 'cooking': {
+ 'name': '烹饪助手',
+ 'icon': '🍳',
+ 'gradient': [const Color(0xFFFF6B35), const Color(0xFFFF9F1C)],
+ },
+ 'health': {
+ 'name': '健康营养',
+ 'icon': '💊',
+ 'gradient': [const Color(0xFF2ECC71), const Color(0xFF27AE60)],
+ },
+ 'data': {
+ 'name': '数据查询',
+ 'icon': '📊',
+ 'gradient': [const Color(0xFF3498DB), const Color(0xFF2980B9)],
+ },
+ 'planning': {
+ 'name': '规划管理',
+ 'icon': '📅',
+ 'gradient': [const Color(0xFF9B59B6), const Color(0xFF8E44AD)],
+ },
+ };
+ return categoryMap[categoryId] ?? {
+ 'name': categoryId,
+ 'icon': '📦',
+ 'gradient': [DesignTokens.primary, DesignTokens.secondary],
+ };
+ }
+
+ Widget _buildCategoryGroup(
+ String category,
+ List tools,
+ Map categoryInfo,
bool isDark,
) {
- return GestureDetector(
- onTap: () => controller.openTool(tool),
- child: Container(
- padding: const EdgeInsets.all(DesignTokens.space3),
- decoration: BoxDecoration(
- color: isDark ? DarkDesignTokens.card : DesignTokens.card,
- borderRadius: DesignTokens.borderRadiusLg,
- border: Border.all(
- color: (isDark ? DarkDesignTokens.text3 : DesignTokens.text3)
- .withValues(alpha: 0.1),
+ return Container(
+ decoration: BoxDecoration(
+ color: isDark ? DarkDesignTokens.card : DesignTokens.card,
+ borderRadius: BorderRadius.circular(20),
+ boxShadow: [
+ BoxShadow(
+ color: isDark
+ ? Colors.black.withValues(alpha: 0.3)
+ : Colors.black.withValues(alpha: 0.05),
+ blurRadius: 20,
+ offset: const Offset(0, 8),
),
- boxShadow: DesignTokens.shadowsSm,
+ ],
+ ),
+ child: Column(
+ crossAxisAlignment: CrossAxisAlignment.start,
+ children: [
+ _buildCategoryHeader(categoryInfo, tools.length, isDark),
+ Padding(
+ padding: const EdgeInsets.fromLTRB(16, 0, 16, 16),
+ child: Wrap(
+ spacing: 12,
+ runSpacing: 12,
+ children: tools.map((tool) {
+ return _buildToolChip(tool, categoryInfo, isDark);
+ }).toList(),
+ ),
+ ),
+ ],
+ ),
+ );
+ }
+
+ Widget _buildCategoryHeader(
+ Map categoryInfo,
+ int count,
+ bool isDark,
+ ) {
+ final gradientColors = categoryInfo['gradient'] as List;
+
+ return Container(
+ padding: const EdgeInsets.all(DesignTokens.space4),
+ decoration: BoxDecoration(
+ gradient: LinearGradient(
+ begin: Alignment.topLeft,
+ end: Alignment.bottomRight,
+ colors: [
+ gradientColors[0].withValues(alpha: 0.15),
+ gradientColors[1].withValues(alpha: 0.08),
+ ],
),
- child: Stack(
- children: [
- Column(
+ borderRadius: const BorderRadius.vertical(top: Radius.circular(20)),
+ ),
+ child: Row(
+ children: [
+ Container(
+ width: 40,
+ height: 40,
+ decoration: BoxDecoration(
+ gradient: LinearGradient(
+ begin: Alignment.topLeft,
+ end: Alignment.bottomRight,
+ colors: gradientColors,
+ ),
+ borderRadius: BorderRadius.circular(12),
+ boxShadow: [
+ BoxShadow(
+ color: gradientColors[0].withValues(alpha: 0.4),
+ blurRadius: 8,
+ offset: const Offset(0, 4),
+ ),
+ ],
+ ),
+ child: Center(
+ child: Text(
+ categoryInfo['icon'] as String,
+ style: const TextStyle(fontSize: 20),
+ ),
+ ),
+ ),
+ const SizedBox(width: DesignTokens.space3),
+ Expanded(
+ child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
- Container(
- width: 44,
- height: 44,
- decoration: BoxDecoration(
- color:
- (isDark
- ? DarkDesignTokens.primary
- : DesignTokens.primary)
- .withValues(alpha: 0.1),
- borderRadius: DesignTokens.borderRadiusMd,
- ),
- child: Center(
- child: Text(
- tool.icon,
- style: const TextStyle(fontSize: 24),
- ),
+ Text(
+ categoryInfo['name'] as String,
+ style: TextStyle(
+ fontSize: DesignTokens.fontLg,
+ fontWeight: FontWeight.w600,
+ color: isDark ? DarkDesignTokens.text1 : DesignTokens.text1,
),
),
- const SizedBox(height: DesignTokens.space2),
Text(
- tool.name,
+ '$count 个工具',
+ style: TextStyle(
+ fontSize: DesignTokens.fontXs,
+ color: isDark ? DarkDesignTokens.text3 : DesignTokens.text3,
+ ),
+ ),
+ ],
+ ),
+ ),
+ ],
+ ),
+ );
+ }
+
+ Widget _buildToolChip(
+ ToolItem tool,
+ Map categoryInfo,
+ bool isDark,
+ ) {
+ final gradientColors = categoryInfo['gradient'] as List;
+
+ return GestureDetector(
+ onTap: () => _openTool(tool),
+ child: Container(
+ width: (MediaQuery.of(context).size.width - 56) / 2,
+ padding: const EdgeInsets.all(DesignTokens.space3),
+ decoration: BoxDecoration(
+ color: isDark
+ ? DarkDesignTokens.glass
+ : DesignTokens.text3.withValues(alpha: 0.04),
+ borderRadius: BorderRadius.circular(16),
+ border: Border.all(
+ color: isDark
+ ? DarkDesignTokens.glassBorder
+ : DesignTokens.text3.withValues(alpha: 0.08),
+ ),
+ ),
+ child: Row(
+ children: [
+ Container(
+ width: 44,
+ height: 44,
+ decoration: BoxDecoration(
+ gradient: LinearGradient(
+ begin: Alignment.topLeft,
+ end: Alignment.bottomRight,
+ colors: [
+ gradientColors[0].withValues(alpha: 0.2),
+ gradientColors[1].withValues(alpha: 0.1),
+ ],
+ ),
+ borderRadius: BorderRadius.circular(12),
+ ),
+ child: Center(
+ child: Text(tool.icon, style: const TextStyle(fontSize: 22)),
+ ),
+ ),
+ const SizedBox(width: DesignTokens.space2),
+ Expanded(
+ child: Column(
+ crossAxisAlignment: CrossAxisAlignment.start,
+ children: [
+ Text(
+ tool.name,
+ style: TextStyle(
+ fontSize: DesignTokens.fontSm,
+ fontWeight: FontWeight.w600,
+ color: isDark
+ ? DarkDesignTokens.text1
+ : DesignTokens.text1,
+ ),
+ maxLines: 1,
+ overflow: TextOverflow.ellipsis,
+ ),
+ const SizedBox(height: 2),
+ Row(
+ children: [
+ Container(
+ width: 6,
+ height: 6,
+ decoration: BoxDecoration(
+ color: tool.needsNetwork
+ ? DesignTokens.green
+ : DesignTokens.primary,
+ shape: BoxShape.circle,
+ ),
+ ),
+ const SizedBox(width: 4),
+ Text(
+ tool.needsNetwork ? '联网' : '本地',
+ style: TextStyle(
+ fontSize: 10,
+ color: isDark
+ ? DarkDesignTokens.text3
+ : DesignTokens.text3,
+ ),
+ ),
+ ],
+ ),
+ ],
+ ),
+ ),
+ Icon(
+ CupertinoIcons.chevron_right,
+ size: 14,
+ color: isDark ? DarkDesignTokens.text3 : DesignTokens.text3,
+ ),
+ ],
+ ),
+ ),
+ );
+ }
+
+ Widget _buildEmptyState(bool isDark) {
+ return Center(
+ child: Column(
+ mainAxisAlignment: MainAxisAlignment.center,
+ children: [
+ Container(
+ width: 80,
+ height: 80,
+ decoration: BoxDecoration(
+ color: DesignTokens.primary.withValues(alpha: 0.1),
+ borderRadius: BorderRadius.circular(24),
+ ),
+ child: const Center(
+ child: Text('🔍', style: TextStyle(fontSize: 36)),
+ ),
+ ),
+ const SizedBox(height: DesignTokens.space4),
+ Text(
+ '未找到相关工具',
+ style: TextStyle(
+ fontSize: DesignTokens.fontLg,
+ fontWeight: FontWeight.w600,
+ color: isDark ? DarkDesignTokens.text1 : DesignTokens.text1,
+ ),
+ ),
+ const SizedBox(height: DesignTokens.space2),
+ Text(
+ '试试其他关键词吧',
+ style: TextStyle(
+ fontSize: DesignTokens.fontSm,
+ color: isDark ? DarkDesignTokens.text3 : DesignTokens.text3,
+ ),
+ ),
+ ],
+ ),
+ );
+ }
+
+ void _openTool(ToolItem tool) {
+ _controller?.recordUsage(tool.id);
+ Navigator.of(context).push(
+ CupertinoPageRoute(
+ builder: (context) => _ToolDetailPage(tool: tool),
+ ),
+ );
+ }
+}
+
+class _ToolDetailPage extends StatelessWidget {
+ final ToolItem tool;
+
+ const _ToolDetailPage({required this.tool});
+
+ @override
+ Widget build(BuildContext context) {
+ final isDark = CupertinoTheme.brightnessOf(context) == Brightness.dark;
+
+ return CupertinoPageScaffold(
+ backgroundColor: isDark
+ ? DarkDesignTokens.background
+ : DesignTokens.background,
+ child: SafeArea(
+ child: Column(
+ children: [
+ _buildHeader(context, isDark),
+ Expanded(
+ child: SingleChildScrollView(
+ padding: const EdgeInsets.all(DesignTokens.space4),
+ child: Column(
+ children: [
+ _buildHeroCard(isDark),
+ const SizedBox(height: DesignTokens.space4),
+ _buildInfoCards(isDark),
+ ],
+ ),
+ ),
+ ),
+ ],
+ ),
+ ),
+ );
+ }
+
+ Widget _buildHeader(BuildContext context, bool isDark) {
+ return Padding(
+ padding: const EdgeInsets.symmetric(
+ horizontal: DesignTokens.space4,
+ vertical: DesignTokens.space2,
+ ),
+ child: Row(
+ children: [
+ GestureDetector(
+ onTap: () => Navigator.of(context).pop(),
+ child: Container(
+ width: 40,
+ height: 40,
+ decoration: BoxDecoration(
+ color: isDark
+ ? DarkDesignTokens.glass
+ : DesignTokens.text3.withValues(alpha: 0.08),
+ borderRadius: BorderRadius.circular(12),
+ ),
+ child: Icon(
+ CupertinoIcons.back,
+ color: isDark ? DarkDesignTokens.text1 : DesignTokens.text1,
+ ),
+ ),
+ ),
+ const SizedBox(width: DesignTokens.space3),
+ Expanded(
+ child: Text(
+ tool.name,
+ style: TextStyle(
+ fontSize: DesignTokens.fontLg,
+ fontWeight: FontWeight.w600,
+ color: isDark ? DarkDesignTokens.text1 : DesignTokens.text1,
+ ),
+ ),
+ ),
+ ],
+ ),
+ );
+ }
+
+ Widget _buildHeroCard(bool isDark) {
+ return Container(
+ width: double.infinity,
+ padding: const EdgeInsets.all(DesignTokens.space5),
+ decoration: BoxDecoration(
+ gradient: LinearGradient(
+ begin: Alignment.topLeft,
+ end: Alignment.bottomRight,
+ colors: [
+ DesignTokens.primary.withValues(alpha: 0.15),
+ DesignTokens.secondary.withValues(alpha: 0.08),
+ ],
+ ),
+ borderRadius: BorderRadius.circular(24),
+ border: Border.all(
+ color: DesignTokens.primary.withValues(alpha: 0.1),
+ ),
+ ),
+ child: Column(
+ children: [
+ Container(
+ width: 80,
+ height: 80,
+ decoration: BoxDecoration(
+ color: DesignTokens.primary.withValues(alpha: 0.15),
+ borderRadius: BorderRadius.circular(24),
+ ),
+ child: Center(
+ child: Text(tool.icon, style: const TextStyle(fontSize: 40)),
+ ),
+ ),
+ const SizedBox(height: DesignTokens.space4),
+ Text(
+ tool.name,
+ style: TextStyle(
+ fontSize: DesignTokens.fontXxl,
+ fontWeight: FontWeight.w700,
+ color: isDark ? DarkDesignTokens.text1 : DesignTokens.text1,
+ ),
+ ),
+ if (tool.description != null) ...[
+ const SizedBox(height: DesignTokens.space2),
+ Text(
+ tool.description!,
+ textAlign: TextAlign.center,
+ style: TextStyle(
+ fontSize: DesignTokens.fontMd,
+ color: isDark ? DarkDesignTokens.text2 : DesignTokens.text2,
+ height: 1.5,
+ ),
+ ),
+ ],
+ const SizedBox(height: DesignTokens.space4),
+ Container(
+ padding: const EdgeInsets.symmetric(
+ horizontal: DesignTokens.space3,
+ vertical: DesignTokens.space2,
+ ),
+ decoration: BoxDecoration(
+ color: tool.needsNetwork
+ ? DesignTokens.green.withValues(alpha: 0.15)
+ : DesignTokens.primary.withValues(alpha: 0.15),
+ borderRadius: BorderRadius.circular(20),
+ ),
+ child: Row(
+ mainAxisSize: MainAxisSize.min,
+ children: [
+ Icon(
+ tool.needsNetwork
+ ? CupertinoIcons.wifi
+ : CupertinoIcons.device_phone_portrait,
+ size: 14,
+ color: tool.needsNetwork
+ ? DesignTokens.green
+ : DesignTokens.primary,
+ ),
+ const SizedBox(width: 6),
+ Text(
+ tool.needsNetwork ? '需要网络连接' : '本地运行',
+ style: TextStyle(
+ fontSize: DesignTokens.fontSm,
+ fontWeight: FontWeight.w500,
+ color: tool.needsNetwork
+ ? DesignTokens.green
+ : DesignTokens.primary,
+ ),
+ ),
+ ],
+ ),
+ ),
+ ],
+ ),
+ );
+ }
+
+ Widget _buildInfoCards(bool isDark) {
+ return Column(
+ children: [
+ _buildInfoCard(
+ icon: CupertinoIcons.chart_bar,
+ title: '使用统计',
+ value: '已使用 ${tool.usageCount} 次',
+ isDark: isDark,
+ ),
+ const SizedBox(height: DesignTokens.space2),
+ _buildInfoCard(
+ icon: CupertinoIcons.folder,
+ title: '所属分类',
+ value: _getCategoryName(tool.category),
+ isDark: isDark,
+ ),
+ ],
+ );
+ }
+
+ Widget _buildInfoCard({
+ required IconData icon,
+ required String title,
+ required String value,
+ required bool isDark,
+ }) {
+ return Container(
+ padding: const EdgeInsets.all(DesignTokens.space4),
+ decoration: BoxDecoration(
+ color: isDark ? DarkDesignTokens.card : DesignTokens.card,
+ borderRadius: BorderRadius.circular(16),
+ border: Border.all(
+ color: isDark
+ ? DarkDesignTokens.glassBorder
+ : DesignTokens.text3.withValues(alpha: 0.08),
+ ),
+ ),
+ child: Row(
+ children: [
+ Container(
+ width: 40,
+ height: 40,
+ decoration: BoxDecoration(
+ color: DesignTokens.primary.withValues(alpha: 0.1),
+ borderRadius: BorderRadius.circular(10),
+ ),
+ child: Icon(icon, size: 20, color: DesignTokens.primary),
+ ),
+ const SizedBox(width: DesignTokens.space3),
+ Expanded(
+ child: Column(
+ crossAxisAlignment: CrossAxisAlignment.start,
+ children: [
+ Text(
+ title,
+ style: TextStyle(
+ fontSize: DesignTokens.fontXs,
+ color: isDark ? DarkDesignTokens.text3 : DesignTokens.text3,
+ ),
+ ),
+ Text(
+ value,
style: TextStyle(
fontSize: DesignTokens.fontMd,
fontWeight: FontWeight.w600,
color: isDark ? DarkDesignTokens.text1 : DesignTokens.text1,
),
- maxLines: 1,
- overflow: TextOverflow.ellipsis,
- ),
- if (tool.description != null &&
- tool.description!.isNotEmpty) ...[
- const SizedBox(height: 2),
- Text(
- tool.description!,
- style: TextStyle(
- fontSize: DesignTokens.fontXs,
- color: isDark
- ? DarkDesignTokens.text3
- : DesignTokens.text3,
- ),
- maxLines: 2,
- overflow: TextOverflow.ellipsis,
- ),
- ],
- const SizedBox(height: DesignTokens.space2),
- Row(
- children: [
- Icon(
- tool.needsNetwork
- ? CupertinoIcons.wifi
- : CupertinoIcons.device_phone_portrait,
- size: 12,
- color: isDark
- ? DarkDesignTokens.text3
- : DesignTokens.text3,
- ),
- const SizedBox(width: 4),
- Text(
- tool.needsNetwork ? '联网' : '本地',
- style: TextStyle(
- fontSize: 10,
- color: isDark
- ? DarkDesignTokens.text3
- : DesignTokens.text3,
- ),
- ),
- ],
),
],
),
- Positioned(
- top: 0,
- right: 0,
- child: Container(
- width: 8,
- height: 8,
- decoration: BoxDecoration(
- color: tool.needsNetwork
- ? DesignTokens.green
- : DesignTokens.primary,
- shape: BoxShape.circle,
- ),
- ),
- ),
- ],
- ),
+ ),
+ ],
),
);
}
+
+ String _getCategoryName(String categoryId) {
+ const categoryNames = {
+ 'cooking': '烹饪助手',
+ 'health': '健康营养',
+ 'data': '数据查询',
+ 'planning': '规划管理',
+ };
+ return categoryNames[categoryId] ?? categoryId;
+ }
}
diff --git a/lib/src/standards/app_pages.dart b/lib/src/standards/app_pages.dart
index 7429ba1..8ae001d 100644
--- a/lib/src/standards/app_pages.dart
+++ b/lib/src/standards/app_pages.dart
@@ -4,6 +4,8 @@ import 'package:mom_kitchen/src/pages/home/home_page.dart';
import 'package:mom_kitchen/src/pages/profile/settings/theme_demo_page.dart';
import 'package:mom_kitchen/src/pages/tools/tools_center_page.dart';
import 'package:mom_kitchen/src/pages/tools/ingredient_detail_page.dart';
+import 'package:mom_kitchen/src/pages/discover/ingredient_recommend_page.dart';
+import 'package:mom_kitchen/src/pages/discover/ingredient_recipe_list_page.dart';
class AppPages {
static final List pages = [
@@ -54,6 +56,29 @@ class AppPages {
],
builder: () => const IngredientDetailPage(),
),
+ PageInfo(
+ route: '/tools/ingredient-recommend',
+ name: '食材推荐',
+ description: '热门食材推荐列表',
+ requiredStandards: [
+ StandardCheck.themeColors,
+ StandardCheck.textColors,
+ StandardCheck.darkMode,
+ ],
+ builder: () => const IngredientRecommendPage(),
+ ),
+ PageInfo(
+ route: '/tools/ingredient-recipes',
+ name: '食材菜品列表',
+ description: '某食材相关的菜品列表',
+ requiredStandards: [
+ StandardCheck.themeColors,
+ StandardCheck.textColors,
+ StandardCheck.darkMode,
+ ],
+ builder: () =>
+ const IngredientRecipeListPage(ingredientId: 0, ingredientName: ''),
+ ),
];
static void registerAll() {
diff --git a/lib/src/standards/page_validator.dart b/lib/src/standards/page_validator.dart
index ac66de6..1424735 100644
--- a/lib/src/standards/page_validator.dart
+++ b/lib/src/standards/page_validator.dart
@@ -150,27 +150,44 @@ class PageValidator {
static final List _validationHistory = [];
static const int _maxHistorySize = 100;
+ static bool _isValidating = false;
+ static DateTime? _lastValidateAt;
+ static const Duration _minValidateInterval = Duration(milliseconds: 500);
+
static List get history =>
List.unmodifiable(_validationHistory);
static void validate(BuildContext context, String pageRoute) {
if (!kDebugMode) return;
- final pageInfo = PageRegistry.getPage(pageRoute);
- if (pageInfo == null) {
- AppLogger.w('⚠️ 页面未注册: $pageRoute');
+ if (_isValidating) return;
+ final now = DateTime.now();
+ final last = _lastValidateAt;
+ if (last != null && now.difference(last) < _minValidateInterval) {
return;
}
+ _lastValidateAt = now;
+ _isValidating = true;
- final standards = PageStandards.of(context);
+ try {
+ final pageInfo = PageRegistry.getPage(pageRoute);
+ if (pageInfo == null) {
+ AppLogger.w('⚠️ 页面未注册: $pageRoute');
+ return;
+ }
- AppLogger.d('🔍 开始验证页面: ${pageInfo.name} ($pageRoute)');
+ final standards = PageStandards.of(context);
- for (final check in pageInfo.requiredStandards) {
- _checkStandard(context, standards, pageRoute, check);
+ AppLogger.d('🔍 开始验证页面: ${pageInfo.name} ($pageRoute)');
+
+ for (final check in pageInfo.requiredStandards) {
+ _checkStandard(context, standards, pageRoute, check);
+ }
+
+ AppLogger.i('✅ 页面验证完成: ${pageInfo.name}');
+ } finally {
+ _isValidating = false;
}
-
- AppLogger.i('✅ 页面验证完成: ${pageInfo.name}');
}
static void _checkStandard(