更多 页面 重构

This commit is contained in:
Developer
2026-04-12 01:07:47 +08:00
parent 442f648128
commit 7b90983bb9
16 changed files with 2246 additions and 988 deletions

View File

@@ -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类过敏原关键词映射和检测逻辑

View File

@@ -1,304 +0,0 @@
<?php
/**
* 🚀 API缓存系统
*
* 支持文件缓存,自动清理过期缓存
* 优化多人多次请求性能
*/
class ApiCache {
private static $cacheDir = null;
private static $defaultTTL = 300;
private static $ttlConfig = array(
'list' => 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;
}
}

View File

@@ -1,104 +0,0 @@
<?php
/**
* 🧹 缓存管理接口
*
* 访问方式: /api/cache_manage.php?action=xxx
*/
require '../zb_system/function/c_system_base.php';
$zbp->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;

View File

@@ -1,206 +0,0 @@
<?php
/**
* 🍳 数据库诊断脚本
* 访问: http://eat.wktyl.com/api/diagnose.php
*/
require '../zb_system/function/c_system_base.php';
$zbp->Load();
header('Content-Type: text/html; charset=utf-8');
function check($msg, $result, $detail = '') {
$icon = $result ? '✅' : '❌';
$color = $result ? 'green' : 'red';
echo "<div style='margin:5px 0;padding:8px;background:" . ($result ? '#e8f5e9' : '#ffebee') . ";border-radius:4px;'>";
echo "<span style='color:$color;font-size:18px;'>$icon</span> <strong>$msg</strong>";
if ($detail) echo "<br><small style='color:#666;margin-left:28px;'>$detail</small>";
echo "</div>";
}
?>
<!DOCTYPE html>
<html>
<head>
<meta charset="UTF-8">
<title>🍳 数据库诊断</title>
<style>
body { font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', sans-serif; max-width: 900px; margin: 40px auto; padding: 20px; background: #f5f5f7; }
.card { background: #fff; border-radius: 12px; padding: 24px; margin-bottom: 20px; box-shadow: 0 2px 8px rgba(0,0,0,0.1); }
h1 { color: #1d1d1f; margin-bottom: 8px; }
h2 { color: #0071e3; border-bottom: 2px solid #0071e3; padding-bottom: 8px; margin-top: 0; }
.code { background: #1d1d1f; color: #f5f5f7; padding: 12px; border-radius: 8px; font-family: monospace; font-size: 13px; overflow-x: auto; margin: 8px 0; }
table { width: 100%; border-collapse: collapse; margin: 12px 0; }
th, td { padding: 10px; text-align: left; border-bottom: 1px solid #e5e5e7; }
th { background: #f5f5f7; font-weight: 600; }
.ok { color: #34c759; }
.err { color: #ff3b30; }
</style>
</head>
<body>
<div class="card">
<h1>🍳 数据库诊断报告</h1>
<p style="color:#666;">生成时间: <?php echo date('Y-m-d H:i:s'); ?></p>
</div>
<div class="card">
<h2>🔌 数据库连接</h2>
<?php
try {
$sql = "SELECT 1";
$result = $zbp->db->Query($sql);
check("数据库连接", true, "类型: " . get_class($zbp->db) . ", 前缀: " . $zbp->db->dbpre);
} catch (Exception $e) {
check("数据库连接", false, $e->getMessage());
}
?>
</div>
<div class="card">
<h2>📋 数据表检查</h2>
<?php
$tables = ['Post', 'Category', 'Tag', 'recipe_ingredient'];
foreach ($tables as $table) {
try {
$fullTable = $zbp->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());
}
}
?>
</div>
<div class="card">
<h2>🍳 菜谱数据检查</h2>
<?php
// 检查公开菜谱
$tablePost = $zbp->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);
?>
<h3 style="margin-top:16px;">示例菜谱数据:</h3>
<table>
<tr><th>ID</th><th>标题</th><th>分类ID</th></tr>
<?php foreach ($samples as $s): ?>
<tr>
<td><?php echo $s['log_ID']; ?></td>
<td><?php echo htmlspecialchars($s['log_Title']); ?></td>
<td><?php echo $s['log_CateID']; ?></td>
</tr>
<?php endforeach; ?>
</table>
<?php
// 检查ID=70是否存在
$sql = "SELECT log_ID, log_Title FROM $tablePost WHERE log_ID = 70 AND log_Type = 0";
$id70 = $zbp->db->Query($sql);
if ($id70) {
check("ID=70 菜谱", true, "标题: " . $id70[0]['log_Title']);
} else {
check("ID=70 菜谱", false, "不存在或不是文章类型");
}
?>
</div>
<div class="card">
<h2>🥬 食材数据检查</h2>
<?php
$tableIng = $zbp->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);
?>
<h3 style="margin-top:16px;">示例食材数据:</h3>
<table>
<tr><th>ingredient_id</th><th>名称</th></tr>
<?php foreach ($samples as $s): ?>
<tr>
<td><?php echo $s['ingredient_id']; ?></td>
<td><?php echo htmlspecialchars($s['name']); ?></td>
</tr>
<?php endforeach; ?>
</table>
<?php
// 检查ID=85是否存在
$sql = "SELECT name FROM $tableIng WHERE ingredient_id = 85 LIMIT 1";
$id85 = $zbp->db->Query($sql);
if ($id85) {
check("ID=85 食材", true, "名称: " . $id85[0]['name']);
} else {
check("ID=85 食材", false, "不存在");
}
?>
</div>
<div class="card">
<h2>🧪 API实际查询测试</h2>
<?php
// 模拟list接口查询
$sql = "SELECT log_ID, log_Title FROM $tablePost WHERE log_Type = 0 AND log_Status = 0 ORDER BY log_PostTime DESC LIMIT 3";
$listResult = $zbp->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) . " 条");
?>
</div>
<div class="card">
<h2>💡 问题诊断建议</h2>
<?php if ($publicCount == 0): ?>
<div class="code">
⚠️ 公开菜谱数量为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;
</div>
<?php endif; ?>
<?php if (!$id70): ?>
<div class="code">
⚠️ ID=70不存在可用ID范围: <?php echo $range['min_id']; ?> ~ <?php echo $range['max_id']; ?>
测试可用的ID:
?act=detail&id=<?php echo $range['min_id']; ?>
</div>
<?php endif; ?>
<?php if (!$id85): ?>
<div class="code">
⚠️ ingredient_id=85不存在可用范围: <?php echo $range['min_id'] ?? 'N/A'; ?> ~ <?php echo $range['max_id'] ?? 'N/A'; ?>
</div>
<?php endif; ?>
</div>
<div class="card" style="text-align:center;color:#86868b;">
<p>🍳 菜谱API诊断工具 | Powered by Z-Blog PHP</p>
</div>
</body>
</html>

View File

@@ -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<String, dynamic>?;
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',

View File

@@ -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) {

View File

@@ -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<ActionController>();
final FavoritesController _favoritesController = Get.find<FavoritesController>();
// 响应式状态
final Rx<RecipeModel?> recipe = Rx<RecipeModel?>(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<void> 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<void> 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<void> _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<void> 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<void> 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<void> 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<void> addToShoppingList(String recipeId) async {
if (recipe.value == null || recipe.value!.ingredients.isEmpty) {
throw Exception('该菜谱暂无食材信息');
}
try {
final controller = Get.find<dynamic>(); // 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}',
);
}
}

View File

@@ -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<String, int> _parseUsageData(String data) {
try {
return Map<String, int>.from(json.decode(data));
} catch (e) {
return <String, int>{};
}
}
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<String, int> usageMap = <String, int>{};
if (usageData != null && usageData.isNotEmpty) {
try {
usageMap = Map<String, int>.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<void> openTool(ToolItem tool) async {
await recordUsage(tool.id);
try {
Get.toNamed(tool.route);
} catch (e) {
debugPrint('ToolsController: Navigation error: $e');
}
}
Future<void> resetUsageData() async {

View File

@@ -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<CategoryBrowsePage> {
} 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<CategoryBrowsePage> {
}
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<CategoryBrowsePage> {
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<CategoryBrowsePage> {
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<CategoryBrowsePage> {
),
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: [
const Text('🍽️', style: TextStyle(fontSize: 48)),
const SizedBox(height: DesignTokens.space3),
Text(
'暂无菜谱',
cat.name,
style: TextStyle(
fontSize: DesignTokens.fontMd,
fontWeight: FontWeight.w600,
color: isDark
? DarkDesignTokens.text2
: DesignTokens.text2,
? DarkDesignTokens.text1
: DesignTokens.text1,
),
maxLines: 1,
overflow: TextOverflow.ellipsis,
),
const SizedBox(height: 4),
Row(
children: [
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.fontXs,
color: isDark
? DarkDesignTokens.text3
: DesignTokens.text3,
),
),
],
),
)
: ListView.builder(
],
),
),
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);
},
),
),
],
);
}

View File

@@ -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<IngredientRecipeListPage> createState() =>
_IngredientRecipeListPageState();
}
class _IngredientRecipeListPageState extends State<IngredientRecipeListPage> {
final ApiService _api = ApiService();
List<RecipeModel> _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<void> _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<RecipeModel> items = [];
if (response.data != null) {
Map<String, dynamic> responseMap;
if (response.data is Map<String, dynamic>) {
responseMap = response.data as Map<String, dynamic>;
} else if (response.data is Map) {
responseMap = Map<String, dynamic>.from(response.data as Map);
} else {
throw Exception('Invalid response format');
}
final apiResponse = ApiResponse.fromJson(responseMap, (data) {
if (data is Map<String, dynamic>) {
final pageData = data['list'] ?? data['data'] ?? [];
if (pageData is List) {
return pageData.map((e) {
if (e is Map<String, dynamic>) {
return RecipeModel.fromJson(e);
} else if (e is Map) {
return RecipeModel.fromJson(Map<String, dynamic>.from(e));
}
throw Exception('Invalid item format');
}).toList();
}
}
if (data is List) {
return data.map((e) {
if (e is Map<String, dynamic>) {
return RecipeModel.fromJson(e);
} else if (e is Map) {
return RecipeModel.fromJson(Map<String, dynamic>.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<RecipeModel>);
}
}
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<void> _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<RecipeModel> items = [];
if (response.data != null) {
Map<String, dynamic> responseMap;
if (response.data is Map<String, dynamic>) {
responseMap = response.data as Map<String, dynamic>;
} else if (response.data is Map) {
responseMap = Map<String, dynamic>.from(response.data as Map);
} else {
throw Exception('Invalid response format');
}
final apiResponse = ApiResponse.fromJson(responseMap, (data) {
if (data is Map<String, dynamic>) {
final pageData = data['list'] ?? data['data'] ?? [];
if (pageData is List) {
return pageData.map((e) {
if (e is Map<String, dynamic>) {
return RecipeModel.fromJson(e);
} else if (e is Map) {
return RecipeModel.fromJson(Map<String, dynamic>.from(e));
}
throw Exception('Invalid item format');
}).toList();
}
}
if (data is List) {
return data.map((e) {
if (e is Map<String, dynamic>) {
return RecipeModel.fromJson(e);
} else if (e is Map) {
return RecipeModel.fromJson(Map<String, dynamic>.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<RecipeModel>);
}
}
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<ScrollNotification>(
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,
),
),
],
),
],
),
],
),
),
),
],
),
),
);
}
}

View File

@@ -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<IngredientRecommendPage> createState() =>
_IngredientRecommendPageState();
}
class _IngredientRecommendPageState extends State<IngredientRecommendPage> {
final RecipeRepository _repo = RecipeRepository();
List<IngredientModel> _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<void> _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<void> _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<ScrollNotification>(
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 '🥬';
}
}

View File

@@ -71,7 +71,8 @@ class _HomePageState extends State<HomePage> {
_isLoadingRecommendations.value = true;
try {
final recommendationService = Get.find<RecommendationService>();
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<HomePage> {
);
}
final currentRecipes = _currentTab == 'today' ? _recipes : _recommendedRecipes;
final currentRecipes = _currentTab == 'today'
? _recipes
: _recommendedRecipes;
final List<Widget> sliverList = <Widget>[
CupertinoSliverRefreshControl(
@@ -422,9 +425,7 @@ class _HomePageState extends State<HomePage> {
),
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(
@@ -479,7 +480,9 @@ class _HomePageState extends State<HomePage> {
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<HomePage> {
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<HomePage> {
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<HomePage> {
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<HomePage> {
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<HomePage> {
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<HomePage> {
),
);
}
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,
),
),
],
),
),
);
}
}

View File

@@ -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<FavoritesPage> {
late final FavoritesController _favoritesController;
late final ToolsController _toolsController;
FavoritesController? _favoritesController;
ToolsController? _toolsController;
bool _isInitialized = false;
@override
void initState() {
super.initState();
void didChangeDependencies() {
super.didChangeDependencies();
if (!_isInitialized) {
_isInitialized = true;
_initControllers();
}
}
void _initControllers() {
try {
if (Get.isRegistered<FavoritesController>()) {
_favoritesController = Get.find<FavoritesController>();
}
if (Get.isRegistered<ToolsController>()) {
_toolsController = Get.find<ToolsController>();
}
} 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<FavoritesPage> {
_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<FavoritesPage> {
}),
),
Obx(
() => _favoritesController.isEditMode.value
() => _favoritesController!.isEditMode.value
? _buildEditBottomBar(isDark)
: const SizedBox.shrink(),
),
@@ -88,19 +110,21 @@ class _FavoritesPageState extends State<FavoritesPage> {
),
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<FavoritesPage> {
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<FavoritesPage> {
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<FavoritesPage> {
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<FavoritesPage> {
);
}
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<FavoritesPage> {
),
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<FavoritesPage> {
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<FavoritesPage> {
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<FavoritesPage> {
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<FavoritesPage> {
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<FavoritesPage> {
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<FavoritesPage> {
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<FavoritesPage> {
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<FavoritesPage> {
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<FavoritesPage> {
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<FavoritesPage> {
});
}
}
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,
),
),
],
),
),
);
}
}

File diff suppressed because it is too large Load Diff

View File

@@ -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<PageInfo> 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() {

View File

@@ -150,12 +150,26 @@ class PageValidator {
static final List<ValidationResult> _validationHistory = [];
static const int _maxHistorySize = 100;
static bool _isValidating = false;
static DateTime? _lastValidateAt;
static const Duration _minValidateInterval = Duration(milliseconds: 500);
static List<ValidationResult> get history =>
List.unmodifiable(_validationHistory);
static void validate(BuildContext context, String pageRoute) {
if (!kDebugMode) return;
if (_isValidating) return;
final now = DateTime.now();
final last = _lastValidateAt;
if (last != null && now.difference(last) < _minValidateInterval) {
return;
}
_lastValidateAt = now;
_isValidating = true;
try {
final pageInfo = PageRegistry.getPage(pageRoute);
if (pageInfo == null) {
AppLogger.w('⚠️ 页面未注册: $pageRoute');
@@ -171,6 +185,9 @@ class PageValidator {
}
AppLogger.i('✅ 页面验证完成: ${pageInfo.name}');
} finally {
_isValidating = false;
}
}
static void _checkStandard(