Dark mode is one of the first features users ask for in any app. Flutter makes it surprisingly easy โ the entire theming system comes down to three properties on MaterialApp: theme, darkTheme, and themeMode. Once you understand how those three work together, adding a toggle that flips your whole app between light and dark takes only a few lines of code.
This guide covers everything from the zero-code system theme approach to a fully persistent user toggle with SharedPreferences, custom colour schemes, an animated icon button variant, and a dedicated settings screen. Every common beginner mistake has a working fix.
- Comfortable with StatefulWidget and setState() โ the toggle relies on it
- Know what
MaterialApp,Scaffold, andThemeDataare โ covered in our Flutter Tutorial for Beginners - Understand pubspec.yaml โ needed for adding the
shared_preferencespackage
1. How Flutter’s Theming System Works
Flutter’s MaterialApp has three theme-related properties that work together as a system. You need to understand all three before writing any toggle code:
| Property | What it defines | When it’s used |
|---|---|---|
theme | Your light ThemeData | When themeMode is .light, or .system and OS is in light mode |
darkTheme | Your dark ThemeData | When themeMode is .dark, or .system and OS is in dark mode |
themeMode | Which theme to apply right now | Always โ this is the selector that controls the other two |
| ThemeMode value | What it does | Best for |
|---|---|---|
ThemeMode.system | Follows the device OS light/dark setting automatically | Apps that want zero effort โ just respect OS preference |
ThemeMode.light | Forces light mode everywhere, ignores OS setting | Apps locked to light mode, or after user explicitly picks light |
ThemeMode.dark | Forces dark mode everywhere, ignores OS setting | Apps locked to dark mode, or after user explicitly picks dark |
// The structure โ all three properties belong on MaterialApp
MaterialApp(
theme: ThemeData(...), // โ your light theme
darkTheme: ThemeData(...), // โ your dark theme
themeMode: ThemeMode.system, // โ selector: light / dark / system
home: const HomePage(),
)
If you only define
theme and not darkTheme, Flutter has no dark theme to switch to. Setting themeMode: ThemeMode.dark will silently fall back to the light theme. Always define both.
2. Step 1 โ Follow the System Theme (Zero Code Toggle)
The easiest possible dark mode requires no toggle at all โ just let the device decide. Set themeMode: ThemeMode.system and define both themes. Flutter handles the rest automatically when the user changes their OS setting:
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(
debugShowCheckedModeBanner: false,
title: 'Dark Mode Demo',
// โโ Light theme โโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโ
theme: ThemeData(
colorScheme: ColorScheme.fromSeed(seedColor: Colors.blue),
useMaterial3: true,
),
// โโ Dark theme โโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโ
darkTheme: ThemeData(
colorScheme: ColorScheme.fromSeed(
seedColor: Colors.blue,
brightness: Brightness.dark, // โ REQUIRED for a correct dark palette
),
useMaterial3: true,
),
// โโ Follows OS light/dark setting automatically โโโโโโโโโโโโโโโโโโโ
themeMode: ThemeMode.system,
home: const HomePage(),
);
}
}
class HomePage extends StatelessWidget {
const HomePage({super.key});
@override
Widget build(BuildContext context) {
final isDark = Theme.of(context).brightness == Brightness.dark;
return Scaffold(
appBar: AppBar(title: const Text('System Theme Demo')),
body: Center(
child: Column(
mainAxisAlignment: MainAxisAlignment.center,
children: [
Icon(
isDark ? Icons.nightlight_round : Icons.wb_sunny,
size: 72,
color: Theme.of(context).colorScheme.primary,
),
const SizedBox(height: 16),
Text(
isDark ? 'Dark mode (from OS)' : 'Light mode (from OS)',
style: Theme.of(context).textTheme.headlineSmall,
),
const SizedBox(height: 8),
Text(
'Change in phone Settings โ Display โ Dark Mode',
style: TextStyle(color: Colors.grey.shade600),
textAlign: TextAlign.center,
),
],
),
),
);
}
}
ColorScheme.fromSeed generates a full Material 3 colour palette from a seed colour. Without brightness: Brightness.dark, it generates a light palette even inside darkTheme. The app technically switches themes but uses wrong colours โ backgrounds stay near-white, text becomes invisible. Always include brightness: Brightness.dark in your dark ThemeData.
3. Step 2 โ Manual Toggle with a Switch Widget
For a user-controlled toggle, keep themeMode as a stateful variable at the MyApp level, update it when the Switch flips, and pass the callback down to whatever screen holds the toggle. The state must live at the same level as MaterialApp โ this is the most common mistake beginners make:
// The toggle flow:
//
// User flips Switch
// โ
// onToggle(!isDark) fires in HomePage
// โ
// _toggleTheme() is called in _MyAppState โ state lives HERE, at MaterialApp level
// โ
// setState() updates _themeMode
// โ
// MaterialApp rebuilds with the new themeMode
// โ
// Every widget in the whole app gets the new theme ๐
import 'package:flutter/material.dart';
void main() {
runApp(const MyApp());
}
// MyApp must be StatefulWidget so it can own _themeMode
class MyApp extends StatefulWidget {
const MyApp({super.key});
@override
State<MyApp> createState() => _MyAppState();
}
class _MyAppState extends State<MyApp> {
// _themeMode lives here โ at the same level as MaterialApp
ThemeMode _themeMode = ThemeMode.light;
void _toggleTheme(bool isDark) {
setState(() {
_themeMode = isDark ? ThemeMode.dark : ThemeMode.light;
});
}
@override
Widget build(BuildContext context) {
return MaterialApp(
debugShowCheckedModeBanner: false,
title: 'Dark Mode Toggle',
theme: ThemeData(
colorScheme: ColorScheme.fromSeed(seedColor: Colors.deepPurple),
useMaterial3: true,
),
darkTheme: ThemeData(
colorScheme: ColorScheme.fromSeed(
seedColor: Colors.deepPurple,
brightness: Brightness.dark,
),
useMaterial3: true,
),
themeMode: _themeMode, // โ controlled by state
// Pass the current mode and callback down to the screen
home: HomePage(
isDark: _themeMode == ThemeMode.dark,
onToggle: _toggleTheme,
),
);
}
}
class HomePage extends StatelessWidget {
final bool isDark;
final ValueChanged<bool> onToggle;
const HomePage({
super.key,
required this.isDark,
required this.onToggle,
});
@override
Widget build(BuildContext context) {
final scheme = Theme.of(context).colorScheme;
return Scaffold(
appBar: AppBar(
title: const Text('Dark Mode Toggle'),
actions: [
// Light/Dark switch in the AppBar
Padding(
padding: const EdgeInsets.only(right: 8),
child: Row(
children: [
Icon(Icons.light_mode, size: 18, color: isDark ? Colors.grey : scheme.primary),
Switch(
value: isDark,
onChanged: onToggle,
),
Icon(Icons.dark_mode, size: 18, color: isDark ? scheme.primary : Colors.grey),
],
),
),
],
),
body: Center(
child: Column(
mainAxisAlignment: MainAxisAlignment.center,
children: [
AnimatedSwitcher(
duration: const Duration(milliseconds: 400),
child: Icon(
isDark ? Icons.nightlight_round : Icons.wb_sunny,
key: ValueKey(isDark), // triggers animation on change
size: 80,
color: scheme.primary,
),
),
const SizedBox(height: 20),
Text(
isDark ? 'Dark Mode is ON ๐' : 'Light Mode is ON โ๏ธ',
style: Theme.of(context).textTheme.headlineSmall,
),
const SizedBox(height: 8),
Text(
'Current: ${isDark ? "ThemeMode.dark" : "ThemeMode.light"}',
style: TextStyle(color: Colors.grey.shade600, fontFamily: 'monospace'),
),
const SizedBox(height: 28),
FilledButton.icon(
onPressed: () => onToggle(!isDark),
icon: Icon(isDark ? Icons.light_mode : Icons.dark_mode),
label: Text(isDark ? 'Switch to Light' : 'Switch to Dark'),
),
],
),
),
);
}
}
4. Step 3 โ Persist the Preference with SharedPreferences
Without persistence, the theme resets to the default every time the app restarts. Add the shared_preferences package to save the user’s choice so it survives app restarts:
# pubspec.yaml โ add this dependency
dependencies:
flutter:
sdk: flutter
shared_preferences: ^2.3.4 # check pub.dev for the latest version
# Then run:
flutter pub get
Now update _MyAppState to load the saved preference on startup and save it on every toggle:
import 'package:flutter/material.dart';
import 'package:shared_preferences/shared_preferences.dart';
void main() {
runApp(const MyApp());
}
class MyApp extends StatefulWidget {
const MyApp({super.key});
@override
State<MyApp> createState() => _MyAppState();
}
class _MyAppState extends State<MyApp> {
ThemeMode _themeMode = ThemeMode.light; // default until preference loads
@override
void initState() {
super.initState();
_loadTheme(); // โ restore saved preference before first frame
}
// Called once on startup โ reads the saved bool from disk
Future<void> _loadTheme() async {
final prefs = await SharedPreferences.getInstance();
final isDark = prefs.getBool('isDark') ?? false; // default to light if not set
setState(() {
_themeMode = isDark ? ThemeMode.dark : ThemeMode.light;
});
}
// Called on every toggle โ saves the new preference to disk immediately
Future<void> _toggleTheme(bool isDark) async {
final prefs = await SharedPreferences.getInstance();
await prefs.setBool('isDark', isDark); // โ persists across app restarts
setState(() {
_themeMode = isDark ? ThemeMode.dark : ThemeMode.light;
});
}
@override
Widget build(BuildContext context) {
return MaterialApp(
debugShowCheckedModeBanner: false,
theme: ThemeData(
colorScheme: ColorScheme.fromSeed(seedColor: Colors.deepPurple),
useMaterial3: true,
),
darkTheme: ThemeData(
colorScheme: ColorScheme.fromSeed(
seedColor: Colors.deepPurple,
brightness: Brightness.dark,
),
useMaterial3: true,
),
themeMode: _themeMode,
home: HomePage(
isDark: _themeMode == ThemeMode.dark,
onToggle: _toggleTheme,
),
);
}
}
Reading from
SharedPreferences is async. If you call it inside build(), it runs on every rebuild and causes the Switch to visually snap back before the async read resolves. Loading once in initState() and storing the result as a plain bool in state avoids this entirely โ the UI only updates once when the preference is loaded.
5. Step 4 โ Custom Colour Schemes for Light and Dark
Using ColorScheme.fromSeed is the easiest approach, but sometimes you need to define specific colours for your brand. Here is how to build a fully custom colour scheme for both themes, with different primary and background colours in each:
import 'package:flutter/material.dart';
class AppTheme {
// โโ Shared seed colour โโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโ
static const Color _seed = Color(0xFF6750A4); // purple brand colour
// โโ Light theme โโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโ
static ThemeData get light => ThemeData(
useMaterial3: true,
colorScheme: ColorScheme.fromSeed(
seedColor: _seed,
brightness: Brightness.light,
// Override specific roles if needed:
primary: const Color(0xFF6750A4),
secondary: const Color(0xFF625B71),
surface: const Color(0xFFFFFBFE),
background: const Color(0xFFFFFBFE),
),
// Custom text theme
textTheme: const TextTheme(
headlineLarge: TextStyle(fontWeight: FontWeight.bold),
bodyLarge: TextStyle(height: 1.6),
),
// Card styling
cardTheme: CardThemeData(
elevation: 2,
shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(12)),
),
// AppBar styling
appBarTheme: const AppBarTheme(
centerTitle: true,
elevation: 0,
),
);
// โโ Dark theme โโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโ
static ThemeData get dark => ThemeData(
useMaterial3: true,
colorScheme: ColorScheme.fromSeed(
seedColor: _seed,
brightness: Brightness.dark, // โ generates dark palette variants
// Override specific roles for dark:
primary: const Color(0xFFD0BCFF),
secondary: const Color(0xFFCCC2DC),
surface: const Color(0xFF1C1B1F),
background: const Color(0xFF1C1B1F),
),
textTheme: const TextTheme(
headlineLarge: TextStyle(fontWeight: FontWeight.bold),
bodyLarge: TextStyle(height: 1.6),
),
cardTheme: CardThemeData(
elevation: 4,
shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(12)),
),
appBarTheme: const AppBarTheme(
centerTitle: true,
elevation: 0,
),
);
}
// Usage in MaterialApp:
MaterialApp(
theme: AppTheme.light, // โ reference the static getter
darkTheme: AppTheme.dark,
themeMode: _themeMode,
home: const HomePage(),
)
Separating your themes into an AppTheme class keeps main.dart clean and makes it easy to adjust colours in one place without hunting through the widget tree.
6. Bonus: Animated Icon Button Toggle
The Switch widget works well, but many apps use a single icon button that animates between a sun and moon. Here is a clean implementation using AnimatedSwitcher:
// A self-contained animated theme toggle icon button
// Drop it anywhere โ AppBar actions, Settings screen, etc.
class ThemeToggleButton extends StatelessWidget {
final bool isDark;
final VoidCallback onToggle;
const ThemeToggleButton({
super.key,
required this.isDark,
required this.onToggle,
});
@override
Widget build(BuildContext context) {
return IconButton(
tooltip: isDark ? 'Switch to light mode' : 'Switch to dark mode',
onPressed: onToggle,
icon: AnimatedSwitcher(
duration: const Duration(milliseconds: 350),
transitionBuilder: (child, animation) => RotationTransition(
turns: animation, // icon spins as it switches
child: FadeTransition(
opacity: animation,
child: child,
),
),
child: Icon(
isDark ? Icons.wb_sunny_rounded : Icons.nightlight_round,
key: ValueKey(isDark), // key change triggers the animation
color: isDark
? Colors.amber.shade300
: Theme.of(context).colorScheme.primary,
),
),
);
}
}
// Usage in an AppBar:
AppBar(
title: const Text('My App'),
actions: [
ThemeToggleButton(
isDark: _themeMode == ThemeMode.dark,
onToggle: () => _toggleTheme(_themeMode != ThemeMode.dark),
),
const SizedBox(width: 8),
],
)
7. Bonus: Settings Screen with Theme Toggle
In a real multi-screen app, the theme toggle typically lives in a Settings screen โ not the AppBar. Here is the full pattern: a BottomNavigationBar app where the Settings tab controls the theme for the whole app:
import 'package:flutter/material.dart';
import 'package:shared_preferences/shared_preferences.dart';
void main() {
runApp(const MyApp());
}
class MyApp extends StatefulWidget {
const MyApp({super.key});
@override
State<MyApp> createState() => _MyAppState();
}
class _MyAppState extends State<MyApp> {
ThemeMode _themeMode = ThemeMode.light;
@override
void initState() {
super.initState();
_loadTheme();
}
Future<void> _loadTheme() async {
final prefs = await SharedPreferences.getInstance();
setState(() {
_themeMode = (prefs.getBool('isDark') ?? false)
? ThemeMode.dark
: ThemeMode.light;
});
}
Future<void> _setThemeMode(ThemeMode mode) async {
final prefs = await SharedPreferences.getInstance();
await prefs.setBool('isDark', mode == ThemeMode.dark);
setState(() => _themeMode = mode);
}
@override
Widget build(BuildContext context) {
return MaterialApp(
debugShowCheckedModeBanner: false,
theme: ThemeData(
colorScheme: ColorScheme.fromSeed(seedColor: Colors.teal),
useMaterial3: true,
),
darkTheme: ThemeData(
colorScheme: ColorScheme.fromSeed(
seedColor: Colors.teal,
brightness: Brightness.dark,
),
useMaterial3: true,
),
themeMode: _themeMode,
home: MainShell(
themeMode: _themeMode,
onThemeModeChanged: _setThemeMode,
),
);
}
}
class MainShell extends StatefulWidget {
final ThemeMode themeMode;
final ValueChanged<ThemeMode> onThemeModeChanged;
const MainShell({
super.key,
required this.themeMode,
required this.onThemeModeChanged,
});
@override
State<MainShell> createState() => _MainShellState();
}
class _MainShellState extends State<MainShell> {
int _selectedIndex = 0;
@override
Widget build(BuildContext context) {
final screens = [
const HomeContent(),
SettingsScreen(
themeMode: widget.themeMode,
onThemeModeChanged: widget.onThemeModeChanged,
),
];
return Scaffold(
body: IndexedStack(index: _selectedIndex, children: screens),
bottomNavigationBar: NavigationBar(
selectedIndex: _selectedIndex,
onDestinationSelected: (i) => setState(() => _selectedIndex = i),
destinations: const [
NavigationDestination(icon: Icon(Icons.home_outlined), selectedIcon: Icon(Icons.home), label: 'Home'),
NavigationDestination(icon: Icon(Icons.settings_outlined), selectedIcon: Icon(Icons.settings), label: 'Settings'),
],
),
);
}
}
// โโ Settings screen with theme controls โโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโ
class SettingsScreen extends StatelessWidget {
final ThemeMode themeMode;
final ValueChanged<ThemeMode> onThemeModeChanged;
const SettingsScreen({
super.key,
required this.themeMode,
required this.onThemeModeChanged,
});
@override
Widget build(BuildContext context) {
return Scaffold(
appBar: AppBar(title: const Text('Settings')),
body: ListView(
children: [
const Padding(
padding: EdgeInsets.fromLTRB(16, 16, 16, 8),
child: Text('Appearance', style: TextStyle(fontWeight: FontWeight.w600, fontSize: 13)),
),
// Option A: Simple dark mode toggle
SwitchListTile(
title: const Text('Dark Mode'),
subtitle: const Text('Override system theme'),
value: themeMode == ThemeMode.dark,
secondary: Icon(
themeMode == ThemeMode.dark ? Icons.nightlight_round : Icons.wb_sunny,
),
onChanged: (isDark) =>
onThemeModeChanged(isDark ? ThemeMode.dark : ThemeMode.light),
),
const Divider(),
// Option B: Three-way radio selection
const Padding(
padding: EdgeInsets.fromLTRB(16, 8, 16, 4),
child: Text('Theme preference', style: TextStyle(fontWeight: FontWeight.w600, fontSize: 13)),
),
RadioListTile<ThemeMode>(
title: const Text('Follow system'),
subtitle: const Text('Use your device setting'),
secondary: const Icon(Icons.phone_android),
value: ThemeMode.system,
groupValue: themeMode,
onChanged: (v) => onThemeModeChanged(v!),
),
RadioListTile<ThemeMode>(
title: const Text('Light'),
secondary: const Icon(Icons.wb_sunny_outlined),
value: ThemeMode.light,
groupValue: themeMode,
onChanged: (v) => onThemeModeChanged(v!),
),
RadioListTile<ThemeMode>(
title: const Text('Dark'),
secondary: const Icon(Icons.nightlight_outlined),
value: ThemeMode.dark,
groupValue: themeMode,
onChanged: (v) => onThemeModeChanged(v!),
),
],
),
);
}
}
class HomeContent extends StatelessWidget {
const HomeContent({super.key});
@override
Widget build(BuildContext context) {
return Scaffold(
appBar: AppBar(title: const Text('Home')),
body: const Center(child: Text('Go to Settings to change the theme')),
);
}
}
Offering “Follow system / Light / Dark” as three radio buttons is a better UX pattern than a binary switch because it lets users who prefer the system default keep that without being forced into a manual choice. Many production apps (Gmail, YouTube, Twitter/X) use this exact three-way pattern in their settings.
8. How to Read the Current Theme Anywhere
Once your theme is set up, you often need to read whether the app is in dark or light mode to conditionally change colours, icons, or images. There are two reliable ways to do this:
// Option 1 โ Theme.of(context).brightness (recommended)
// Reads your app's active themeMode โ responds to your manual toggle
final isDark = Theme.of(context).brightness == Brightness.dark;
// Option 2 โ MediaQuery.of(context).platformBrightness
// Always reads the OS setting regardless of your manual toggle
final isOsDark = MediaQuery.of(context).platformBrightness == Brightness.dark;
// โโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโ
// Practical example โ different colours per theme
Container(
color: isDark ? Colors.grey.shade900 : Colors.grey.shade100,
child: Text(
'Adaptive container',
style: TextStyle(
color: isDark ? Colors.white : Colors.black87,
),
),
)
// Better approach โ use ColorScheme roles instead of hardcoded colours
// These always adjust correctly in both light and dark mode:
Container(
color: Theme.of(context).colorScheme.surface,
child: Text(
'Using ColorScheme',
style: TextStyle(
color: Theme.of(context).colorScheme.onSurface,
),
),
)
Rather than checking
isDark and hardcoding colours, use Theme.of(context).colorScheme roles like surface, onSurface, primary, onPrimary, background, and onBackground. These automatically return the right colour for the current theme โ no conditional logic needed, and your app looks correct in both modes automatically.
9. Common Beginner Mistakes
Mistake 1: Defining only theme but not darkTheme
// โ Wrong โ no darkTheme defined, ThemeMode.dark silently shows the light theme
MaterialApp(
theme: ThemeData.light(),
themeMode: ThemeMode.dark, // does nothing useful โ Flutter falls back to theme
)
// โ
Correct โ both themes defined, selector has something to switch to
MaterialApp(
theme: ThemeData(
colorScheme: ColorScheme.fromSeed(seedColor: Colors.blue),
useMaterial3: true,
),
darkTheme: ThemeData(
colorScheme: ColorScheme.fromSeed(
seedColor: Colors.blue,
brightness: Brightness.dark, // โ required
),
useMaterial3: true,
),
themeMode: ThemeMode.dark,
)
Mistake 2: Forgetting brightness: Brightness.dark in the dark theme
// โ Wrong โ generates a LIGHT colour palette inside darkTheme
// App "switches" but backgrounds stay near-white, text may be invisible
darkTheme: ThemeData(
colorScheme: ColorScheme.fromSeed(seedColor: Colors.blue),
useMaterial3: true,
)
// โ
Correct โ brightness: Brightness.dark generates the correct dark palette
darkTheme: ThemeData(
colorScheme: ColorScheme.fromSeed(
seedColor: Colors.blue,
brightness: Brightness.dark, // โ this is the fix
),
useMaterial3: true,
)
Mistake 3: Keeping themeMode state too low in the widget tree
// โ Wrong โ _themeMode is in HomePage, which is a CHILD of MaterialApp.
// Calling setState in HomePage only rebuilds HomePage โ not MaterialApp.
// MaterialApp never sees the new themeMode, so the theme never changes.
class _HomePageState extends State<HomePage> {
ThemeMode _themeMode = ThemeMode.light; // โ wrong level
void _toggle(bool isDark) {
setState(() => _themeMode = isDark ? ThemeMode.dark : ThemeMode.light);
}
// MaterialApp is the parent โ it never rebuilds from this setState
}
// โ
Correct โ _themeMode lives in _MyAppState, which OWNS MaterialApp.
// setState here rebuilds MaterialApp with the new themeMode, changing the whole app.
class _MyAppState extends State<MyApp> {
ThemeMode _themeMode = ThemeMode.light; // โ correct level
void _toggle(bool isDark) {
setState(() => _themeMode = isDark ? ThemeMode.dark : ThemeMode.light);
}
Widget build(BuildContext context) => MaterialApp(themeMode: _themeMode, ...);
}
Mistake 4: Switch snapping back โ reading SharedPreferences inside build()
// โ Wrong โ FutureBuilder in build() re-reads SharedPreferences on every rebuild.
// While the Future resolves, the Switch shows the default value and then snaps.
Widget build(BuildContext context) {
return FutureBuilder<bool>(
future: SharedPreferences.getInstance()
.then((prefs) => prefs.getBool('isDark') ?? false),
builder: (ctx, snapshot) => Switch(value: snapshot.data ?? false, ...),
);
}
// โ
Correct โ load once in initState(), store as a plain bool in state.
// Build() reads the bool directly โ no async, no snapping.
@override
void initState() {
super.initState();
_loadTheme(); // one-time async load
}
Future<void> _loadTheme() async {
final prefs = await SharedPreferences.getInstance();
setState(() {
_themeMode = (prefs.getBool('isDark') ?? false)
? ThemeMode.dark
: ThemeMode.light;
});
}
Widget build(BuildContext context) {
// _themeMode is now a plain bool in state โ no async, no snapping
return Switch(value: _themeMode == ThemeMode.dark, ...);
}
Mistake 5: Hardcoding colours instead of using ColorScheme roles
// โ Wrong โ hardcoded colours break in dark mode
Container(
color: Colors.white, // looks fine in light, invisible in dark
child: Text(
'Hello',
style: TextStyle(color: Colors.black), // same problem
),
)
// โ
Correct โ ColorScheme roles adapt automatically to both themes
Container(
color: Theme.of(context).colorScheme.surface,
child: Text(
'Hello',
style: TextStyle(
color: Theme.of(context).colorScheme.onSurface,
),
),
)
10. Interview Q&A
A: It is a property on
MaterialApp that tells Flutter which theme to apply: ThemeMode.light forces light mode, ThemeMode.dark forces dark mode, and ThemeMode.system follows the device OS setting. It acts as a selector between the theme and darkTheme you define.
A:
ThemeMode.system follows the OS setting automatically. ThemeMode.light forces the light theme defined in theme:. ThemeMode.dark forces the dark theme defined in darkTheme:. If darkTheme is not defined, ThemeMode.dark falls back silently to the light theme.
A: Keep
themeMode as a state variable in the StatefulWidget that owns MaterialApp โ typically _MyAppState. Pass a callback down to the widget containing the toggle. When the user flips it, call setState() to update themeMode. Because the state lives at the MaterialApp level, every widget in the app re-renders with the new theme.
A: Use the
shared_preferences package. On each toggle, save a bool with prefs.setBool('isDark', isDark). In initState(), load it with prefs.getBool('isDark') and call setState() to apply it before the first frame renders. This ensures the app opens in the correct theme every restart.
A:
ColorScheme.fromSeed generates a complete Material 3 colour palette from a single seed colour. Without brightness: Brightness.dark, it generates a light palette even when used inside darkTheme. The app technically switches theme objects but the colours remain light โ backgrounds stay near-white and text may become invisible. brightness: Brightness.dark tells the generator to produce dark variants of every colour role.
A: Use
Theme.of(context).brightness == Brightness.dark. This responds to your app’s themeMode state rather than always reading the OS setting. For widgets that should adapt to both themes automatically without manual checks, use Theme.of(context).colorScheme roles like surface, onSurface, primary โ these return the right colour for the current theme without any conditional logic.
11. Related Posts
| Post | Why it’s relevant |
|---|---|
| Flutter Widgets Explained: Stateless vs Stateful | The dark mode toggle depends entirely on StatefulWidget and setState() โ the prerequisite concept to understand first. |
| Understanding pubspec.yaml: A Beginner’s Complete Guide | The persistence step requires adding shared_preferences as a dependency โ this guide explains exactly how to do that. |
| Flutter Bottom Navigation Bar: Complete Beginner’s Guide | The Settings screen pattern in this guide builds directly on the bottom navigation shell shown there. |
| Flutter Tutorial for Beginners: From Install to First App | Introduces MaterialApp, Scaffold, and ThemeData โ the three building blocks this guide builds on top of. |
| Hot Reload vs Hot Restart in Flutter | After adding shared_preferences to pubspec.yaml, a full restart is required โ this guide explains why and when. |