✅ 21 - 待办事项App

阶段五:实战项目 · 第21课
✅ 验证通过

📖 核心概念

待办事项App是Flutter最经典的入门实战项目。别小看它——一个完整的Todo App需要处理数据建模、CRUD操作、状态管理、数据持久化、搜索过滤、排序分组等几乎所有App开发核心问题。本课将用Provider+sqflite构建一个生产级待办App。

📊 数据模型设计

1. Todo模型与优先级系统

好的数据模型是App的基石。我们的Todo模型包含优先级、标签、截止日期等实用字段。

// lib/models/todo.dart
class Todo {
  final int? id;
  final String title;
  final String? description;
  final Priority priority;
  final bool isDone;
  final List<String> tags;
  final DateTime? dueDate;
  final DateTime createdAt;
  final DateTime? completedAt;

  const Todo({
    this.id,
    required this.title,
    this.description,
    this.priority = Priority.normal,
    this.isDone = false,
    this.tags = const [],
    this.dueDate,
    required this.createdAt,
    this.completedAt,
  });

  // 从数据库Map创建
  factory Todo.fromMap(Map<String, dynamic> map) => Todo(
    id: map['id'] as int?,
    title: map['title'] as String,
    description: map['description'] as String?,
    priority: Priority.values[map['priority'] as int],
    isDone: (map['is_done'] as int) == 1,
    tags: (map['tags'] as String).split(',').where((t) => t.isNotEmpty).toList(),
    dueDate: map['due_date'] != null
      ? DateTime.fromMillisecondsSinceEpoch(map['due_date'] as int)
      : null,
    createdAt: DateTime.fromMillisecondsSinceEpoch(map['created_at'] as int),
    completedAt: map['completed_at'] != null
      ? DateTime.fromMillisecondsSinceEpoch(map['completed_at'] as int)
      : null,
  );

  // 转为数据库Map
  Map<String, dynamic> toMap() => {
    if (id != null) 'id': id,
    'title': title,
    'description': description,
    'priority': priority.index,
    'is_done': isDone ? 1 : 0,
    'tags': tags.join(','),
    'due_date': dueDate?.millisecondsSinceEpoch,
    'created_at': createdAt.millisecondsSinceEpoch,
    'completed_at': completedAt?.millisecondsSinceEpoch,
  };

  // copyWith模式 - 不可变更新
  Todo copyWith({
    int? id, String? title, String? description,
    Priority? priority, bool? isDone, List<String>? tags,
    DateTime? dueDate, DateTime? createdAt, DateTime? completedAt,
  }) => Todo(
    id: id ?? this.id,
    title: title ?? this.title,
    description: description ?? this.description,
    priority: priority ?? this.priority,
    isDone: isDone ?? this.isDone,
    tags: tags ?? this.tags,
    dueDate: dueDate ?? this.dueDate,
    createdAt: createdAt ?? this.createdAt,
    completedAt: completedAt ?? this.completedAt,
  );
}

enum Priority { low, normal, high, urgent }

extension PriorityUI on Priority {
  String get label => ['低', '普通', '高', '紧急'][index];
  Color get color => [
    Colors.grey, Colors.blue, Colors.orange, Colors.red
  ][index];
  IconData get icon => [
    Icons.arrow_downward, Icons.remove, Icons.arrow_upward, Icons.priority_high
  ][index];
}

🗄️ SQLite数据持久化

2. 数据库Helper封装

// lib/data/todo_database.dart
class TodoDatabase {
  static final TodoDatabase _instance = TodoDatabase._internal();
  factory TodoDatabase() => _instance;
  TodoDatabase._internal();

  Database? _db;

  Future<Database> get database async {
    if (_db != null && _db!.isOpen) return _db!;
    _db = await _initDb();
    return _db!;
  }

  Future<Database> _initDb() async {
    final dbPath = await getDatabasesPath();
    return openDatabase(
      join(dbPath, 'todos.db'),
      version: 2,
      onCreate: (db, version) async {
        await db.execute('''
          CREATE TABLE todos (
            id INTEGER PRIMARY KEY AUTOINCREMENT,
            title TEXT NOT NULL,
            description TEXT,
            priority INTEGER NOT NULL DEFAULT 1,
            is_done INTEGER NOT NULL DEFAULT 0,
            tags TEXT DEFAULT '',
            due_date INTEGER,
            created_at INTEGER NOT NULL,
            completed_at INTEGER
          )
        ''');
        // 创建索引加速查询
        await db.execute(
          'CREATE INDEX idx_todos_done ON todos(is_done)'
        );
        await db.execute(
          'CREATE INDEX idx_todos_priority ON todos(priority)'
        );
      },
      onUpgrade: (db, oldVersion, newVersion) async {
        if (oldVersion < 2) {
          await db.execute(
            'ALTER TABLE todos ADD COLUMN tags TEXT DEFAULT \'\''
          );
        }
      },
    );
  }

  // CRUD 操作
  Future<int> insert(Todo todo) async {
    final db = await database;
    return db.insert('todos', todo.toMap());
  }

  Future<List<Todo>> getAll({String? filter, String? search}) async {
    final db = await database;
    var where = <String>[];
    var args = <dynamic>[];

    if (filter == 'active') {
      where.add('is_done = 0');
    } else if (filter == 'completed') {
      where.add('is_done = 1');
    }

    if (search != null && search.isNotEmpty) {
      where.add('(title LIKE ? OR description LIKE ?)');
      args.addAll(['%$search%', '%$search%']);
    }

    final maps = await db.query(
      'todos',
      where: where.isEmpty ? null : where.join(' AND '),
      whereArgs: args.isEmpty ? null : args,
      orderBy: 'is_done ASC, priority DESC, created_at DESC',
    );
    return maps.map((m) => Todo.fromMap(m)).toList();
  }

  Future<int> update(Todo todo) async {
    final db = await database;
    return db.update(
      'todos', todo.toMap(),
      where: 'id = ?', whereArgs: [todo.id],
    );
  }

  Future<int> delete(int id) async {
    final db = await database;
    return db.delete('todos', where: 'id = ?', whereArgs: [id]);
  }

  Future<int> deleteCompleted() async {
    final db = await database;
    return db.delete('todos', where: 'is_done = 1');
  }
}

🔄 Provider状态管理

3. TodoProvider - 完整的增删改查

// lib/providers/todo_provider.dart
class TodoProvider extends ChangeNotifier {
  final TodoDatabase _db = TodoDatabase();

  List<Todo> _todos = [];
  String _filter = 'all'; // all, active, completed
  String _searchQuery = '';
  String _sortBy = 'created'; // created, priority, dueDate
  bool _isLoading = false;
  String? _error;

  // Getters
  List<Todo> get todos => _todos;
  String get filter => _filter;
  String get searchQuery => _searchQuery;
  bool get isLoading => _isLoading;
  String? get error => _error;

  // 统计信息
  int get totalCount => _todos.length;
  int get activeCount => _todos.where((t) => !t.isDone).length;
  int get completedCount => _todos.where((t) => t.isDone).length;

  // 加载数据
  Future<void> loadTodos() async {
    _isLoading = true;
    _error = null;
    notifyListeners();

    try {
      _todos = await _db.getAll(
        filter: _filter == 'all' ? null : _filter,
        search: _searchQuery.isEmpty ? null : _searchQuery,
      );
      _isLoading = false;
      notifyListeners();
    } catch (e) {
      _error = e.toString();
      _isLoading = false;
      notifyListeners();
    }
  }

  // 添加待办
  Future<void> addTodo(Todo todo) async {
    await _db.insert(todo);
    await loadTodos();
  }

  // 切换完成状态
  Future<void> toggleTodo(Todo todo) async {
    final updated = todo.copyWith(
      isDone: !todo.isDone,
      completedAt: !todo.isDone ? DateTime.now() : null,
    );
    await _db.update(updated);
    await loadTodos();
  }

  // 更新待办
  Future<void> updateTodo(Todo todo) async {
    await _db.update(todo);
    await loadTodos();
  }

  // 删除待办
  Future<void> deleteTodo(int id) async {
    await _db.delete(id);
    await loadTodos();
  }

  // 清除已完成
  Future<void> clearCompleted() async {
    await _db.deleteCompleted();
    await loadTodos();
  }

  // 设置过滤条件
  void setFilter(String filter) {
    _filter = filter;
    loadTodos();
  }

  // 设置搜索关键词
  void setSearch(String query) {
    _searchQuery = query;
    loadTodos();
  }
}

📱 UI层实现

4. 主页面与列表

// lib/pages/home_page.dart
class HomePage extends StatelessWidget {
  @override
  Widget build(BuildContext context) {
    return Scaffold(
      appBar: AppBar(
        title: const Text('待办事项'),
        actions: [
          IconButton(
            icon: const Icon(Icons.search),
            onPressed: () => showSearch(
              context: context,
              delegate: TodoSearchDelegate(),
            ),
          ),
          PopupMenuButton<String>(
            onSelected: (value) {
              final provider = context.read<TodoProvider>();
              if (value == 'clear') {
                provider.clearCompleted();
              } else {
                provider.setFilter(value);
              }
            },
            itemBuilder: (_) => [
              const PopupMenuItem(value: 'all', child: Text('全部')),
              const PopupMenuItem(value: 'active', child: Text('未完成')),
              const PopupMenuItem(value: 'completed', child: Text('已完成')),
              const PopupMenuDivider(),
              const PopupMenuItem(value: 'clear', child: Text('清除已完成')),
            ],
          ),
        ],
      ),
      body: Consumer<TodoProvider>(
        builder: (context, provider, _) {
          if (provider.isLoading) {
            return const Center(child: CircularProgressIndicator());
          }
          if (provider.todos.isEmpty) {
            return const Center(child: Text('没有待办事项'));
          }
          return ListView.builder(
            itemCount: provider.todos.length,
            itemBuilder: (context, index) {
              final todo = provider.todos[index];
              return TodoCard(
                todo: todo,
                onToggle: () => provider.toggleTodo(todo),
                onDelete: () => provider.deleteTodo(todo.id!),
                onTap: () => _navigateToEdit(context, todo),
              );
            },
          );
        },
      ),
      floatingActionButton: FloatingActionButton(
        onPressed: () => _navigateToAdd(context),
        child: const Icon(Icons.add),
      ),
      bottomNavigationBar: Consumer<TodoProvider>(
        builder: (_, provider, __) => BottomAppBar(
          child: Text(
            '${provider.activeCount} 项待办 / 共 ${provider.totalCount} 项',
            textAlign: TextAlign.center,
          ),
        ),
      ),
    );
  }
}

5. TodoCard组件

class TodoCard extends StatelessWidget {
  final Todo todo;
  final VoidCallback onToggle;
  final VoidCallback onDelete;
  final VoidCallback onTap;

  const TodoCard({
    required this.todo,
    required this.onToggle,
    required this.onDelete,
    required this.onTap,
  });

  @override
  Widget build(BuildContext context) {
    return Dismissible(
      key: ValueKey(todo.id),
      direction: DismissDirection.endToStart,
      background: Container(
        color: Colors.red,
        alignment: Alignment.centerRight,
        padding: const EdgeInsets.only(right: 16),
        child: const Icon(Icons.delete, color: Colors.white),
      ),
      onDismissed: (_) => onDelete(),
      child: Card(
        margin: const EdgeInsets.symmetric(horizontal: 12, vertical: 4),
        child: ListTile(
          leading: Checkbox(
            value: todo.isDone,
            onChanged: (_) => onToggle(),
            activeColor: todo.priority.color,
          ),
          title: Text(
            todo.title,
            style: TextStyle(
              decoration: todo.isDone ? TextDecoration.lineThrough : null,
              color: todo.isDone ? Colors.grey : null,
            ),
          ),
          subtitle: _buildSubtitle(),
          trailing: Row(
            mainAxisSize: MainAxisSize.min,
            children: [
              Icon(todo.priority.icon, color: todo.priority.color, size: 16),
              if (todo.dueDate != null)
                Padding(
                  padding: const EdgeInsets.only(left: 8),
                  child: Text(
                    _formatDate(todo.dueDate!),
                    style: TextStyle(
                      fontSize: 12,
                      color: todo.dueDate!.isBefore(DateTime.now())
                        ? Colors.red : Colors.grey,
                    ),
                  ),
                ),
            ],
          ),
          onTap: onTap,
        ),
      ),
    );
  }
}
✓ 添加待办:标题+描述+优先级+标签+截止日期 ✓ 完成切换:勾选/取消完成状态 ✓ 左滑删除:Dismissible手势删除 ✓ 搜索过滤:按标题/描述关键词搜索 ✓ 状态过滤:全部/未完成/已完成 ✓ 持久化:SQLite存储,重启不丢失 ✓ 统计信息:底部显示待办/完成数量
💡 架构建议:保持三层分离——Model(数据结构) → Database(持久化) → Provider(状态) → UI(展示)。这种分层让每层可独立测试和替换。

📝 练习

  1. 为TodoApp添加标签管理功能——可创建/删除标签,按标签过滤待办
  2. 实现拖拽排序——使用ReorderableListView让用户自定义待办顺序
  3. 添加定时提醒——使用flutter_local_notifications在截止日期前发送通知
  4. 实现数据导出——将待办列表导出为CSV或JSON文件

🏆 成就解锁:效率大师

你已构建了一个完整的待办事项App!掌握了:

下一课预告:天气App——网络请求、JSON解析、动态UI、定位服务,让你的App连接真实世界!