Flutter Tutorial

Flutter Beginner Lesson 126 of 135 3 min read

To-Do App in Flutter with Local Storage (Project with Source Code)

Build a to-do app in Flutter: add, complete, swipe to delete with undo, filters, and saving tasks with shared_preferences. Full runnable source code.

On this page

Every developer builds a to-do app, and for good reason: it covers lists, forms, state, JSON and saving data. This version saves your tasks, so they are still there after you close the app (or reload this page).

The full app

import 'dart:convert';
import 'package:flutter/material.dart';
import 'package:shared_preferences/shared_preferences.dart';

void main() => runApp(MaterialApp(
      debugShowCheckedModeBanner: false,
      theme: ThemeData(colorSchemeSeed: Colors.deepPurple),
      home: const TodoPage(),
    ));

class Task {
  Task({required this.title, this.done = false});
  final String title;
  bool done;

  Map<String, dynamic> toJson() => {'title': title, 'done': done};
  factory Task.fromJson(Map<String, dynamic> json) =>
      Task(title: json['title'] as String, done: json['done'] as bool);
}

enum Filter { all, active, done }

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

  @override
  State<TodoPage> createState() => _TodoPageState();
}

class _TodoPageState extends State<TodoPage> {
  final _controller = TextEditingController();
  List<Task> _tasks = [];
  Filter _filter = Filter.all;
  bool _loading = true;

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

  @override
  void dispose() {
    _controller.dispose();
    super.dispose();
  }

  Future<void> _load() async {
    final prefs = await SharedPreferences.getInstance();
    final saved = prefs.getString('tasks');
    setState(() {
      if (saved != null) {
        _tasks = (jsonDecode(saved) as List)
            .map((e) => Task.fromJson(e as Map<String, dynamic>))
            .toList();
      }
      _loading = false;
    });
  }

  Future<void> _save() async {
    final prefs = await SharedPreferences.getInstance();
    await prefs.setString('tasks', jsonEncode(_tasks.map((t) => t.toJson()).toList()));
  }

  void _add() {
    final text = _controller.text.trim();
    if (text.isEmpty) return;
    setState(() => _tasks.insert(0, Task(title: text)));
    _controller.clear();
    _save();
  }

  void _toggle(Task task) {
    setState(() => task.done = !task.done);
    _save();
  }

  void _delete(Task task) {
    final index = _tasks.indexOf(task);
    setState(() => _tasks.removeAt(index));
    _save();
    ScaffoldMessenger.of(context)
      ..clearSnackBars()
      ..showSnackBar(SnackBar(
        content: Text('Deleted "${task.title}"'),
        action: SnackBarAction(
          label: 'Undo',
          onPressed: () {
            setState(() => _tasks.insert(index, task));
            _save();
          },
        ),
      ));
  }

  List<Task> get _visible => switch (_filter) {
        Filter.all => _tasks,
        Filter.active => _tasks.where((t) => !t.done).toList(),
        Filter.done => _tasks.where((t) => t.done).toList(),
      };

  @override
  Widget build(BuildContext context) {
    final left = _tasks.where((t) => !t.done).length;
    return Scaffold(
      appBar: AppBar(title: Text('My tasks ($left left)')),
      body: _loading
          ? const Center(child: CircularProgressIndicator())
          : Column(
              children: [
                Padding(
                  padding: const EdgeInsets.all(12),
                  child: TextField(
                    controller: _controller,
                    textInputAction: TextInputAction.done,
                    onSubmitted: (_) => _add(),
                    decoration: InputDecoration(
                      hintText: 'What do you need to do?',
                      border: const OutlineInputBorder(),
                      suffixIcon: IconButton(icon: const Icon(Icons.add), onPressed: _add),
                    ),
                  ),
                ),
                SegmentedButton<Filter>(
                  segments: const [
                    ButtonSegment(value: Filter.all, label: Text('All')),
                    ButtonSegment(value: Filter.active, label: Text('Active')),
                    ButtonSegment(value: Filter.done, label: Text('Done')),
                  ],
                  selected: {_filter},
                  onSelectionChanged: (s) => setState(() => _filter = s.first),
                ),
                const SizedBox(height: 8),
                Expanded(
                  child: _visible.isEmpty
                      ? const Center(child: Text('Nothing here. Add a task above!'))
                      : ListView.builder(
                          itemCount: _visible.length,
                          itemBuilder: (context, i) {
                            final task = _visible[i];
                            return Dismissible(
                              key: ObjectKey(task),
                              direction: DismissDirection.endToStart,
                              background: Container(
                                color: Colors.red,
                                alignment: Alignment.centerRight,
                                padding: const EdgeInsets.only(right: 20),
                                child: const Icon(Icons.delete, color: Colors.white),
                              ),
                              onDismissed: (_) => _delete(task),
                              child: CheckboxListTile(
                                value: task.done,
                                onChanged: (_) => _toggle(task),
                                title: Text(
                                  task.title,
                                  style: TextStyle(
                                    decoration: task.done ? TextDecoration.lineThrough : null,
                                    color: task.done ? Colors.grey : null,
                                  ),
                                ),
                              ),
                            );
                          },
                        ),
                ),
              ],
            ),
    );
  }
}

How it works

  • Model. Task has toJson and fromJson, so the whole list can be turned into a JSON string and back. See JSON and model classes.
  • Saving. After every change, _save writes the list to shared_preferences. On start, _load reads it back (with a loading spinner meanwhile). See shared_preferences.
  • Swipe to delete with undo. Dismissible removes the row; the SnackBar action puts the task back at the same index.
  • Filters. A getter returns the visible tasks with a switch expression; the full list is never changed by filtering.

Make it yours

  1. Add a due date with showDatePicker (pickers).
  2. Edit a task by tapping it (show a dialog with a TextField).
  3. Move the logic into a ChangeNotifier or a Riverpod Notifier (see the notes app).
  4. Store tasks in SQLite instead, for thousands of tasks.
Practise in the playground Updated