Flutter 小白入门教程Flutter 小白入门教程
首页
学习指南
项目实战
Flutter 官网
编程指南
首页
学习指南
项目实战
Flutter 官网
编程指南
  • 项目实战

    • 🚀 项目实战
    • 待办事项 App 完整开发

待办事项 App 完整开发

嗨,朋友!我是长安。

在这个项目中,我们将从零开始开发一个功能完整、界面精美的待办事项 App。准备好了吗?让我们开始吧!

📦 第一步:创建项目

打开终端,创建新项目:

flutter create todo_app
cd todo_app

📁 第二步:规划目录结构

一个好的目录结构能让代码更易维护。在 lib 目录下创建以下结构:

lib/
├── main.dart           # 入口文件
├── models/             # 数据模型
│   └── todo.dart
├── pages/              # 页面
│   ├── home_page.dart
│   └── add_todo_page.dart
├── widgets/            # 可复用组件
│   └── todo_item.dart
└── services/           # 服务(数据持久化)
    └── storage_service.dart

🏗️ 第三步:定义数据模型

创建 lib/models/todo.dart:

class Todo {
  final String id;
  String title;
  String? description;
  bool isCompleted;
  DateTime createdAt;

  Todo({
    required this.id,
    required this.title,
    this.description,
    this.isCompleted = false,
    DateTime? createdAt,
  }) : createdAt = createdAt ?? DateTime.now();

  // 从 JSON 创建 Todo 对象(用于数据持久化)
  factory Todo.fromJson(Map<String, dynamic> json) {
    return Todo(
      id: json['id'] as String,
      title: json['title'] as String,
      description: json['description'] as String?,
      isCompleted: json['isCompleted'] as bool? ?? false,
      createdAt: DateTime.parse(json['createdAt'] as String),
    );
  }

  // 转换为 JSON(用于数据持久化)
  Map<String, dynamic> toJson() {
    return {
      'id': id,
      'title': title,
      'description': description,
      'isCompleted': isCompleted,
      'createdAt': createdAt.toIso8601String(),
    };
  }

  // 复制并修改
  Todo copyWith({
    String? id,
    String? title,
    String? description,
    bool? isCompleted,
    DateTime? createdAt,
  }) {
    return Todo(
      id: id ?? this.id,
      title: title ?? this.title,
      description: description ?? this.description,
      isCompleted: isCompleted ?? this.isCompleted,
      createdAt: createdAt ?? this.createdAt,
    );
  }
}

💾 第四步:数据持久化服务

首先,添加依赖。编辑 pubspec.yaml:

dependencies:
  flutter:
    sdk: flutter
  shared_preferences: ^2.2.2
  uuid: ^4.2.1

然后运行:

flutter pub get

创建 lib/services/storage_service.dart:

import 'dart:convert';
import 'package:shared_preferences/shared_preferences.dart';
import '../models/todo.dart';

class StorageService {
  static const String _todosKey = 'todos';
  static const String _themeKey = 'isDarkMode';

  // 保存待办事项列表
  static Future<void> saveTodos(List<Todo> todos) async {
    final prefs = await SharedPreferences.getInstance();
    final todosJson = todos.map((todo) => todo.toJson()).toList();
    await prefs.setString(_todosKey, jsonEncode(todosJson));
  }

  // 加载待办事项列表
  static Future<List<Todo>> loadTodos() async {
    final prefs = await SharedPreferences.getInstance();
    final todosString = prefs.getString(_todosKey);
    
    if (todosString == null) return [];
    
    final List<dynamic> todosJson = jsonDecode(todosString);
    return todosJson
        .map((json) => Todo.fromJson(json as Map<String, dynamic>))
        .toList();
  }

  // 保存主题设置
  static Future<void> saveTheme(bool isDarkMode) async {
    final prefs = await SharedPreferences.getInstance();
    await prefs.setBool(_themeKey, isDarkMode);
  }

  // 加载主题设置
  static Future<bool> loadTheme() async {
    final prefs = await SharedPreferences.getInstance();
    return prefs.getBool(_themeKey) ?? false;
  }
}

🎨 第五步:创建待办事项卡片组件

创建 lib/widgets/todo_item.dart:

import 'package:flutter/material.dart';
import '../models/todo.dart';

class TodoItem extends StatelessWidget {
  final Todo todo;
  final VoidCallback onToggle;
  final VoidCallback onDelete;

  const TodoItem({
    super.key,
    required this.todo,
    required this.onToggle,
    required this.onDelete,
  });

  @override
  Widget build(BuildContext context) {
    final theme = Theme.of(context);
    
    return Dismissible(
      key: Key(todo.id),
      direction: DismissDirection.endToStart,
      onDismissed: (_) => onDelete(),
      background: Container(
        alignment: Alignment.centerRight,
        padding: const EdgeInsets.only(right: 20),
        decoration: BoxDecoration(
          color: Colors.red.shade400,
          borderRadius: BorderRadius.circular(12),
        ),
        child: const Icon(Icons.delete, color: Colors.white),
      ),
      child: Card(
        margin: const EdgeInsets.symmetric(horizontal: 16, vertical: 6),
        elevation: 2,
        shape: RoundedRectangleBorder(
          borderRadius: BorderRadius.circular(12),
        ),
        child: InkWell(
          onTap: onToggle,
          borderRadius: BorderRadius.circular(12),
          child: Padding(
            padding: const EdgeInsets.all(16),
            child: Row(
              children: [
                // 复选框
                AnimatedContainer(
                  duration: const Duration(milliseconds: 200),
                  width: 28,
                  height: 28,
                  decoration: BoxDecoration(
                    color: todo.isCompleted
                        ? theme.colorScheme.primary
                        : Colors.transparent,
                    border: Border.all(
                      color: todo.isCompleted
                          ? theme.colorScheme.primary
                          : Colors.grey,
                      width: 2,
                    ),
                    borderRadius: BorderRadius.circular(8),
                  ),
                  child: todo.isCompleted
                      ? const Icon(Icons.check, size: 18, color: Colors.white)
                      : null,
                ),
                const SizedBox(width: 16),
                // 内容
                Expanded(
                  child: Column(
                    crossAxisAlignment: CrossAxisAlignment.start,
                    children: [
                      Text(
                        todo.title,
                        style: TextStyle(
                          fontSize: 16,
                          fontWeight: FontWeight.w500,
                          decoration: todo.isCompleted
                              ? TextDecoration.lineThrough
                              : null,
                          color: todo.isCompleted
                              ? Colors.grey
                              : theme.textTheme.bodyLarge?.color,
                        ),
                      ),
                      if (todo.description != null &&
                          todo.description!.isNotEmpty) ...[
                        const SizedBox(height: 4),
                        Text(
                          todo.description!,
                          style: TextStyle(
                            fontSize: 14,
                            color: Colors.grey[600],
                            decoration: todo.isCompleted
                                ? TextDecoration.lineThrough
                                : null,
                          ),
                          maxLines: 2,
                          overflow: TextOverflow.ellipsis,
                        ),
                      ],
                      const SizedBox(height: 8),
                      Text(
                        _formatDate(todo.createdAt),
                        style: TextStyle(
                          fontSize: 12,
                          color: Colors.grey[500],
                        ),
                      ),
                    ],
                  ),
                ),
                // 删除按钮
                IconButton(
                  onPressed: onDelete,
                  icon: Icon(
                    Icons.delete_outline,
                    color: Colors.grey[400],
                  ),
                ),
              ],
            ),
          ),
        ),
      ),
    );
  }

  String _formatDate(DateTime date) {
    return '${date.year}-${date.month.toString().padLeft(2, '0')}-${date.day.toString().padLeft(2, '0')} ${date.hour.toString().padLeft(2, '0')}:${date.minute.toString().padLeft(2, '0')}';
  }
}

📝 第六步:创建添加待办页面

创建 lib/pages/add_todo_page.dart:

import 'package:flutter/material.dart';
import 'package:uuid/uuid.dart';
import '../models/todo.dart';

class AddTodoPage extends StatefulWidget {
  const AddTodoPage({super.key});

  @override
  State<AddTodoPage> createState() => _AddTodoPageState();
}

class _AddTodoPageState extends State<AddTodoPage> {
  final _formKey = GlobalKey<FormState>();
  final _titleController = TextEditingController();
  final _descriptionController = TextEditingController();

  @override
  void dispose() {
    _titleController.dispose();
    _descriptionController.dispose();
    super.dispose();
  }

  void _submit() {
    if (_formKey.currentState!.validate()) {
      final todo = Todo(
        id: const Uuid().v4(),
        title: _titleController.text.trim(),
        description: _descriptionController.text.trim().isEmpty
            ? null
            : _descriptionController.text.trim(),
      );
      Navigator.pop(context, todo);
    }
  }

  @override
  Widget build(BuildContext context) {
    return Scaffold(
      appBar: AppBar(
        title: const Text('添加待办'),
        actions: [
          TextButton(
            onPressed: _submit,
            child: const Text('保存'),
          ),
        ],
      ),
      body: SingleChildScrollView(
        padding: const EdgeInsets.all(20),
        child: Form(
          key: _formKey,
          child: Column(
            crossAxisAlignment: CrossAxisAlignment.stretch,
            children: [
              // 标题输入
              TextFormField(
                controller: _titleController,
                decoration: InputDecoration(
                  labelText: '标题',
                  hintText: '请输入待办事项...',
                  prefixIcon: const Icon(Icons.title),
                  border: OutlineInputBorder(
                    borderRadius: BorderRadius.circular(12),
                  ),
                  filled: true,
                ),
                textInputAction: TextInputAction.next,
                validator: (value) {
                  if (value == null || value.trim().isEmpty) {
                    return '请输入标题';
                  }
                  return null;
                },
              ),
              const SizedBox(height: 20),
              // 描述输入
              TextFormField(
                controller: _descriptionController,
                decoration: InputDecoration(
                  labelText: '描述(可选)',
                  hintText: '添加更多细节...',
                  prefixIcon: const Icon(Icons.description),
                  border: OutlineInputBorder(
                    borderRadius: BorderRadius.circular(12),
                  ),
                  filled: true,
                ),
                maxLines: 4,
              ),
              const SizedBox(height: 30),
              // 提交按钮
              ElevatedButton(
                onPressed: _submit,
                style: ElevatedButton.styleFrom(
                  padding: const EdgeInsets.symmetric(vertical: 16),
                  shape: RoundedRectangleBorder(
                    borderRadius: BorderRadius.circular(12),
                  ),
                ),
                child: const Text(
                  '添加待办',
                  style: TextStyle(fontSize: 16),
                ),
              ),
            ],
          ),
        ),
      ),
    );
  }
}

🏠 第七步:创建首页

创建 lib/pages/home_page.dart:

import 'package:flutter/material.dart';
import '../models/todo.dart';
import '../services/storage_service.dart';
import '../widgets/todo_item.dart';
import 'add_todo_page.dart';

class HomePage extends StatefulWidget {
  final bool isDarkMode;
  final VoidCallback onThemeToggle;

  const HomePage({
    super.key,
    required this.isDarkMode,
    required this.onThemeToggle,
  });

  @override
  State<HomePage> createState() => _HomePageState();
}

class _HomePageState extends State<HomePage> {
  List<Todo> _todos = [];
  bool _isLoading = true;

  @override
  void initState() {
    super.initState();
    _loadTodos();
  }

  Future<void> _loadTodos() async {
    final todos = await StorageService.loadTodos();
    setState(() {
      _todos = todos;
      _isLoading = false;
    });
  }

  Future<void> _saveTodos() async {
    await StorageService.saveTodos(_todos);
  }

  void _addTodo(Todo todo) {
    setState(() {
      _todos.insert(0, todo);
    });
    _saveTodos();
  }

  void _toggleTodo(int index) {
    setState(() {
      _todos[index] = _todos[index].copyWith(
        isCompleted: !_todos[index].isCompleted,
      );
    });
    _saveTodos();
  }

  void _deleteTodo(int index) {
    final deletedTodo = _todos[index];
    setState(() {
      _todos.removeAt(index);
    });
    _saveTodos();

    // 显示撤销提示
    ScaffoldMessenger.of(context).showSnackBar(
      SnackBar(
        content: Text('已删除 "${deletedTodo.title}"'),
        action: SnackBarAction(
          label: '撤销',
          onPressed: () {
            setState(() {
              _todos.insert(index, deletedTodo);
            });
            _saveTodos();
          },
        ),
        duration: const Duration(seconds: 3),
      ),
    );
  }

  Future<void> _navigateToAddPage() async {
    final result = await Navigator.push<Todo>(
      context,
      MaterialPageRoute(builder: (context) => const AddTodoPage()),
    );
    if (result != null) {
      _addTodo(result);
    }
  }

  @override
  Widget build(BuildContext context) {
    final completedCount = _todos.where((t) => t.isCompleted).length;
    final totalCount = _todos.length;

    return Scaffold(
      appBar: AppBar(
        title: const Text('我的待办事项'),
        centerTitle: true,
        actions: [
          IconButton(
            onPressed: widget.onThemeToggle,
            icon: Icon(
              widget.isDarkMode ? Icons.light_mode : Icons.dark_mode,
            ),
            tooltip: widget.isDarkMode ? '切换到浅色模式' : '切换到深色模式',
          ),
        ],
      ),
      body: _isLoading
          ? const Center(child: CircularProgressIndicator())
          : _todos.isEmpty
              ? _buildEmptyState()
              : _buildTodoList(completedCount, totalCount),
      floatingActionButton: FloatingActionButton.extended(
        onPressed: _navigateToAddPage,
        icon: const Icon(Icons.add),
        label: const Text('添加待办'),
      ),
    );
  }

  Widget _buildEmptyState() {
    return Center(
      child: Column(
        mainAxisAlignment: MainAxisAlignment.center,
        children: [
          Icon(
            Icons.checklist,
            size: 100,
            color: Colors.grey[300],
          ),
          const SizedBox(height: 20),
          Text(
            '暂无待办事项',
            style: TextStyle(
              fontSize: 20,
              color: Colors.grey[500],
            ),
          ),
          const SizedBox(height: 10),
          Text(
            '点击下方按钮添加新的待办',
            style: TextStyle(
              fontSize: 14,
              color: Colors.grey[400],
            ),
          ),
        ],
      ),
    );
  }

  Widget _buildTodoList(int completedCount, int totalCount) {
    return Column(
      children: [
        // 进度条
        Container(
          padding: const EdgeInsets.all(16),
          child: Column(
            children: [
              Row(
                mainAxisAlignment: MainAxisAlignment.spaceBetween,
                children: [
                  Text(
                    '已完成 $completedCount / $totalCount',
                    style: const TextStyle(fontWeight: FontWeight.w500),
                  ),
                  Text(
                    '${totalCount > 0 ? (completedCount / totalCount * 100).toInt() : 0}%',
                    style: TextStyle(
                      color: Theme.of(context).colorScheme.primary,
                      fontWeight: FontWeight.bold,
                    ),
                  ),
                ],
              ),
              const SizedBox(height: 8),
              ClipRRect(
                borderRadius: BorderRadius.circular(10),
                child: LinearProgressIndicator(
                  value: totalCount > 0 ? completedCount / totalCount : 0,
                  minHeight: 8,
                  backgroundColor: Colors.grey[300],
                ),
              ),
            ],
          ),
        ),
        // 列表
        Expanded(
          child: ListView.builder(
            padding: const EdgeInsets.only(bottom: 80),
            itemCount: _todos.length,
            itemBuilder: (context, index) {
              return TodoItem(
                todo: _todos[index],
                onToggle: () => _toggleTodo(index),
                onDelete: () => _deleteTodo(index),
              );
            },
          ),
        ),
      ],
    );
  }
}

🎯 第八步:更新入口文件

更新 lib/main.dart:

import 'package:flutter/material.dart';
import 'pages/home_page.dart';
import 'services/storage_service.dart';

void main() {
  runApp(const MyApp());
}

class MyApp extends StatefulWidget {
  const MyApp({super.key});

  @override
  State<MyApp> createState() => _MyAppState();
}

class _MyAppState extends State<MyApp> {
  bool _isDarkMode = false;

  @override
  void initState() {
    super.initState();
    _loadTheme();
  }

  Future<void> _loadTheme() async {
    final isDarkMode = await StorageService.loadTheme();
    setState(() {
      _isDarkMode = isDarkMode;
    });
  }

  void _toggleTheme() {
    setState(() {
      _isDarkMode = !_isDarkMode;
    });
    StorageService.saveTheme(_isDarkMode);
  }

  @override
  Widget build(BuildContext context) {
    return MaterialApp(
      title: 'Todo App',
      debugShowCheckedModeBanner: false,
      theme: ThemeData(
        colorScheme: ColorScheme.fromSeed(
          seedColor: Colors.blue,
          brightness: Brightness.light,
        ),
        useMaterial3: true,
        cardTheme: CardTheme(
          elevation: 2,
          shape: RoundedRectangleBorder(
            borderRadius: BorderRadius.circular(12),
          ),
        ),
        appBarTheme: const AppBarTheme(
          centerTitle: true,
          elevation: 0,
        ),
      ),
      darkTheme: ThemeData(
        colorScheme: ColorScheme.fromSeed(
          seedColor: Colors.blue,
          brightness: Brightness.dark,
        ),
        useMaterial3: true,
        cardTheme: CardTheme(
          elevation: 2,
          shape: RoundedRectangleBorder(
            borderRadius: BorderRadius.circular(12),
          ),
        ),
        appBarTheme: const AppBarTheme(
          centerTitle: true,
          elevation: 0,
        ),
      ),
      themeMode: _isDarkMode ? ThemeMode.dark : ThemeMode.light,
      home: HomePage(
        isDarkMode: _isDarkMode,
        onThemeToggle: _toggleTheme,
      ),
    );
  }
}

🏃 第九步:运行项目

flutter run

恭喜!你的待办事项 App 完成了!🎉

✨ 项目功能总结

功能实现方式
添加待办表单 + Navigator.pop 返回数据
完成状态切换setState + copyWith
删除待办Dismissible 滑动删除 + SnackBar 撤销
数据持久化shared_preferences + JSON 序列化
主题切换ThemeData + themeMode
进度显示LinearProgressIndicator

📝 项目结构回顾

lib/
├── main.dart                 # 入口文件,主题配置
├── models/
│   └── todo.dart             # 数据模型
├── pages/
│   ├── home_page.dart        # 首页
│   └── add_todo_page.dart    # 添加页面
├── widgets/
│   └── todo_item.dart        # 待办卡片组件
└── services/
    └── storage_service.dart  # 数据持久化服务

💪 扩展练习

现在,尝试自己添加以下功能:

练习1:编辑功能

添加点击待办事项进入编辑页面的功能。

练习2:分类筛选

添加"全部"、"未完成"、"已完成"的筛选标签。

练习3:搜索功能

在顶部添加搜索框,实现待办事项搜索。

练习4:排序功能

实现按创建时间、完成状态排序。

练习2 答案提示

在 home_page.dart 中添加筛选功能:

enum TodoFilter { all, active, completed }

class _HomePageState extends State<HomePage> {
  TodoFilter _filter = TodoFilter.all;
  
  List<Todo> get _filteredTodos {
    switch (_filter) {
      case TodoFilter.active:
        return _todos.where((t) => !t.isCompleted).toList();
      case TodoFilter.completed:
        return _todos.where((t) => t.isCompleted).toList();
      case TodoFilter.all:
      default:
        return _todos;
    }
  }
  
  // 在 AppBar 下方添加筛选按钮
  Widget _buildFilterChips() {
    return Padding(
      padding: const EdgeInsets.symmetric(horizontal: 16),
      child: Row(
        children: [
          FilterChip(
            label: const Text('全部'),
            selected: _filter == TodoFilter.all,
            onSelected: (_) => setState(() => _filter = TodoFilter.all),
          ),
          const SizedBox(width: 8),
          FilterChip(
            label: const Text('未完成'),
            selected: _filter == TodoFilter.active,
            onSelected: (_) => setState(() => _filter = TodoFilter.active),
          ),
          const SizedBox(width: 8),
          FilterChip(
            label: const Text('已完成'),
            selected: _filter == TodoFilter.completed,
            onSelected: (_) => setState(() => _filter = TodoFilter.completed),
          ),
        ],
      ),
    );
  }
}

🎉 总结

恭喜你完成了这个项目!通过这个实战,你学会了:

  • ✅ 项目结构规划
  • ✅ 数据模型设计
  • ✅ 状态管理实践
  • ✅ 页面导航和传参
  • ✅ 本地数据持久化
  • ✅ 主题系统实现
  • ✅ 组件化开发

现在你已经具备了开发一个完整 Flutter App 的能力!继续探索,构建更多有趣的应用吧!

📚 推荐继续学习

  • 状态管理进阶:Provider、Riverpod、Bloc
  • 网络请求:Dio、http 包
  • 数据库:SQLite、Hive
  • Firebase 集成:认证、云数据库、推送
  • 发布应用:Android 和 iOS 上架流程

由 编程指南 提供

最近更新: 2026/2/3 16:24
Contributors: 王长安
Prev
🚀 项目实战