Home Tutorials Training Consulting Books Company Contact us


Get more...

This tutorial explains how to build stateful widget. It is intended to be a blue-print for applications managing its state.

1. Create a stateful widget

In this application you build an example app for handling state. The final result will look very simple like this:

result stateful widget

In this exercise we try to follow best practices for an real life application:

  • the data model is separated from the business model

  • the data is provided by a special class, i.e. data provider which makes it easier to adjust the logic of retrieving and saving the data

  • our provider extends `ChangeNotifier, which allows us to notify listeners (typically widgets) when the data changes

  • the provider will be asynchronous, this makes the initial code a bit more complex but this is required once the data is read from a real data source

1.1. Create an empty Flutter project

Create a new empty Flutter project named stateful_widget_example. Open the created directory in the IDE of your choice.

1.2. Create data model

In the lib folder create a new folder named models. Create a file named task.dart in the folder models with the following content.

import 'package:flutter/foundation.dart';

class Task extends ChangeNotifier { (1)
  String title;
  bool _isDone;

  Task({required this.title, bool isDone = false}) : _isDone = isDone;

  set isDone(bool value) {
    _isDone = value;
    notifyListeners(); (2)
  }

  bool get isDone => _isDone; // Getter method for isDone
}
1 this allows other classes to register to be notified if the data object changes
2 notify any listeners if done status changes

1.3. Create a data provider

In the lib folder create a new folder named services. Create a file named task_data_provider.dart in the folder services with the following content.

import 'package:flutter/material.dart';
import 'package:flutter/foundation.dart';

import '../models/task.dart';

class TaskDataProvider extends ChangeNotifier implements Listenable { (1)
  final List<Task> _tasks = [ (2)
    Task(title: 'Task 1'),
    Task(title: 'Task 2'),
    Task(title: 'Task 3'),
    Task(title: 'Task 4'),
    Task(title: 'Task 5'),
  ];

  @override
  void addListener(VoidCallback listener) {
    super.addListener(listener);
    // Register listeners for existing tasks
    for (var task in _tasks) {
      task.addListener(listener);
    }
  }

  @override
  void removeListener(VoidCallback listener) {
    super.removeListener(listener);
    // Remove listeners for existing tasks
    for (var task in _tasks) {
      task.removeListener(listener);
    }
  }

  Future<List<Task>> get tasks => Future.value(_tasks);

  Future<bool> addTask(Task newTask) async {
    newTask.addListener(notifyListeners); // Register listener to the new task
    _tasks.add(newTask);
    notifyListeners();
    return true; (3)
  }

  Future<bool> save() async {
    final bool saved = await _persistenceService.save(_tasks);
    if (saved) {
      notifyListeners();
    }
    return saved;
  }

  Future<bool> removeTask(int index) async {
    _tasks.removeAt(index);
    notifyListeners();
    return true; (4)
  }
}
1 ChangeNotifier allows other classes to register to be notified if the data object changes and Listenable allows to use easy way to register for changes in the task model if a listener gets attached to the data provider
2 we start with a hard-coded data set, this will be later replaced with a persistence layer
3 later used to signal to the caller if adding was successful
4 later used to signal to the caller if removing was successful

1.4. Create the screen to display your task

n the lib folder create a new folder named screens. Create a file named tasks_screen.dart in the folder screens. In this file, create the following widget.

import 'package:flutter/material.dart';

import '../models/task.dart';
import '../services/task_data_provider.dart';

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

  @override
  State<StatefulWidget> createState() => TasksScreenState();
}

class TasksScreenState extends State<TasksScreen> {
  late TaskDataProvider taskDataProvider;
  TextEditingController controller = TextEditingController();
  List<Task> tasks = [];

  @override
  void initState() {
    super.initState();
    taskDataProvider = TaskDataProvider();
    taskDataProvider
        .addListener(_onTaskChanges); // Register listener to the new task

    _fetchTasks();
  }

  @override
  void dispose() {
    taskDataProvider.removeListener(
        _onTaskChanges); // Remove listener to avoid memory leaks
    super.dispose();
  }

  void _onTaskChanges() {
    // When tasks change, update the UI
    _fetchTasks();
  }

  Future<void> _fetchTasks() async {
    try {
      List<Task> fetchedTasks = await taskDataProvider.tasks;
      setState(() {
        tasks = fetchedTasks;
      });
    } catch (error) {
      debugPrint('debug: Error during reading the tasks');
    }
  }

  @override
  Widget build(BuildContext context) {
    return Scaffold(
      appBar: AppBar(
        title: const Text('Task List'),
      ),
      body: ListView.builder(
        itemCount: tasks.length,
        itemBuilder: (context, index) {
          return _buildTaskItem(context, index);
        },
      ),
      floatingActionButton: FloatingActionButton(
        child: const Icon(Icons.add),
        onPressed: () {
          _addTask();
        },
      ),
    );
  }

  Widget _buildTaskItem(BuildContext context, int index) {
    final task = tasks[index];

    return CheckboxListTile(
      title: Text(task.title),
      value: task.isDone,
      onChanged: (value) {
        _toggleTask(task, value!);
      },
    );
  }

  _addTask() {
    showDialog(
      context: context,
      builder: (BuildContext context) {
        return AlertDialog(
          title: const Text('Create new task'),
          content: TextField(
            controller: controller,
            autofocus: true,
          ),
          actions: [
            TextButton(
              onPressed: () {
                Navigator.of(context).pop();
              },
              child: const Text('Cancel'),
            ),
            TextButton(
              onPressed: () {
                final task = Task(title: controller.value.text);

                taskDataProvider.addTask(task);
                controller.clear();

                Navigator.of(context).pop();
              },
              child: const Text('Add'),
            ),
          ],
        );
      },
    );
  }

  _toggleTask(Task task, bool isChecked) {
    task.isDone = isChecked;
    taskDataProvider.save();
  }
}

1.5. Show your task list widget in your application

Change the main.dart file to the following.

iimport 'package:flutter/material.dart';
import 'screens/tasks_screen.dart';

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

class MainApp extends StatelessWidget {
  const MainApp({super.key});

  @override
  Widget build(BuildContext context) {
    return const MaterialApp(
      title: 'Tasks',
      home: TasksScreen(),
    );
  }
}

1.6. Run and test

Start your application and test it. You can create new tasks and set existing tasks to completed.

2. Persisting the data as JSON

2.1. Add methods to convert tasks from and to JSON

Add methods to the Task class to create and save task data from and to JSON.

import 'package:flutter/foundation.dart';

class Task extends ChangeNotifier {
  String title;
  bool _isDone;

  Task({required this.title, bool isDone = false}) : _isDone = isDone;

  set isDone(bool value) {
    _isDone = value;
    notifyListeners(); // Notify listeners when isDone changes
  }

  bool get isDone => _isDone; // Getter method for isDone

  // Convert Task instance to JSON
  Map<String, dynamic> toJson() {
    return {
      'title': title,
      'isDone': _isDone,
    };
  }

  // Create Task instance from JSON
  factory Task.fromJson(Map<String, dynamic> json) {
    return Task(
      title: json['title'],
      isDone: json['isDone'] ??
          false, // Default to false if 'isDone' is not present
    );
  }
}

2.2. Add library to your project

Now add the path_provider library to your project. You can do this, by running the following command on the command line.

flutter pub add path_provider

This commands adds path_provider as dependency to your pubspec.yaml file. This entry looks similar to the following, you may have a different version and you will have other entries in this file.

// more entries
dependencies:
// more entries
  path_provider: ^2.1.2
// more entries

2.3. Create class which allows to save and load task from JSON files

Create the data_service.dart file in the lib/services folder with the following content.

import 'dart:io';
import 'dart:convert';

import 'package:path_provider/path_provider.dart';

import '../models/task.dart';

class PersistenceService {
  Future<List<Task>> load() async {
    try {
      final directory = await getApplicationDocumentsDirectory();
      File file = File('${directory.path}/tasks.json');

      if (await file.exists()) {
        String jsonString = await file.readAsString();
        List<dynamic> jsonList = json.decode(jsonString);

        return jsonList.map((jsonTask) => Task.fromJson(jsonTask)).toList();
      }
    } catch (e) {
      // Something wrong with the file, lets restart
      return [];
    }
    return [];
  }

  Future<bool> save(List<Task> tasks) async {
    try {
      final directory = await getApplicationDocumentsDirectory();

      File file = File('${directory.path}/tasks.json');

      // Convert list to JSON
      List<Map<String, dynamic>> jsonList =
          tasks.map((task) => task.toJson()).toList();

      // Save JSON into the file (create or overwrite)
      await file.writeAsString(json.encode(jsonList), flush: true);

      return true; // Successfully saved
    } catch (e) {
      return false;
    }
  }
}

2.4. Load and save task data in your tasks screen widget

Adjust your TaskDataProvider widget to load the data at startup and save it whenever a new task is created.

import 'package:flutter/material.dart';
import 'package:flutter/foundation.dart';
import 'data_service.dart';

import '../models/task.dart';

class TaskDataProvider extends ChangeNotifier implements Listenable {
  final List<Task> _tasks = [];
  bool initialLoadDone = false;
  final _persistenceService = PersistenceService();

  @override
  void addListener(VoidCallback listener) {
    super.addListener(listener);
    // Register listeners for existing tasks
    for (var task in _tasks) {
      task.addListener(listener);
    }
  }

  @override
  void removeListener(VoidCallback listener) {
    super.removeListener(listener);
    // Remove listeners for existing tasks
    for (var task in _tasks) {
      task.removeListener(listener);
    }
  }

  Future<List<Task>> get tasks async {
    if (!initialLoadDone) {
      _tasks.addAll(await _persistenceService.load());
      initialLoadDone = true;
    }
    return _tasks;
  }

  Future<bool> addTask(Task newTask) async {
    newTask.addListener(notifyListeners); // Register listener to the new task
    _tasks.add(newTask);

    final bool saved = await _persistenceService.save(_tasks);
    if (saved) {
      notifyListeners();
    }
    return saved;
  }

  Future<bool> removeTask(int index) async {
    notifyListeners();
    final bool saved = await _persistenceService.save(_tasks);
    if (saved) {
      notifyListeners();
    }
    return saved;
  }
}

The user interface should not be affected. Data should now be persisted.

If you get an error while running the app, perform a flutter clean.

3. Links and Literature