Flutter 框架跨平台鸿蒙开发 - 菜谱大全开发教程
数据模型:Recipe和RecipeStep模型设计筛选算法:分类和搜索组合筛选:SharedPreferences持久化UI设计:网格布局、卡片设计、步骤高亮交互体验复杂数据模型设计列表筛选和搜索本地数据持久化网格布局和卡片设计状态管理和UI更新这个项目可以作为学习Flutter应用开发的实用案例,通过扩展功能可以打造更加完善的美食菜谱平台。欢迎加入开源鸿蒙跨平台社区:https://openh
·
Flutter菜谱大全开发教程
项目简介
菜谱大全是一款实用的美食菜谱应用,提供丰富的菜谱资源和详细的制作步骤。本项目使用Flutter实现了完整的菜谱浏览、搜索、收藏等功能,帮助用户轻松学习烹饪。
运行效果图

核心特性
- 菜谱浏览:网格布局展示菜谱列表
- 分类筛选:8大菜系分类快速筛选
- 搜索功能:支持菜名和标签搜索
- 详细步骤:图文并茂的制作步骤
- 食材清单:完整的食材用量列表
- 收藏功能:收藏喜欢的菜谱
- 步骤高亮:当前步骤高亮显示
- 信息展示:烹饪时间、难度、人份
- 标签系统:多标签分类管理
- 数据持久化:本地存储收藏数据
技术架构
数据模型设计
菜谱模型
class Recipe {
final String id; // 唯一标识
final String name; // 菜名
final String category; // 分类
final String difficulty; // 难度
final int cookTime; // 烹饪时间(分钟)
final int servings; // 人份
final List<String> ingredients; // 食材列表
final List<RecipeStep> steps; // 制作步骤
final String imageUrl; // 图片URL
final List<String> tags; // 标签
}
字段说明:
id:唯一标识符name:菜谱名称category:所属分类(家常菜、川菜等)difficulty:难度等级(简单、中等、困难)cookTime:预计烹饪时间servings:适合人数ingredients:食材及用量steps:详细制作步骤imageUrl:菜品图片tags:搜索标签
步骤模型
class RecipeStep {
final int stepNumber; // 步骤序号
final String description; // 步骤描述
final String? imageUrl; // 步骤图片(可选)
RecipeStep({
required this.stepNumber,
required this.description,
this.imageUrl,
});
}
状态管理
class _RecipeHomePageState extends State<RecipeHomePage> {
List<Recipe> allRecipes = []; // 所有菜谱
List<Recipe> filteredRecipes = []; // 筛选后的菜谱
Set<String> favorites = {}; // 收藏的菜谱ID
String selectedCategory = '全部'; // 选中的分类
String searchQuery = ''; // 搜索关键词
final List<String> categories = [ // 分类列表
'全部', '家常菜', '川菜', '粤菜',
'湘菜', '素菜', '汤羹', '甜品',
];
}
核心功能实现
1. 菜谱筛选算法
void _filterRecipes() {
setState(() {
filteredRecipes = allRecipes.where((recipe) {
// 分类匹配
final matchesCategory = selectedCategory == '全部' ||
recipe.category == selectedCategory;
// 搜索匹配(菜名或标签)
final matchesSearch = searchQuery.isEmpty ||
recipe.name.toLowerCase().contains(searchQuery.toLowerCase()) ||
recipe.tags.any((tag) =>
tag.toLowerCase().contains(searchQuery.toLowerCase()));
return matchesCategory && matchesSearch;
}).toList();
});
}
筛选逻辑:
-
分类筛选:
- "全部"显示所有菜谱
- 其他分类只显示匹配的菜谱
-
搜索筛选:
- 空搜索词显示所有
- 匹配菜名(不区分大小写)
- 匹配标签(不区分大小写)
-
组合筛选:
- 同时满足分类和搜索条件
- 使用
where方法过滤
时间复杂度:O(n×m)
- n:菜谱数量
- m:平均标签数量
2. 收藏功能
Future<void> _loadFavorites() async {
final prefs = await SharedPreferences.getInstance();
setState(() {
favorites = (prefs.getStringList('recipe_favorites') ?? []).toSet();
});
}
Future<void> _saveFavorites() async {
final prefs = await SharedPreferences.getInstance();
await prefs.setStringList('recipe_favorites', favorites.toList());
}
void _toggleFavorite(String recipeId) {
setState(() {
if (favorites.contains(recipeId)) {
favorites.remove(recipeId);
} else {
favorites.add(recipeId);
}
});
_saveFavorites();
}
收藏机制:
- 使用
Set存储收藏ID(去重) SharedPreferences持久化存储- 切换收藏状态立即保存
- 启动时自动加载
存储格式:
// SharedPreferences
key: 'recipe_favorites'
value: ['1', '2', '3'] // 菜谱ID列表
3. 搜索功能
Widget _buildSearchBar() {
return Container(
padding: const EdgeInsets.all(16),
child: TextField(
decoration: InputDecoration(
hintText: '搜索菜谱...',
prefixIcon: const Icon(Icons.search),
border: OutlineInputBorder(
borderRadius: BorderRadius.circular(30),
),
filled: true,
fillColor: Colors.grey.shade100,
),
onChanged: (value) {
searchQuery = value;
_filterRecipes();
},
),
);
}
搜索特性:
- 实时搜索(
onChanged) - 圆角搜索框
- 搜索图标提示
- 浅灰色背景
4. 分类标签
Widget _buildCategoryTabs() {
return SizedBox(
height: 50,
child: ListView.builder(
scrollDirection: Axis.horizontal,
padding: const EdgeInsets.symmetric(horizontal: 16),
itemCount: categories.length,
itemBuilder: (context, index) {
final category = categories[index];
final isSelected = selectedCategory == category;
return Padding(
padding: const EdgeInsets.only(right: 8),
child: ChoiceChip(
label: Text(category),
selected: isSelected,
onSelected: (selected) {
setState(() {
selectedCategory = category;
});
_filterRecipes();
},
),
);
},
),
);
}
ChoiceChip特性:
- 单选模式
- 选中状态高亮
- 水平滚动
- 点击切换分类
UI组件设计
1. 菜谱网格
GridView.builder(
padding: const EdgeInsets.all(16),
gridDelegate: const SliverGridDelegateWithFixedCrossAxisCount(
crossAxisCount: 2, // 2列
childAspectRatio: 0.75, // 宽高比
crossAxisSpacing: 16, // 列间距
mainAxisSpacing: 16, // 行间距
),
itemCount: filteredRecipes.length,
itemBuilder: (context, index) {
// 构建菜谱卡片
},
)
布局参数:
- 2列网格
- 宽高比0.75(竖向卡片)
- 16像素间距
- 响应式布局
2. 菜谱卡片
Card(
clipBehavior: Clip.antiAlias,
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Stack(
children: [
// 图片区域
Container(
height: 120,
width: double.infinity,
color: Colors.grey.shade300,
child: Icon(Icons.restaurant, size: 60),
),
// 收藏按钮
Positioned(
top: 8,
right: 8,
child: CircleAvatar(
radius: 16,
backgroundColor: Colors.white,
child: IconButton(
icon: Icon(
isFavorite ? Icons.favorite : Icons.favorite_border,
color: Colors.red,
),
onPressed: () => _toggleFavorite(recipe.id),
),
),
),
],
),
// 信息区域
Padding(
padding: const EdgeInsets.all(8),
child: Column(
children: [
Text(recipe.name),
Row([
Icon(Icons.timer),
Text('${recipe.cookTime}分钟'),
Icon(Icons.signal_cellular_alt),
Text(recipe.difficulty),
]),
],
),
),
],
),
)
卡片结构:
- 图片 + 收藏按钮(Stack布局)
- 菜名 + 信息(Column布局)
- 圆角裁剪
- 阴影效果
3. 详情页头部
Widget _buildInfo() {
return Container(
padding: const EdgeInsets.all(16),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(
widget.recipe.name,
style: const TextStyle(
fontSize: 24,
fontWeight: FontWeight.bold,
),
),
const SizedBox(height: 12),
Wrap(
spacing: 16,
runSpacing: 8,
children: [
_buildInfoChip(Icons.timer, '${widget.recipe.cookTime}分钟'),
_buildInfoChip(Icons.signal_cellular_alt, widget.recipe.difficulty),
_buildInfoChip(Icons.people, '${widget.recipe.servings}人份'),
_buildInfoChip(Icons.category, widget.recipe.category),
],
),
const SizedBox(height: 12),
Wrap(
spacing: 8,
children: widget.recipe.tags.map((tag) {
return Chip(
label: Text(tag),
backgroundColor: Colors.orange.shade100,
);
}).toList(),
),
],
),
);
}
信息展示:
- 大标题菜名
- 信息芯片(时间、难度、人份、分类)
- 标签列表
- Wrap自动换行
4. 食材清单
Widget _buildIngredients() {
return Container(
padding: const EdgeInsets.all(16),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
const Text(
'食材清单',
style: TextStyle(
fontSize: 20,
fontWeight: FontWeight.bold,
),
),
const SizedBox(height: 12),
...widget.recipe.ingredients.map((ingredient) {
return Padding(
padding: const EdgeInsets.symmetric(vertical: 4),
child: Row(
children: [
Icon(Icons.check_circle_outline,
size: 20,
color: Colors.green.shade600),
const SizedBox(width: 8),
Text(ingredient, style: const TextStyle(fontSize: 16)),
],
),
);
}),
],
),
);
}
清单特点:
- 绿色勾选图标
- 清晰的食材列表
- 包含用量信息
- 易于阅读
5. 制作步骤
Widget _buildSteps() {
return Container(
padding: const EdgeInsets.all(16),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
const Text('制作步骤', style: TextStyle(fontSize: 20)),
const SizedBox(height: 12),
...widget.recipe.steps.map((step) {
final isCurrentStep = step.stepNumber - 1 == currentStep;
return GestureDetector(
onTap: () {
setState(() {
currentStep = step.stepNumber - 1;
});
},
child: Container(
margin: const EdgeInsets.only(bottom: 16),
padding: const EdgeInsets.all(16),
decoration: BoxDecoration(
color: isCurrentStep
? Colors.orange.shade50
: Colors.grey.shade50,
borderRadius: BorderRadius.circular(12),
border: Border.all(
color: isCurrentStep
? Colors.orange
: Colors.grey.shade300,
width: isCurrentStep ? 2 : 1,
),
),
child: Row(
children: [
// 步骤序号
Container(
width: 32,
height: 32,
decoration: BoxDecoration(
color: isCurrentStep
? Colors.orange
: Colors.grey.shade400,
shape: BoxShape.circle,
),
child: Center(
child: Text('${step.stepNumber}'),
),
),
const SizedBox(width: 12),
// 步骤描述
Expanded(
child: Text(step.description),
),
],
),
),
);
}),
],
),
);
}
步骤高亮:
- 当前步骤橙色高亮
- 其他步骤灰色背景
- 点击切换当前步骤
- 圆形序号标识
- 清晰的步骤描述
功能扩展建议
1. 在线菜谱API
class RecipeService {
final String baseUrl = 'https://api.example.com';
Future<List<Recipe>> fetchRecipes({
String? category,
String? keyword,
int page = 1,
}) async {
final response = await http.get(
Uri.parse('$baseUrl/recipes').replace(queryParameters: {
if (category != null) 'category': category,
if (keyword != null) 'keyword': keyword,
'page': page.toString(),
}),
);
if (response.statusCode == 200) {
final data = jsonDecode(response.body);
return (data['recipes'] as List)
.map((json) => Recipe.fromJson(json))
.toList();
}
throw Exception('Failed to load recipes');
}
Future<Recipe> fetchRecipeDetail(String id) async {
final response = await http.get(
Uri.parse('$baseUrl/recipes/$id'),
);
if (response.statusCode == 200) {
return Recipe.fromJson(jsonDecode(response.body));
}
throw Exception('Failed to load recipe detail');
}
}
2. 图片上传
class ImageUploader {
Future<String> uploadImage(File imageFile) async {
final request = http.MultipartRequest(
'POST',
Uri.parse('https://api.example.com/upload'),
);
request.files.add(
await http.MultipartFile.fromPath(
'image',
imageFile.path,
),
);
final response = await request.send();
final responseData = await response.stream.toBytes();
final result = jsonDecode(String.fromCharCodes(responseData));
return result['url'];
}
}
3. 用户评论
class Comment {
final String id;
final String userId;
final String userName;
final String content;
final int rating;
final DateTime createdAt;
Comment({
required this.id,
required this.userId,
required this.userName,
required this.content,
required this.rating,
required this.createdAt,
});
}
class CommentWidget extends StatelessWidget {
final Comment comment;
Widget build(BuildContext context) {
return Card(
child: Padding(
padding: const EdgeInsets.all(12),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Row(
children: [
CircleAvatar(child: Text(comment.userName[0])),
const SizedBox(width: 8),
Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(comment.userName),
Row(
children: List.generate(5, (index) {
return Icon(
index < comment.rating
? Icons.star
: Icons.star_border,
size: 16,
color: Colors.amber,
);
}),
),
],
),
],
),
const SizedBox(height: 8),
Text(comment.content),
const SizedBox(height: 4),
Text(
_formatDate(comment.createdAt),
style: TextStyle(fontSize: 12, color: Colors.grey),
),
],
),
),
);
}
}
4. 购物清单
class ShoppingList {
final String id;
final List<ShoppingItem> items;
ShoppingList({required this.id, required this.items});
}
class ShoppingItem {
final String ingredient;
final String amount;
bool isPurchased;
ShoppingItem({
required this.ingredient,
required this.amount,
this.isPurchased = false,
});
}
class ShoppingListPage extends StatefulWidget {
final Recipe recipe;
Widget build(BuildContext context) {
return Scaffold(
appBar: AppBar(title: Text('购物清单')),
body: ListView.builder(
itemCount: recipe.ingredients.length,
itemBuilder: (context, index) {
final ingredient = recipe.ingredients[index];
return CheckboxListTile(
title: Text(ingredient),
value: shoppingList[index].isPurchased,
onChanged: (value) {
setState(() {
shoppingList[index].isPurchased = value!;
});
},
);
},
),
);
}
}
5. 烹饪计时器
class CookingTimer extends StatefulWidget {
final int duration;
State<CookingTimer> createState() => _CookingTimerState();
}
class _CookingTimerState extends State<CookingTimer> {
late int remainingSeconds;
Timer? timer;
bool isRunning = false;
void initState() {
super.initState();
remainingSeconds = widget.duration * 60;
}
void startTimer() {
setState(() {
isRunning = true;
});
timer = Timer.periodic(Duration(seconds: 1), (timer) {
setState(() {
if (remainingSeconds > 0) {
remainingSeconds--;
} else {
timer.cancel();
isRunning = false;
_showCompletionDialog();
}
});
});
}
void pauseTimer() {
timer?.cancel();
setState(() {
isRunning = false;
});
}
void resetTimer() {
timer?.cancel();
setState(() {
remainingSeconds = widget.duration * 60;
isRunning = false;
});
}
Widget build(BuildContext context) {
final minutes = remainingSeconds ~/ 60;
final seconds = remainingSeconds % 60;
return Column(
children: [
Text(
'${minutes.toString().padLeft(2, '0')}:${seconds.toString().padLeft(2, '0')}',
style: TextStyle(fontSize: 48, fontWeight: FontWeight.bold),
),
Row(
mainAxisAlignment: MainAxisAlignment.center,
children: [
IconButton(
icon: Icon(isRunning ? Icons.pause : Icons.play_arrow),
onPressed: isRunning ? pauseTimer : startTimer,
),
IconButton(
icon: Icon(Icons.refresh),
onPressed: resetTimer,
),
],
),
],
);
}
}
6. 营养信息
class NutritionInfo {
final int calories;
final double protein;
final double carbs;
final double fat;
final double fiber;
NutritionInfo({
required this.calories,
required this.protein,
required this.carbs,
required this.fat,
required this.fiber,
});
}
Widget _buildNutritionInfo(NutritionInfo nutrition) {
return Card(
child: Padding(
padding: const EdgeInsets.all(16),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text('营养成分', style: TextStyle(fontSize: 18, fontWeight: FontWeight.bold)),
SizedBox(height: 12),
_buildNutritionRow('热量', '${nutrition.calories} 千卡'),
_buildNutritionRow('蛋白质', '${nutrition.protein}g'),
_buildNutritionRow('碳水化合物', '${nutrition.carbs}g'),
_buildNutritionRow('脂肪', '${nutrition.fat}g'),
_buildNutritionRow('膳食纤维', '${nutrition.fiber}g'),
],
),
),
);
}
7. 视频教程
import 'package:video_player/video_player.dart';
class VideoRecipePlayer extends StatefulWidget {
final String videoUrl;
State<VideoRecipePlayer> createState() => _VideoRecipePlayerState();
}
class _VideoRecipePlayerState extends State<VideoRecipePlayer> {
late VideoPlayerController _controller;
void initState() {
super.initState();
_controller = VideoPlayerController.network(widget.videoUrl)
..initialize().then((_) {
setState(() {});
});
}
Widget build(BuildContext context) {
return _controller.value.isInitialized
? AspectRatio(
aspectRatio: _controller.value.aspectRatio,
child: Stack(
alignment: Alignment.bottomCenter,
children: [
VideoPlayer(_controller),
VideoProgressIndicator(_controller, allowScrubbing: true),
_buildControls(),
],
),
)
: Center(child: CircularProgressIndicator());
}
Widget _buildControls() {
return Container(
color: Colors.black26,
child: Row(
mainAxisAlignment: MainAxisAlignment.center,
children: [
IconButton(
icon: Icon(
_controller.value.isPlaying ? Icons.pause : Icons.play_arrow,
color: Colors.white,
),
onPressed: () {
setState(() {
_controller.value.isPlaying
? _controller.pause()
: _controller.play();
});
},
),
],
),
);
}
void dispose() {
_controller.dispose();
super.dispose();
}
}
性能优化
1. 图片缓存
import 'package:cached_network_image/cached_network_image.dart';
Widget _buildRecipeImage(String imageUrl) {
return CachedNetworkImage(
imageUrl: imageUrl,
placeholder: (context, url) => Center(
child: CircularProgressIndicator(),
),
errorWidget: (context, url, error) => Icon(Icons.error),
fit: BoxFit.cover,
);
}
2. 分页加载
class RecipeListPage extends StatefulWidget {
State<RecipeListPage> createState() => _RecipeListPageState();
}
class _RecipeListPageState extends State<RecipeListPage> {
final ScrollController _scrollController = ScrollController();
List<Recipe> recipes = [];
int currentPage = 1;
bool isLoading = false;
bool hasMore = true;
void initState() {
super.initState();
_loadRecipes();
_scrollController.addListener(_onScroll);
}
void _onScroll() {
if (_scrollController.position.pixels ==
_scrollController.position.maxScrollExtent) {
_loadMore();
}
}
Future<void> _loadRecipes() async {
setState(() {
isLoading = true;
});
final newRecipes = await RecipeService().fetchRecipes(page: currentPage);
setState(() {
recipes.addAll(newRecipes);
isLoading = false;
hasMore = newRecipes.isNotEmpty;
});
}
Future<void> _loadMore() async {
if (!isLoading && hasMore) {
currentPage++;
await _loadRecipes();
}
}
}
3. 搜索防抖
import 'dart:async';
class SearchDebouncer {
final int milliseconds;
Timer? _timer;
SearchDebouncer({this.milliseconds = 500});
void run(VoidCallback action) {
_timer?.cancel();
_timer = Timer(Duration(milliseconds: milliseconds), action);
}
void dispose() {
_timer?.cancel();
}
}
// 使用
final _debouncer = SearchDebouncer(milliseconds: 300);
TextField(
onChanged: (value) {
_debouncer.run(() {
_performSearch(value);
});
},
)
项目结构
lib/
├── main.dart
├── models/
│ ├── recipe.dart
│ ├── recipe_step.dart
│ └── nutrition_info.dart
├── screens/
│ ├── recipe_home_page.dart
│ ├── recipe_detail_page.dart
│ ├── favorites_page.dart
│ └── shopping_list_page.dart
├── widgets/
│ ├── recipe_card.dart
│ ├── recipe_step_widget.dart
│ └── ingredient_list.dart
├── services/
│ ├── recipe_service.dart
│ ├── storage_service.dart
│ └── image_service.dart
└── utils/
├── constants.dart
└── helpers.dart
依赖包
dependencies:
flutter:
sdk: flutter
shared_preferences: ^2.2.2 # 本地存储
# 可选扩展
http: ^1.2.0 # 网络请求
cached_network_image: ^3.3.1 # 图片缓存
video_player: ^2.8.2 # 视频播放
image_picker: ^1.0.7 # 图片选择
总结
本项目实现了一个功能完整的菜谱应用,涵盖以下核心技术:
- 数据模型:Recipe和RecipeStep模型设计
- 筛选算法:分类和搜索组合筛选
- 收藏功能:SharedPreferences持久化
- UI设计:网格布局、卡片设计、步骤高亮
- 交互体验:搜索、筛选、收藏、步骤切换
通过本教程,你可以学习到:
- 复杂数据模型设计
- 列表筛选和搜索
- 本地数据持久化
- 网格布局和卡片设计
- 状态管理和UI更新
这个项目可以作为学习Flutter应用开发的实用案例,通过扩展功能可以打造更加完善的美食菜谱平台。
欢迎加入开源鸿蒙跨平台社区:https://openharmonycrossplatform.csdn.net
更多推荐
所有评论(0)