Flutter Theming | The Right Way

Hitesh Verma
5 min readMar 14, 2023

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.

Find the updated article here.

Light and dark modes in an app

Let’s start with creating 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.

void toggleTheme(BuildContext context){
switch (_themeMode) {
case ThemeMode.light:
_themeMode = ThemeMode.dark;
break;
case ThemeMode.dark:
_themeMode = ThemeMode.light;
break;
case ThemeMode.system:
if (Theme.of(context).scaffoldBackgroundColor == Colors.white) {
_themeMode = ThemeMode.dark;
} else {
_themeMode = ThemeMode.light;
}
}

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.
    if (Theme.of(context).scaffoldBackgroundColor == Colors.white){} , if true the system default will be light mode else dark mode.

After the _themeMode value is updated, simply notify the listener to update the app.

Use the AppTheme class's theme values in the MaterialApp root widget.

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

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

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

class _MyAppState extends State<MyApp> {
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<void> _init() async {
_prefs ??= await SharedPreferences.getInstance();
}

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

Future<void> 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<void> toggleTheme(BuildContext context) async{
switch (_themeMode) {
case ThemeMode.light:
_themeMode = ThemeMode.dark;
break;
case ThemeMode.dark:
_themeMode = ThemeMode.light;
break;
case ThemeMode.system:
if (Theme.of(context).scaffoldBackgroundColor == Colors.white) {
_themeMode = ThemeMode.dark;
} else {
_themeMode = ThemeMode.light;
}
}

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<void> _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 uses 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.

Thank you for reading this article! Get the complete source code from here.

--

--