接口更新

This commit is contained in:
Developer
2026-04-11 07:07:13 +08:00
parent 346fc795f7
commit 2d7484fd29
40 changed files with 2680 additions and 2254 deletions

View File

@@ -148,7 +148,7 @@ class AppRoutes {
),
GetPage(
name: recipeDetail,
page: () => RecipeDetailPage(recipeId: Get.arguments),
page: () => RecipeDetailPage(recipeId: '${Get.arguments ?? '1'}'),
middlewares: [PageStandardsMiddleware()],
),
GetPage(

View File

@@ -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;
}
}

View File

@@ -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,

View File

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

View File

@@ -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)));
}
}

View File

@@ -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(

View File

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

View File

@@ -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;
}

View File

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

View File

@@ -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,

View File

@@ -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';
}
}