Home

Awesome

<div align="center">

Flutter Stopwatch App โฑ

<span>A tutorial for building a basic stopwatch from scratch using flutter.</span>

</div>

What? ๐Ÿคทโ€โ™€๏ธ

This is a quick tutorial that will get you up-and-running in less than 20 minutes with a simple app that works as a stopwatch which also persists state in-between sessions.

Why? ๐Ÿ’ก

We wanted to build a simple Flutter app with a stopwatch to test complexity before adding this to our app.

Giving it a whirl ๐Ÿ“ฒ

You can (and should) try the finished app before trying to build it. Simply clone this project, fetch the dependencies and get it running!

git clone https://github.com/dwyl/flutter-stopwatch-tutorial.git

cd flutter-stopwatch-tutorial

flutter pub get

If this is your time running a Flutter app, either be it on a real device or an emulator, please check the learn-flutter repository to setup everything you need to get started on your Flutter journey!

After all of this, you can simply run the app and give it a try!

Running on a real device

If you are interested in running the app on your Android or iOS device, you should follow these instructions.

Android

Running the app on an Android device is quite easy. You first need to enable developer options and USB debugging on your device. You can tap your device build number several times and the "Developer Options" option will come up. Now it's just a matter of enabling USB debugging as well, and you should be sorted.

After this, you just plug your phone to your computer with a USB cable. You can check if the device is properly connected by running:

flutter devices

And you should be able to see the connected phone.

connected_device

If you are using Visual Studio, you can choose the device in the bottom bar and pick your phone. To run, simply press F5 or Run > Start debugging and the build process will commence, and the app will be running on your phone!

If this is your first time running on an Android device/emulator, it might take some time so Gradle downloads all the needed dependencies, binaries and respective SDKs to build the app to be run on the app. Just make sure you have a solid internet connection.

Do not interrupt the the building process on the first setup. This will result in a corrupted .gradle file and you need to clean up to get the app working again. If this happens to you, check the learn-flutter repo in the Running on a real device section to fix this issue.

iOS

The process is a wee more complicated because you need an Apple ID to sign up for a Developer Account.

After this having your Developr Account, open XCode and sign in with your ID (inside Preferences > Accounts).

preferences

Inside Manager Certificates, click on the "+" sign and select iOS Development.

certificates

After this, plug the device to your computer. Find the device in the dropdown (Window > Organizer). Below the team pop-up menu, click on Fix Issue and then on XCode click the Run button.

In subsequent runs, you can deploy with VSCode or any other IDE. This certificate setup is only needed on the first time with XCode.

Demo

Here's a quick demo of what the app will look like, running on an OnePlus 6T.

We're going to have a stopwatch that persists each timer that is created everytime it's paused. This way, we are saving not only the amount of times the stopwatch was stopped and restarted but also when.

demo

How? ๐Ÿ’ป

Project setup

Let's get cracking!

In this walkthrough, we are going to use Visual Studio Code. We will assume you have this IDE installed, as well as the Flutter and Dart extensions installed. If not, do so.

extensions

After restarting VSCode, we can now create our project! Click on View > Command Palette, type "Flutter" and click on Flutter: New Project. It will ask you for a name of the new project. Name it stopwatch_demo.

To run the app, follow the previous steps if you want to run on a real device. If you want to run on an emulator, click on the device button in the bottom bar in VS Code, choose the device you want to run in and you should be set! Now simply press F5 or Run > Start debugging and wait for the build process to finish.

Your app should look like this (we are running on an iPhone 14 Pro Max emulator).

boilerplate

Congrats, you got the default app running! :tada:

Basic stopwatch

Now let's add a basic stopwatch to our application. In the main.dart file, replace the code with the following snippet.

import 'dart:async';

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

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

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

  @override
  Widget build(BuildContext context) {
    return const MaterialApp(title: 'Stopwatch Example', home: StopwatchPage());
  }
}

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

  @override
  createState() => _StopwatchPageState();
}

class _StopwatchPageState extends State<StopwatchPage> {
  late Stopwatch _stopwatch;
  late Timer _timer;

  @override
  void initState() {
    super.initState();
    _stopwatch = Stopwatch();
    _timer = Timer.periodic(const Duration(milliseconds: 200), (timer) {
      setState(() {});
    });
  }

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

  void handleStartStop() {
    if (_stopwatch.isRunning) {
      _stopwatch.stop();
    } else {
      _stopwatch.start();
    }
    setState(() {}); // re-render the page
  }

  @override
  Widget build(BuildContext context) {
    return Scaffold(
      appBar: AppBar(title: const Text('Stopwatch Example')),
      body: Center(
        child: Column(
          mainAxisAlignment: MainAxisAlignment.center,
          children: <Widget>[
            Text(formatTime(_stopwatch.elapsedMilliseconds),
                style: const TextStyle(fontSize: 48.0)),
            ElevatedButton(
                onPressed: handleStartStop,
                child: Text(_stopwatch.isRunning ? 'Stop' : 'Start')),
          ],
        ),
      ),
    );
  }
}

Inside the lib directory, create a new file called utils.dart and add the following code to 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";
}

Let's breakdown the changes we just made. In the main.dart file, we are creating a stateful widget (a widget that is not static) StopwatchPage. These widgets have a state, which makes the widget dynamic throughout its lifetime. When creating a stateful widget, a state class is created alongside it, representing the state of the widget and determines what is built and shown to the user.

In this StopwatchPage widget, we are adding two fields to its state: _stopwatch and _timer. The first one is literally a Stopwatch class that is offered by the Dart SDK natively. This class allows us to start, stop and reset a stopwatch. It's a rather simple implementation. However, there are not any hooks that we have that lets us rerender the UI. Therefore, we create the second field _timer, which will rerender the Text containing the time elapsed every 200ms.

In the UI, we have two buttons. One button toggles between Start and Stop, which is handled by the handleStartStop handler. At the end of the handler, we add a setState(() {}), which forces a re-render of the UI.

The Text showing the time elapsed makes use of the formatTime function we added to utils.dart to correctly format and show the elapsed time.

Your app should now look like this.

<img width="600" alt="basic_setup" src="https://user-images.githubusercontent.com/17494745/202561805-a9e60139-027e-4e7c-9a43-d411652432a4.png">

You can press the button and it will toggle between "start" and "stop", pausing and restarting the stopwatch.

Persisting timers

If we want to persist the time elapsed between sessions, we need a way to persist each timer (duration between a starting and stopping stopwatch at a time) on the local device. For this, we are going to be using drift, which allows relational persistence inside our device.

The following steps follow their docs, adapted to our scenario. If you get stuck, follow their documentation and you'll find the right path straight away!

Let's first add the needed dependencies. Head over to the pubspec.yml and add the following dependencies.

dependencies:
  drift: ^2.2.0
  sqlite3_flutter_libs: ^0.5.0
  path_provider: ^2.0.0
  path: ^1.8.2

dev_dependencies:
  drift_dev: ^2.2.0+1
  build_runner: ^2.2.1

and then run the following command to fetch the dependencies.

flutter pub get

Now that everything is installed, we are ready to start declaring our relational schema and database tables. For this, create a file called database.dart and paste the following code.

import 'package:drift/drift.dart';
import 'dart:io';

import 'package:drift/native.dart';
import 'package:path_provider/path_provider.dart';
import 'package:path/path.dart' as p;

part 'database.g.dart';

// This will generate a table called "Timers". 
// The rows of the table will be represented by a class called "Timer"
class Timers extends Table {
  IntColumn get id => integer().autoIncrement()();
  DateTimeColumn get start => dateTime()();
  DateTimeColumn get stop => dateTime().nullable()();
}

// This annotation tells drift to prepare a database class that uses both of the
// tables we just defined. We'll see how to use that database class in a moment.
@DriftDatabase(tables: [Timers])
class MyDatabase extends _$MyDatabase {
}

In this file we define the Timers table, which has three columns:

Additionally, with the @DriftDatabase annotation we add an array of the tables we want to create.

We now need to generate the needed files to import in the app to access the database. For this, using the configuration file database.dart we just created, we generate the code.

Run flutter pub run build_runner build and you will notice a database.g.dart file was created. To use this file, change the MyDatabase class defined in the database.dart file defined earlier.

@DriftDatabase(tables: [Timers])
class MyDatabase extends _$MyDatabase {
  // we tell the database where to store the data with this constructor
  MyDatabase() : super(_openConnection());

  // you should bump this number whenever you change or add a table definition.
  @override
  int get schemaVersion => 1;
}

LazyDatabase _openConnection() {
  // the LazyDatabase util lets us find the right location for the file async.
  return LazyDatabase(() async {
    // put the database file, called db.sqlite here, into the documents folder
    // for your app.
    final dbFolder = await getApplicationDocumentsDirectory();
    final file = File(p.join(dbFolder.path, 'db.sqlite'));
    return NativeDatabase(file);
  });
}

And now we are ready to use our MyDatabase instance in our app!

The database class created is ready to be used. However, in Flutter app, Drift database classes are typically instantiated at the top of the widget tree and then passed down using state management tools, like provider or riverpod, making it accessible on any widget inside the tree. If you are interested, check the following page for information about state management integration -> https://drift.simonbinder.eu/faq/#using-the-database

You can check if the database is accessible by switching the main function to the following piece of code, inside the main.lib.

import 'database.dart' as Db;

Future<void> main() async {
  WidgetsFlutterBinding.ensureInitialized();
  final database = Db.MyDatabase();

  // Simple select:
  final allTimers = await database.select(database.timers).get();
  print('Timers in database: $allTimers');

  runApp(MyApp());
}

It is really important to import database.dart as Db. This is because we created a Timer class, which can conflict with Dart's native Timer class.

If you run the app, you should see the following in the terminal.

flutter: Timers in database: []

Creating and updating timers

Let's insert a row inside the Timer table everytime the stopwatch is started and update the stop field everytime the stopwatch is stopped.

Inside the main.dart file, update the code so it looks like the following.

import 'dart:async';

import 'package:flutter/material.dart';
import 'package:drift/drift.dart' as drift;

import 'database.dart' as Db;
import 'utils.dart';

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

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

  @override
  Widget build(BuildContext context) {
    return const MaterialApp(title: 'Stopwatch Example', home: StopwatchPage());
  }
}

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

  @override
  createState() => _StopwatchPageState();
}

class _StopwatchPageState extends State<StopwatchPage> {
  late Stopwatch _stopwatch;
  late Timer _timer;
  late Db.MyDatabase _database;
  late int currentId = 1;

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

    WidgetsFlutterBinding.ensureInitialized();
    _database = Db.MyDatabase();

    _stopwatch = Stopwatch();

    // 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(() {});
      }
    });
  }

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

  Future<void> handleStartStop() async {
    if (_stopwatch.isRunning) {

      // Updating timer of the currentId
      final updatedTimer =
          Db.TimersCompanion(stop: drift.Value(DateTime.now()));

      (_database.update(_database.timers)
            ..where((tbl) => tbl.id.equals(currentId)))
          .write(updatedTimer);

      //final allTimers = await _database.select(_database.timers).get();
      //print(allTimers);

      _stopwatch.stop();
      setState(() {});
    } else {

      // Getting the newly created timer ID to change state with
      final insertedId = await _database
          .into(_database.timers)
          .insert(Db.TimersCompanion.insert(start: DateTime.now()));

      _stopwatch.start();
      setState(() {
        currentId = insertedId;
      });
    }
  }

  @override
  Widget build(BuildContext context) {
    return Scaffold(
      appBar: AppBar(title: const Text('Stopwatch Example')),
      body: Center(
        child: Column(
          mainAxisAlignment: MainAxisAlignment.center,
          children: <Widget>[
            Text(formatTime(_stopwatch.elapsedMilliseconds),
                style: const TextStyle(fontSize: 48.0)),
            Text(currentId.toString()),
            ElevatedButton(
                onPressed: handleStartStop,
                child: Text(_stopwatch.isRunning ? 'Stop' : 'Start')),
          ],
        ),
      ),
    );
  }
}

Inside the _StopwatchPageState state widget, we are going to be adding two new fields.

These variables are initialized inside the initState(). This function is called just a single time, on widget mount. Inside this function, we use WidgetsFlutterBinding.ensureInitialized() to make sure that everything is initialized. You can learn more about why you need this if you check their docs, in the "Next Steps" section.

We are changing the handleStartStop() function to properly interact with the database depending if the stopwatch is running or not. If the stopwatch was started, we insert a new timer in the table.

final insertedId = await _database
          .into(_database.timers)
          .insert(Db.TimersCompanion.insert(start: DateTime.now()));

As you can see from the previous snippet, we are using the generated class TimersCompanion, which has a constructor that is used to create objects and insert in the database. If the column is nullable or has a default value (like, for example, the id that auto-increments), the field can be ommited. All others must be set.

After inserting, we update the state of the widget to update the currentId with the one that was inserted in the database.

On the other hand, if the stopwatch is already running and the user wants to stop, we update the current timer stop field in the database. For this, we create a TimersCompanion with a stop value (using Drift's class Value) and then use it when updating the databse.

(_database.update(_database.timers)
            ..where((tbl) => tbl.id.equals(currentId)))
          .write(updatedTimer);

To update, we use the currentId in the widget state and update the row using the write() function.

At the end of the flow, we rerender the UI wdiget by calling setState((){}). This is needed or else the stopwatch won't properly stop.

Deleting all timers

It would be nice to have a button that would delete all the timers. Let's do that.

Inside the main.dart file, in the build function, add an ElevatedButton, so it look likes this.

  @override
  Widget build(BuildContext context) {
    return Scaffold(
      appBar: AppBar(title: const Text('Stopwatch Example')),
      body: Center(
        child: Column(
          mainAxisAlignment: MainAxisAlignment.center,
          children: <Widget>[
            Text(formatTime(_stopwatch.elapsedMilliseconds),
                style: const TextStyle(fontSize: 48.0)),
            Text(currentId.toString()),
            ElevatedButton(
                onPressed: handleStartStop,
                child: Text(_stopwatch.isRunning ? 'Stop' : 'Start')),
            ElevatedButton(
                onPressed: deleteHistoricTimers,
                style: ElevatedButton.styleFrom(
                    backgroundColor: Colors.red,
                    textStyle: TextStyle(color: Colors.white)),
                child: const Text('Delete')),
          ],
        ),
      ),
    );
  }

This button is calling a function when pressed. Let's implement it ๐Ÿ™‚.

  // Deletes all timers
  Future<void> deleteHistoricTimers() async {
    _database.delete(_database.timers).go();
  }

As you can see, it's fairly simple. This function just accesses the database and deletes the all the timers inside the Timer table.

If you want to check if the deleting is working, uncomment the lines inside the handleStartStop() function.

//final allTimers = await _database.select(_database.timers).get();
//print(allTimers);

and run the app. It should look like this.

<img width="600" alt="deleting" src="https://user-images.githubusercontent.com/17494745/202709192-9bad053e-e562-4777-9ad3-eec5286c2c98.png">

If you start and stop a few times, you will see the incrementId increase and see the the terminal logging the Timers database table everytime you stop the stopwatch.

flutter: [Timer(id: 46, start: 2022-11-18 12:49:33.000, stop: 2022-11-18 12:49:35.000)]

If you press Delete and start and stop the stopwatch, you will see that the array will only have a single Timer. This means that deleting is properly working!

Persisting between sessions and extending stopwatch capabilities

As it stands, we are not making use of the timers we are persisting. This is because the Dart's SDK Stopwatch class is too simple for what we want. It can start and stop in a session just fine but it doesn't maintain its value between sessions (e.g. closing and reopining the app).

Therefore, we need to extend the Stopwatch class to be able to have this requirement. When mounting the app, we can fetch the persisted timers and see how much time has already elapsed. Therefore, we need to initialize a Stopwatch object with an initial elapsed time. With this in mind, let's create a class that wraps the Stopwatch class and adds an initialOffset that we can add to it. We are going to override the isRunning, elapsed and elapsedMiliseconds functions

Create a file called stopwatch.dart file and add the following code to it.

class StopwatchEx {
  final Stopwatch _stopWatch = Stopwatch();
  Duration _initialOffset;

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

  start() => _stopWatch.start();

  stop() => _stopWatch.stop();

  reset({Duration? newInitialOffset}) {
    _stopWatch.reset();
    _initialOffset = newInitialOffset ?? const Duration();
  }

  bool get isRunning => _stopWatch.isRunning;

  Duration get elapsed => _stopWatch.elapsed + _initialOffset;

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

Inside the main.dart file, change it so it looks like the following.

import 'dart:async';

import 'package:flutter/material.dart';
import 'package:drift/drift.dart' as drift;
import 'package:stopwatch_demo/stopwatch.dart';

import 'database.dart' as Db;
import 'utils.dart';

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

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

  @override
  Widget build(BuildContext context) {
    return const MaterialApp(title: 'Stopwatch Example', home: StopwatchPage());
  }
}

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

  @override
  createState() => _StopwatchPageState();
}

class _StopwatchPageState extends State<StopwatchPage> {
  late Future<StopwatchEx> _stopwatch;
  late Db.MyDatabase _database;
  late int currentId = 1;

  late Timer _timer;

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

    // Initializing variables -------
    _database = Db.MyDatabase();

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

    // Fetching current stopwatch duration
    _stopwatch = initializeStopwatch();
  }

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

  // Deletes all timers
  Future<void> deleteHistoricTimers() async {
    // Deleting persisted timers
    _database.delete(_database.timers).go();

    // Reset stopwatch timer
    final stopwatch = await _stopwatch;
    stopwatch.reset();
    setState(() {});
  }

  Future<StopwatchEx> initializeStopwatch() async {
    // Fetch all the persisted timers
    final allTimers = await _database.select(_database.timers).get();

    if (allTimers.isEmpty) return StopwatchEx();

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

    return StopwatchEx(initialOffset: accumulativeDuration);
  }

  // Handles starting and stop
  Future<void> handleStartStop() async {
    final stopwatch = await _stopwatch;
    if (stopwatch.isRunning) {
      // Updating timer of the currentId
      final updatedTimer =
          Db.TimersCompanion(stop: drift.Value(DateTime.now()));

      (_database.update(_database.timers)
            ..where((tbl) => tbl.id.equals(currentId)))
          .write(updatedTimer);

      stopwatch.stop();
      setState(() {});
    } else {
      // Getting the newly created timer ID to change state with
      final insertedId = await _database
          .into(_database.timers)
          .insert(Db.TimersCompanion.insert(start: DateTime.now()));

      stopwatch.start();
      setState(() {
        currentId = insertedId;
      });
    }
  }

  @override
  Widget build(BuildContext context) {
    return Scaffold(
      appBar: AppBar(title: const Text('Stopwatch Example')),
      body: Center(
        child: FutureBuilder<StopwatchEx>(
          future: _stopwatch,
          builder: (context, snapshot) {
            if (snapshot.hasData) {
              final stopwatch = snapshot.data!;

              return Column(
                mainAxisAlignment: MainAxisAlignment.center,
                children: <Widget>[
                  Padding(
                    padding: const EdgeInsets.only(bottom: 16.0),
                    child: Text(formatTime(stopwatch.elapsedMilliseconds),
                        style: const TextStyle(fontSize: 48.0)),
                  ),
                  Row(
                    mainAxisAlignment: MainAxisAlignment.center,
                    children: [
                      Padding(
                        padding: const EdgeInsets.only(right: 32.0),
                        child: FloatingActionButton(
                          onPressed: handleStartStop,
                          child: stopwatch.isRunning
                              ? const Icon(Icons.stop)
                              : const Icon(Icons.play_arrow),
                        ),
                      ),
                      FloatingActionButton(
                        onPressed:
                            !stopwatch.isRunning ? deleteHistoricTimers : null,
                        backgroundColor: stopwatch.isRunning
                            ? Colors.redAccent.shade100
                            : Colors.red,
                        child: const Icon(Icons.delete),
                      ),
                    ],
                  ),
                ],
              );
            } else if (snapshot.hasError) {
              return Text('${snapshot.error}');
            }

            // By default, show a loading spinner.
            return const CircularProgressIndicator();
          },
        ),
      ),
    );
  }
}

Let's do a rundown on the changes applied. The _stopwatch field is now using the StopwatchEx class, which is our wrapped class. When initializing the variables inside the page, we want our _stopwatch field to not start from scratch but from a time that was previously stopped. This is why we persist the timers. Therefore, to initialize the _stopwatch, we need to access the database and fetch the timers to see how much time it has elapsed. This is an asynchronous operation, meaning that the _stopwatch field has to be wrapped in a Future class.

To initialize the _stopwatch field, we create an initializeStopwatch() function that is called in initState(). Inside the initializeStopwatch() function, we fetch all the timers inside the database and get cumulative duration elapsed. This value will be used when instantiating a StopwatchEx class, that is created with this initial offset.

Another change that was applied relates to deleting timers. Now, when deleting timers, the stopwatch is reset.

Additionally, since _stopwatch is a Future field, everytime it is needs to be accessed, we have to use await. This is what happens in handleStartStop().

Lastly, in the build function, we make use of the FutureBuilder widget. As the name implies, it's a widget made to handle async data operations. The UI is rendered according to the result of these async operations.

The changes made follow the following template.

FutureBuilder<StopwatchEx>(
  future: _stopwatch,
  builder: (context, snapshot) {
    if (snapshot.hasData) {
      return Text(snapshot.data!);
    } else if (snapshot.hasError) {
      return Text('${snapshot.error}');
    }

    // By default, show a loading spinner.
    return const CircularProgressIndicator();
  },
)

We've added two FloatingActionButton, one to toggle between "Start" and "Stop" and another one to reset the stopwatch (and deleting the persisted timers, as well).

Congratulations, your app now allows you to start and stop the stopwatch and maintain the elapsed time even if you closed and reopened the app (thanks to persisting the timers inside the Drift database) ๐ŸŽ‰.

Your app should now look like this.

<img width="600" alt="persisting" src="https://user-images.githubusercontent.com/17494745/202722175-07ecc495-bb9a-41ab-81c9-363045cd2a80.png">

Adding a page to see persisted timers

Wouldn't it be nice to have a page where we could see the timers that are currently in the database? We fancy it would ๐Ÿ˜‰.

Let's do it.

Inside the build function function, in the appBar property, change it to the following snipet of code. This will add an IconButton that, when pressed, it will navigate the user to another page showing a list of the persisted timers.

appBar: AppBar(
        title: const Text('Stopwatch Example'),
        actions: [
          IconButton(
            icon: const Icon(Icons.list),
            onPressed: navigateToPersistedTimersListPage,
            tooltip: 'completed todo list',
          ),
        ],
      ),

As you can see, when pressed, a _pushCompleted function is called. Let's implement it.

  void navigateToPersistedTimersListPage() {
    _database
        .select(_database.timers)
        .get()
        .then((allTimers) => Navigator.of(context).push(
              MaterialPageRoute<void>(
                builder: (context) {
                  final tiles = allTimers.map(
                    (timer) {
                      return ListTile(
                        title: Text("ID: ${timer.id}"),
                        subtitle: Text(
                          "Start: ${timer.start} \n"
                          "End: ${timer.stop}",
                        ),
                      );
                    },
                  );
                  final divided = tiles.isNotEmpty
                      ? ListTile.divideTiles(
                          context: context,
                          tiles: tiles,
                        ).toList()
                      : <Widget>[];

                  return Scaffold(
                    appBar: AppBar(
                      title: const Text('Persisted timers'),
                    ),
                    body: ListView(children: divided),
                  );
                },
              ),
            ));
  }

In this function, we are fetching all the timers inside the Timer table of the Drift database. After fetching all the timers, we use the Navigator class to navigate to a route. In this same function, we define the route. It will hold a ListView consisting of an array of ListTiles. In each ListTile, we merely print the Timer information - the id, start and stop fields.

If we try to run the app now, it's likely an error stating There are multiple heroes that share the same tag within a subtree. is thrown. To fix this, simply add a heroTag property to each FloatingActionButton inside the build function inside _StopwatchPageState widget state class.

Here's how our build function was changed to.

  @override
  Widget build(BuildContext context) {
    return Scaffold(
      appBar: AppBar(
        title: const Text('Stopwatch Example'),
        actions: [
          IconButton(
            icon: const Icon(Icons.list),
            onPressed: navigateToPersistedTimersListPage,
            tooltip: 'completed todo list',
          ),
        ],
      ),
      body: Center(
        child: FutureBuilder<StopwatchEx>(
          future: _stopwatch,
          builder: (context, snapshot) {
            if (snapshot.hasData) {
              final stopwatch = snapshot.data!;

              return Column(
                mainAxisAlignment: MainAxisAlignment.center,
                children: <Widget>[
                  Padding(
                    padding: const EdgeInsets.only(bottom: 16.0),
                    child: Text(formatTime(stopwatch.elapsedMilliseconds),
                        style: const TextStyle(fontSize: 48.0)),
                  ),
                  Row(
                    mainAxisAlignment: MainAxisAlignment.center,
                    children: [
                      Padding(
                        padding: const EdgeInsets.only(right: 32.0),
                        child: FloatingActionButton(
                          heroTag: "startstop_btn",
                          onPressed: handleStartStop,
                          child: stopwatch.isRunning
                              ? const Icon(Icons.stop)
                              : const Icon(Icons.play_arrow),
                        ),
                      ),
                      FloatingActionButton(
                        heroTag: "delete_btn",
                        onPressed:
                            !stopwatch.isRunning ? deleteHistoricTimers : null,
                        backgroundColor: stopwatch.isRunning
                            ? Colors.redAccent.shade100
                            : Colors.red,
                        child: const Icon(Icons.delete),
                      ),
                    ],
                  ),
                ],
              );
            } else if (snapshot.hasError) {
              return Text('${snapshot.error}');
            }

            // By default, show a loading spinner.
            return const CircularProgressIndicator();
          },
        ),
      ),
    );
  }

If we run the app now, it should work properly! ๐Ÿ‘ You will find a button on the right side of the appbar. If you click it, you will see a list of the current timers that are persisted inside the database.

<img width="600" alt="final" src="https://user-images.githubusercontent.com/17494745/202734558-8497a442-6ff4-4007-83d6-764a878b7f15.png">

Quick code cleanup

Let's just clean our code a little bit. The MyApp class is not really necessary here. Let's delete it and call the StopwatchPage class directly from the main() function.

main() {
  runApp(const MaterialApp(title: 'Stopwatch Example', home: StopwatchPage()));
}

That is it! Your application should now work properly! Congratulations! ๐ŸŽ‰

You just created not only a simple stopwatch application but also learnt about how to leverage the Drift database to create a local database and save information on your device and use this information to maintain the application state across sessions.

Awesome job!

Your main.dart should look similar to this repo's code.

What's next? ๐Ÿคจ

If you found this walkthrough useful, don't be afraid to star the repo so we know we're doing something right.

Your feedback is always welcome! If you think there's an error or if something's not working, do open an issue and let's discuss!