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

筛选逻辑

  1. 分类筛选

    • "全部"显示所有菜谱
    • 其他分类只显示匹配的菜谱
  2. 搜索筛选

    • 空搜索词显示所有
    • 匹配菜名(不区分大小写)
    • 匹配标签(不区分大小写)
  3. 组合筛选

    • 同时满足分类和搜索条件
    • 使用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            # 图片选择

总结

本项目实现了一个功能完整的菜谱应用,涵盖以下核心技术:

  1. 数据模型:Recipe和RecipeStep模型设计
  2. 筛选算法:分类和搜索组合筛选
  3. 收藏功能:SharedPreferences持久化
  4. UI设计:网格布局、卡片设计、步骤高亮
  5. 交互体验:搜索、筛选、收藏、步骤切换

通过本教程,你可以学习到:

  • 复杂数据模型设计
  • 列表筛选和搜索
  • 本地数据持久化
  • 网格布局和卡片设计
  • 状态管理和UI更新

这个项目可以作为学习Flutter应用开发的实用案例,通过扩展功能可以打造更加完善的美食菜谱平台。
欢迎加入开源鸿蒙跨平台社区:https://openharmonycrossplatform.csdn.net

Logo

讨论HarmonyOS开发技术,专注于API与组件、DevEco Studio、测试、元服务和应用上架分发等。

更多推荐