Skip to content

A step-by-step example/tutorial explaining the benefits of the BLoC architecture and bloc library including tests!

License

Notifications You must be signed in to change notification settings

dwyl/flutter-bloc-tutorial

Repository files navigation

bloc-logo

Learn how to use Bloc in Flutter to manage your App's state.

GitHub Workflow Status codecov.io contributions welcome HitCount


Use these links to skip straight to the section that interests you:

Quick Note 🗒️

This tutorial assumes you have prior basic knowledge of Flutter. If this is your first time using Flutter, please visit dwyl/learn-flutter to learn the basics.

After that, we highly recommend you follow dwyl/**flutter-todo-list-tutorial** because it covers building a Todo List without the "advanced" state management of Bloc.

Why? 👀

If you've worked with Flutter, you've probably came across stateful widgets. In these widgets, the state and how/when it changes determines how many times the widget is rendered. The state that is contained within the widget is referred to as "local state".

A Flutter app consists of multiple widgets, which together make up the widget tree. While each widget can have its own local state, other widgets seldom need to access this kind of state.

However, there is state that is needed across many widgets of the app. This shared state is called application state, and pertains to the state of the whole app. An example of these are shopping carts in an e-commerce app or personal preferences.

Consider the following GIF, taken directly from the Flutter docs: docs.flutter.dev/development/data-and-backend/state-mgmt

shared-state

Each widget in the widget tree might have its own local state but there's a piece of application state (i.e. shared state) in the form of a shopping cart. This cart is accessible from any widget of the app - in this case, the MyCart widget uses it to list what item was added to it.

This is an example of shared state. In the Flutter ecosystem, there are a few frameworks that you can choose that will help you setup and use shared state in your application and do the heavy lifting for you. Examples include Riverpod, Provider and Bloc. Bloc is the newest of the options and is built by people who previously used the other options on larger Flutter Apps.

Do I actually need this?

As it often occurs, it depends. For simple apps, adding the bloc library and having your code follow an opinionated format will probably incur more boilerplate and learning curve than is actually needed.

Yes, the bloc library also has cubits (we will discuss this later on this document), which aim to simplify the implementation of blocs. However, by being forced to separate concerns (we will see later that BLoC effectively separates business logic from UI) and embedding blocs into our application can be too much if we just want to create simple features.

The hard part is knowing where the line is. Bloc's properties shine with large apps because it's much easier to maintain. It might not shine in simpler apps because it will have more code than it actually needs to have.

There's no easy answer for this. There are often discussions about whether to use Riverpod or Bloc, when these libraries have a few distinctions:

  • Riverpod is a library that focuses on dependency injection, and makes lifting state up and down the widget tree much more convenient. This process can effectively be used as shared state between widgets.

  • Bloc os focussed on making state management as safe and predictable as possible. You can find some comments from the creator of the flutter_bloc library in: reddit.com/r/FlutterDev/comments/bmrvey

While this app might be "too simple for Bloc", it's meant to showcase on how one implements it in a Flutter app.

What? 🤷‍♂️

BLoC is an acronym for Business Logic Components, and is a design pattern created by Google that separates business logic from the presentation layer.

From this concept arose Bloc, a state management library created by Felix Angelov which aims to easily implement this design pattern in Flutter apps.

For every interaction that is made in the application, state should emerge from it. For example, when you make an API call, the app should show a loading animation (loading state). When the internet required but not available, this should be reflected in the interface so the person knows they have reduced functionality.

There are a few benefits for using Bloc:

  • the logic is kept out of the widgets.
  • it's easy to test logic and widgets separately. "When my state is X, widgets should be Y".
  • we can trace person interactions made in widgets through blocs (we will talk about this in the next section).

Before implementing anything, we need to understand how the Bloc library works.

The following sections take inspiration from the official Bloc docs. If you want a more in-depth explanation, we highly encourage you to check them.

Core concepts 🧱

Let's delve a bit into the core concepts of BLoC design pattern. Knowing these will make it much easier to use Bloc, the framework.

Streams are the cornerstone of BLoC. A Stream is an an order of asynchronous events. Quoting Bloc's docs:

If you're unfamiliar with Streams, just think of a pipe with water flowing through it. The pipe is the Stream and the water is the asynchronous data.

It's like an Iterable, but instead of getting the next event when you ask for it, the Stream tells you there's an event when it's ready.

A Stream provides a way to receive a sequence of events/elements. If an error occurs, an error event is thrown. If the Stream has emitted all of its elements, a "done" event is thrown.

Let's start with the most "difficult" concept. Quoting Bloc's docs:

A Bloc is a more advanced class which relies on events to trigger state changes rather than functions. Bloc also extends BlocBase, meaning it has a similar public API to Cubit. However, rather than calling a function on a Bloc and directly emitting a new state, Blocs receive events and convert the incoming events into outgoing states.

Phew, that was a mouthful! Let's break that down.

bloc-diagram

Image credit: youtube.com/BradCypert

We define Events for a given class that we want to manage. In the example above, we are managing Pets. We have three possible events, in this scenario.

We can call petBloc.add(event) (which is a bloc) and pass an instance of the event to the bloc.

The bloc can do some logic inside. For example, it makes an API call or accesses a service.

Afterwards, the bloc emits a state.

A Cubit is a much simpler, minimalist version of a Bloc. Unlike Bloc, the Cubit exposes functions that can be invoked to trigger state changes.

Check the following diagram:

cubit-diagram

Image credit: youtube.com/BradCypert

Cubits, although similar, differ from Blocs because they don't have events. The Cubit has methods, there is no need to pass instances of events like we do in Blocs.

Inside these methods you would write your logic (making a call to an API, for example), and inside the same method you would also emit state.

Wait... so which to use?

You may be wondering yourself "Which one should I choose?". As always, the answer is... it depends.

Cubit Bloc
Simplicity
  • states
  • functions
  • states
  • events
  • event handlers
Traceability Transition (onTransition())
Advanced Event Transformations EventTransformer

Cubit shines with its simplicity and is better suited for simple use cases. If your team is struggling to model event transitions, you might want to start with Cubits.

However, if you value traceability or are having a hard time mocking cubits for widget tests, you might want to use Blocs.

If you're unsure of the event-driven approach, start with Cubit. You can always refactor the code later on to a Bloc when you have a clearer idea of the possible events of your application.

For an in-depth comparison between the two, please see: bloclibrary.dev/#/coreconcepts?id=cubit-vs-bloc

BLoC concepts in Flutter 🦋

Now that we are aware of the most important Bloc concepts, let's see how these could be implemented in Flutter.

Defining a Bloc

To create a bloc in Flutter, we need to:

  • provide an initial state.
  • set up event listeners and handlers.
  • add events to bloc via bloc.add.

Check the following piece of code.

class PetBloc extends Bloc<PetEvent, PetState> {
    final PetRepo repo;

    // Defining the class
    PetBloc(this.repo)) : super(PetInitial()) {
        on<LoadPet>(_loadPet);
        on<DeletePet>(_deletePet);
    }

    // LoadPet event handler
    _loadPet(LoadPet event, Emitter<PetState> emit) async {
        emit(PetLoading());
        var pet = await repo.getById(event.petId).first;
        emit(PetLoaded(pet))
    }

    // DeletePet event handler
    _deletePet(DeletePet event, Emitter<PetState> emit) async {
        emit(PetLoading());
        var pet = await repo.delete(event.petId);
        emit(PetDeleted(pet))
    }
}

We are defining PetBloc, which extends the Bloc class. We define PetBloc with the PetState base class and PetEvent base class.

The Bloc class requires us to register event handlers via on<Event>, which is what is done in the constructor.

Bloc Events

Events are necessary to define Blocs. When defining Bloc Events, we should follow some guidelines.

  • have a "root"/base class for event (PetEvent).
  • use Equatable, a library that allows us to make better equality comparisons between class instances. Having this will prevent duplicate events from triggering back-to-back.
abstract class PetEvent extends Equatable {
    const PetEvent()

    @override
    List<Object> get props => [];
}

class LoadPet extends PetEvent {
    final String petId;

    const LoadPet(this.petId);

    @override
    List<Object> get props => [this.petId];
}

Bloc State

A State is what is emitted by the bloc. In fact, the app UI will change according to state changes.

Similarly to Bloc Events, we can leverage Equatable to avoid triggering duplicate emissions of the same state if nothing has changed.

abstract class PetState extends Equatable {
    const PetState()

    @override
    List<Object> get props => [];
}

class PetInitial extends PetState {}
class PetDeleted extends PetState {}
class PetLoading extends PetState {}

Dependency injection with BlocProvider

BlocProvider is a Flutter widget that creates and provides access to a Bloc to all of its children below on the widget tree.

bloc-provider

photocredits go to https://www.mitrais.com/news-updates/getting-started-with-flutter-bloc-pattern/.

We instantiate BlocProvider like so:

BlocProvider(
    create: (context) => 
        PetBloc(
            Provider.of<PetRepo>(context, listen: false)
        )
)

With this widget, we can dependency inject widgets (which is great for testing) and allow other widgets to make calls to the bloc.

You can check in the piece of code below how you can "get" the bloc from the tree.

BlocProvider.of<PetsBloc>(context)
    .add(LoadPets(widget.householdId, PetStatus.All))

Updating the UI following Bloc state changes with BlocBuilder

BlocBuilder is a widget that helps us rebuild the UI based on bloc state changes. This widget rebuilds the UI every time a bloc/cubit emits a new state.

Here's how it can be used in practice.

BlocBuilder<PetBloc, PetState>(
    builder: (context, state) {
        if (state is PetLoading) {
            // Show loading icon
        }
        if (state is PetFailed) {
            // Show failed text
        }
        if (state is PetLoaded) {
            // Show loaded text
        }

        // Default return
        return Container()
    }
)

Listening to state changes with BlocListener

BlocListener is a widget that listens to a bloc state change and executes code when the state changes.

A state change will trigger a rerun of the listener function. You don't want to create widgets here, but you can surely set a new state, or change the navigation to another route, or even show a snackbar notification!

Here's how you can use BlocListener.

BlocListener<PetBloc, PetState>(
    listener: (context, state) {
        // Change navigation route
        if (state is PetDeleted) {
            Navigator.of(context).pop();
        }

        // Show a snackbar
        if (state is PetUpdated) {
            ScaffoldMessenger.of(context).showSnackbar(
                SnackBar(content: Text("Pet updated!"))
            )
        }

    }
)

BlocConsumer

If you want to both build widgets according to state changes and listen to state changes events, you can inclusively use the BlocConsumer widget!

This widget basically combines BlocListener and BlocBuilder into a single widget!

Here's how you may use it:

BlocConsumer<PetBloc, PetState>(
  listener: (context, state) {
    // do stuff here based on PetBloc's state
  },
  builder: (context, state) {
    // return widget here based on BlocA's state
  }
)

How? 💻

We are going to be building a Todo list app, where each todo item has a timer.

The person should be able to set the item as done and start/stop the item pertaining to the todo item.

Before You Start! 💡

Before you attempt to follow this tutorial, make sure you have everything installed on your computer.

If you want a guide on how to install Flutter and the needed dependencies, follow our guide in dwyl/learn-flutter in https://github.com/dwyl/learn-flutter#install-%EF%B8%8F.

0. Create a new Flutter project

We need to create a brand new Flutter project. If you don't know how to do this, we have created a small guide for you in https://github.com/dwyl/learn-flutter#0-setting-up-a-new-project.

Follow the steps. After this, your lib/main.dart file should look like this.

import 'package:flutter/material.dart';

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

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

  @override
  Widget build(BuildContext context) {
    return const MaterialApp(
      home: Scaffold(
        body: Center(
          child: Text('Hello World!'),
        ),
      ),
    );
  }
}

If you run the application, you will see the following screen.

cubit-diagram

Note: If you are having trouble debugging the Flutter project, follow the instructions to run on a real device, see: [dwyl/flutter-stopwatch-tutorial#running-on-a-real-device]https://github.com/dwyl/flutter-stopwatch-tutorial#running-on-a-real-device

Now we are ready to start to implement our project with Bloc!

1. Timer and Item classes

Before starting to implement any widgets, there are two classes that are going to be needed to fulfil our app requirements.

Let's start with our Item class.

1.1 Item class

The Item class will hold all the information pertaining to the person. We know that the class will have:

  • a description.
  • a boolean property so we know it's completed or not.
  • an id.
  • a list of timers, where each timer pertains to a start and stop operation.

We are expecting this class to have at least three functions: one to start the timer, another to stop the timer and another to get the total timers duration to display to the person how much time has elapsed.

Let's start by writing our tests. Create a directory and file with the path test/unit/todo_test.dart, and write the next two tests in it.

import 'dart:io';

import 'package:flutter_test/flutter_test.dart';
import 'package:todo/item.dart';

void main() {
  test('Cumulative duration after starting and stopping timer should be more than 0', () {
    const description = "description";

    final item = Item(description: description);

    // Checking attributes
    expect(item.description, description);

    // Start and stop timer
    item.startTimer();
    sleep(const Duration(milliseconds: 500));
    item.stopTimer();

    // Start and stop timer another time
    item.startTimer();
    sleep(const Duration(milliseconds: 500));
    item.stopTimer();

    // Some time must have passed
    expect(item.getCumulativeDuration(), isNot(equals(0)));
  });

  test('Start timer multiple times and stopping timer will not error out', () {
    const description = "description";

    final item = Item(description: description);

    // Checking attributes
    expect(item.description, description);

    // Start timers three times
    item.startTimer();
    item.startTimer();
    item.startTimer();

    // Stop timer after half a second
    sleep(const Duration(milliseconds: 500));
    item.stopTimer();

    // Some time must have passed
    expect(item.getCumulativeDuration(), isNot(equals(0)));
  });
}

We are creating two tests scenarios.

In the first, we create an Item with a description and id. We check if these properties exist within the class instance. We then start the timer, wait for half a second and stop it. We do this operation two times, and finally check if the cumulative duration yielded by the class is not 0.

In the second one, we check if the person can accidentally call startTimer() multiple times. The class should be able to handle this edge case.

If we run flutter test, the tests will obviously fail, as we didn't yet implement this class. Let's do that right now!

Firstly, we need a way to create ids on the go. Usually you'd associate the id of an Item class with the id from the database. However, since we are doing everything locally, we will use the uuid package to generate random uuid every time a class is instantiated.

To install this package, add the following line to the dependencies section in pubspec.yaml.

uuid: ^3.0.6

And run flutter pub get. This will install the dependency.

After this, create a file in lib/item.dart and paste the following contents.

import 'package:uuid/uuid.dart';

// Uuid to generate Ids for the Items
Uuid uuid = const Uuid();

/// Item class.
/// Each `Item` has an `id`, `description` and `completed` boolean field.
class Item {
  final String id = uuid.v4();
  final String description;
  final bool completed;
  final List<ItemTimer> _timersList = [];

  Item({
    required this.description,
    this.completed = false,
  });

  // Adds a new timer that starts on current time
  startTimer() {
    if (_timersList.isEmpty) {
      _timersList.add(ItemTimer(null, start: DateTime.now()));
    } else {
      ItemTimer lastTimer = _timersList.last;

      // Only create a new timer if the last one is finished
      if (lastTimer.end != null) {
        _timersList.add(ItemTimer(null, start: DateTime.now()));
      }
    }
  }

  // Stop the timer that is at the end of the list
  stopTimer() {
    if (_timersList.isNotEmpty) {
      ItemTimer lastTimer = _timersList.last;

      // Only stop last timer if the end is null
      if (lastTimer.end == null) {
        lastTimer.end = DateTime.now();
        _timersList[_timersList.length - 1] = lastTimer;
      }
    }
  }

  getCumulativeDuration() {
    if (_timersList.isEmpty) return Duration.zero;

    // Accumulate the duration of every timer
    Duration accumulativeDuration = const Duration();
    for (ItemTimer timer in _timersList) {
      final stop = timer.end;
      if (stop != null) {
        accumulativeDuration += stop.difference(timer.start);
      }
    }

    return accumulativeDuration;
  }
}

// Timer class
class ItemTimer {
  final DateTime start;
  DateTime? end;

  ItemTimer(this.end, {required this.start});
}

Let's break this down! In the Item class there are four properties. When instantiating this class, we use the uuid package to create the id.

In the startTimer() function, we create add a new timer to the _timersList timer list only if:

  • the list is empty.
  • the last timer in the list is not ongoing.

In the stopTimer() function, we alter the last timer in the _timersList array only if:

  • the last timer object in the list is ongoing.
  • the list is not empty.

To get the cumulative duration of all the timers, we create the getCumulativeDuration() function, which iterates over the array and gets the duration of each timer object.

The timer object is simply a ItemTimer class that has two properties: a start and end DateTime. This class is defined at the end of the file.

And that's it! If we execute the tests we've implemented by running flutter test, we will see the following output from the terminal.

00:02 +2 -4: Some tests failed. 

Awesome! This means the test pass! We have four failing tests, all pertaining to the widget_test.dart file. This is normal, as we haven't had the opportunity to implement these features. We will do that later!

1.2 Timer class

Now let's focus on the Timer class.

We want each todo item to have timers that the person can start and stop. Each todo item will have a list of timers, each one with a start and stop DateTime property. To get the cumulative duration, we simply iterate over the list of timers and get the duration of each one and sum them all up!

However, we want to show to the person the current ongoing time and if the timer is ongoing or not.

For this, we can use the Timer class provided by Flutter for this. However, this Dart SDK class is too simple for what we want. We want the stopwatch to be able to start from an initial offset so we can compound the duration of each timer properly. This is not possible with the base class.

With this in mind, we need to extend this class to have this capability. We are going to be using a simplified version of the Timer extension class that was implemented in dwyl/flutter-stopwatch-tutorial.

Create the file lib/stopwatch.dart and paste the following code.

class TimerEx {
  final Timer _stopWatch = Timer();

  final Duration _initialOffset;

  TimerEx({Duration initialOffset = Duration.zero}) : _initialOffset = initialOffset;

  start() => _stopWatch.start();

  stop() => _stopWatch.stop();

  bool get isRunning => _stopWatch.isRunning;

  int get elapsedMilliseconds => _stopWatch.elapsedMilliseconds + _initialOffset.inMilliseconds;
}

As you can see, the Timer class is wrapped in our TimerEx. It basically allows us to have an initial offset on the stopwatch object. This will make it possible to properly print the stopwatch time while it's running and not have it reset every time the person starts and stops the timer.

2. Basic app layout

Now that we have everything we need, let's start building our app!

We should first start with the basic layout. We will need at least three things:

  • a way for the person to create a new todo item.
  • a text stating how many items are left.
  • a list of todo items that can be toggled and have a way to create and stop timers within them.

To build this layout, we will be using a TDD approach. Let's write the tests first on what we expect the widgets to do and then implement them!

2.1 Adding widget tests

These will be the widget tests that are relevant for UX. We are going to be testing the following constraints:

  • when loading the app, the person should be shown a Textfield to create a new todo item and a Text to show how many incomplete todo items are left.
  • when the person inputs the text and presses Done on the keyboard, a new Item widget should be shown.
  • when the person clicks on the Item widget, it should toggle between a "completed" or "not completed" state.
  • each Item widget should have a Timer Button that can be pressed to start/stop the timer.
  • the current time spent on each Item should be shown below the button and start when the person presses the "Start" button and stop when the person presses the "Stop" button.

Let's create the tests! Don't worry, since the widgets don't exist, most of these tests won't even compile at first. We are going to be adding the widgets and keys on each one to be used in these tests.

Create the file in the test/widget/widget_test.dart path and paste the following contents.

import 'package:flutter/material.dart';
import 'package:flutter_test/flutter_test.dart';
import 'package:todo/main.dart';

void main() {
  testWidgets('Build correctly setup and is loaded', (WidgetTester tester) async {
    await tester.pumpWidget(const MainApp());
    await tester.pump();

    // Find the text input and string stating 0 todos created
    expect(find.byKey(textfieldKey), findsOneWidget);
    expect(find.byKey(itemsLeftStringKey), findsOneWidget);
  });

  testWidgets('Adding a new todo item shows a card', (WidgetTester tester) async {
    await tester.pumpWidget(const MainApp());
    await tester.pumpAndSettle();

    // Find the text input and string stating 0 todos created
    expect(find.byKey(textfieldKey), findsOneWidget);
    expect(find.byKey(itemsLeftStringKey), findsOneWidget);
    expect(find.byKey(itemCardWidgetKey), findsNothing);

    // Type text into todo input
    await tester.enterText(find.byKey(textfieldKey), 'new todo');
    expect(
        find.descendant(
          of: find.byKey(textfieldKey),
          matching: find.text('new todo'),
        ),
        findsOneWidget);

    await tester.testTextInput.receiveAction(TextInputAction.done);

    // Input is cleared
    expect(
      find.descendant(
        of: find.byKey(textfieldKey),
        matching: find.text('new todo'),
      ),
      findsNothing,
    );

    // Pump the widget so it renders the new item
    await tester.pumpAndSettle(const Duration(seconds: 2));

    // Expect to find at least one widget, pertaining to the one that was added
    expect(find.byKey(itemCardWidgetKey), findsOneWidget);
  });

  testWidgets('Adding a new todo item and checking it as done', (WidgetTester tester) async {
    await tester.pumpWidget(const MainApp());
    await tester.pumpAndSettle();

    // Find the text input and string stating 0 todos created
    expect(find.byKey(textfieldKey), findsOneWidget);
    expect(find.byKey(itemCardWidgetKey), findsNothing);

    // Type text into todo input
    await tester.enterText(find.byKey(textfieldKey), 'new todo');
    await tester.testTextInput.receiveAction(TextInputAction.done);

    // Pump the widget so it renders the new item
    await tester.pumpAndSettle(const Duration(seconds: 2));

    // Expect to find at least one widget, pertaining to the one that was added
    expect(find.byKey(itemCardWidgetKey), findsOneWidget);

    // Getting widget to test its value
    Finder checkboxFinder = find.descendant(of: find.byKey(itemCardWidgetKey), matching: find.byType(Icon));
    Icon checkboxWidget = tester.firstWidget<Icon>(checkboxFinder);

    expect(checkboxWidget.icon, Icons.radio_button_unchecked);

    // Tap on item card
    await tester.tap(find.byKey(itemCardWidgetKey));
    await tester.pump(const Duration(seconds: 2));

    // Updating item card widget and checkbox value should be true
    checkboxWidget = tester.firstWidget<Icon>(checkboxFinder);
    expect(checkboxWidget.icon, Icons.task_alt);
  });

  testWidgets('Adding a new todo item and clicking timer button', (WidgetTester tester) async {
    await tester.pumpWidget(const MainApp());
    await tester.pumpAndSettle();

    // Find the text input and string stating 0 todos created
    expect(find.byKey(textfieldKey), findsOneWidget);
    expect(find.byKey(itemCardWidgetKey), findsNothing);

    // Tap textfield to open new page to create todo item
    await tester.tap(find.byKey(textfieldKey));
    await tester.pumpAndSettle(const Duration(seconds: 2));

    // Type text into todo input and tap "Save" button to add new todo item
    await tester.enterText(find.byKey(textfieldOnNewPageKey), 'new todo');
    await tester.tap(find.byKey(saveButtonKey));
    await tester.pumpAndSettle(const Duration(seconds: 2));

    // Pump the widget so it renders the new item
    await tester.pumpAndSettle(const Duration(seconds: 2));

    // Expect to find at least one widget, pertaining to the one that was added
    expect(find.byKey(itemCardWidgetKey), findsOneWidget);

    // Getting widget to test its value
    ElevatedButton buttonWidget = tester.firstWidget<ElevatedButton>(find.byKey(itemCardTimerButtonKey));

    // Button should be stopped
    Text buttonText = buttonWidget.child as Text;
    expect(buttonText.data, "Start");

    // Tap on timer button.
    await tester.tap(find.byKey(itemCardTimerButtonKey));
    await tester.pump(const Duration(seconds: 2));
    await tester.pumpAndSettle();

    // Updating widget and button should be ongoing
    buttonWidget = tester.firstWidget<ElevatedButton>(find.byKey(itemCardTimerButtonKey));
    buttonText = buttonWidget.child as Text;
    expect(buttonText.data, "Stop");

    // Tap on timer button AGAIN
    await tester.tap(find.byKey(itemCardTimerButtonKey));
    await tester.pump(const Duration(seconds: 2));
    await tester.pumpAndSettle();

    // Updating widget and button should be stopped
    buttonWidget = tester.firstWidget<ElevatedButton>(find.byKey(itemCardTimerButtonKey));
    buttonText = buttonWidget.child as Text;
    expect(buttonText.data, "Resume");
  });
}

Each test scenario refers to each bullet point that was stated above. The comments in each test scenario should be self explanatory.

These tests make use of the keys pertaining to each widget.

  • textfieldKey pertains to the Textfield input where the person types the text to create a new todo item.
  • itemsLeftStringKey pertains to the Text string that shows how many items are still to be completed.
  • itemCardWidgetKey pertains to the todo item card widget.
  • itemCardTimerButtonKey pertains to the timer button inside the todo item card widget.

2.2 Creating widgets

Now it's high time to start changing the lib/main.dart file and create a basic layout for our application!

Head over to lib/main.dart and change it so it looks like the following.

import 'package:flutter/material.dart';

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

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

  @override
  State<MainApp> createState() => _MainAppState();
}

class _MainAppState extends State<MainApp> {
  @override
  Widget build(BuildContext context) {
    return MaterialApp(
        home: Scaffold(
            body: ListView(
      padding: const EdgeInsets.symmetric(horizontal: 20, vertical: 40),
      children: [
        TextField(
          decoration: const InputDecoration(
            labelText: 'What needs to be done?',
          ),
          onSubmitted: (value) {
            print("submit new todo:$value");
          },
        ),
        const SizedBox(height: 42),
        const Padding(
          padding: EdgeInsets.only(bottom: 16.0),
          child: Text('X items left', style: TextStyle(fontSize: 20)),
        ),
        const Item(),
        const Item(),
      ],
    )));
  }
}

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

  @override
  Widget build(BuildContext context) {
    return Material(
      color: Colors.white,
      elevation: 6,
      child: Container(
        constraints: const BoxConstraints(minHeight: 70),
        child: ListTile(
          onTap: () {
            print('tapped');
          },
          leading: const Icon(
            Icons.task_alt,
            color: Colors.blue,
            size: 18.0,
          ),
          trailing: Wrap(
            children: [
              Column(
                children: [
                  ElevatedButton(
                    style: ElevatedButton.styleFrom(
                      backgroundColor: Colors.green,
                      elevation: 0,
                    ),
                    onPressed: () {},
                    child: const Text("Start"),
                  ),
                  const Text("00:00:00", style: TextStyle(fontSize: 11))
                ],
              )
            ],
          ),
          title: const Text("sometext"),
        ),
      ),
    );
  }
}

As you can see, we've made a few changes.

The MainApp stateful widget consists of three main elements:

  • the Textfield where the person will input text and create a new todo item.
  • a Text with the string "X items left".
  • a list of Item widgets.

Each Item widget is a stateless widget that uses the Material class as the root and has the icon showing whether the todo item is completed, the timer button that can be pressed and the description of the todo item.

If you run the app, it should look like this.

basic-layout

As you can see, the app is non-functional. If we click on any buttons, todo items or even try to input text and press "Done" on the keyboard, nothing happens.

To make this app work, we're going to be using Bloc to manage the state of the app.

Let's go! 🏃‍♂️

3. Adding Bloc to our project

We are going to be adding two dependencies to this project:

Note

If you are interested in learning why equatable is useful when paired with bloc, check https://stackoverflow.com/questions/64316700/using-equatable-class-with-flutter-bloc.

Add the following two lines to the dependencies section in the pubspec.yaml file.

  flutter_bloc: ^8.0.0
  equatable: ^2.0.0

And then run flutter pub get to install these newly added dependencies.

Awesome! Now we are ready to setup the basic bloc components in our Flutter app!

3.1 Adding Bloc tests

We've explained the main concepts of Bloc in BLoC concepts in Flutter 🦋. We recommend reading through that first so you have a better understanding of what we are going to implement now.

Firstly, we are going to be writing the tests for the three bloc components we're implementing.

  • the bloc itself.
  • bloc events.
  • bloc states.

Create a directory inside test called bloc and add a file called test/bloc/todo_state_test.dart.

import 'package:flutter_test/flutter_test.dart';
import 'package:todo/bloc/todo_bloc.dart';

void main() {
  group('TodoState', () {
    group('TodoInitialState', () {
      test('supports value comparison', () {
        expect(TodoInitialState(), TodoInitialState());
      });
    });

    group('TodoListLoadedState', () {
      test('supports value comparison', () {
        expect(const TodoListLoadedState(), const TodoListLoadedState());
      });
    });

    group('TodoListErrorState', () {
      test('supports value comparison', () {
        expect(TodoListErrorState(), TodoListErrorState());
      });
    });
  });
}

Our Bloc instance in the app will essentially have three states:

  • the TodoInitialState, which refers to the initial state of the app when it loads.
  • the TodoListLoadedState, when the list is correctly loaded. This makes sense if our app would have to retrieve data from an API. Since we'll be doing everything locally, our app will always achieve this state successfully.
  • the TodoListErrorState, in case the list is not correctly loaded. This will never occur in our case, as our app deals with todo items locally. However, if you were to fetch the todo items from an API and get an error, this state would prop up instead of TodoListLoadedState.

Great! In the same directory, create a file called todo_event_test.dart inside test/bloc/ and paste the following piece of code.

import 'package:flutter_test/flutter_test.dart';
import 'package:todo/bloc/todo_bloc.dart';
import 'package:todo/item.dart';


void main() {
  group('TodoEvent', () {
    group('TodoListStarted', () {
      test('supports value comparison', () {
        expect(TodoListStarted(), TodoListStarted());
      });
    });

    group('AddTodoEvent', () {
      final item = Item(description: "description");
      test('supports value comparison', () {
        expect(AddTodoEvent(item), AddTodoEvent(item));
      });
    });

    group('RemoveTodoEvent', () {
      final item = Item(description: "description");
      test('supports value comparison', () {
        expect(RemoveTodoEvent(item), RemoveTodoEvent(item));
      });
    });

    group('ToggleTodoEvent', () {
      final item = Item(description: "description");
      test('supports value comparison', () {
        expect(ToggleTodoEvent(item), ToggleTodoEvent(item));
      });
    });
  });
}

Note

The reason we are importing 'package:todo/bloc/todo_bloc.dart' and not 'package:todo/bloc/todo_event.dart' is because the latter will be part of the todo_bloc.dart file.

Both todo_bloc.dart and todo_event.dart don't exist yet because we're doing TDD but will soon enough!

In this file we are testing Bloc events. In our application, we will have four possible events:

  • TodoListStarted, which is created when the todo list is initialized.
  • AddTodoEvent, which is created whenever a person creates a todo item.
  • RemoveTodoEvent, which is created whenever a person wants to delete an item (we won't be using this event but it's good to show how it can be used).
  • ToggleTodoEvent, which is created whenever a todo item is toggled between "done" and "not done".

We're almost done! The last (and arguably the most important) test file we'll create will pertain to the bloc definition. For this, create a file called todo_bloc_test.dart inside test/bloc/ and add the code:

import 'package:bloc_test/bloc_test.dart';
import 'package:flutter_test/flutter_test.dart';
import 'package:todo/bloc/todo_bloc.dart';
import 'package:todo/item.dart';

void main() {
  group('TodoBloc', () {
    // List of items to mock
    Item newItem = Item(description: "todo description");

    blocTest(
      'emits [] when nothing is added',
      build: () => TodoBloc(),
      expect: () => [],
    );

    blocTest(
      'emits [TodoListLoadedState] when AddTodoEvent is created',
      build: () => TodoBloc()..add(TodoListStarted()),
      act: (bloc) {
        bloc.add(AddTodoEvent(newItem));
      },
      expect: () => <TodoState>[
        const TodoListLoadedState(items: []), // when the todo bloc was loaded
        TodoListLoadedState(items: [newItem]) // when the todo bloc was added an event
      ],
    );

    blocTest(
      'emits [TodoListLoadedState] when RemoveTodoEvent is created',
      build: () => TodoBloc()..add(TodoListStarted()),
      act: (bloc) {
        Item newItem = Item(description: "todo description");
        bloc
          ..add(AddTodoEvent(newItem))
          ..add(RemoveTodoEvent(newItem)); // add and remove
      },
      expect: () => <TodoState>[const TodoListLoadedState(items: []), const TodoListLoadedState(items: [])],
    );

    blocTest(
      'emits [TodoListLoadedState] when ToggleTodoEvent is created',
      build: () => TodoBloc()..add(TodoListStarted()),
      act: (bloc) {
        Item newItem = Item(description: "todo description");
        bloc
          ..add(AddTodoEvent(newItem))
          ..add(ToggleTodoEvent(newItem));
      },
      expect: () => [
        isA<TodoListLoadedState>(),
        isA<TodoListLoadedState>().having((obj) => obj.items.first.completed, 'completed', false),
        isA<TodoListLoadedState>().having((obj) => obj.items.first.completed, 'completed', true)
      ],
    );
  });
}

In this file we are testing the possible bloc mutations that will occur in the app. To test this behaviour, we are using the blocTest function to construct the bloc (in the build parameter), creating events in the act parameter and checking the behaviour in the expect parameter.

We are testing how the list of todo items inside the TodoListLoadedState changes when we add, remove and toggle todo items.

Note

We are creating events against the bloc using the bloc.add() method.

3.2 Adding bloc to our app

If we execute the tests we've coded, they will obviously fail because we haven't a bloc implemented. Let's do that now!

3.2.1 Bloc states

In the lib directory, create a directory called bloc. Within this directory, create a file called todo_state.dart and paste the following code into it.

part of 'todo_bloc.dart';

abstract class TodoState extends Equatable {
  const TodoState();
}


// Initial TodoBloc state
class TodoInitialState extends TodoState {
  @override
  List<Object> get props => [];
}

// TodoBloc state when the todo item list is loaded
class TodoListLoadedState extends TodoState {
  final List<Item> items;
  const TodoListLoadedState({this.items = const []});
  @override
  List<Object> get props => [items];
}

// TodoBloc state when a todo item errors when loading
class TodoListErrorState extends TodoState {
  @override
  List<Object> get props => [];
}

We are simply creating a class pertaining to each possible state we've mentioned earlier. Since we are extending Equatable to avoid any errors when comparing classes and avoid duplicate events being thrown, we have to override the get method.

As you can see, the TodoListLoadedState has the todo items list as property. This property will be changed throughout the lifecycle of the app whenever an event is created (be it adding, removing or toggling an item).

3.2.2 Bloc events

Inside the same lib/bloc directory, create a file called todo_event.dart and use the following code.

part of 'todo_bloc.dart';

abstract class TodoEvent extends Equatable {
  const TodoEvent();
}

// Event to kick start the todo list event
class TodoListStarted extends TodoEvent {
  @override
  List<Object> get props => [];
}

// AddTodo event when an item is added
class AddTodoEvent extends TodoEvent {
  final Item todoObj;

  const AddTodoEvent(this.todoObj);

  @override
  List<Object> get props => [todoObj];
}

// RemoveTodo event when an item is removed
class RemoveTodoEvent extends TodoEvent {
  final Item todoObj;

  const RemoveTodoEvent(this.todoObj);

  @override
  List<Object> get props => [todoObj];
}

// RemoveTodo event when an item is toggled
class ToggleTodoEvent extends TodoEvent {
  final Item todoObj;

  const ToggleTodoEvent(this.todoObj);

  @override
  List<Object> get props => [todoObj];
}

Similarly to the State file, we are using Equatable when defining the event classes and overriding the get method.

We are creating a class for each possible event in the app. The event carries information about the item that is being edited/removed/added.

3.2.3 Creating the bloc

Finally, let's add the bloc to manage our list of todo items!

In the same lib/bloc directory, add a file called todo_bloc.dart and use the code displayed next.

import 'package:bloc/bloc.dart';
import 'package:equatable/equatable.dart';
import 'package:meta/meta.dart';
import 'package:todo/item.dart';

part 'todo_event.dart';
part 'todo_state.dart';


class TodoBloc extends Bloc<TodoEvent, TodoState> {

  TodoBloc() : super(TodoInitialState()) {
    on<TodoListStarted>(_onStart);
    on<AddTodoEvent>(_addTodo);
    on<RemoveTodoEvent>(_removeTodo);
    on<ToggleTodoEvent>(_toggleTodo);
  }

  _onStart(TodoListStarted event, Emitter<TodoState> emit) {
    emit(const TodoListLoadedState(items: []));
  }

  // AddTodo event handler which emits TodoAdded state
  _addTodo(AddTodoEvent event, Emitter<TodoState> emit) {
    final state = this.state;

    if (state is TodoListLoadedState) {
      emit(TodoListLoadedState(items: [...state.items, event.todoObj]));
    }
  }

  // RemoveTodo event handler which emits TodoDeleted state
  _removeTodo(RemoveTodoEvent event, Emitter<TodoState> emit) {
    final state = this.state;

    if (state is TodoListLoadedState) {
      List<Item> items = state.items;
      items.removeWhere((element) => element.id == event.todoObj.id);

      emit(TodoListLoadedState(items: items));
    }
  }

  _toggleTodo(ToggleTodoEvent event, Emitter<TodoState> emit) {
    final state = this.state;

    if (state is TodoListLoadedState) {

      List<Item> items = List.from(state.items);
      int indexToChange = items.indexWhere((element) => element.id == event.todoObj.id);

      // If the element is found, we create a copy of the element with the `completed` field toggled.
      if (indexToChange != -1) {
        Item itemToChange = items[indexToChange];
        Item updatedItem = Item(description: itemToChange.description, completed: !itemToChange.completed);

        items[indexToChange] = updatedItem;
      }

      emit(TodoListLoadedState(items: [...items]));
    }
  }
}

We are defining our TodoBloc class by extending the Bloc class . In the constructor, we are defining every event handler.

  TodoBloc() : super(TodoInitialState()) {
    on<TodoListStarted>(_onStart);
    on<AddTodoEvent>(_addTodo);
    on<RemoveTodoEvent>(_removeTodo);
    on<ToggleTodoEvent>(_toggleTodo);
  }

These event handlers emit states. In the UI, we will listen to state changes and update the widgets accordingly.

You might have noticed whenever we add/remove/toggle a todo item, we are creating a new todo item list. We need to create a new List object so widgets like BlocBuilder or BlocListeners know that the state has changed. For more information, check the following link: https://stackoverflow.com/questions/65379743/flutter-bloc-cant-update-my-list-of-boolean.

You might have also noticed the following snippet of code in some of the event handlers.

if (state is TodoListLoadedState)

Adding/removing/toggling todo items only make sense when the todo item list is loaded. If the app is not in a correct state, we need to handle these events accordingly. In our case, we don't emit() any new states when the app is not on a correct state itself.

3.2.4 Run the tests!

Now that we've defined and set up Bloc in our app, we can run the tests to see if it behaves how we intend it to!

In your terminal window, run flutter test. You should see the following output.

00:02 +13 -4: Some tests failed. 

Hurray! 🎉

This means our everything works as intended! We are now ready to change our app to use bloc! It's high time we tackle those pesky failing tests!

4. Changing widgets to listen to state changes

Now it's time to change our widgets so they react ot state changes and finally add functionality to our app!

Before making any changes, we need to create the keys for the widgets we've used in our tests.

On top of the lib/main.dart file, right above the main function, add the following keys.

final textfieldKey = UniqueKey();
final itemsLeftStringKey = UniqueKey();
final itemCardWidgetKey = UniqueKey();
final itemCardTimerButtonKey = UniqueKey();

4.1 Providing our TodoBloc to the whole app

We first need to provide bloc to our app. For this, we are going to be using the BlocProvider function.

We are going to be doing this in the root of our application. Locate the MainApp stateful widget. Let's split it in two: MainApp will be a stateless widget that will now have the HomePage stateful widget as a child. The code that is currently in the _MainAppState will be in HomePage class.

Change these two classes in lib/main.dart so they look like this:

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

  @override
  Widget build(BuildContext context) {
    return BlocProvider(
      create: (context) => TodoBloc()..add(TodoListStarted()),
      child: const HomePage(),
    );
  }
}

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

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

class _HomePageState extends State<HomePage> {

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

  @override
  Widget build(BuildContext context) {
    return MaterialApp(
      ...
    )
  }

Inside BlocProvider, we are instantiating the TodoBloc we defined in lib/bloc/todo_bloc.dart and immediately emitting a TodoListStarted event. This event is handled in the TodoBloc class definition and emits a TodoListLoadedState.

With this provider, the TodoBloc is now accessible on all the widgets below in the widget tree. The child parameter of BlocProvider is the HomePage class we've just created.

4.2 Creating and listing Item widgets

Now that we've provided the TodoBloc to the widget tree, it's high time we make use of it! Let's focus on creating todo items and listing them to the person.

Let's focus on the former. We are going to first need a TextEditingController to manage the text input in the TextField.

Declare it in the _HomePageState as a property and properly dispose it to avoid memory leaks.

Note

We are using this in a stateful widget because we need to dispose the controller properly or else we'll have troubles with memory leaks.

Find more information about in https://stackoverflow.com/questions/61425969/is-it-okay-to-use-texteditingcontroller-in-statelesswidget-in-flutter.

class _HomePageState extends State<HomePage> {

  TextEditingController txtFieldController = TextEditingController();

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

We are not going to wrap the whole body of the _HomePageState with BlocBuilder. This will make it so we can listen to new state changes and the UI updates accordingly with new information.

  @override
  Widget build(BuildContext context) {
    return MaterialApp(home: Scaffold(body: BlocBuilder<TodoBloc, TodoState>(
      builder: (context, state) {
        return ListView(
          ...
        )
    )
  }

We now have access to context and state. The latter is quite important, so we know what to render depending on the state of the app. In our case, we are only interested in rendering our app when it's in a TodoListLoadedState state.

Here comes the fun part! First, change the Item widget class in lib/main.dart to ItemCard so it does not conflict with the Item class we've implemented earlier.

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

And now let's change the _HomePageState build function so it looks like so! Don't worry, we'll break it down what we've changed!

  @override
  Widget build(BuildContext context) {
    return MaterialApp(home: Scaffold(body: BlocBuilder<TodoBloc, TodoState>(
      builder: (context, state) {
        // If the list is loaded
        if (state is TodoListLoadedState) {
          int numItemsLeft = state.items.length - state.items.where((element) => element.completed).length;
          List<Item> items = state.items;

          return ListView(
            padding: const EdgeInsets.symmetric(horizontal: 20, vertical: 40),
            children: [
              // Textfield to add new todo item
              TextField(
                key: textfieldKey,
                controller: txtFieldController,
                decoration: const InputDecoration(
                  labelText: 'What do we need to do?',
                ),
                onSubmitted: (value) {
                  if(value.isNotEmpty) {
                    // Create new item and create AddTodo event
                    Item newItem = Item(description: value);
                    BlocProvider.of<TodoBloc>(context).add(AddTodoEvent(newItem));

                    // Clear textfield
                    txtFieldController.clear();
                  }
                },
              ),

              const SizedBox(height: 42),

              // Title for items left
              Padding(
                padding: const EdgeInsets.only(bottom: 16.0),
                child: Text(key: itemsLeftStringKey, '$numItemsLeft items left', style: const TextStyle(fontSize: 20)),
              ),

              // List of items
              if (items.isNotEmpty) const Divider(height: 0),
              for (var i = 0; i < items.length; i++) ...[if (i > 0) const Divider(height: 0), ItemCard()],
            ],
          );
        }

        // If the state of the ItemList is not loaded, we show error.
        else {
          return const Center(child: Text("Error loading items list."));
        }
      },
    )));
  }

With BlocBuilder, by having access to the state, we therefore have access to the list of todo items. We can get the number of incomplete todo items by iterating over the list. After checking if the state of the app is properly set, we get this information in the snippet of code below.

  if (state is TodoListLoadedState) {
    int numItemsLeft = state.items.length - state.items.where((element) => element.completed).length;
    List<Item> items = state.items;

    ...
  }

We are going to be using these two properties in the Text that shows the number of items left...

Text(key: itemsLeftStringKey, '$numItemsLeft items left', style: const TextStyle(fontSize: 20))

...and to list the todo items.

if (items.isNotEmpty) const Divider(height: 0),
for (var i = 0; i < items.length; i++) ...[if (i > 0) const Divider(height: 0), ItemCard(item: items[i])],

For each item in the todo list item in TodoBloc, we create an ItemCard widget.

Note

We aren't currently passing any information to these widgets, so they will always look the same. Don't worry, we will address this in the next section!

The last thing we ought to change is the TextField to create a new todo items. Let's check the onSubmitted function we've changed.

onSubmitted: (value) {
  // Create new item and create AddTodo event
  Item newItem = Item(description: value);
  BlocProvider.of<TodoBloc>(context).add(AddTodoEvent(newItem));

  // Clear textfield
  txtFieldController.clear();
},

When the person submits the text, an AddTodoEvent is created with the new text. This is handled in TodoBloc, as we've shown prior.

And that's it! If you run the application now, input any text you like and press Done, you will see the new todo items being created!

listing/creating

Each todo item is the same. That's because we are not passing each todo item inside TodoBloc to the ItemCard widget!

4.3 Toggling and start/stopping timers in ItemCard

As it stands, the app is not really useful. People need to be able to toggle each ItemCard and start/stop timers. Let's address both of these concerns! 🎉

Firstly, we are going to be converting our ItemCard from a stateless widget to a stateful widget. We need to do this because we are using the TimerEx to show the current timer value and a Timer class to re-render the widget so the timer value is shown properly. The ItemCard will now receive an item.

// Widget that controls the item card
class ItemCard extends StatefulWidget {
  final Item item;

  const ItemCard({required this.item, super.key});

  @override
  State<ItemCard> createState() => _ItemCardState();
}

class _ItemCardState extends State<ItemCard> {
...
}

Don't forget to pass on the Item object when listing the items inside the build function of _HomePageState class!

Note

We've added a padding in-between list items to make it prettier 😊.

if (items.isNotEmpty) const Divider(height: 0),
for (var i = 0; i < items.length; i++) ...[
  if (i > 0) const Divider(height: 0),
  Padding(
    padding: const EdgeInsets.symmetric(vertical: 8.0),
    child: ItemCard(item: items[i]),
  )
],       

Let's now change the _ItemCardState to look like the following code.

class _ItemCardState extends State<ItemCard> {
  // Timer to be displayed
  late TimerEx _stopwatch;

  // Used to re-render the text showing the timer
  late Timer _timer;

  @override
  void initState() {
    super.initState();
    WidgetsFlutterBinding.ensureInitialized();

    _stopwatch = TimerEx(initialOffset: widget.item.getCumulativeDuration());

    // Timer to rerender the page so the text shows the seconds passing by
    _timer = Timer.periodic(const Duration(milliseconds: 200), (timer) {
      if (_stopwatch.isRunning) {
        setState(() {});
      }
    });
  }

  // Timer needs to be disposed when widget is destroyed to avoid memory leaks
  @override
  void dispose() {
    _timer.cancel();
    super.dispose();
  }

  // Start and stop timer button handler
  handleButtonClick() {
    // If timer is ongoing, we stop the stopwatch and the timer in the todo item.
    if (_stopwatch.isRunning) {
      widget.item.stopTimer();
      _stopwatch.stop();

      // Re-render
      setState(() {});
    }

    // If we are to start timer, start the timer in todo item and stopwatch.
    else {
      widget.item.startTimer();
      _stopwatch.start();

      // Re-render
      setState(() {});
    }
  }

  @override
  Widget build(BuildContext context) {
    return Material(
      key: itemCardWidgetKey,
      color: Colors.white,
      elevation: 6,
      child: Container(
        constraints: const BoxConstraints(minHeight: 70),
        child: ListTile(
          onTap: () {
            // Create a ToggleTodo event to toggle the `complete` field
            context.read<TodoBloc>().add(ToggleTodoEvent(widget.item));
          },

          // Checkbox-style icon showing if it's completed or not
          leading: widget.item.completed
              ? const Icon(
                  Icons.task_alt,
                  color: Colors.blue,
                  size: 18.0,
                )
              : const Icon(
                  Icons.radio_button_unchecked,
                  color: Colors.blue,
                  size: 18.0,
                ),

          // Start and stop timer with stopwatch text
          trailing: Wrap(
            children: [
              Column(
                children: [
                  ElevatedButton(
                    key: itemCardTimerButtonKey,
                    style: ElevatedButton.styleFrom(
                      backgroundColor: _stopwatch.isRunning ? Colors.red : Colors.green,
                      elevation: 0,
                    ),
                    onPressed: handleButtonClick,
                    child: _stopwatch.isRunning ? const Text("Stop") : const Text("Start"),
                  ),
                  Text(formatTime(_stopwatch.elapsedMilliseconds), style: const TextStyle(fontSize: 11))
                ],
              )
            ],
          ),

          // Todo item description
          title: Text(widget.item.description),
        ),
      ),
    );
  }
}

Let's break down our changes! We first initialize _stopwatch and _timer. The former is a variable of the class TimerEx, the wrapper of the Timer class we created earlier. This will be used to show the value of the timer. On the other hand, the latter is a Timer. This is used to re-render the widget every 200 milliseconds so the stopwatch text shows the value of the timer properly.

We are defining these variables in the initState function, which is executed when the widget is mounted. To update the widget every 200 milliseconds, we simply call setState(() {}); to force a re-render.

  void initState() {
    super.initState();
    WidgetsFlutterBinding.ensureInitialized();

    _stopwatch = TimerEx(initialOffset: widget.item.getCumulativeDuration());

    // Timer to rerender the page so the text shows the seconds passing by
    _timer = Timer.periodic(const Duration(milliseconds: 200), (timer) {
      if (_stopwatch.isRunning) {
        setState(() {});
      }
    });
  }

To avoid memory leaks, we dispose of the _timer variable when the widget is destroyed in the dispose() function.

4.3.1 Start and stopping the timer

The button and the elapsed time text is placed within the trailing parameter of the Container that is the ItemCard.

trailing: Wrap(
  children: [
    Column(
      children: [
        ElevatedButton(
          key: itemCardTimerButtonKey,
          style: ElevatedButton.styleFrom(
            backgroundColor: _stopwatch.isRunning ? Colors.red : Colors.green,
            elevation: 0,
          ),
          onPressed: handleButtonClick,
          child: _stopwatch.isRunning ? const Text("Stop") : const Text("Start"),
        ),
        Text(formatTime(_stopwatch.elapsedMilliseconds), style: const TextStyle(fontSize: 11))
      ],
    )
  ],
),

We are leveraging the _stopwatch variable to show either a "Start" or "Stop" button. Additionally, we call the elapsedMilliseconds() function and show it to the person. This value is properly shown because the widget is rendered every 200ms, as stated previously.

We are using a formatTime() function. We have used the code from this answer from StackOverflow -> https://stackoverflow.com/a/56458586/20281592. Create a file in lib called utils.dart and paste the following code from it.

String formatTime(int milliseconds) {
  var secs = milliseconds ~/ 1000;
  var hours = (secs ~/ 3600).toString().padLeft(2, '0');
  var minutes = ((secs % 3600) ~/ 60).toString().padLeft(2, '0');
  var seconds = (secs % 60).toString().padLeft(2, '0');
  return "$hours:$minutes:$seconds";
}

This will format the code properly to be shown to the person.

Moving on, the handleButtonClick() is called every time the button is pressed. In this function, depending on whether the timer is running or not, we call the stopTimer() or startTimer() functions of the Item class.

4.3.2 Toggling the ItemCard

Luckily, toggling the todo item between "complete" and "incomplete" is super easy! We just need to create a ToggleTodoEvent!

onTap: () {
  // Create a ToggleTodo event to toggle the `complete` field
  context.read<TodoBloc>().add(ToggleTodoEvent(widget.item));
},

Check the file lib/main.dart for the final version of how the file should look like!

5. Run the app!

We've made all the necessary changes! Let's run the app and see how it fares!

final

Awesome! 🎉

Our tests should all pass as well! Run flutter test. You should see this output.

00:03 +17: All tests passed! 

Heck yeah! 🥳

We are successfully leveraging Bloc to manage the list of todo items. These items are accessible by all the widgets in the widget tree. Every widget is able to access them and also make changes to them, which is what happens when we mutate the todo item list, either by adding new elements or toggling items within it.

6. Give the Interface a New Look ✨ (Optional)

We hope this tutorial was useful to you, because it certainly is to us! Given we are developing our own App, this section will focus on changing the interface so it looks as close to the wireframes in Figma as possible.

As you can see, the TextField is meant to expand to use as much of the page when focussed.
After saving, it should contract back to its compact size.

Let's start changing our app!

6.1 Refactor ItemCard

To make the ItemCard look as close to the Figma wireframes as possible, we are going to make some changes to it. Mainly:

  • Change the icons when toggled.
  • person is only able to toggle the card when the timer is not running.
  • Hide the timer button when the card is toggled to "completed".
  • Change the style of the text when the todo item is marked as "completed".

To make these changes, open lib/main.dart and locate the TodoItemCard class. Replace build function of _ItemCardState with the following piece of code. Don't forget to add the added functions that will dynamically change the text, color and style of the widgets according to the state of the stopwatch.

  // Start and stop timer button handler
  _handleButtonClick() {
    // If timer is ongoing, we stop the stopwatch and the timer in the todo item.
    if (_stopwatch.isRunning) {
      widget.item.stopTimer();
      _stopwatch.stop();

      // Re-render
      setState(() {});
    }

    // If we are to start timer, start the timer in todo item and stopwatch.
    else {
      widget.item.startTimer();
      _stopwatch.start();

      // Re-render
      setState(() {});
    }
  }

  // Set proper background to timer button according to status of stopwatch
  _renderButtonBackground() {
    if (_stopwatch.elapsedMilliseconds == 0) {
      return const Color.fromARGB(255, 75, 192, 169);
    } else {
      return _stopwatch.isRunning ? Colors.red : Colors.green;
    }
  }

  // Set button text according to status of stopwatch
  _renderButtonText() {
    if (_stopwatch.elapsedMilliseconds == 0) {
      return "Start";
    } else {
      return _stopwatch.isRunning ? "Stop" : "Resume";
    }
  }

  @override
  Widget build(BuildContext context) {
    double deviceWidth = MediaQuery.of(context).size.width;

    double descriptionFontSize = deviceWidth * .07;
    double stopwatchFontSize = deviceWidth * .055;
    double buttonFontSize = deviceWidth * .05;

    return Container(
      key: itemCardWidgetKey,
      constraints: const BoxConstraints(minHeight: 70),
      child: ListTile(
        onTap: () {
          // Create a ToggleTodo event to toggle the `complete` field
          // ONLY if the timer is stopped
          if (!_stopwatch.isRunning) {
            context.read<TodoBloc>().add(ToggleTodoEvent(widget.item));
          }
        },

        // Checkbox-style icon showing if it's completed or not
        leading: Column(
          mainAxisAlignment: MainAxisAlignment.center,
          children: [
            widget.item.completed
                ?  Icon(
                    Icons.check_box,
                    color: const Color.fromARGB(255, 126, 121, 121),
                    size: deviceWidth * 0.07,
                  )
                :  Icon(
                    Icons.check_box_outline_blank,
                    color: Colors.black,
                    size: deviceWidth * 0.07,
                  ),
          ],
        ),

        title: Row(
          children: [

            // Todo item description
            Expanded(
              child: Container(
                margin: const EdgeInsets.only(right: 16.0),
                child: Text(widget.item.description,
                    style: TextStyle(
                        fontSize: descriptionFontSize,
                        decoration: widget.item.completed ? TextDecoration.lineThrough : TextDecoration.none,
                        fontStyle: widget.item.completed ? FontStyle.italic : FontStyle.normal,
                        color: widget.item.completed ? const Color.fromARGB(255, 126, 121, 121) : Colors.black)),
              ),
            ),

            // Stopwatch and timer button 
            Column(
              children: [
                Text(formatTime(_stopwatch.elapsedMilliseconds), style: TextStyle(color: Colors.black54, fontSize: stopwatchFontSize)),

                // If the item is completed, we hide the button
                if (!widget.item.completed)
                  ElevatedButton(
                    key: itemCardTimerButtonKey,
                    style: ElevatedButton.styleFrom(
                        backgroundColor: _renderButtonBackground(),
                        elevation: 0,
                        shape: const RoundedRectangleBorder(borderRadius: BorderRadius.zero)),
                    onPressed: _handleButtonClick,
                    child: Text(
                      _renderButtonText(),
                      style: TextStyle(fontSize: buttonFontSize),
                    ),
                  ),
              ],
            )
          ],
        ),
      ),
    );
  }

We've removed the timer button and stopwatch from the trailing parameter and made it part of the title parameter inside a Row because we had a few behaviour problems with the timer button. The button would overflow the trailing container and, when pressed, it would sometimes mark the todo item as "done" instead of starting/stopping the timer.

You can learn more about this behaviour on Stack Overflow in https://stackoverflow.com/questions/62747015/listtile-with-button.

If you run the app, you should see your ItemCard look like this!

changed_todocard

6.2 Refactoring the HomePage

Let's focus on refactoring the HomePage now. As we've seen in the pictures from the Figma wireframes prior, the Textfield will effectively extend to fill the whole page so the person can input text to create a new todo. This extension is actually a new page that we will transition into.

With this in mind, we will need to separate the appBar to its own widget because we are going to be using it in two pages: the HomePage (which was already using it) and the NewTodoPage (the new page that the person will input text and create a new todo item - not yet created).

So here's what we are going to do to the HomePage stateful class.

  • extract the AppBar to a different widget called NavigationBar.
  • convert HomePage to a stateless widget. This is because the TextField will just be a gateway to open a new page - we are not going to input any text. So it's useless to have `HomePage as a stateful widget.

6.2.1 Extracting AppBar

Let's go over the first one first. Create a new NavigationBar stateless widget class for the appbar.

// Widget for the navigation bar
class NavigationBar extends StatelessWidget implements PreferredSizeWidget {
  // Boolean that tells the bar to have a button to go to the previous page
  final bool showGoBackButton;
  // Build context for the "go back" button works
  final BuildContext givenContext;

  const NavigationBar({super.key, required this.givenContext, this.showGoBackButton = false});

  @override
  Widget build(BuildContext context) {
    return AppBar(
      title: Row(
        mainAxisAlignment: MainAxisAlignment.center,
        mainAxisSize: MainAxisSize.min,
        children: [
          // dwyl logo
          Image.asset("assets/icon/icon.png", fit: BoxFit.fitHeight, height: 30),
        ],
      ),
      backgroundColor: const Color.fromARGB(255, 81, 72, 72),
      elevation: 0.0,
      centerTitle: true,
      leading: showGoBackButton
          ? BackButton(
            key: backButtonKey,
              onPressed: () {
                Navigator.pop(givenContext);
              },
            )
          : null,
    );
  }

  @override
  Size get preferredSize => const Size.fromHeight(50);
}

We've made a few changes to the AppBar. We can pass the widget an option to go back to the page where it came from. This is because this NavigationBar will also be present in the new page the person will be redirected whenever he creates a new todo item. For this, we are adding a "Go Back" button to the start of the AppBar that pops the context that is passed on into the widget.

We are also using an image icon. We need to create the directory assets/icon and place icon.png there (you can download the image from this repo). Inside pubspec.yaml, we need to add the path to this folder so Flutter has access to the resource.

flutter:
  uses-material-design: true

  assets:
    - assets/icon/

Don't forget to add the following line alongside the other keys.

final backButtonKey = UniqueKey();

This key will be used to identify the button to go back when we test our changes.

6.2.2 Converting HomePage to stateless widget

Now we are going to convert the HomePage to a stateless widget.

Inside lib/main.dart...

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

  @override
  Widget build(BuildContext context) {
    double deviceWidth = MediaQuery.of(context).size.width;
    double fontSize = deviceWidth * .07;

    return Scaffold(
        appBar: NavigationBar(
          givenContext: context,
        ),
        body: BlocBuilder<TodoBloc, TodoState>(
          builder: (context, state) {
            // If the list is loaded
            if (state is TodoListLoadedState) {
              List<Item> items = state.items;

              return SafeArea(
                child: Column(
                  children: [
                    Padding(
                      padding: const EdgeInsets.only(right: 0, left: 0, top: 0),
                      child:
                          // Textfield to add new todo item (will open another page)
                          TextField(
                              key: textfieldKey,
                              keyboardType: TextInputType.none,
                              maxLines: 3,
                              onTap: () {
                                Navigator.of(context).push(navigateToNewTodoItemPage());
                              },
                              style: TextStyle(fontSize: fontSize),
                              decoration: InputDecoration(
                                  border: const OutlineInputBorder(borderRadius: BorderRadius.zero),
                                  hintText: 'Capture more things on your mind...',
                                  hintStyle: TextStyle(fontSize: fontSize)),
                              textAlignVertical: TextAlignVertical.top),
                    ),

                    // List of items
                    Expanded(
                      child: ListView(
                        padding: const EdgeInsets.symmetric(horizontal: 20, vertical: 40),
                        scrollDirection: Axis.vertical,
                        shrinkWrap: true,
                        children: [
                          if (items.isNotEmpty) const Divider(height: 0),
                          for (var i = 0; i < items.length; i++) ...[
                            if (i > 0) const Divider(height: 0),
                            Padding(
                              padding: const EdgeInsets.symmetric(vertical: 8.0),
                              child: ItemCard(item: items[i]),
                            )
                          ],
                        ],
                      ),
                    ),
                  ],
                ),
              );
            }

            // If the state of the TodoItemList is not loaded, we show error.ˆ
            else {
              return const Center(child: Text("Error loading items list."));
            }
          },
        ));
  }
}

As you can see, we are now using the NavigationBar as an AppBar. We've made changes to the TextField as well. It doesn't show a keyboard when tapped. And when tapped, it navigates to a new page we've yet created. This will be the NewTodoPage that will have an expanded TextField to create a new todo item.

We've also changed how the MaterialApp widget. It now wraps the HomePage in the MainApp widget. This is because we are now using MediaQuery class to get the width of the device. This width will be important to make our text size responsive. This means it will grow as the device grows in size, to maintain the aspect ratio across all devices! MediaQuery must be under the MaterialApp widget within the widget tree to work, hence why we are making this change.

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

  @override
  Widget build(BuildContext context) {
    return BlocProvider(
      create: (context) => TodoBloc()..add(TodoListStarted()),
      child: const MaterialApp(home: HomePage()),   // MaterialApp now wraps from `MainApp`
    );
  }
}

That looks good! Now let's tackle this navigation requirement now.

6.2.3 Navigating to NewTodoPage

The person needs to be able to navigate to the new page. Let's implement the navigateToNewTodoItemPage() function we are using.

Route navigateToNewTodoItemPage() {
  return PageRouteBuilder(
    pageBuilder: (context, animation, secondaryAnimation) => const NewTodoPage(),
    transitionDuration: Duration.zero,
    reverseTransitionDuration: Duration.zero,
  );
}

We are using PageRouteBuilder for the transition between pages. By default, the transition between pages has a default animation. We can remove this transition by overriding these values in transitionDuration and reverseTransitionDuration.

If you are interested in adding a custom transition with SlideTransition to create a sliding animation between pages and learn more about tweening, please check https://docs.flutter.dev/cookbook/animation/page-route-animation.

As you can see, this animation is transitioning into the NewTodoPage(), which we've yet implemented.

Let's just create this page so we can check if the navigation works.

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

  @override
  State<NewTodoPage> createState() => _NewTodoPageState();
}

class _NewTodoPageState extends State<NewTodoPage> {
  // https://stackoverflow.com/questions/61425969/is-it-okay-to-use-texteditingcontroller-in-statelesswidget-in-flutter
  TextEditingController txtFieldController = TextEditingController();

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

  @override
  Widget build(BuildContext context) {
    return const Center(child: Text("centered"));
  }
}

Let's run the app to see if the person is navigated into the new page after tapping the TextField.

navigation

Awesome! 🎉

6.3 Creating the NewTodoPage

Now that's sorted, let's change our NewTodoPage! Locate the _NewTodoPageState build function and replace with the following.

  Widget build(BuildContext context) {

    double deviceWidth = MediaQuery.of(context).size.width;
    double textfieldFontSize = deviceWidth * .07;
    double buttonFontSize = deviceWidth * .06;

    return MaterialApp(
        home: Scaffold(
            appBar: NavigationBar(
              givenContext: context,
              showGoBackButton: true,
            ),
            body: SafeArea(
              child: Padding(
                padding: const EdgeInsets.only(right: 16.0, left: 16.0, top: 16.0),
                child: Column(
                  children: [
                    // Textfield that is expanded and borderless
                    Expanded(
                      child: TextField(
                        key: textfieldOnNewPageKey,
                        controller: txtFieldController,
                        expands: true,
                        maxLines: null,
                        autofocus:true,
                        style: TextStyle(fontSize: textfieldFontSize),
                        decoration: InputDecoration(
                            border: const OutlineInputBorder(borderRadius: BorderRadius.zero),
                            hintText: 'Capture more things on your mind...',
                            hintMaxLines: 2,
                            hintStyle: TextStyle(fontSize: textfieldFontSize)),
                        textAlignVertical: TextAlignVertical.top,
                      ),
                    ),

                    // Save button.
                    // When submitted, it adds a new todo item, clears the controller and navigates back
                    Align(
                      alignment: Alignment.bottomRight,
                      child: ElevatedButton(
                        key: saveButtonKey,
                        style: ElevatedButton.styleFrom(
                          backgroundColor: const Color.fromARGB(255, 75, 192, 169),
                          shape: const RoundedRectangleBorder(borderRadius: BorderRadius.zero),
                        ),
                        onPressed: () {
                          final value = txtFieldController.text;
                          if (value.isNotEmpty) {
                            // Create new item and create AddTodo event
                            Item newTodoItem = Item(description: value);
                            BlocProvider.of<TodoBloc>(context).add(AddTodoEvent(newTodoItem));

                            // Clear textfield
                            txtFieldController.clear();

                            // Go back to home page
                            Navigator.pop(context);
                          }
                        },
                        child: Text(
                          'Save',
                          style: TextStyle(fontSize: buttonFontSize),
                        ),
                      ),
                    ),
                  ],
                ),
              ),
            )));
  }

As you can see, we are reusing some code that was previously in HomePage.

  • the TextField is almost the same, except it is expanded to fill the whole screen by setting the expands and maxLines parameters.
  • we are adding a Save button which, when tapped, creates a new todo item and returns back to the previous page.

With both of these new components, we need to add keys to later test them.

final textfieldOnNewPageKey = UniqueKey();
final saveButtonKey = UniqueKey();

6.4 Fix failing tests

Now that we've implemented the navigation and a new page, we've broken our tests.

Replace the test/widget/widget_test.dart code with the following.

import 'package:flutter/material.dart';
import 'package:flutter_test/flutter_test.dart';
import 'package:todo/main.dart';

void main() {
  testWidgets('Build correctly setup and is loaded', (WidgetTester tester) async {
    await tester.pumpWidget(const MainApp());
    await tester.pump();

    // Find the text input and string stating 0 todos created
    expect(find.byKey(textfieldKey), findsOneWidget);
  });

  testWidgets('Adding a new todo item shows a card', (WidgetTester tester) async {
    await tester.pumpWidget(const MainApp());
    await tester.pumpAndSettle();

    // Find the text input and string stating 0 todos created
    expect(find.byKey(textfieldKey), findsOneWidget);
    expect(find.byKey(itemCardWidgetKey), findsNothing);

    // Tap textfield to open new page to create todo item
    await tester.tap(find.byKey(textfieldKey));
    await tester.pumpAndSettle(const Duration(seconds: 2));

    // Type text into todo input
    await tester.enterText(find.byKey(textfieldOnNewPageKey), 'new todo');
    expect(
        find.descendant(
          of: find.byKey(textfieldOnNewPageKey),
          matching: find.text('new todo'),
        ),
        findsOneWidget);

    // Tap "Save" button to add new todo item
    await tester.tap(find.byKey(saveButtonKey));
    await tester.pumpAndSettle(const Duration(seconds: 2));

    // Input is cleared
    expect(
      find.descendant(
        of: find.byKey(textfieldOnNewPageKey),
        matching: find.text('new todo'),
      ),
      findsNothing,
    );

    // Pump the widget so it renders the new item
    await tester.pumpAndSettle(const Duration(seconds: 2));

    // Expect to find at least one widget, pertaining to the one that was added
    expect(find.byKey(itemCardWidgetKey), findsOneWidget);
  });

  testWidgets('Adding a new todo item and checking it as done', (WidgetTester tester) async {
    await tester.pumpWidget(const MainApp());
    await tester.pumpAndSettle();

    // Find the text input and string stating 0 todos created
    expect(find.byKey(textfieldKey), findsOneWidget);
    expect(find.byKey(itemCardWidgetKey), findsNothing);

    // Tap textfield to open new page to create todo item
    await tester.tap(find.byKey(textfieldKey));
    await tester.pumpAndSettle(const Duration(seconds: 2));

    // Type text into todo input and tap "Save" button to add new todo item
    await tester.enterText(find.byKey(textfieldOnNewPageKey), 'new todo');
    await tester.tap(find.byKey(saveButtonKey));
    await tester.pumpAndSettle(const Duration(seconds: 2));

    // Pump the widget so it renders the new item
    await tester.pumpAndSettle(const Duration(seconds: 2));

    // Expect to find at least one widget, pertaining to the one that was added
    expect(find.byKey(itemCardWidgetKey), findsOneWidget);

    // Getting widget to test its value
    Finder checkboxFinder = find.descendant(of: find.byKey(itemCardWidgetKey), matching: find.byType(Icon));
    Icon checkboxWidget = tester.firstWidget<Icon>(checkboxFinder);

    expect(checkboxWidget.icon, Icons.check_box_outline_blank);

    // Tap on item card
    await tester.tap(find.byKey(itemCardWidgetKey));
    await tester.pump(const Duration(seconds: 2));

    // Updating item card widget and checkbox value should be true
    checkboxWidget = tester.firstWidget<Icon>(checkboxFinder);
    expect(checkboxWidget.icon, Icons.check_box);
  });

  testWidgets('Adding a new todo item and clicking timer button', (WidgetTester tester) async {
    await tester.pumpWidget(const MainApp());
    await tester.pumpAndSettle();

    // Find the text input and string stating 0 todos created
    expect(find.byKey(textfieldKey), findsOneWidget);
    expect(find.byKey(itemCardWidgetKey), findsNothing);

    // Tap textfield to open new page to create todo item
    await tester.tap(find.byKey(textfieldKey));
    await tester.pumpAndSettle(const Duration(seconds: 2));

    // Type text into todo input and tap "Save" button to add new todo item
    await tester.enterText(find.byKey(textfieldOnNewPageKey), 'new todo');
    await tester.tap(find.byKey(saveButtonKey));
    await tester.pumpAndSettle(const Duration(seconds: 2));

    // Pump the widget so it renders the new item
    await tester.pumpAndSettle(const Duration(seconds: 2));

    // Expect to find at least one widget, pertaining to the one that was added
    expect(find.byKey(itemCardWidgetKey), findsOneWidget);

    // Getting widget to test its value
    ElevatedButton buttonWidget = tester.firstWidget<ElevatedButton>(find.byKey(itemCardTimerButtonKey));

    // Button should be stopped
    Text buttonText = buttonWidget.child as Text;
    expect(buttonText.data, "Start");

    // Tap on timer button.
    await tester.tap(find.byKey(itemCardTimerButtonKey));
    await tester.pump(const Duration(seconds: 2));
    await tester.pumpAndSettle();

    // Updating widget and button should be ongoing
    buttonWidget = tester.firstWidget<ElevatedButton>(find.byKey(itemCardTimerButtonKey));
    buttonText = buttonWidget.child as Text;
    expect(buttonText.data, "Stop");

    // Tap on timer button AGAIN
    await tester.tap(find.byKey(itemCardTimerButtonKey));
    await tester.pump(const Duration(seconds: 2));
    await tester.pumpAndSettle();

    // Updating widget and button should be stopped
    buttonWidget = tester.firstWidget<ElevatedButton>(find.byKey(itemCardTimerButtonKey));
    buttonText = buttonWidget.child as Text;
    expect(buttonText.data, "Resume");
  });

  testWidgets('Navigate to new page and go back', (WidgetTester tester) async {
    await tester.pumpWidget(const MainApp());
    await tester.pumpAndSettle();

    // Find the text input and string stating 0 todos created
    expect(find.byKey(textfieldKey), findsOneWidget);
    expect(find.byKey(itemCardWidgetKey), findsNothing);

    // Tap textfield to open new page to create todo item
    await tester.tap(find.byKey(textfieldKey));
    await tester.pumpAndSettle(const Duration(seconds: 2));

    // Go back to the page
    expect(find.byKey(textfieldKey), findsNothing);

    await tester.tap(find.byKey(backButtonKey));
    await tester.pumpAndSettle(const Duration(seconds: 2));

    // User went back to the home page
    expect(find.byKey(textfieldKey), findsOneWidget);
  });
}

We've made two fundamental changes:

  • we've added a new test to simulate the person going back from the NewTodoPage.
  • the TextField that the person uses to input text to create a new todo item is a different one - it is located in the NewTodoPage. Therefore, we tap the original TextField inside the HomePage to navigate into the new page, instead of emulating user input in it.

And that's it! If we run the tests, all should pass!

00:03 +18: All tests passed!  

Hurray! 🥳

Now let's run the app and see how it fares!

final_changed

Looking awesome! We ought to be done! We've successfully added navigation with a sliding animation and it now resembles the Figma wireframes we've shown before!

6.5 Alternative text responsiveness

We have our text objects react to the width of the screen. However, in bigger screens, the text becomes too big. We want to make the text responsive and change accordingly in case the contents exceed the width of its boundaries.

In order to have our app adapt to bigger screens, we can create a simple widget that will render whatever we want according to screen width breakpoints.

Let's create a file called breakpoints.dart inside lib and use the following code.

import 'package:flutter/material.dart';

const kMobileBreakpoint = 425.0;
const kTabletBreakpoint = 768.0;
const kDesktopBreakpoint = 1024.0;
const kDesktopLargeBreakpoint = 1440.0;

class ResponsiveLayout extends StatelessWidget {
  final Widget mobileBody;
  final Widget? tabletBody;
  final Widget? desktopBody;

  const ResponsiveLayout({super.key, required this.mobileBody, this.tabletBody, this.desktopBody});

  @override
  Widget build(BuildContext context) {

    double deviceWidth = MediaQuery.of(context).size.width;

    return LayoutBuilder(builder: (_, __) {
      if (deviceWidth < kMobileBreakpoint) {
        return mobileBody;
      } else if (deviceWidth < kTabletBreakpoint) {
        return tabletBody ?? mobileBody;
      } else {
        return desktopBody ?? tabletBody ?? mobileBody;
      }
    });
  }
}

We are using MediaQuery to conditionally render content with LayoutBuilder.

And now we just need to use this widget to render widget according to different screen sizes. We can do it like so, for example:

const ResponsiveLayout(
mobileBody: Text(
  'Save',
  style: TextStyle(fontSize: 24),
),
tabletBody: Text(
  'Save',
  style: TextStyle(fontSize: 40),
)),

If you are curious to see the changes needed, please check the following commit 1b3dfc3.

I need help! ❓

If you have some feedback or have any question, do not hesitate and open an issue!
We are here to help and are happy for your contribution!

About

A step-by-step example/tutorial explaining the benefits of the BLoC architecture and bloc library including tests!

Topics

Resources

License

Stars

Watchers

Forks

Releases

No releases published

Packages