Flutter 实战:recipe_manager 菜谱管理器的分类录入、评分展示与鸿蒙适配解析
Flutter 实战:recipe_manager 菜谱管理器的分类录入、评分展示与鸿蒙适配解析
前言
欢迎加入开源鸿蒙跨平台社区:https://openharmonycrossplatform.csdn.net
recipe_manager 是一个基于 Flutter 实现的轻量菜谱管理器。它内置了几条示例菜谱,支持录入菜谱名称、选择菜系分类、填写制作耗时、设置评分,并在列表中用分类图标、分类颜色、耗时和星级展示菜谱信息。
本文基于项目真实源码展开,重点分析 菜谱数据结构、表单录入流程、分类图标与颜色映射、评分下拉展示、列表渲染与删除、控制器生命周期 和 鸿蒙适配关注点。文章内容可直接发布到 CSDN,不包含面向作者的检查说明。
菜谱管理器看起来只是一个表单加列表,但它包含了很多移动端应用的基础能力:输入、选择、校验、添加、删除、空状态、颜色映射和滚动布局。把这些细节拆清楚,能帮助我们更稳地构建 Flutter 小工具应用。

图示说明:本文围绕 Flutter 菜谱管理器的表单录入、分类展示、列表管理和跨端适配展开,适合用于鸿蒙、Android、iOS 等多端应用开发复盘。
一、项目定位与功能概览
1.1 应用主题
recipe_manager 的定位是一个 菜谱记录与分类管理工具。用户可以新增菜谱,并在列表中查看已有菜谱。每条菜谱包含名称、分类、制作耗时和评分。
核心功能如下:
| 功能 | 页面表现 | 源码实现 |
|---|---|---|
| 内置菜谱 | 默认显示三条记录 | _recipes 初始列表 |
| 菜谱名称 | Recipe Name 输入框 |
_nameController |
| 菜系分类 | Category 下拉框 |
_selectedCategory |
| 制作耗时 | Time (min) 输入框 |
_timeController |
| 菜谱评分 | Rating 下拉框 |
_selectedRating |
| 添加菜谱 | Add Recipe 按钮 |
_addRecipe() |
| 删除菜谱 | 删除图标按钮 | _deleteRecipe() |
| 分类展示 | 图标和颜色 | _getCategoryIcon()、_getCategoryColor() |
1.2 默认数据
项目启动后已有三条菜谱:
| 名称 | 分类 | 耗时 | 评分 |
|---|---|---|---|
| Pasta Carbonara | Italian | 30 分钟 | 5 |
| Chicken Curry | Indian | 45 分钟 | 4 |
| Caesar Salad | Salad | 15 分钟 | 5 |
这些默认数据让页面打开后就有列表内容,便于验证 UI 展示和删除逻辑。
1.3 学习价值
这个项目适合学习以下 Flutter 实战能力:
- 如何用
List<Map<String, dynamic>>保存轻量业务数据。 - 如何用
TextEditingController管理表单输入。 - 如何用
DropdownButtonFormField处理分类和评分选择。 - 如何根据分类映射图标和颜色。
- 如何用
ListTile渲染业务列表。 - 如何面向鸿蒙验证输入、下拉、滚动和触摸体验。
二、工程结构与运行方式
2.1 工程结构
项目保持标准 Flutter 工程结构,核心逻辑集中在 lib/main.dart:
| 文件或目录 | 作用 | 说明 |
|---|---|---|
lib/main.dart |
应用入口与页面实现 | 包含菜谱表单、列表和删除逻辑 |
pubspec.yaml |
依赖声明 | 使用 Flutter SDK 与 Material 图标 |
test/widget_test.dart |
Widget 测试入口 | 可扩展为菜谱业务测试 |
ohos/ |
鸿蒙平台工程目录 | 用于跨端构建和适配 |
2.2 依赖声明
项目没有引入复杂第三方依赖:
dependencies:
flutter:
sdk: flutter
cupertino_icons: ^1.0.8
这意味着菜谱管理、分类显示和列表操作都在 Dart 层完成,不依赖数据库、网络接口或平台通道。
2.3 常用命令
开发和验证时可以使用以下命令:
flutter pub get
flutter analyze
flutter test
flutter run
| 命令 | 作用 | 使用场景 |
|---|---|---|
flutter pub get |
获取依赖 | 首次运行或依赖变化 |
flutter analyze |
静态分析 | 检查语法和 lint |
flutter test |
执行测试 | 验证 Widget 行为 |
flutter run |
启动应用 | 本地调试界面 |
三、应用入口与主题配置
3.1 main 函数
Flutter 应用入口非常直接:
void main() {
runApp(const MyApp());
}
菜谱管理器不需要启动时加载远程数据,也没有异步初始化,入口保持简洁。
3.2 MyApp 根组件
根组件负责创建 MaterialApp:
class MyApp extends StatelessWidget {
const MyApp({super.key});
Widget build(BuildContext context) {
return MaterialApp(
title: 'Recipe Manager',
theme: ThemeData(
colorScheme: ColorScheme.fromSeed(seedColor: Colors.orange),
),
home: const MyHomePage(title: 'Recipe Manager'),
);
}
}
这里有三个关键信息:
- 应用标题是
Recipe Manager。 - 主题种子色是
Colors.orange。 - 首页是
MyHomePage。
3.3 主题色选择
橙色与食物、烹饪类应用比较契合。源码中添加按钮也使用 Colors.orange,和应用主题形成呼应。
当前源码没有显式设置
useMaterial3: true,因此文章以真实代码为准。如果后续需要统一 Material 3 表现,可以在ThemeData中补充该配置。
四、状态字段设计
4.1 菜谱列表
核心数据是 _recipes:
final List<Map<String, dynamic>> _recipes = [
{'name': 'Pasta Carbonara', 'category': 'Italian', 'time': 30, 'rating': 5},
{'name': 'Chicken Curry', 'category': 'Indian', 'time': 45, 'rating': 4},
{'name': 'Caesar Salad', 'category': 'Salad', 'time': 15, 'rating': 5},
];
每条菜谱都是一个 Map,包含名称、分类、耗时和评分。
4.2 表单状态
新增菜谱需要两类输入控制器和两个选择状态:
final TextEditingController _nameController = TextEditingController();
final TextEditingController _timeController = TextEditingController();
String _selectedCategory = 'Italian';
int _selectedRating = 3;
4.3 配置集合
分类和评分选项由两个列表提供:
final List<String> _categories = [
'Italian', 'Chinese', 'Indian', 'Mexican', 'Salad', 'Dessert', 'Other'
];
final List<int> _ratings = [1, 2, 3, 4, 5];
4.4 状态字段说明
| 字段 | 类型 | 默认值 | 作用 |
|---|---|---|---|
_recipes |
List<Map<String, dynamic>> |
三条菜谱 | 保存菜谱列表 |
_nameController |
TextEditingController |
空 | 管理菜谱名称输入 |
_timeController |
TextEditingController |
空 | 管理耗时输入 |
_selectedCategory |
String |
Italian |
当前选中分类 |
_selectedRating |
int |
3 |
当前选中评分 |
_categories |
List<String> |
七个分类 | 分类下拉数据源 |
_ratings |
List<int> |
1 到 5 | 评分下拉数据源 |
五、Controller 生命周期管理
5.1 控制器的作用
项目使用两个输入控制器:
final TextEditingController _nameController = TextEditingController();
final TextEditingController _timeController = TextEditingController();
它们分别管理菜谱名称和制作耗时。
5.2 dispose 释放资源
页面销毁时释放控制器:
void dispose() {
_nameController.dispose();
_timeController.dispose();
super.dispose();
}
这是 Flutter 表单页面中非常重要的资源管理习惯。
5.3 生命周期表
| 阶段 | 行为 | 对应字段 |
|---|---|---|
| 页面创建 | 初始化控制器 | _nameController、_timeController |
| 用户输入 | 控制器保存文本 | 两个输入框 |
| 添加菜谱 | 读取并清空文本 | _addRecipe() |
| 页面销毁 | 释放控制器 | dispose() |
六、添加菜谱逻辑
6.1 addRecipe 方法
新增菜谱的核心逻辑如下:
void _addRecipe() {
if (_nameController.text.isEmpty) return;
final time = int.tryParse(_timeController.text) ?? 30;
setState(() {
_recipes.add({
'name': _nameController.text,
'category': _selectedCategory,
'time': time,
'rating': _selectedRating,
});
_nameController.clear();
_timeController.clear();
});
}
6.2 名称校验
源码只对名称做了必填校验:
if (_nameController.text.isEmpty) return;
如果菜谱名称为空,点击添加不会产生任何记录。
6.3 耗时默认值
耗时使用 int.tryParse() 解析:
final time = int.tryParse(_timeController.text) ?? 30;
如果耗时为空或无法解析为整数,会默认使用 30 分钟。这是一种简单的容错策略。
6.4 添加后的状态变化
成功添加菜谱后会发生三件事:
_recipes增加一条记录。- 菜谱名称输入框被清空。
- 耗时输入框被清空。
分类和评分不会自动重置,方便用户连续录入同一类型菜谱。
七、删除菜谱逻辑
7.1 deleteRecipe 方法
删除逻辑非常直接:
void _deleteRecipe(int index) {
setState(() {
_recipes.removeAt(index);
});
}
7.2 删除索引来源
列表渲染时使用 List.generate(_recipes.length, ...),每个列表项的下标就是 _recipes 中对应菜谱的下标。
onPressed: () => _deleteRecipe(index)
7.3 删除后的界面变化
删除后调用 setState(),页面会重新构建。如果列表为空,就会显示空状态。
7.4 删除交互的边界
当前删除是立即生效,没有二次确认。对于演示项目来说足够简单;如果做成正式菜谱产品,可以加入撤销、确认弹窗或软删除。
八、分类颜色映射
8.1 getCategoryColor 方法
分类颜色由 _getCategoryColor() 决定:
Color _getCategoryColor(String category) {
switch (category) {
case 'Italian': return Colors.red;
case 'Chinese': return Colors.orange;
case 'Indian': return Colors.amber;
case 'Mexican': return Colors.green;
case 'Salad': return Colors.teal;
case 'Dessert': return Colors.pink;
default: return Colors.grey;
}
}
8.2 颜色语义
不同分类使用不同颜色,可以让用户在列表中快速区分菜系。
| 分类 | 颜色 |
|---|---|
| Italian | 红色 |
| Chinese | 橙色 |
| Indian | 琥珀色 |
| Mexican | 绿色 |
| Salad | 青绿色 |
| Dessert | 粉色 |
| Other | 灰色 |
8.3 默认分支
default 返回灰色:
default: return Colors.grey;
这保证即使未来出现未配置分类,界面也能正常显示。
8.4 维护建议
当分类越来越多时,可以把名称、颜色和图标合并为一个配置对象,避免两个 switch 分散维护。
九、分类图标映射
9.1 getCategoryIcon 方法
分类图标由 _getCategoryIcon() 决定:
IconData _getCategoryIcon(String category) {
switch (category) {
case 'Italian': return Icons.local_pizza;
case 'Chinese': return Icons.ramen_dining;
case 'Indian': return Icons.local_fire_department;
case 'Mexican': return Icons.local_fire_department;
case 'Salad': return Icons.eco;
case 'Dessert': return Icons.cake;
default: return Icons.restaurant;
}
}
9.2 图标语义
| 分类 | 图标 | 含义 |
|---|---|---|
| Italian | Icons.local_pizza |
披萨、意式餐饮 |
| Chinese | Icons.ramen_dining |
面食、亚洲餐饮 |
| Indian | Icons.local_fire_department |
辛香风味 |
| Mexican | Icons.local_fire_department |
热辣风味 |
| Salad | Icons.eco |
清爽、蔬菜 |
| Dessert | Icons.cake |
甜点 |
| Other | Icons.restaurant |
通用餐饮 |
9.3 在下拉框中展示图标
分类下拉框里同时展示图标和文字:
DropdownMenuItem(
value: cat,
child: Row(
children: [
Icon(_getCategoryIcon(cat), color: _getCategoryColor(cat), size: 20),
const SizedBox(width: 8),
Text(cat),
],
),
)
9.4 在列表中复用图标
列表项的头像区域也复用分类图标和颜色,保证输入和展示一致。
十、表单 UI 结构
10.1 Add New Recipe 卡片
新增表单放在一张 Card 中:
Card(
child: Padding(
padding: const EdgeInsets.all(16),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
const Text('Add New Recipe'),
// Recipe Name
// Category
// Time and Rating
// Add Recipe Button
],
),
),
)
10.2 菜谱名称输入
名称输入框使用餐具图标:
TextField(
controller: _nameController,
decoration: InputDecoration(
labelText: 'Recipe Name',
prefixIcon: const Icon(Icons.restaurant_menu),
border: OutlineInputBorder(borderRadius: BorderRadius.circular(12)),
),
)
10.3 分类选择
分类选择使用 DropdownButtonFormField<String>,它比普通下拉框更适合表单场景。
10.4 添加按钮
底部按钮使用 ElevatedButton.icon:
ElevatedButton.icon(
onPressed: _addRecipe,
icon: const Icon(Icons.add),
label: const Text('Add Recipe'),
style: ElevatedButton.styleFrom(
backgroundColor: Colors.orange,
padding: const EdgeInsets.all(16),
),
)
按钮撑满宽度,适合移动端单手操作。
十一、耗时与评分输入
11.1 耗时输入框
耗时输入框使用数字键盘:
TextField(
controller: _timeController,
keyboardType: TextInputType.number,
decoration: InputDecoration(
labelText: 'Time (min)',
prefixIcon: const Icon(Icons.timer),
border: OutlineInputBorder(borderRadius: BorderRadius.circular(12)),
),
)
11.2 评分下拉框
评分使用 DropdownButtonFormField<int>:
DropdownButtonFormField<int>(
value: _selectedRating,
decoration: InputDecoration(
labelText: 'Rating',
border: OutlineInputBorder(borderRadius: BorderRadius.circular(12)),
),
items: _ratings.map((r) {
return DropdownMenuItem(
value: r,
child: Row(
children: [
Text('⭐' * r),
],
),
);
}).toList(),
onChanged: (val) {
setState(() {
_selectedRating = val!;
});
},
)
11.3 星级展示
评分通过字符串重复实现:
Text('⭐' * r)
例如评分为 3 时,会显示 3 个星级符号。
11.4 Row 双列布局
耗时和评分放在同一行:
Row(
children: [
Expanded(child: TextField(...)),
const SizedBox(width: 12),
Expanded(child: DropdownButtonFormField<int>(...)),
],
)
两个 Expanded 让它们平分可用宽度,布局简洁。
十二、菜谱列表渲染
12.1 My Recipes 标题
表单下方是菜谱列表区域:
const Text(
'My Recipes',
style: TextStyle(fontSize: 20, fontWeight: FontWeight.bold),
)
12.2 空状态
当列表为空时显示空状态:
if (_recipes.isEmpty)
Card(
child: Padding(
padding: const EdgeInsets.all(24),
child: Center(
child: Column(
children: [
Icon(Icons.restaurant, size: 48, color: Colors.grey.shade400),
const SizedBox(height: 8),
Text('No recipes yet', style: TextStyle(color: Colors.grey.shade600)),
],
),
),
),
)
12.3 非空列表
非空时用 List.generate() 渲染全部菜谱:
...List.generate(_recipes.length, (index) {
final recipe = _recipes[index];
return Card(
child: ListTile(...),
);
})
12.4 展开运算符
这里使用 ... 将多个列表项展开到 Column 的 children 中,非常适合小型列表。
十三、ListTile 菜谱项设计
13.1 leading 区域
列表项左侧是圆形头像:
CircleAvatar(
backgroundColor: _getCategoryColor(recipe['category']).withAlpha(50),
child: Icon(
_getCategoryIcon(recipe['category']),
color: _getCategoryColor(recipe['category']),
),
)
头像背景使用分类色的透明版本,图标使用分类色,视觉层级清楚。
13.2 title 区域
标题显示菜谱名称:
title: Text(
recipe['name'],
style: const TextStyle(fontWeight: FontWeight.bold),
)
13.3 subtitle 区域
副标题显示耗时和评分:
subtitle: Row(
children: [
Text('${recipe['time']} min'),
const SizedBox(width: 8),
Text('⭐' * recipe['rating']),
],
)
13.4 trailing 区域
右侧是删除按钮:
trailing: IconButton(
icon: const Icon(Icons.delete, color: Colors.red),
onPressed: () => _deleteRecipe(index),
)
这种结构非常适合移动端列表项:左侧识别分类,中间展示主要信息,右侧提供操作。
十四、鸿蒙适配关注点
14.1 为什么适配风险较低
recipe_manager 主要由 Flutter 标准组件和 Dart 状态逻辑构成,不依赖相机、定位、数据库、文件系统或网络接口,因此基础适配风险较低。
| 模块 | 是否依赖平台能力 | 适配关注度 |
|---|---|---|
| 表单输入 | Flutter 标准输入 | 中 |
| 下拉选择 | Flutter 标准组件 | 中 |
| 列表展示 | Flutter 标准组件 | 低 |
| 删除操作 | Dart 状态更新 | 低 |
| 数据持久化 | 当前未实现 | 高 |
14.2 输入法与软键盘
鸿蒙设备上需要重点验证:
- 菜谱名称输入是否顺畅。
- 耗时数字键盘是否正常弹出。
- 软键盘弹出后表单是否可滚动。
- 下拉框是否被键盘或屏幕边缘遮挡。
14.3 滚动布局
页面使用 SingleChildScrollView:
body: SingleChildScrollView(
padding: const EdgeInsets.all(16),
child: Column(...),
)
这对移动端非常重要,因为表单和列表内容可能超过一屏。
14.4 数据边界
当前菜谱只保存在内存中,应用重启后新增内容不会保留。若要做正式菜谱管理产品,需要引入本地持久化或云端同步。
当前项目适合作为 Flutter 表单和列表管理样例,不应理解为具备长期数据保存能力的完整菜谱产品。
十五、测试设计与默认测试改造
15.1 当前测试入口
项目中的测试文件仍是默认计数器测试。对于菜谱管理器,更有价值的是验证初始列表、添加菜谱、删除菜谱和空状态。
15.2 初始页面测试
可以验证默认菜谱是否出现:
testWidgets('recipe manager renders initial recipes', (WidgetTester tester) async {
await tester.pumpWidget(const MyApp());
expect(find.text('Recipe Manager'), findsWidgets);
expect(find.text('Add New Recipe'), findsOneWidget);
expect(find.text('Pasta Carbonara'), findsOneWidget);
expect(find.text('Chicken Curry'), findsOneWidget);
expect(find.text('Caesar Salad'), findsOneWidget);
});
15.3 添加菜谱测试
输入名称后点击添加:
testWidgets('can add a recipe', (WidgetTester tester) async {
await tester.pumpWidget(const MyApp());
await tester.enterText(find.byType(TextField).first, 'Tomato Soup');
await tester.enterText(find.byType(TextField).last, '20');
await tester.tap(find.text('Add Recipe'));
await tester.pump();
expect(find.text('Tomato Soup'), findsOneWidget);
expect(find.text('20 min'), findsOneWidget);
});
15.4 空名称测试
名称为空时不应添加新记录:
testWidgets('does not add recipe without name', (WidgetTester tester) async {
await tester.pumpWidget(const MyApp());
await tester.tap(find.text('Add Recipe'));
await tester.pump();
expect(find.text('No recipes yet'), findsNothing);
});
15.5 删除菜谱测试
点击删除后,对应菜谱应从列表中消失:
testWidgets('can delete a recipe', (WidgetTester tester) async {
await tester.pumpWidget(const MyApp());
await tester.tap(find.byIcon(Icons.delete).first);
await tester.pump();
expect(find.text('Pasta Carbonara'), findsNothing);
});
十六、可维护性优化方向
16.1 抽离 Recipe 模型
当前使用 Map<String, dynamic>,写法简单但类型不够明确。可以抽成模型:
class Recipe {
const Recipe({
required this.name,
required this.category,
required this.time,
required this.rating,
});
final String name;
final String category;
final int time;
final int rating;
}
16.2 抽离分类配置
分类名称、颜色、图标可以整合为一个对象:
class RecipeCategory {
const RecipeCategory({
required this.name,
required this.icon,
required this.color,
});
final String name;
final IconData icon;
final Color color;
}
16.3 增加持久化
如果要让新增菜谱在重启后保留,可以考虑:
| 方案 | 适合场景 |
|---|---|
| SharedPreferences | 少量配置或简单列表 |
| SQLite | 结构化菜谱库 |
| 文件存储 | 导入导出 |
| 云同步 | 多设备菜谱管理 |
16.4 表单校验增强
当前只校验名称非空。正式产品可以继续增加:
- 耗时必须大于 0。
- 名称去除前后空格。
- 重名提示。
- 评分范围校验。
十七、功能扩展方向
17.1 增加菜谱详情页
当前列表只展示名称、耗时和评分。后续可以增加详情页,展示食材、步骤、备注和图片。
17.2 增加搜索和筛选
当菜谱数量增加后,可以增加:
- 按名称搜索。
- 按分类筛选。
- 按评分排序。
- 按耗时排序。
17.3 增加图片
菜谱管理器很适合加入图片。可以支持本地图片选择或网络图片展示。
17.4 增加收藏功能
可以给列表项增加收藏按钮,方便用户标记常做菜谱。
十八、常见问题与优化建议
18.1 为什么菜谱使用 Map 保存
Map 写法简单,适合小项目快速演示。随着字段增加,建议改成强类型 Recipe 模型。
18.2 为什么耗时为空时默认 30 分钟
源码使用 int.tryParse(_timeController.text) ?? 30,这是一种容错策略。它可以避免耗时为空时添加失败,但正式产品也可以改为提示用户填写。
18.3 为什么新增后只清空名称和耗时
分类和评分保留当前选择,方便连续添加同类菜谱。例如连续录入多个 Italian 菜谱时不用重复选择分类。
18.4 为什么列表删除没有确认
当前项目以演示为主,删除即时生效。正式产品建议增加确认、撤销或回收站。
18.5 鸿蒙适配最应该关注什么
重点关注文本输入、数字键盘、下拉框弹出、列表滚动、删除按钮触摸反馈和软键盘遮挡。当前项目没有持久化,新增菜谱不会在重启后保留。
十九、完整流程复盘
19.1 页面启动流程
main()
-> runApp(MyApp)
-> MaterialApp
-> MyHomePage
-> 初始化三条菜谱
-> build 渲染表单和列表
19.2 添加菜谱流程
输入菜谱名称
-> 选择分类
-> 输入耗时
-> 选择评分
-> 点击 Add Recipe
-> 校验名称
-> 解析耗时
-> 添加到 _recipes
-> 清空输入框
-> 刷新列表
19.3 删除菜谱流程
点击删除按钮
-> _deleteRecipe(index)
-> removeAt 移除对应记录
-> setState 触发刷新
-> 列表重新渲染
19.4 空状态流程
_recipes 为空
-> 不渲染 ListTile 列表
-> 显示 No recipes yet
-> 提示用户添加菜谱
二十、相关资源与继续学习
20.1 Flutter 学习资源
菜谱管理器涉及输入、下拉、列表和测试,可以结合以下资源学习:
| 资源 | 内容 |
|---|---|
| Flutter Docs | Flutter 官方开发文档 |
| Dart 官方文档 | Dart 语言与核心库 |
| Widget catalog | Flutter 常用组件 |
| Flutter testing | Widget 测试与交互模拟 |
20.2 菜谱应用扩展方向
后续可以继续增强:
- 菜谱详情页。
- 食材清单。
- 烹饪步骤。
- 图片上传。
- 搜索与筛选。
- 本地持久化。
- 收藏与评分统计。
20.3 跨端实践价值
recipe_manager 很适合作为 Flutter 适配鸿蒙的小型表单应用样例。它依赖很轻,但覆盖了输入框、下拉框、列表项、删除按钮、滚动布局和空状态,能帮助开发者验证很多跨端基础交互。
总结
recipe_manager 用简洁的 Flutter 代码实现了菜谱管理器的核心体验:用户可以填写菜谱名称、选择分类、输入耗时、设置评分,并将菜谱添加到列表中;列表通过分类图标、分类颜色、耗时和星级评分展示菜谱信息,也支持删除记录。
从工程角度看,这个项目最值得学习的是“表单状态 + 业务列表 + 分类视觉映射”的组合方式。面向鸿蒙适配时,项目依赖较轻,主要需要验证输入法、下拉框、滚动布局和触摸反馈。对于想学习 Flutter 表单和列表管理的开发者来说,它是一个清晰、实用且容易扩展的案例。
如果这篇文章对你有帮助,欢迎点赞、收藏、关注,你的支持是我持续创作的动力!
相关资源:
更多推荐



所有评论(0)