Dark mode in Flutter using Riverpod
Published on September 17, 22'
flutter

Table of contents

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 and the other 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:

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

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:

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

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

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.

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:

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:

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:

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.

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.

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:

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

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:

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.

©   Matija Novosel 2024