接口更新
This commit is contained in:
@@ -148,7 +148,7 @@ class AppRoutes {
|
||||
),
|
||||
GetPage(
|
||||
name: recipeDetail,
|
||||
page: () => RecipeDetailPage(recipeId: Get.arguments),
|
||||
page: () => RecipeDetailPage(recipeId: '${Get.arguments ?? '1'}'),
|
||||
middlewares: [PageStandardsMiddleware()],
|
||||
),
|
||||
GetPage(
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
// 2026-04-09 | RecipeModel | 菜谱数据模型 | 对齐api.php返回字段结构
|
||||
// 2026-04-10 | API v2.0.0: 新增 code/allergens/meta 字段,增强 ingredients 分类结构(main/auxiliary/seasoning)
|
||||
// 2026-04-11 | 新增 author/categoryHierarchy 字段,增强 IngredientDetail(别名/介绍/营养/指导/功效)
|
||||
class RecipeModel {
|
||||
final int id;
|
||||
final String title;
|
||||
@@ -8,6 +9,8 @@ class RecipeModel {
|
||||
final String? cover;
|
||||
final int? categoryId;
|
||||
final String? categoryName;
|
||||
final List<CategoryHierarchyItem> categoryHierarchy;
|
||||
final RecipeAuthor? author;
|
||||
final List<TagItem> tags;
|
||||
final List<IngredientItem> ingredients;
|
||||
final CategorizedIngredients? categorizedIngredients;
|
||||
@@ -27,6 +30,8 @@ class RecipeModel {
|
||||
this.cover,
|
||||
this.categoryId,
|
||||
this.categoryName,
|
||||
this.categoryHierarchy = const [],
|
||||
this.author,
|
||||
this.tags = const [],
|
||||
this.ingredients = const [],
|
||||
this.categorizedIngredients,
|
||||
@@ -102,6 +107,8 @@ class RecipeModel {
|
||||
_parseStringOrNull(json['imageUrl']),
|
||||
categoryId: categoryId,
|
||||
categoryName: categoryName,
|
||||
categoryHierarchy: _parseCategoryHierarchy(categoryObj),
|
||||
author: _parseAuthor(json['author']),
|
||||
tags: _parseTags(json['tags']),
|
||||
ingredients: _parseIngredients(json['ingredients']),
|
||||
categorizedIngredients: _parseCategorizedIngredients(json['ingredients']),
|
||||
@@ -199,6 +206,32 @@ class RecipeModel {
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
static List<CategoryHierarchyItem> _parseCategoryHierarchy(dynamic json) {
|
||||
try {
|
||||
if (json is! Map<String, dynamic>) return [];
|
||||
final hierarchy = json['hierarchy'];
|
||||
if (hierarchy is! List) return [];
|
||||
return hierarchy
|
||||
.whereType<Map<String, dynamic>>()
|
||||
.map((e) {
|
||||
try {
|
||||
return CategoryHierarchyItem.fromJson(e);
|
||||
} catch (_) {
|
||||
return null;
|
||||
}
|
||||
})
|
||||
.whereType<CategoryHierarchyItem>()
|
||||
.toList();
|
||||
} catch (_) {
|
||||
return [];
|
||||
}
|
||||
}
|
||||
|
||||
static RecipeAuthor? _parseAuthor(dynamic json) {
|
||||
if (json is! Map<String, dynamic>) return null;
|
||||
return RecipeAuthor.fromJson(json);
|
||||
}
|
||||
}
|
||||
|
||||
class TagItem {
|
||||
@@ -277,27 +310,48 @@ class IngredientItem {
|
||||
}
|
||||
|
||||
class IngredientDetail {
|
||||
final String? allergen;
|
||||
final String? allergenType;
|
||||
final List<String> alias;
|
||||
final List<String> usageTip;
|
||||
final String? introduction;
|
||||
final String? nutrition;
|
||||
final String? usageTip;
|
||||
final String? guidance;
|
||||
final String? effect;
|
||||
final String? other;
|
||||
final List<String> allergen;
|
||||
final List<String> allergenType;
|
||||
|
||||
const IngredientDetail({
|
||||
this.allergen,
|
||||
this.allergenType,
|
||||
this.alias = const [],
|
||||
this.usageTip = const [],
|
||||
this.introduction,
|
||||
this.nutrition,
|
||||
this.usageTip,
|
||||
this.guidance,
|
||||
this.effect,
|
||||
this.other,
|
||||
this.allergen = const [],
|
||||
this.allergenType = const [],
|
||||
});
|
||||
|
||||
factory IngredientDetail.fromJson(Map<String, dynamic> json) {
|
||||
return IngredientDetail(
|
||||
allergen: _parseStringField(json['allergen']),
|
||||
allergenType: _parseStringField(json['allergen_type']),
|
||||
alias: _parseStringList(json['alias']),
|
||||
usageTip: _parseStringList(json['usage_tip']),
|
||||
introduction: _parseStringField(json['introduction']),
|
||||
nutrition: _parseStringField(json['nutrition']),
|
||||
usageTip: _parseStringField(json['usage_tip']),
|
||||
guidance: _parseStringField(json['guidance']),
|
||||
effect: _parseStringField(json['effect']),
|
||||
other: _parseStringField(json['other']),
|
||||
allergen: _parseStringList(json['allergen']),
|
||||
allergenType: _parseStringList(json['allergen_type']),
|
||||
);
|
||||
}
|
||||
|
||||
static List<String> _parseStringList(dynamic v) {
|
||||
if (v is List) return v.map((e) => e.toString()).toList();
|
||||
if (v is String && v.isNotEmpty) return [v];
|
||||
return [];
|
||||
}
|
||||
|
||||
static String? _parseStringField(dynamic v) {
|
||||
if (v == null) return null;
|
||||
if (v is String) return v.isEmpty ? null : v;
|
||||
@@ -305,7 +359,13 @@ class IngredientDetail {
|
||||
return null;
|
||||
}
|
||||
|
||||
bool get hasAllergen => allergen != null && allergen!.isNotEmpty;
|
||||
bool get hasAllergen => allergen.isNotEmpty;
|
||||
bool get hasAlias => alias.isNotEmpty;
|
||||
bool get hasUsageTip => usageTip.isNotEmpty;
|
||||
bool get hasIntroduction => introduction != null && introduction!.isNotEmpty;
|
||||
bool get hasNutrition => nutrition != null && nutrition!.isNotEmpty;
|
||||
bool get hasGuidance => guidance != null && guidance!.isNotEmpty;
|
||||
bool get hasEffect => effect != null && effect!.isNotEmpty;
|
||||
}
|
||||
|
||||
class CategorizedIngredients {
|
||||
@@ -450,14 +510,26 @@ class RecipeStatistics {
|
||||
final int views;
|
||||
final int likes;
|
||||
final int recommends;
|
||||
final int comments;
|
||||
final int recommendScore;
|
||||
|
||||
const RecipeStatistics({this.views = 0, this.likes = 0, this.recommends = 0});
|
||||
const RecipeStatistics({
|
||||
this.views = 0,
|
||||
this.likes = 0,
|
||||
this.recommends = 0,
|
||||
this.comments = 0,
|
||||
this.recommendScore = 0,
|
||||
});
|
||||
|
||||
factory RecipeStatistics.fromJson(Map<String, dynamic> json) {
|
||||
return RecipeStatistics(
|
||||
views: _parseInt(json['views'] ?? json['view_count']),
|
||||
likes: _parseInt(json['likes'] ?? json['like_count']),
|
||||
recommends: _parseInt(json['recommends'] ?? json['recommend_count']),
|
||||
comments: _parseInt(json['comments'] ?? json['comment_count']),
|
||||
recommendScore: _parseInt(
|
||||
json['recommend_score'] ?? json['recommendScore'],
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
@@ -477,6 +549,7 @@ class RecipeMeta {
|
||||
final String? difficulty;
|
||||
final String? time;
|
||||
final List<String> eatingTime;
|
||||
final Map<String, int> indices;
|
||||
|
||||
const RecipeMeta({
|
||||
this.process,
|
||||
@@ -484,6 +557,7 @@ class RecipeMeta {
|
||||
this.difficulty,
|
||||
this.time,
|
||||
this.eatingTime = const [],
|
||||
this.indices = const {},
|
||||
});
|
||||
|
||||
factory RecipeMeta.fromJson(Map<String, dynamic> json) {
|
||||
@@ -493,6 +567,15 @@ class RecipeMeta {
|
||||
difficulty: _parseString(json['difficulty']),
|
||||
time: _parseString(json['time']),
|
||||
eatingTime: _parseStringList(json['eating_time']),
|
||||
indices: _parseIndices(json['indices']),
|
||||
);
|
||||
}
|
||||
|
||||
static Map<String, int> _parseIndices(dynamic value) {
|
||||
if (value is! Map) return {};
|
||||
return value.map(
|
||||
(k, v) =>
|
||||
MapEntry(k.toString(), v is int ? v : (v is double ? v.toInt() : 0)),
|
||||
);
|
||||
}
|
||||
|
||||
@@ -542,3 +625,80 @@ class RecipeMeta {
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
class CategoryHierarchyItem {
|
||||
final int id;
|
||||
final String name;
|
||||
final String? alias;
|
||||
final int level;
|
||||
|
||||
const CategoryHierarchyItem({
|
||||
required this.id,
|
||||
required this.name,
|
||||
this.alias,
|
||||
this.level = 0,
|
||||
});
|
||||
|
||||
factory CategoryHierarchyItem.fromJson(Map<String, dynamic> json) {
|
||||
return CategoryHierarchyItem(
|
||||
id: _safeInt(json['id']),
|
||||
name: _safeString(json['name']) ?? '',
|
||||
alias: _safeString(json['alias']),
|
||||
level: _safeInt(json['level']),
|
||||
);
|
||||
}
|
||||
|
||||
static int _safeInt(dynamic v) {
|
||||
if (v == null) return 0;
|
||||
if (v is int) return v;
|
||||
if (v is double) return v.toInt();
|
||||
if (v is String) return int.tryParse(v) ?? 0;
|
||||
return 0;
|
||||
}
|
||||
|
||||
static String? _safeString(dynamic v) {
|
||||
if (v == null) return null;
|
||||
if (v is String) return v.isEmpty ? null : v;
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
class RecipeAuthor {
|
||||
final int id;
|
||||
final String name;
|
||||
final String? alias;
|
||||
final String? email;
|
||||
final String? homepage;
|
||||
|
||||
const RecipeAuthor({
|
||||
required this.id,
|
||||
required this.name,
|
||||
this.alias,
|
||||
this.email,
|
||||
this.homepage,
|
||||
});
|
||||
|
||||
factory RecipeAuthor.fromJson(Map<String, dynamic> json) {
|
||||
return RecipeAuthor(
|
||||
id: _safeInt(json['id']),
|
||||
name: _safeString(json['name']) ?? '',
|
||||
alias: _safeString(json['alias']),
|
||||
email: _safeString(json['email']),
|
||||
homepage: _safeString(json['homepage']),
|
||||
);
|
||||
}
|
||||
|
||||
static int _safeInt(dynamic v) {
|
||||
if (v == null) return 0;
|
||||
if (v is int) return v;
|
||||
if (v is double) return v.toInt();
|
||||
if (v is String) return int.tryParse(v) ?? 0;
|
||||
return 0;
|
||||
}
|
||||
|
||||
static String? _safeString(dynamic v) {
|
||||
if (v == null) return null;
|
||||
if (v is String) return v.isEmpty ? null : v;
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -12,6 +12,7 @@ import 'package:mom_kitchen/src/config/design_tokens.dart';
|
||||
import 'package:mom_kitchen/src/models/recipe/category_model.dart';
|
||||
import 'package:mom_kitchen/src/models/recipe/recipe_model.dart';
|
||||
import 'package:mom_kitchen/src/repositories/recipe_repository.dart';
|
||||
import 'package:mom_kitchen/src/widgets/recipe_image.dart';
|
||||
|
||||
class CategoryBrowsePage extends StatefulWidget {
|
||||
final CategoryModel? category;
|
||||
@@ -352,35 +353,16 @@ class _CategoryBrowsePageState extends State<CategoryBrowsePage> {
|
||||
),
|
||||
child: Row(
|
||||
children: [
|
||||
Container(
|
||||
width: 60,
|
||||
height: 60,
|
||||
decoration: BoxDecoration(
|
||||
color: DesignTokens.primaryLight,
|
||||
borderRadius: DesignTokens.borderRadiusMd,
|
||||
ClipRRect(
|
||||
borderRadius: DesignTokens.borderRadiusMd,
|
||||
child: RecipeImage(
|
||||
recipeId: recipe.id,
|
||||
coverUrl: recipe.cover,
|
||||
width: 60,
|
||||
height: 60,
|
||||
fit: BoxFit.cover,
|
||||
mode: RecipeImageMode.thumbnail,
|
||||
),
|
||||
child: recipe.cover != null && recipe.cover!.isNotEmpty
|
||||
? ClipRRect(
|
||||
borderRadius: DesignTokens.borderRadiusMd,
|
||||
child: Image.network(
|
||||
recipe.cover!,
|
||||
fit: BoxFit.cover,
|
||||
errorBuilder: (_, __, ___) => const Center(
|
||||
child: Icon(
|
||||
CupertinoIcons.photo,
|
||||
color: DesignTokens.primary,
|
||||
size: 24,
|
||||
),
|
||||
),
|
||||
),
|
||||
)
|
||||
: const Center(
|
||||
child: Icon(
|
||||
CupertinoIcons.photo,
|
||||
color: DesignTokens.primary,
|
||||
size: 24,
|
||||
),
|
||||
),
|
||||
),
|
||||
const SizedBox(width: DesignTokens.space3),
|
||||
Expanded(
|
||||
@@ -388,7 +370,7 @@ class _CategoryBrowsePageState extends State<CategoryBrowsePage> {
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
Text(
|
||||
recipe.title ?? '',
|
||||
recipe.title,
|
||||
style: TextStyle(
|
||||
fontSize: DesignTokens.fontMd,
|
||||
fontWeight: FontWeight.w600,
|
||||
|
||||
@@ -11,12 +11,12 @@ import 'package:get/get.dart';
|
||||
import 'package:mom_kitchen/src/config/design_tokens.dart';
|
||||
import 'package:mom_kitchen/src/repositories/recipe_repository.dart';
|
||||
import 'package:mom_kitchen/src/models/recipe/recipe_model.dart';
|
||||
import 'recipe_detail_page.dart';
|
||||
import 'package:mom_kitchen/src/config/app_routes.dart';
|
||||
import 'package:mom_kitchen/src/widgets/nutrition_dashboard_card.dart';
|
||||
import 'package:mom_kitchen/src/widgets/base/skeleton_loader.dart';
|
||||
import 'package:mom_kitchen/src/services/ui/toast_service.dart';
|
||||
import 'package:mom_kitchen/src/controllers/meal_record_controller.dart';
|
||||
import 'package:mom_kitchen/src/widgets/recipe_image.dart';
|
||||
|
||||
class HomePage extends StatefulWidget {
|
||||
const HomePage({super.key});
|
||||
@@ -428,15 +428,14 @@ class _HomePageState extends State<HomePage> {
|
||||
decoration: BoxDecoration(
|
||||
color: DesignTokens.dynamicPrimaryLight,
|
||||
),
|
||||
child: recipe.hasCover
|
||||
? Image.network(
|
||||
recipe.cover!,
|
||||
fit: BoxFit.cover,
|
||||
width: double.infinity,
|
||||
errorBuilder: (_, _, _) =>
|
||||
_buildPlaceholderImage(recipe.title[0]),
|
||||
)
|
||||
: _buildPlaceholderImage(recipe.title[0]),
|
||||
child: RecipeImage(
|
||||
recipeId: recipe.id,
|
||||
coverUrl: recipe.cover,
|
||||
fit: BoxFit.cover,
|
||||
width: double.infinity,
|
||||
height: 180,
|
||||
mode: RecipeImageMode.thumbnail,
|
||||
),
|
||||
),
|
||||
|
||||
// 内容区
|
||||
@@ -562,40 +561,6 @@ class _HomePageState extends State<HomePage> {
|
||||
);
|
||||
}
|
||||
|
||||
Widget _buildPlaceholderImage(String letter) {
|
||||
return Center(
|
||||
child: Container(
|
||||
width: double.infinity,
|
||||
height: double.infinity,
|
||||
decoration: BoxDecoration(
|
||||
gradient: LinearGradient(
|
||||
begin: Alignment.topLeft,
|
||||
end: Alignment.bottomRight,
|
||||
colors: [
|
||||
DesignTokens.primary.withValues(alpha: 0.15),
|
||||
DesignTokens.secondary.withValues(alpha: 0.08),
|
||||
],
|
||||
),
|
||||
),
|
||||
child: Column(
|
||||
mainAxisAlignment: MainAxisAlignment.center,
|
||||
children: [
|
||||
Text(
|
||||
letter,
|
||||
style: TextStyle(
|
||||
fontSize: 48,
|
||||
fontWeight: FontWeight.bold,
|
||||
color: DesignTokens.primary.withValues(alpha: 0.3),
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 8),
|
||||
Text('🍽️', style: const TextStyle(fontSize: 32)),
|
||||
],
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
Widget _buildCategoryCard(String title, Color color, bool isDark) {
|
||||
return GestureDetector(
|
||||
onTap: () {
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -11,7 +11,7 @@ import 'package:get/get.dart';
|
||||
import '../../controllers/search_controller.dart';
|
||||
import '../../config/design_tokens.dart';
|
||||
import '../../models/recipe/recipe_model.dart';
|
||||
import 'recipe_detail_page.dart';
|
||||
import '../../widgets/recipe_image.dart';
|
||||
|
||||
class SearchPage extends StatefulWidget {
|
||||
const SearchPage({super.key});
|
||||
@@ -493,36 +493,17 @@ class _SearchPageState extends State<SearchPage> {
|
||||
),
|
||||
child: Row(
|
||||
children: [
|
||||
if (recipe.cover != null && recipe.cover!.isNotEmpty)
|
||||
ClipRRect(
|
||||
borderRadius: DesignTokens.borderRadiusSm,
|
||||
child: Image.network(
|
||||
recipe.cover!,
|
||||
width: 48,
|
||||
height: 48,
|
||||
fit: BoxFit.cover,
|
||||
errorBuilder: (_, __, ___) => Container(
|
||||
width: 48,
|
||||
height: 48,
|
||||
color: isDark
|
||||
? DarkDesignTokens.text3.withValues(alpha: 0.1)
|
||||
: DesignTokens.text3.withValues(alpha: 0.05),
|
||||
child: const Icon(CupertinoIcons.photo, size: 20),
|
||||
),
|
||||
),
|
||||
)
|
||||
else
|
||||
Container(
|
||||
ClipRRect(
|
||||
borderRadius: DesignTokens.borderRadiusSm,
|
||||
child: RecipeImage(
|
||||
recipeId: recipe.id,
|
||||
coverUrl: recipe.cover,
|
||||
width: 48,
|
||||
height: 48,
|
||||
decoration: BoxDecoration(
|
||||
color: isDark
|
||||
? DarkDesignTokens.text3.withValues(alpha: 0.1)
|
||||
: DesignTokens.text3.withValues(alpha: 0.05),
|
||||
borderRadius: DesignTokens.borderRadiusSm,
|
||||
),
|
||||
child: const Icon(CupertinoIcons.photo, size: 20),
|
||||
fit: BoxFit.cover,
|
||||
mode: RecipeImageMode.thumbnail,
|
||||
),
|
||||
),
|
||||
const SizedBox(width: DesignTokens.space3),
|
||||
Expanded(
|
||||
child: Column(
|
||||
@@ -659,19 +640,13 @@ class _SearchPageState extends State<SearchPage> {
|
||||
// 封面图
|
||||
ClipRRect(
|
||||
borderRadius: BorderRadius.circular(DesignTokens.radiusSm),
|
||||
child: Container(
|
||||
child: RecipeImage(
|
||||
recipeId: recipeId ?? 0,
|
||||
coverUrl: cover,
|
||||
width: 90,
|
||||
height: 90,
|
||||
color: (isDark ? DarkDesignTokens.text3 : DesignTokens.text3)
|
||||
.withValues(alpha: 0.08),
|
||||
child: cover != null && cover!.isNotEmpty
|
||||
? Image.network(
|
||||
cover!,
|
||||
fit: BoxFit.cover,
|
||||
errorBuilder: (_, _, _) =>
|
||||
_buildPlaceholderIcon(isDark),
|
||||
)
|
||||
: _buildPlaceholderIcon(isDark),
|
||||
fit: BoxFit.cover,
|
||||
mode: RecipeImageMode.thumbnail,
|
||||
),
|
||||
),
|
||||
const SizedBox(width: DesignTokens.space3),
|
||||
@@ -752,8 +727,4 @@ class _SearchPageState extends State<SearchPage> {
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
Widget _buildPlaceholderIcon(bool isDark) {
|
||||
return Center(child: Text('🍽️', style: const TextStyle(fontSize: 32)));
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,7 +1,6 @@
|
||||
import 'package:flutter/cupertino.dart';
|
||||
import 'package:get/get.dart';
|
||||
import 'package:mom_kitchen/src/controllers/user/personalization_controller.dart';
|
||||
import 'package:mom_kitchen/src/l10n/app_localizations.dart';
|
||||
import 'package:mom_kitchen/src/services/core/app_service.dart';
|
||||
import 'package:mom_kitchen/src/services/ui/theme_service.dart';
|
||||
import 'package:mom_kitchen/src/widgets/skeleton_widgets.dart';
|
||||
@@ -16,7 +15,6 @@ class PersonalizationPage extends StatelessWidget {
|
||||
init: PersonalizationController(),
|
||||
builder: (controller) {
|
||||
final themeService = AppService.instance.theme;
|
||||
final l10n = AppLocalizations.of(context)!;
|
||||
|
||||
return CupertinoPageScaffold(
|
||||
navigationBar: CupertinoNavigationBar(
|
||||
|
||||
@@ -13,6 +13,7 @@ import 'package:mom_kitchen/src/config/design_tokens.dart';
|
||||
import 'package:mom_kitchen/src/config/api_config.dart';
|
||||
import 'package:mom_kitchen/src/models/recipe/recipe_model.dart';
|
||||
import 'package:mom_kitchen/src/repositories/recipe_repository.dart';
|
||||
import 'package:mom_kitchen/src/widgets/recipe_image.dart';
|
||||
|
||||
class EatingTimeItem {
|
||||
final int id;
|
||||
@@ -390,11 +391,13 @@ class _EatingTimesPageState extends State<EatingTimesPage> {
|
||||
final searchKeyword = item.name
|
||||
.replaceAll(RegExp(r'[时段均可作菜佐食。]'), '')
|
||||
.trim();
|
||||
final result = await _recipeRepo.fetchList(
|
||||
search: searchKeyword.isNotEmpty ? searchKeyword : item.name,
|
||||
page: 1,
|
||||
limit: 20,
|
||||
).timeout(const Duration(seconds: 10));
|
||||
final result = await _recipeRepo
|
||||
.fetchList(
|
||||
search: searchKeyword.isNotEmpty ? searchKeyword : item.name,
|
||||
page: 1,
|
||||
limit: 20,
|
||||
)
|
||||
.timeout(const Duration(seconds: 10));
|
||||
|
||||
Navigator.of(context).pop();
|
||||
|
||||
@@ -481,36 +484,17 @@ class EatingTimeRecipesPage extends StatelessWidget {
|
||||
),
|
||||
child: Row(
|
||||
children: [
|
||||
if (recipe.cover != null && recipe.cover!.isNotEmpty)
|
||||
ClipRRect(
|
||||
borderRadius: DesignTokens.borderRadiusSm,
|
||||
child: Image.network(
|
||||
recipe.cover!,
|
||||
width: 64,
|
||||
height: 64,
|
||||
fit: BoxFit.cover,
|
||||
errorBuilder: (_, __, ___) => Container(
|
||||
width: 64,
|
||||
height: 64,
|
||||
color: isDark
|
||||
? DarkDesignTokens.text3.withValues(alpha: 0.1)
|
||||
: DesignTokens.text3.withValues(alpha: 0.05),
|
||||
child: const Icon(CupertinoIcons.photo, size: 24),
|
||||
),
|
||||
),
|
||||
)
|
||||
else
|
||||
Container(
|
||||
ClipRRect(
|
||||
borderRadius: DesignTokens.borderRadiusSm,
|
||||
child: RecipeImage(
|
||||
recipeId: recipe.id,
|
||||
coverUrl: recipe.cover,
|
||||
width: 64,
|
||||
height: 64,
|
||||
decoration: BoxDecoration(
|
||||
color: isDark
|
||||
? DarkDesignTokens.text3.withValues(alpha: 0.1)
|
||||
: DesignTokens.text3.withValues(alpha: 0.05),
|
||||
borderRadius: DesignTokens.borderRadiusSm,
|
||||
),
|
||||
child: const Icon(CupertinoIcons.photo, size: 24),
|
||||
fit: BoxFit.cover,
|
||||
mode: RecipeImageMode.thumbnail,
|
||||
),
|
||||
),
|
||||
const SizedBox(width: DesignTokens.space3),
|
||||
Expanded(
|
||||
child: Column(
|
||||
@@ -521,7 +505,9 @@ class EatingTimeRecipesPage extends StatelessWidget {
|
||||
style: TextStyle(
|
||||
fontSize: DesignTokens.fontMd,
|
||||
fontWeight: FontWeight.w500,
|
||||
color: isDark ? DarkDesignTokens.text1 : DesignTokens.text1,
|
||||
color: isDark
|
||||
? DarkDesignTokens.text1
|
||||
: DesignTokens.text1,
|
||||
),
|
||||
maxLines: 2,
|
||||
overflow: TextOverflow.ellipsis,
|
||||
@@ -532,7 +518,9 @@ class EatingTimeRecipesPage extends StatelessWidget {
|
||||
recipe.intro!,
|
||||
style: TextStyle(
|
||||
fontSize: DesignTokens.fontXs,
|
||||
color: isDark ? DarkDesignTokens.text3 : DesignTokens.text3,
|
||||
color: isDark
|
||||
? DarkDesignTokens.text3
|
||||
: DesignTokens.text3,
|
||||
),
|
||||
maxLines: 1,
|
||||
overflow: TextOverflow.ellipsis,
|
||||
@@ -542,17 +530,25 @@ class EatingTimeRecipesPage extends StatelessWidget {
|
||||
recipe.categoryName!.isNotEmpty) ...[
|
||||
const SizedBox(height: 4),
|
||||
Container(
|
||||
padding: const EdgeInsets.symmetric(horizontal: 6, vertical: 2),
|
||||
padding: const EdgeInsets.symmetric(
|
||||
horizontal: 6,
|
||||
vertical: 2,
|
||||
),
|
||||
decoration: BoxDecoration(
|
||||
color: (isDark ? DarkDesignTokens.primary : DesignTokens.primary)
|
||||
.withValues(alpha: 0.1),
|
||||
color:
|
||||
(isDark
|
||||
? DarkDesignTokens.primary
|
||||
: DesignTokens.primary)
|
||||
.withValues(alpha: 0.1),
|
||||
borderRadius: BorderRadius.circular(8),
|
||||
),
|
||||
child: Text(
|
||||
recipe.categoryName!,
|
||||
style: TextStyle(
|
||||
fontSize: 10,
|
||||
color: isDark ? DarkDesignTokens.primary : DesignTokens.primary,
|
||||
color: isDark
|
||||
? DarkDesignTokens.primary
|
||||
: DesignTokens.primary,
|
||||
),
|
||||
),
|
||||
),
|
||||
|
||||
@@ -21,11 +21,25 @@ class PageStandardsMiddleware extends GetMiddleware {
|
||||
|
||||
@override
|
||||
GetPage? onPageCalled(GetPage? page) {
|
||||
if (page == null) return null;
|
||||
if (page == null) return page;
|
||||
|
||||
WidgetsBinding.instance.addPostFrameCallback((_) {
|
||||
PageValidator.validate(Get.context!, page.name);
|
||||
});
|
||||
try {
|
||||
final context = Get.context;
|
||||
if (context == null) return page;
|
||||
|
||||
WidgetsBinding.instance.addPostFrameCallback((_) {
|
||||
try {
|
||||
final ctx = Get.context;
|
||||
if (ctx != null && ctx.mounted) {
|
||||
PageValidator.validate(ctx, page.name);
|
||||
}
|
||||
} catch (e) {
|
||||
AppLogger.w('页面验证异常: ${page.name} - $e');
|
||||
}
|
||||
});
|
||||
} catch (e) {
|
||||
AppLogger.w('路由中间件异常: ${page.name} - $e');
|
||||
}
|
||||
|
||||
return page;
|
||||
}
|
||||
|
||||
@@ -31,6 +31,7 @@ class GlassFeedCard extends StatelessWidget {
|
||||
this.subtitle,
|
||||
this.category,
|
||||
this.imageUrl,
|
||||
this.recipeId,
|
||||
this.viewCount,
|
||||
this.likeCount,
|
||||
this.recommendCount,
|
||||
@@ -84,6 +85,25 @@ class GlassFeedCard extends StatelessWidget {
|
||||
}
|
||||
|
||||
Widget _buildImage() {
|
||||
if (recipeId != null && recipeId! > 0) {
|
||||
return ClipRRect(
|
||||
borderRadius: const BorderRadius.only(
|
||||
topLeft: Radius.circular(DesignTokens.radiusLg),
|
||||
topRight: Radius.circular(DesignTokens.radiusLg),
|
||||
),
|
||||
child: AspectRatio(
|
||||
aspectRatio: 16 / 9,
|
||||
child: RecipeImage(
|
||||
recipeId: recipeId!,
|
||||
coverUrl: imageUrl,
|
||||
fit: BoxFit.cover,
|
||||
mode: RecipeImageMode.thumbnail,
|
||||
tapToOriginal: true,
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
return ClipRRect(
|
||||
borderRadius: const BorderRadius.only(
|
||||
topLeft: Radius.circular(DesignTokens.radiusLg),
|
||||
|
||||
@@ -23,6 +23,7 @@ class RecipeCard extends StatelessWidget {
|
||||
subtitle: recipe.intro,
|
||||
category: recipe.categoryName,
|
||||
imageUrl: recipe.cover,
|
||||
recipeId: recipe.id,
|
||||
viewCount: recipe.statistics?.views,
|
||||
likeCount: recipe.statistics?.likes,
|
||||
recommendCount: recipe.statistics?.recommends,
|
||||
|
||||
@@ -1,21 +1,27 @@
|
||||
/*
|
||||
* 文件: recipe_image.dart
|
||||
* 名称: 菜谱图片组件
|
||||
* 作用: 支持缓存+多级fallback的菜谱图片显示
|
||||
* 作用: 支持缓存+多级fallback+缩略图压缩+点击查看原图的菜谱图片显示
|
||||
* 创建: 2026-04-11
|
||||
* 更新: 2026-04-11 初始创建
|
||||
* 更新: 2026-04-11 增加缩略图压缩模式+点击查看原图+缓存管理
|
||||
*
|
||||
* Fallback链: {id}a.jpg → {id}b.jpg → {id}.jpg → back.png → 本地error.png → 空白
|
||||
* 缓存策略: 内存缓存+磁盘缓存(path_provider临时目录)
|
||||
* Fallback链: coverUrl → {id}a.jpg → {id}b.jpg → {id}.jpg → back.png → 本地error.png → 空白
|
||||
* 缓存策略: 内存缓存(24h) + 磁盘缓存(7天, path_provider临时目录)
|
||||
* 缩略图: 列表模式自动压缩至 thumbnailMaxPx 指定尺寸,减少流量和内存
|
||||
* 原图: 详情页或点击时加载全尺寸图片
|
||||
*/
|
||||
|
||||
import 'dart:io';
|
||||
import 'dart:typed_data';
|
||||
import 'dart:ui' as ui;
|
||||
import 'package:flutter/cupertino.dart';
|
||||
import 'package:flutter/foundation.dart';
|
||||
import 'package:flutter/rendering.dart';
|
||||
import 'package:path_provider/path_provider.dart';
|
||||
import 'package:mom_kitchen/src/config/design_tokens.dart';
|
||||
|
||||
enum RecipeImageMode { thumbnail, full }
|
||||
|
||||
class RecipeImage extends StatefulWidget {
|
||||
final int recipeId;
|
||||
final double? width;
|
||||
@@ -23,6 +29,9 @@ class RecipeImage extends StatefulWidget {
|
||||
final BoxFit fit;
|
||||
final String? coverUrl;
|
||||
final BorderRadius? borderRadius;
|
||||
final RecipeImageMode mode;
|
||||
final bool tapToOriginal;
|
||||
final int thumbnailMaxPx;
|
||||
|
||||
const RecipeImage({
|
||||
super.key,
|
||||
@@ -32,8 +41,23 @@ class RecipeImage extends StatefulWidget {
|
||||
this.fit = BoxFit.cover,
|
||||
this.coverUrl,
|
||||
this.borderRadius,
|
||||
this.mode = RecipeImageMode.thumbnail,
|
||||
this.tapToOriginal = false,
|
||||
this.thumbnailMaxPx = 400,
|
||||
});
|
||||
|
||||
const RecipeImage.full({
|
||||
super.key,
|
||||
required this.recipeId,
|
||||
this.width,
|
||||
this.height,
|
||||
this.fit = BoxFit.cover,
|
||||
this.coverUrl,
|
||||
this.borderRadius,
|
||||
}) : mode = RecipeImageMode.full,
|
||||
tapToOriginal = false,
|
||||
thumbnailMaxPx = 400;
|
||||
|
||||
@override
|
||||
State<RecipeImage> createState() => _RecipeImageState();
|
||||
}
|
||||
@@ -48,6 +72,7 @@ class _RecipeImageState extends State<RecipeImage> {
|
||||
bool _hasError = false;
|
||||
Uint8List? _imageBytes;
|
||||
String? _currentUrl;
|
||||
bool _isShowingOriginal = false;
|
||||
|
||||
List<String> get _fallbackUrls {
|
||||
final id = widget.recipeId;
|
||||
@@ -64,6 +89,9 @@ class _RecipeImageState extends State<RecipeImage> {
|
||||
return urls;
|
||||
}
|
||||
|
||||
String get _cacheKeyPrefix =>
|
||||
widget.mode == RecipeImageMode.thumbnail ? 'thumb_' : 'full_';
|
||||
|
||||
@override
|
||||
void initState() {
|
||||
super.initState();
|
||||
@@ -74,11 +102,13 @@ class _RecipeImageState extends State<RecipeImage> {
|
||||
void didUpdateWidget(RecipeImage oldWidget) {
|
||||
super.didUpdateWidget(oldWidget);
|
||||
if (oldWidget.recipeId != widget.recipeId ||
|
||||
oldWidget.coverUrl != widget.coverUrl) {
|
||||
oldWidget.coverUrl != widget.coverUrl ||
|
||||
oldWidget.mode != widget.mode) {
|
||||
_fallbackIndex = 0;
|
||||
_isLoading = true;
|
||||
_hasError = false;
|
||||
_imageBytes = null;
|
||||
_isShowingOriginal = false;
|
||||
_loadImage();
|
||||
}
|
||||
}
|
||||
@@ -86,17 +116,20 @@ class _RecipeImageState extends State<RecipeImage> {
|
||||
Future<void> _loadImage() async {
|
||||
final urls = _fallbackUrls;
|
||||
if (_fallbackIndex >= urls.length) {
|
||||
setState(() {
|
||||
_isLoading = false;
|
||||
_hasError = true;
|
||||
});
|
||||
if (mounted) {
|
||||
setState(() {
|
||||
_isLoading = false;
|
||||
_hasError = true;
|
||||
});
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
final url = urls[_fallbackIndex];
|
||||
_currentUrl = url;
|
||||
final cacheKey = '$_cacheKeyPrefix$url';
|
||||
|
||||
final cached = _getFromMemoryCache(url);
|
||||
final cached = _getFromMemoryCache(cacheKey);
|
||||
if (cached != null) {
|
||||
if (mounted) {
|
||||
setState(() {
|
||||
@@ -108,9 +141,9 @@ class _RecipeImageState extends State<RecipeImage> {
|
||||
return;
|
||||
}
|
||||
|
||||
final diskCached = await _getFromDiskCache(url);
|
||||
final diskCached = await _getFromDiskCache(cacheKey);
|
||||
if (diskCached != null) {
|
||||
_addToMemoryCache(url, diskCached);
|
||||
_addToMemoryCache(cacheKey, diskCached);
|
||||
if (mounted && _currentUrl == url) {
|
||||
setState(() {
|
||||
_imageBytes = diskCached;
|
||||
@@ -132,15 +165,25 @@ class _RecipeImageState extends State<RecipeImage> {
|
||||
BytesBuilder(),
|
||||
(b, d) => b..add(d),
|
||||
);
|
||||
final data = bytes.toBytes();
|
||||
final rawData = bytes.toBytes();
|
||||
client.close();
|
||||
|
||||
_addToMemoryCache(url, data);
|
||||
_saveToDiskCache(url, data);
|
||||
Uint8List finalData;
|
||||
if (widget.mode == RecipeImageMode.thumbnail && !_isShowingOriginal) {
|
||||
finalData = await _compressImage(
|
||||
Uint8List.fromList(rawData),
|
||||
widget.thumbnailMaxPx,
|
||||
);
|
||||
} else {
|
||||
finalData = Uint8List.fromList(rawData);
|
||||
}
|
||||
|
||||
_addToMemoryCache(cacheKey, finalData);
|
||||
_saveToDiskCache(cacheKey, finalData);
|
||||
|
||||
if (mounted && _currentUrl == url) {
|
||||
setState(() {
|
||||
_imageBytes = Uint8List.fromList(data);
|
||||
_imageBytes = finalData;
|
||||
_isLoading = false;
|
||||
_hasError = false;
|
||||
});
|
||||
@@ -155,6 +198,52 @@ class _RecipeImageState extends State<RecipeImage> {
|
||||
}
|
||||
}
|
||||
|
||||
Future<Uint8List> _compressImage(Uint8List data, int maxPx) async {
|
||||
try {
|
||||
final codec = await ui.instantiateImageCodecFromBuffer(
|
||||
await ui.ImmutableBuffer.fromUint8List(data),
|
||||
);
|
||||
final frame = await codec.getNextFrame();
|
||||
final image = frame.image;
|
||||
|
||||
final srcW = image.width;
|
||||
final srcH = image.height;
|
||||
final srcPx = srcW * srcH;
|
||||
|
||||
if (srcPx <= maxPx) {
|
||||
image.dispose();
|
||||
codec.dispose();
|
||||
return data;
|
||||
}
|
||||
|
||||
final scale = (maxPx / srcPx);
|
||||
final targetW = (srcW * scale).ceil();
|
||||
final targetH = (srcH * scale).ceil();
|
||||
|
||||
final recorder = ui.PictureRecorder();
|
||||
final canvas = Canvas(recorder);
|
||||
canvas.drawImageRect(
|
||||
image,
|
||||
Rect.fromLTWH(0, 0, srcW.toDouble(), srcH.toDouble()),
|
||||
Rect.fromLTWH(0, 0, targetW.toDouble(), targetH.toDouble()),
|
||||
Paint()..filterQuality = FilterQuality.medium,
|
||||
);
|
||||
|
||||
final picture = recorder.endRecording();
|
||||
final resized = await picture.toImage(targetW, targetH);
|
||||
final byteData = await resized.toByteData(format: ui.ImageByteFormat.png);
|
||||
image.dispose();
|
||||
codec.dispose();
|
||||
resized.dispose();
|
||||
|
||||
if (byteData == null) return data;
|
||||
return byteData.buffer.asUint8List();
|
||||
} catch (e) {
|
||||
debugPrint('RecipeImage compress error: $e');
|
||||
return data;
|
||||
}
|
||||
}
|
||||
|
||||
void _tryNextFallback() {
|
||||
_fallbackIndex++;
|
||||
if (mounted) {
|
||||
@@ -162,33 +251,44 @@ class _RecipeImageState extends State<RecipeImage> {
|
||||
}
|
||||
}
|
||||
|
||||
Uint8List? _getFromMemoryCache(String url) {
|
||||
final entry = _memoryCache[url];
|
||||
Future<void> _loadOriginalImage() async {
|
||||
if (_isShowingOriginal) return;
|
||||
setState(() {
|
||||
_isShowingOriginal = true;
|
||||
_isLoading = true;
|
||||
_fallbackIndex = 0;
|
||||
_imageBytes = null;
|
||||
});
|
||||
_loadImage();
|
||||
}
|
||||
|
||||
Uint8List? _getFromMemoryCache(String key) {
|
||||
final entry = _memoryCache[key];
|
||||
if (entry == null) return null;
|
||||
if (DateTime.now().difference(entry.cachedAt).inHours > 24) {
|
||||
_memoryCache.remove(url);
|
||||
_memoryCache.remove(key);
|
||||
return null;
|
||||
}
|
||||
return entry.data;
|
||||
}
|
||||
|
||||
void _addToMemoryCache(String url, List<int> data) {
|
||||
void _addToMemoryCache(String key, List<int> data) {
|
||||
if (_memoryCache.length > 200) {
|
||||
final oldest = _memoryCache.entries.reduce(
|
||||
(a, b) => a.value.cachedAt.isBefore(b.value.cachedAt) ? a : b,
|
||||
);
|
||||
_memoryCache.remove(oldest.key);
|
||||
}
|
||||
_memoryCache[url] = _CacheEntry(Uint8List.fromList(data), DateTime.now());
|
||||
_memoryCache[key] = _CacheEntry(Uint8List.fromList(data), DateTime.now());
|
||||
}
|
||||
|
||||
Future<Uint8List?> _getFromDiskCache(String url) async {
|
||||
Future<Uint8List?> _getFromDiskCache(String key) async {
|
||||
try {
|
||||
final dir = await getTemporaryDirectory();
|
||||
final cacheDir = Directory('${dir.path}/recipe_images');
|
||||
if (!cacheDir.existsSync()) return null;
|
||||
|
||||
final fileName = _urlToFileName(url);
|
||||
final fileName = _urlToFileName(key);
|
||||
final file = File('${cacheDir.path}/$fileName');
|
||||
if (!file.existsSync()) return null;
|
||||
|
||||
@@ -204,21 +304,21 @@ class _RecipeImageState extends State<RecipeImage> {
|
||||
}
|
||||
}
|
||||
|
||||
Future<void> _saveToDiskCache(String url, List<int> data) async {
|
||||
Future<void> _saveToDiskCache(String key, List<int> data) async {
|
||||
try {
|
||||
final dir = await getTemporaryDirectory();
|
||||
final cacheDir = Directory('${dir.path}/recipe_images');
|
||||
if (!cacheDir.existsSync()) {
|
||||
cacheDir.createSync(recursive: true);
|
||||
}
|
||||
final fileName = _urlToFileName(url);
|
||||
final fileName = _urlToFileName(key);
|
||||
final file = File('${cacheDir.path}/$fileName');
|
||||
file.writeAsBytesSync(data);
|
||||
} catch (_) {}
|
||||
}
|
||||
|
||||
String _urlToFileName(String url) {
|
||||
var name = url.replaceAll(RegExp(r'[/:.]'), '_');
|
||||
String _urlToFileName(String key) {
|
||||
var name = key.replaceAll(RegExp(r'[/:.]'), '_');
|
||||
if (name.length > 120) name = name.substring(name.length - 120);
|
||||
return name;
|
||||
}
|
||||
@@ -230,18 +330,7 @@ class _RecipeImageState extends State<RecipeImage> {
|
||||
Widget child;
|
||||
|
||||
if (_isLoading) {
|
||||
child = Container(
|
||||
width: widget.width,
|
||||
height: widget.height,
|
||||
color: (isDark ? DarkDesignTokens.text3 : DesignTokens.text3)
|
||||
.withValues(alpha: 0.06),
|
||||
child: Center(
|
||||
child: CupertinoActivityIndicator(
|
||||
radius: 12,
|
||||
color: isDark ? DarkDesignTokens.text3 : DesignTokens.text3,
|
||||
),
|
||||
),
|
||||
);
|
||||
child = _buildLoadingWidget(isDark);
|
||||
} else if (_hasError || _imageBytes == null) {
|
||||
child = _buildErrorWidget(isDark);
|
||||
} else {
|
||||
@@ -254,6 +343,53 @@ class _RecipeImageState extends State<RecipeImage> {
|
||||
);
|
||||
}
|
||||
|
||||
if (widget.tapToOriginal &&
|
||||
widget.mode == RecipeImageMode.thumbnail &&
|
||||
!_isShowingOriginal) {
|
||||
child = GestureDetector(
|
||||
onTap: _loadOriginalImage,
|
||||
child: Stack(
|
||||
fit: StackFit.passthrough,
|
||||
children: [
|
||||
child,
|
||||
if (!_isLoading && !_hasError && _imageBytes != null)
|
||||
Positioned(
|
||||
right: 6,
|
||||
bottom: 6,
|
||||
child: Container(
|
||||
padding: const EdgeInsets.symmetric(
|
||||
horizontal: 6,
|
||||
vertical: 2,
|
||||
),
|
||||
decoration: BoxDecoration(
|
||||
color: CupertinoColors.black.withValues(alpha: 0.5),
|
||||
borderRadius: BorderRadius.circular(8),
|
||||
),
|
||||
child: const Row(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
children: [
|
||||
Icon(
|
||||
CupertinoIcons.zoom_in,
|
||||
size: 12,
|
||||
color: CupertinoColors.white,
|
||||
),
|
||||
SizedBox(width: 2),
|
||||
Text(
|
||||
'原图',
|
||||
style: TextStyle(
|
||||
fontSize: 10,
|
||||
color: CupertinoColors.white,
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
if (widget.borderRadius != null) {
|
||||
child = ClipRRect(borderRadius: widget.borderRadius!, child: child);
|
||||
}
|
||||
@@ -261,6 +397,22 @@ class _RecipeImageState extends State<RecipeImage> {
|
||||
return child;
|
||||
}
|
||||
|
||||
Widget _buildLoadingWidget(bool isDark) {
|
||||
return Container(
|
||||
width: widget.width,
|
||||
height: widget.height,
|
||||
color: (isDark ? DarkDesignTokens.text3 : DesignTokens.text3).withValues(
|
||||
alpha: 0.06,
|
||||
),
|
||||
child: Center(
|
||||
child: CupertinoActivityIndicator(
|
||||
radius: 12,
|
||||
color: isDark ? DarkDesignTokens.text3 : DesignTokens.text3,
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
Widget _buildErrorWidget(bool isDark) {
|
||||
return Container(
|
||||
width: widget.width,
|
||||
@@ -324,4 +476,11 @@ class RecipeImageCache {
|
||||
} catch (_) {}
|
||||
return size;
|
||||
}
|
||||
|
||||
static Future<String> getCacheSizeText() async {
|
||||
final bytes = await getCacheSize();
|
||||
if (bytes < 1024) return '$bytes B';
|
||||
if (bytes < 1024 * 1024) return '${(bytes / 1024).toStringAsFixed(1)} KB';
|
||||
return '${(bytes / (1024 * 1024)).toStringAsFixed(1)} MB';
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user