TECH

Flutter Theming | The Right Way

As light and dark theme options can be seen in almost every app today, it has become a new norm for good user experience. This article will show how t

Hitesh Verma May 12, 2023 · 5 min. read

As light and dark theme options can be seen in almost every app today, it has become a new norm for good user experience. This article will show how to add themes to any flutter app without using any package for every platform in an easy, maintainable, and efficient way.

Light & dark mode in flutter
Light & dark mode in flutter

Create the theme class

Controll the overall app theme from this single place.

import 'package:flutter/material.dart';

class AppTheme extends ChangeNotifier {
  static final _instance = AppTheme._();

  AppTheme._();

  factory AppTheme() {
    return _instance;
  }
}

The AppTheme class extends ChangeNotifier to notify listeners whenever the theme changes, which will then update the app with the new theme. The AppTheme class is a singleton class for a single app theme state.

Add a variable to hold the current theme value in the AppTheme class

ThemeMode _themeMode = ThemeMode.system;

ThemeMode get themeMode => _themeMode;

The _themeMode is a private variable with a getter, to avoid changing its value from outside the class. It can have one of the three values: ThemeMode.system, ThemeMode.light, or ThemeMode.dark.

The _themeMode is set to ThemeMode.system initially, to use the system's default theme when no theme is selected by the user for the app.

Add light and dark ThemeData getters in the AppTheme class

ThemeData get lightTheme => ThemeData(
        scaffoldBackgroundColor: Colors.white,
        iconTheme: const IconThemeData(color: Colors.black),
        textTheme: const TextTheme(
          bodyLarge: TextStyle(fontSize: 16, color: Colors.grey),
          titleLarge: TextStyle(
            fontSize: 24,
            color: Colors.black,
            fontWeight: FontWeight.bold,
          ),
        ),
      );

ThemeData get darkTheme => ThemeData(
        scaffoldBackgroundColor: Colors.grey[900],
        iconTheme: const IconThemeData(color: Colors.white),
        textTheme: TextTheme(
          bodyLarge: TextStyle(fontSize: 16, color: Colors.grey[200]),
          titleLarge: const TextStyle(
            fontSize: 24,
            color: Colors.white,
            fontWeight: FontWeight.bold,
          ),
        ),
      );

Specify all the theme styles in their respective ThemeData. These styles will be accessible in the MaterialApp root widget using the lightTheme and darkTheme getters.

Add a method to change the theme in the AppTheme class.

bool isDarkMode(BuildContext context) => Theme.of(context).scaffoldBackgroundColor == Colors.grey[900];

void toggleTheme(BuildContext context){
    switch (_themeMode) {
      case ThemeMode.light:
        _themeMode = ThemeMode.dark;
        break;
      case ThemeMode.dark:
        _themeMode = ThemeMode.light;
        break;
      case ThemeMode.system:
        if (isDarkMode(context)) {
          _themeMode = ThemeMode.light;
        } else {
          _themeMode = ThemeMode.dark;
        }
    }

    notifyListeners();
}

The toggleTheme method toggles the app theme between light and dark modes by changing the _themeMode value. The logic is as follows.

1. If the _themeMode was light mode, change it to ThemeMode.dark.
2. If the _themeMode was dark mode, change it to ThemeMode.light.
3. If the _themeMode was system default, then it can either be light or dark. So a condition has been added that checks for the current scaffold color to determine whether the system default is light or dark.
bool isDarkMode(BuildContext context) => Theme.of(context).scaffoldBackgroundColor == Colors.grey[900];
After the _themeMode value is updated, simply notify the listener to update the app.

Use the theme values from the AppTheme class in the MaterialApp root widget.

class _MyAppState extends State {
  final _appTheme = AppTheme();

  @override
  void initState() {
    super.initState();
    _appTheme.addListener(() => setState(() {}));
  }

  @override
  Widget build(BuildContext context) {
    return MaterialApp(
      theme: _appTheme.lightTheme,
      darkTheme: _appTheme.darkTheme,
      themeMode: _appTheme.themeMode,
      home: Builder(
        builder: (context) => Scaffold(
          body: Center(
            child: Row(
              mainAxisAlignment: MainAxisAlignment.center,
              children: [
                Text(
                  'Toggle Theme',
                  style: Theme.of(context).textTheme.bodyLarge,
                ),
                IconButton(
                  onPressed: () => _appTheme.toggleTheme(context),
                  icon: const Icon(Icons.settings_display_rounded),
                ),
              ],
            ),
          ),
        ),
      ),
    );
  }
}

Added a listener to AppTheme class in the initState method, which on notification calls setState to rebuild the MaterialApp widget with a new theme mode.

An IconButton is used which calls the toggleTheme method of the AppTheme class when pressed and changes the app theme. You can simply use the onPressed logic anywhere in the app.

The light and dark themes are now added to the app successfully! But there is one problem, the app is not saving the selected theme state, so whenever the app re-opens it uses the system's default theme, and the user has to change it every time, and we don't want that.

Save the selected theme.

Here shared_preferences is used to store the app theme value, but any other local storage would work fine.

Let's create a local database helper class.

import 'package:shared_preferences/shared_preferences.dart';

class LocalDB {
  SharedPreferences? _prefs;

  Future _init() async {
    _prefs ??= await SharedPreferences.getInstance();
  }

  Future isDarkMode() async {
    await _init();
    return _prefs?.getBool('isDarkMode');
  }

  Future setTheme(bool isDarkMode) async {
    await _init();
    await _prefs?.setBool('isDarkMode', isDarkMode);
  }
}

A boolean value is stored in the local database for the key isDarkMode, true indicates that the user has selected the dark mode, false indicates that the user has selected the light mode, and null indicates that the user has not selected any theme and that the system's default theme should be used.

Update the local database whenever the theme is toggled in the AppTheme class.

final _localDb = LocalDB();

Future toggleTheme(BuildContext context) async{
    switch (_themeMode) {
      case ThemeMode.light:
        _themeMode = ThemeMode.dark;
        break;
      case ThemeMode.dark:
        _themeMode = ThemeMode.light;
        break;
      case ThemeMode.system:
        if (isDarkMode(context)) {
          _themeMode = ThemeMode.light;
        } else {
          _themeMode = ThemeMode.dark;
        }
    }

    notifyListeners();
    await _localDb.setTheme(_themeMode == ThemeMode.dark);
}

await _localDb.setTheme(_themeMode == ThemeMode.dark);
will set the isDarkMode to true if _themeMode is changed to ThemeMode.dark otherwise to false in the local database.

Set the initial app theme based on the local database saved value.

Get the saved theme value from the local database when the AppTheme class is instantiated for the first time.

AppTheme._() {
    _getThemeMode();
}

Future _getThemeMode() async {
    final isDarkMode = await _localDb.isDarkMode();

    if(isDarkMode == true){
        _themeMode = ThemeMode.dark;
        notifyListeners();
    } else if(isDarkMode == false){
        _themeMode = ThemeMode.light;
        notifyListeners();
    }
}

The _getThemeMode is called from the private named constructor of the AppTheme class, which in turn is called when the singleton object is created
static final _instance = AppTheme._();
The _getThemeMode first fetches the isDarkMode flag value from the local database. If the value turns out to be true, then _themeMode is set to ThemeMode.dark and notifyListeners() is called to update the app with the dark theme. On the other hand, if the value turns out to be false, then _themeMode is set to ThemeMode.light and notifyListeners() is called to update the app with the light theme. And if the value turns out to be null then we don't have to do anything as _themeMode was already initialized with ThemeMode.system and the app is using the system's default theme.

That's it! The light and dark themes are added to the app and also user preference for the theme is stored locally and is used every time the app is re-opened.

Complete source code

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

void main() => runApp(const MyApp());

class MyApp extends StatefulWidget {
  const MyApp({Key? key}) : super(key: key);

  @override
  State createState() => _MyAppState();
}

class _MyAppState extends State {
  final _appTheme = AppTheme();

  @override
  void initState() {
    super.initState();
    _appTheme.addListener(() => setState(() {}));
  }

  @override
  Widget build(BuildContext context) {
    return MaterialApp(
      theme: _appTheme.lightTheme,
      darkTheme: _appTheme.darkTheme,
      themeMode: _appTheme.themeMode,
      home: Builder(
        builder: (context) => Scaffold(
          body: Center(
            child: Row(
              mainAxisAlignment: MainAxisAlignment.center,
              children: [
                Text(
                  'Toggle Theme',
                  style: Theme.of(context).textTheme.bodyLarge,
                ),
                IconButton(
                  onPressed: () => _appTheme.toggleTheme(context),
                  icon: const Icon(Icons.settings_display_rounded),
                ),
              ],
            ),
          ),
        ),
      ),
    );
  }
}

class AppTheme extends ChangeNotifier {
  static final _instance = AppTheme._();
  final _localDb = LocalDB();

  ThemeMode _themeMode = ThemeMode.system;

  AppTheme._() {
    _getThemeMode();
  }

  factory AppTheme() {
    return _instance;
  }

  ThemeMode get themeMode => _themeMode;

  ThemeData get lightTheme => ThemeData(
        scaffoldBackgroundColor: Colors.white,
        iconTheme: const IconThemeData(color: Colors.black),
        textTheme: const TextTheme(
          bodyLarge: TextStyle(fontSize: 16, color: Colors.grey),
          titleLarge: TextStyle(
            fontSize: 24,
            color: Colors.black,
            fontWeight: FontWeight.bold,
          ),
        ),
      );

  ThemeData get darkTheme => ThemeData(
        scaffoldBackgroundColor: Colors.grey[900],
        iconTheme: const IconThemeData(color: Colors.white),
        textTheme: TextTheme(
          bodyLarge: TextStyle(fontSize: 16, color: Colors.grey[200]),
          titleLarge: const TextStyle(
            fontSize: 24,
            color: Colors.white,
            fontWeight: FontWeight.bold,
          ),
        ),
      );

   bool isDarkMode(BuildContext context) => Theme.of(context).scaffoldBackgroundColor == Colors.grey[900];

  Future toggleTheme(BuildContext context) async {
    switch (_themeMode) {
      case ThemeMode.light:
        _themeMode = ThemeMode.dark;
        break;
      case ThemeMode.dark:
        _themeMode = ThemeMode.light;
        break;
      case ThemeMode.system:
        if (isDarkMode(context)) {
          _themeMode = ThemeMode.light;
        } else {
          _themeMode = ThemeMode.dark;
        }
    }

    notifyListeners();
    await _localDb.setTheme(_themeMode == ThemeMode.dark);
  }

  Future _getThemeMode() async {
    final isDarkMode = await _localDb.isDarkMode();

    if (isDarkMode == true) {
      _themeMode = ThemeMode.dark;
      notifyListeners();
    } else if (isDarkMode == false) {
      _themeMode = ThemeMode.light;
      notifyListeners();
    }
  }
}

class LocalDB {
  SharedPreferences? _prefs;

  Future _init() async {
    _prefs ??= await SharedPreferences.getInstance();
  }

  Future isDarkMode() async {
    await _init();
    return _prefs?.getBool('isDarkMode');
  }

  Future setTheme(bool isDarkMode) async {
    await _init();
    await _prefs?.setBool('isDarkMode', isDarkMode);
  }
}

Thank you for reading this article. I hope it helped you :-)

Read next

Customized Calendar in Flutter

Hey, have you ever wanted a date-picker in your app but not in a dialog box? And you tried all the dart packages out there but cannot change their UIs

Hitesh Verma May 28, 2023 · 9 min read

Flutter: Custom Cupertino Date Picker

Hitesh Verma in TECH
May 28, 2023 · 6 min read

Flutter | Highlight searched text in results

Hitesh Verma in TECH
May 12, 2023 · 4 min read