Dark mode in Flutter using Riverpod
Tags: flutter, mobile, dart
Published: 2022-09-18 11:05:05

## Introduction

With more and more applications being made there is a feature that is almost necessary to have today - the famous dark mode that stresses the eyes a lot less than the default bright aesthetic.

In this post we'll be going through the process of creating a toggleable and persistent dark mode in a Flutter app from start to finish.

## Getting started

Start off by making a new project using the Flutter CLI: ` flutter create <project_name>`

Then we'll need two more packages, one being [Riverpod](https://riverpod.dev/) and the other [Shared preferences](https://pub.dev/packages/shared_preferences).

`flutter pub add flutter_riverpod && flutter pub add shared_preferences`

**Riverpod** will be used for state management and for notifying the entire app that the change from dark to light mode or vice versa happened. **Shared preferences** will be used to persist the changes when the app is turned off.

## Setting up

By creating a new project, your `main.dart` file should look like this:

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

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

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

  @override
  Widget build(BuildContext context) {
    return MaterialApp(
      title: 'Flutter Demo',
      theme: ThemeData(
        primarySwatch: Colors.blue,
      ),
      home: const MyHomePage(title: 'Flutter Demo Home Page'),
    );
  }
}

class MyHomePage extends StatefulWidget {
  const MyHomePage({super.key, required this.title});

  final String title;

  @override
  State<MyHomePage> createState() => _MyHomePageState();
}

class _MyHomePageState extends State<MyHomePage> {
  int _counter = 0;

  void _incrementCounter() {
    setState(() {
      _counter++;
    });
  }

  @override
  Widget build(BuildContext context) {
    return Scaffold(
      appBar: AppBar(
        title: Text(widget.title),
      ),
      body: Center(
        child: Column(
          mainAxisAlignment: MainAxisAlignment.center,
          children: <Widget>[
            const Text(
              'You have pushed the button this many times:',
            ),
            Text(
              '$_counter',
              style: Theme.of(context).textTheme.headline4,
            ),
          ],
        ),
      ),
      floatingActionButton: FloatingActionButton(
        onPressed: _incrementCounter,
        tooltip: 'Increment',
        child: const Icon(Icons.add),
      ),
    );
  }
}
```

![Boilerplate starting code](https://dev-to-uploads.s3.amazonaws.com/uploads/articles/51tg84gfsmuhtplcnlby.png)

This is the default boilerplate code that comes with a new app, it's up to you but I'll be changing the UI to something more barren just for convenience:

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

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

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

  @override
  Widget build(BuildContext context) {
    return MaterialApp(
      title: 'Flutter Demo',
      theme: ThemeData(
        primarySwatch: Colors.blue,
      ),
      home: Scaffold(
        appBar: AppBar(
          title: const Text("Dark mode"),
        ),
        body: Center(
          child: Column(
            mainAxisAlignment: MainAxisAlignment.center,
            children: [],
          ),
        ),
      ),
    );
  }
}
```

The `MaterialApp` widget has a few properties we'll need to examine to get a better understanding of how theming works.

First off there's the `themeMode` property which indicates what type of `ThemeMode` is active. There are three of these modes, those being: `ThemeMode.light`, `ThemeMode.dark` and `ThemeMode.system`.

You could leave the `themeMode` with the system option in cases where you'd like for the phone itself to take control over the theming, but in this case we'll be doing it manualy. Switching between the values triggers the app to force a different theme, these being the light and dark mode themes defined with the `theme` and `darkTheme` properties, respectively.

## Theming the modes

Speaking of the `theme` and `darkTheme` properties, they define which colors will be used to paint the UI depending on the theme chosen. Both of them use the `ThemeData` class that defines many properties that describe the UI like which text will be used, which colors will be used for the text, `AppBar` etc.

They also come with a default theme, one for the light (`ThemeData.light()`) and the other for the dark (`ThemeData.dark()`) mode.

The default dark mode looks like this:

![Image description](https://dev-to-uploads.s3.amazonaws.com/uploads/articles/4uxci06w20eyh327rx5a.png)

But you can change it to whatever you'd like by editing the `darkTheme` property:

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

  @override
  Widget build(BuildContext context) {
    return MaterialApp(
      title: 'Flutter Demo',
      theme: ThemeData(
        primarySwatch: Colors.blue,
      ),
      darkTheme: ThemeData.dark(),
      themeMode: ThemeMode.dark,
      <...>
    );
  }
}
```

## State management

In order to notify the app which theme to switch to we'll have to define a Riverpod notifier. A `StateNotifier` hosts **immutable** state that can change according to user actions and furthermore centralizes a lot of business logic in a single place, read more [here](https://riverpod.dev/docs/providers/state_notifier_provider).

In addition to this we'll have to expose it to the app using a `Provider`.

Make a file anywhere with the name `darkModeProvider.dart`. The code we'll be using:

```dart
import 'package:flutter_riverpod/flutter_riverpod.dart';

class DarkModeNotifier extends StateNotifier<bool> {
  DarkModeNotifier() : super(false);

  void toggle() {
    state = !state;
  }
}

final darkModeProvider = StateNotifierProvider<DarkModeNotifier, bool>(
  (ref) => DarkModeNotifier(),
);
```

Our `DarkModeNotifier` class extends the generic `StateNotifier` class and we set up the starting state by using `super`. To change the state we'll need a method called `toggle` which just switches from true to false and vice versa.

Finally we'll need to expose the notifier to the app using a provider which just returns an instance of the `DarkModeNotifier`.

Import the same package into the `main.dart` file and wrap the parameter of the `runApp` function with a `ProviderScope` widget:

```dart
import 'package:flutter_riverpod/flutter_riverpod.dart';

void main() {
  runApp(const ProviderScope(child: MyApp()));
}
```

This is a necessary step as the app can't read a provider or watch for changes without it.

Furthermore, we need to spruce up our main view as well:

```dart
import 'package:dark_mode/darkModeProvider.dart';
import 'package:flutter/material.dart';
import 'package:flutter_riverpod/flutter_riverpod.dart';

void main() {
  runApp(const ProviderScope(child: MyApp()));
}

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

  @override
  Widget build(BuildContext context, WidgetRef ref) {
    var darkMode = ref.watch(darkModeProvider);

    return MaterialApp(
      title: 'Flutter Demo',
      theme: ThemeData(
        primarySwatch: Colors.blue,
      ),
      darkTheme: ThemeData.dark(),
      themeMode: darkMode ? ThemeMode.dark : ThemeMode.light,
      <...>
    );
  }
}
```

The first thing to notice is that `MyApp` now extends `ConsumerWidget` and not `StatelessWidget`. Both of these serve a similar role, but `ConsumerWidget` is special because it allows Riverpod to function properly and it opens up the possibility of subscribing to our dark mode notifier changes.

```dart
Widget build(BuildContext context, WidgetRef ref)
```

Your IDE tools should already warn you about the second parameter the build method now needs and that is ref, `WidgetRef` to be precise. This parameter is used for injecting providers into your widget and watching for changes, triggering events etc.

```dart
var darkMode = ref.watch(darkModeProvider);
```

The method we'll need to use here is `watch`. As the name implies it watches for changes in the provider and the state of the notifier, reacting accordingly and changing the state that relies on this provider - in our case the `themeMode` property.

## Changing the state

By setting up the foundations of our state now we can use the notifier method to switch the internal state.

Let's add a `Switch` to the app which triggers the `toggle` method of the notifier:

```dart
import 'package:dark_mode/darkModeProvider.dart';
import 'package:flutter/material.dart';
import 'package:flutter_riverpod/flutter_riverpod.dart';

void main() {
  runApp(const ProviderScope(child: MyApp()));
}

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

  @override
  Widget build(BuildContext context, WidgetRef ref) {
    var darkMode = ref.watch(darkModeProvider);

    return MaterialApp(
      title: 'Flutter Demo',
      theme: ThemeData(
        primarySwatch: Colors.blue,
      ),
      darkTheme: ThemeData.dark(),
      themeMode: darkMode ? ThemeMode.dark : ThemeMode.light,
      home: Scaffold(
        appBar: AppBar(
          title: const Text("Dark mode"),
        ),
        body: Center(
          child: Column(
            mainAxisAlignment: MainAxisAlignment.center,
            children: [
              Switch(
                value: darkMode,
                onChanged: (val) {
                  ref.read(darkModeProvider.notifier).toggle();
                },
              ),
            ],
          ),
        ),
      ),
    );
  }
}
```

The value of the switch depends on the provider and upon tapping it the `toggle` function is triggered toggling the value of the notifier state. This is achieved by using the `read` method which uses the notifier property of the provider to expose the notifier methods.

Your app should look like this:

![Dark mode switch](https://dev-to-uploads.s3.amazonaws.com/uploads/articles/b7svzr1w2y5koed39r2v.gif)

You can also use the dark mode provider in other widgets if you want a custom color or behaviour depending on if dark mode is turned on or not.

## Persisting the changes

The changes we made allowed us to control whether dark mode was turned on or not but these changes will not be present if we turn off the app. To combat this we'll be using Shared preferences.

This package uses the already existing `SharedPreferences` Android object which stores values as key-pairs locally on your phone akin to a cache.

To get the shared preferences instance you need to await it meaning that most of the methods needed to instantiate it will be asynchronous. To accommodate these changes we'll need to change the dark mode notifier just a tad bit:

```dart
import 'package:flutter_riverpod/flutter_riverpod.dart';
import 'package:shared_preferences/shared_preferences.dart';

class DarkModeNotifier extends StateNotifier<bool> {
  late SharedPreferences prefs;

  Future _init() async {
    prefs = await SharedPreferences.getInstance();
    var darkMode = prefs.getBool("darkMode");
    state = darkMode ?? false;
  }

  DarkModeNotifier() : super(false) {
    _init();
  }

  void toggle() async {
    state = !state;
    prefs.setBool("darkMode", state);
  }
}

final darkModeProvider = StateNotifierProvider<DarkModeNotifier, bool>(
  (ref) => DarkModeNotifier(),
);
```

As we can't use the body of the constructor itself to initialize the state asynchronously we'll have to define a function that does it instead. Our `toggle` method has changed a tiny bit as well, using the prefs to set a key-value pair.

Restarting the app now persists the changes.

## Conclusion

Even though it can be done with a few other methods, implementing dark mode features into your application is an easy and useful thing to do.

You can find the GitHub repository here: 

{% embed https://github.com/MatijaNovosel/flutter-dark-mode %}