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(