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 实战能力:

  1. 如何用 List<Map<String, dynamic>> 保存轻量业务数据。
  2. 如何用 TextEditingController 管理表单输入。
  3. 如何用 DropdownButtonFormField 处理分类和评分选择。
  4. 如何根据分类映射图标和颜色。
  5. 如何用 ListTile 渲染业务列表。
  6. 如何面向鸿蒙验证输入、下拉、滚动和触摸体验。

二、工程结构与运行方式

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 添加后的状态变化

成功添加菜谱后会发生三件事:

  1. _recipes 增加一条记录。
  2. 菜谱名称输入框被清空。
  3. 耗时输入框被清空。

分类和评分不会自动重置,方便用户连续录入同一类型菜谱。

七、删除菜谱逻辑

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 表单和列表管理的开发者来说,它是一个清晰、实用且容易扩展的案例。

如果这篇文章对你有帮助,欢迎点赞、收藏、关注,你的支持是我持续创作的动力!


相关资源:

Logo

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

更多推荐