Building a Flutter app: “Dark mode” feature

This post is part of my “Building a Flutter App” dev journal, which begins here.

In this post: I add a settings drawer and a “Dark Mode” toggle to my app. The app saves this setting locally so that the setting persists through app restarts.

First, the final product

The user toggles between dark mode and light mode in a settings drawer.

Material UI’s built in “dark” theme

First, I wanted to see what kind of “dark theme” was already supported in Flutter/Material UI. I changed the theme property to ThemeData.dark(), like so:

return MaterialApp(
      routes: routes,
      theme: ThemeData.dark(),
      home: MainPage(title: 'Grocery Go!'),
    );

Sweet – this default palette will be fine for now.

Adding a “Settings” drawer with a “Dark Mode” toggle switch

Material UI offers a “drawer” widget for headers, and that’s what I am going to begin with. I basically took their example and dropped it into my own main.dart Scaffold, and swapped one of their ListTiles for a SwitchListTile.

main.dart

...

class _MainPageState extends State<MainPage> {

  final DatabaseManager db = DatabaseManager();

  _goToList(ShoppingList list) {
    Navigator.pushNamed(context, MainShoppingList.routeName, arguments: MainShoppingListArguments(list));
  }

  _editStore(Store store) {
    Navigator.pushNamed(context, ExistingStore.routeName, arguments: ExistingStoreArguments(store));
  }

  _editList(ShoppingList list) {
    Navigator.pushNamed(context, ExistingList.routeName, arguments: ExistingListArguments(list));
  }

  bool darkTheme = false; 

@override
  Widget build(BuildContext context) {

    const headerShoppingLists = "Shopping Lists";
    const headerStores = "Stores";

    return Scaffold(
      appBar: AppBar(
        title: Text(widget.title),
      ),
      drawer: Drawer(
        child: ListView(
          padding: EdgeInsets.zero,
          children: <Widget>[
            DrawerHeader(
              decoration: BoxDecoration(
                color: Colors.blue,
              ),
              child: Text(
                'Grocery Go',
                style: TextStyle(
                  color: Colors.white,
                  fontSize: 22,
                ),
              ),
            ),
            SwitchListTile(
              title: Text('Dark Mode'),
              value: darkTheme,
              onChanged: (bool value) {
                setState(() {
                  darkTheme = value;
                });
              },
            ),
            ListTile(
              leading: Icon(Icons.account_circle),
              title: Text('Account management'),
              subtitle: Text('Logged in as TILCode')
            ),
            ListTile(
              leading: Icon(Icons.settings),
              title: Text('App preferences'),
            ),
          ],
        ),
      ),
      body: LayoutBuilder(
        builder: (BuildContext context, BoxConstraints viewportConstraints) {
          return SingleChildScrollView(
            child: Column(
              mainAxisSize: MainAxisSize.min,
              mainAxisAlignment: MainAxisAlignment.start,
              children: <Widget>[
                ItemListHeader(text: headerShoppingLists),
                ItemListStream(dbStream: db.getShoppingListStream(), listType: 'shopping list', onTap: _goToList, onInfoTap: _editList),
                ItemListHeader(text: headerStores),
                ItemListStream(dbStream: db.getStoresStream(), listType: 'store', onTap: _editStore, onInfoTap: _editStore),
              ],
            ),
          );
        }),
    );

Now there’s a drawer with a “Dark Mode” toggle but it doesn’t do anything yet.

Toggling between light/dark mode

Still in main.dart, the next thing I did was make the theme conditional on the darkTheme variable. But wait, darkTheme isn’t available up here where MaterialApp is called. Hmm.

class GroceryGoApp extends StatelessWidget {

  @override
  Widget build(BuildContext context) {

    var routes = {
      ExistingList.routeName: (context) => ExistingList(),
      MainShoppingList.routeName: (context) => MainShoppingList(),
      NewShoppingList.routeName: (context) => NewShoppingList(),
      ExistingStore.routeName: (context) => ExistingStore(),
      NewStore.routeName: (context) => NewStore(),
      NewItem.routeName: (context) => NewItem(),
      ExistingItem.routeName: (context) => ExistingItem(),
    };

    return MaterialApp(
      routes: routes,
      theme: darkTheme ? ThemeData.dark() : ThemeData.light(),
      home: MainPage(title: 'Grocery Go!'),
    );
  }
}

I decided to fix by changing the GroceryGoApp class into a StatefulWidget so I could lift the darkTheme variable up into it and check its value to determine if the theme should be ThemeData.dark() or ThemeData.light().

Then, instead of passing a title string into MainPage (which wasn’t doing anything anyway), I instead pass darkTheme and toggleTheme as parameters.

class GroceryGoApp extends StatefulWidget {

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

class _GroceryGoAppState extends State<GroceryGoApp> {

  bool darkTheme = false;

  void toggleTheme (bool value) {
    setState(() {
      darkTheme = value;
    });
  }

  @override
  Widget build(BuildContext context) {

    var routes = {
      ExistingList.routeName: (context) => ExistingList(),
      MainShoppingList.routeName: (context) => MainShoppingList(),
      NewShoppingList.routeName: (context) => NewShoppingList(),
      ExistingStore.routeName: (context) => ExistingStore(),
      NewStore.routeName: (context) => NewStore(),
      NewItem.routeName: (context) => NewItem(),
      ExistingItem.routeName: (context) => ExistingItem(),
    };

    return MaterialApp(
      routes: routes,
      theme: darkTheme ? ThemeData.dark() : ThemeData.light(),
      home: MainPage(darkTheme: darkTheme, toggleTheme: toggleTheme),
    );
  }
}

Next, in MainPage, I updated its parameters to take darkTheme and toggleTheme as passed in from GroceryGoApp. Here’s the entirety of the MainPage stateful widget and its state so you can see how the darkTheme and toggleTheme parameters come into MainPage and get passed to _MainPageState and used by the SwitchListTile.

class MainPage extends StatefulWidget {
  final darkTheme;
  final toggleTheme;

  MainPage({Key key, this.darkTheme, this.toggleTheme}) : super(key: key);

  @override
  _MainPageState createState() => _MainPageState(darkTheme: darkTheme, toggleTheme: toggleTheme);
}

class _MainPageState extends State<MainPage> {

  final darkTheme;
  final toggleTheme;

  _MainPageState({this.darkTheme, this.toggleTheme});

  final DatabaseManager db = DatabaseManager();

  _goToList(ShoppingList list) {
    Navigator.pushNamed(context, MainShoppingList.routeName, arguments: MainShoppingListArguments(list));
  }

  _editStore(Store store) {
    Navigator.pushNamed(context, ExistingStore.routeName, arguments: ExistingStoreArguments(store));
  }

  _editList(ShoppingList list) {
    Navigator.pushNamed(context, ExistingList.routeName, arguments: ExistingListArguments(list));
  }

  @override
  Widget build(BuildContext context) {

    const headerShoppingLists = "Shopping Lists";
    const headerStores = "Stores";

    return Scaffold(
      appBar: AppBar(
        title: Text('Grocery Go'),
      ),
      drawer: Drawer(
        child: ListView(
          padding: EdgeInsets.zero,
          children: <Widget>[
            DrawerHeader(
              decoration: BoxDecoration(
                color: Colors.blue,
              ),
              child: Text(
                'Grocery Go',
                style: TextStyle(
                  color: Colors.white,
                  fontSize: 22,
                ),
              ),
            ),
            SwitchListTile(
              title: Text('Dark Mode'),
              value: darkTheme,
              onChanged: toggleTheme,
            ),
            ListTile(
              leading: Icon(Icons.account_circle),
              title: Text('Account management'),
              subtitle: Text('Logged in as TILCode')
            ),
            ListTile(
              leading: Icon(Icons.settings),
              title: Text('App preferences'),
            ),
          ],
        ),
      ),
      body: LayoutBuilder(
        builder: (BuildContext context, BoxConstraints viewportConstraints) {
          return SingleChildScrollView(
            child: Column(
              mainAxisSize: MainAxisSize.min,
              mainAxisAlignment: MainAxisAlignment.start,
              children: <Widget>[
                ItemListHeader(text: headerShoppingLists),
                ItemListStream(dbStream: db.getShoppingListStream(), listType: 'shopping list', onTap: _goToList, onInfoTap: _editList),
                ItemListHeader(text: headerStores),
                ItemListStream(dbStream: db.getStoresStream(), listType: 'store', onTap: _editStore, onInfoTap: _editStore),
              ],
            ),
          );
        }),
    );
  }
}

Success! Well, sort of – the little toggle switch isn’t “toggling”. What’s up with that?

Fixing the SwitchListTile switch not toggling

As it turned out, this seemingly-small thing ended up occupying quite a bit of my time. The fix was to actually stop passing darkTheme and toggleTheme into _MainPageState the way I had been.

Instead, I just run an empty constructor and then, when I need to refer to darkTheme and toggleTheme, I address them as widget.darkTheme and widget.toggleTheme. (More on why after the code sample.)

class MainPage extends StatefulWidget {
  final darkTheme;
  final toggleTheme;

  MainPage({Key key, this.darkTheme, this.toggleTheme}) : super(key: key);

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

class _MainPageState extends State<MainPage> {

  _MainPageState();

  final DatabaseManager db = DatabaseManager();

  _goToList(ShoppingList list) {
    Navigator.pushNamed(context, MainShoppingList.routeName, arguments: MainShoppingListArguments(list));
  }

  _editStore(Store store) {
    Navigator.pushNamed(context, ExistingStore.routeName, arguments: ExistingStoreArguments(store));
  }

  _editList(ShoppingList list) {
    Navigator.pushNamed(context, ExistingList.routeName, arguments: ExistingListArguments(list));
  }

  @override
  Widget build(BuildContext context) {

    const headerShoppingLists = "Shopping Lists";
    const headerStores = "Stores";

    return Scaffold(
      appBar: AppBar(
        title: Text('Grocery Go'),
      ),
      drawer: Drawer(
        child: ListView(
          padding: EdgeInsets.zero,
          children: <Widget>[
            DrawerHeader(
              decoration: BoxDecoration(
                color: Colors.blue,
              ),
              child: Text(
                'Grocery Go',
                style: TextStyle(
                  color: Colors.white,
                  fontSize: 22,
                ),
              ),
            ),
            SwitchListTile(
              title: Text('Dark Mode'),
              value: widget.darkTheme,
              onChanged: widget.toggleTheme,
            ),
            ListTile(
              leading: Icon(Icons.account_circle),
              title: Text('Account management'),
              subtitle: Text('Logged in as TILCode')
            ),
            ListTile(
              leading: Icon(Icons.settings),
              title: Text('App preferences'),
            ),
          ],
        ),
      ),
      body: LayoutBuilder(
        builder: (BuildContext context, BoxConstraints viewportConstraints) {
          return SingleChildScrollView(
            child: Column(
              mainAxisSize: MainAxisSize.min,
              mainAxisAlignment: MainAxisAlignment.start,
              children: <Widget>[
                ItemListHeader(text: headerShoppingLists),
                ItemListStream(dbStream: db.getShoppingListStream(), listType: 'shopping list', onTap: _goToList, onInfoTap: _editList),
                ItemListHeader(text: headerStores),
                ItemListStream(dbStream: db.getStoresStream(), listType: 'store', onTap: _editStore, onInfoTap: _editStore),
              ],
            ),
          );
        }),
    );
  }
}

This kinda blew my mind – I thought you had to explicitly pass parameters into the State object, but it turns out that’s not the case. In fact, you’re not supposed to pass them in via the constructor, and you should access them using widget.fieldName like I did here.

(Furthermore, any parameters that are passed to the State object through the constructor will never get updated. Lesson learned! I may have to update some of my other State objects.)

Persisting the user’s dark theme/light theme choice through app reload

Currently, the app defaults to “light mode” every time you reload the app. I’d like to persist the user’s preference, but I want to store it locally (on the device) instead of pushing it to the database.

Shared Preferences is one common solution – it’s a Flutter package that makes it easy to store key/value pairs locally on the device.

I followed this guide and made the following changes to my project’s code:

pubspec.yaml

...

dependencies:
  intl: ^0.16.1
  timeago: ^2.0.26
  cloud_firestore: ^0.13.6
  firebase_storage: ^3.1.6
  shared_preferences: ^0.5.7+3
  ...

(Android Studio prompted me to update after I added this line, but you can also do the update manually via the Terminal with flutter pub get).

main.dart

import 'package:shared_preferences/shared_preferences.dart';

/* GroceryGoApp now expects a 'preferences' parameter 
   I pass it an instance of SharedPreferences. */

void main() async {
  runApp(GroceryGoApp(preferences: await SharedPreferences.getInstance()));
}

// Here's GroceryGoApp receiving its instance of SharedPreferences in its constructor

class GroceryGoApp extends StatefulWidget {
  final SharedPreferences preferences;
  GroceryGoApp({Key key, @required this.preferences}) : super(key: key);

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

/* 
Hey look, a chance to use my new "widget." trick - 
I don't explicitly pass preferences into State, I get them via widget.preferences...
I also replaced 'darkTheme' with a const because I like to YELL_AT_MY_CODE
(really, I just wanted to define the key name in one place in case I end up changing it, because calling it 'darktheme' in some places and 'dark mode' in others is already starting to bother me) */

class _GroceryGoAppState extends State<GroceryGoApp> {
  static const DARK_THEME_KEY = 'darkTheme';
  bool get darkTheme => widget.preferences.getBool(DARK_THEME_KEY) ?? false;

  void toggleTheme(bool value) {
    setState(() {
      widget.preferences.setBool(DARK_THEME_KEY, !darkTheme);
    });
  }

  @override
  Widget build(BuildContext context) {

The first rebuild after installing SharedPreferences was pretty slow, but when it was done – ta-dah!

Oh, no, what’s this? A bunch of error output:

Launching lib/main.dart on iPhone SE (2nd generation) in debug mode...
Running Xcode build...
Xcode build done.                                           22.9s
	path: satisfied (Path is satisfied), interface: en0
Configuring the default Firebase app...
Configured the default Firebase app __FIRAPP_DEFAULT.
	path: satisfied (Path is satisfied), interface: en0
	path: satisfied (Path is satisfied), interface: en0
[VERBOSE-2:ui_dart_state.cc(157)] Unhandled Exception: ServicesBinding.defaultBinaryMessenger was accessed before the binding was initialized.
If you're running an application and need to access the binary messenger before `runApp()` has been called (for example, during plugin initialization), then you need to explicitly call the `WidgetsFlutterBinding.ensureInitialized()` first.
If you're running a test, you can call the `TestWidgetsFlutterBinding.ensureInitialized()` as the first line in your test's `main()` method to initialize the binding.
#0      defaultBinaryMessenger.<anonymous closure> (package:flutter/src/services/binary_messenger.dart:76:7)
#1      defaultBinaryMessenger (package:flutter/src/services/binary_messenger.dart:89:4)
#2      MethodChannel.binaryMessenger (package:flutter/src/services/platform_channel.dart:140:62)
#3      MethodChannel.invokeMethod (package:flutter/src/services/platform_channel.dart:314:35)
#4      MethodChannel.invokeMapMethod (package:flutter/src/services/platfo<…>
Debug service listening on ws://127.0.0.1:64936/RkSpLN3ICpY=/ws
Syncing files to device iPhone SE (2nd generation)...

The app itself is a plain white screen. Hmm.

[Did a bunch of Googling and reading – this Stack Overflow post was the most helpful.]

This seems to happen because the app is waiting on main to do something (main is now async, so that makes sense), and the fix is to add WidgetsFlutterBinding.ensureInitialized() to the first line of main.

void main() async {
  WidgetsFlutterBinding.ensureInitialized();
  runApp(GroceryGoApp(preferences: await SharedPreferences.getInstance()));
}

With SharedPreferences in place, I can now toggle the app to “dark mode”, close the simulator, re-build, and the app is still in “dark mode” when I reopen the app. Yay!

View this feature’s pull request.

Now the user can cross items off and add them back to the active list by tapping on them. Return to the feature work list.

Leave a Reply

Your email address will not be published.

This site uses Akismet to reduce spam. Learn how your comment data is processed.