Flutter Widgets Fundamentals

Dark Mode & Dynamic Themes

45 min Lesson 17 of 18

Why Dark Mode Matters

Dark mode isn’t just an aesthetic preference -- it’s a critical feature that improves user experience, reduces eye strain in low-light environments, and saves battery on OLED screens. Flutter provides first-class support for dark mode through MaterialApp’s darkTheme parameter and the ThemeMode enum. In this lesson, you’ll learn how to implement a complete dark mode system with automatic detection, manual toggling, and persistent preferences.

Industry Standard: All major platforms (iOS, Android, macOS, Windows) support system-wide dark mode. Users expect apps to respect their system preference, and many app stores consider dark mode support as part of quality guidelines.

ThemeMode: The Three States

Flutter’s ThemeMode enum determines which theme the app uses. Understanding these three modes is the foundation of dark mode implementation.

ThemeMode Options

MaterialApp(
  // Light theme -- used when ThemeMode.light or ThemeMode.system (light)
  theme: AppTheme.lightTheme,

  // Dark theme -- used when ThemeMode.dark or ThemeMode.system (dark)
  darkTheme: AppTheme.darkTheme,

  // Controls which theme is active
  themeMode: ThemeMode.system,  // Default: follows system setting
  // ThemeMode.light   -- Always use light theme
  // ThemeMode.dark    -- Always use dark theme
  // ThemeMode.system  -- Follow the OS dark/light mode setting
)

Setting Up Light and Dark Themes

The key to good dark mode is defining both themes with intentional design, not just inverting colors. Dark themes need careful consideration of elevation, contrast, and surface colors.

Complete Light and Dark Theme Definitions

class AppTheme {
  static const _seedColor = Color(0xFF1E88E5);

  static ThemeData get lightTheme {
    final colorScheme = ColorScheme.fromSeed(
      seedColor: _seedColor,
      brightness: Brightness.light,
    );

    return ThemeData(
      useMaterial3: true,
      colorScheme: colorScheme,
      scaffoldBackgroundColor: colorScheme.surface,
      appBarTheme: AppBarTheme(
        backgroundColor: colorScheme.surface,
        foregroundColor: colorScheme.onSurface,
        elevation: 0,
        scrolledUnderElevation: 1,
      ),
      cardTheme: CardTheme(
        elevation: 1,
        color: colorScheme.surface,
        shape: RoundedRectangleBorder(
          borderRadius: BorderRadius.circular(12),
        ),
      ),
      dividerTheme: DividerThemeData(
        color: colorScheme.outlineVariant,
      ),
      inputDecorationTheme: InputDecorationTheme(
        filled: true,
        fillColor: colorScheme.surfaceContainerHighest.withOpacity(0.3),
        border: OutlineInputBorder(
          borderRadius: BorderRadius.circular(8),
        ),
      ),
    );
  }

  static ThemeData get darkTheme {
    final colorScheme = ColorScheme.fromSeed(
      seedColor: _seedColor,
      brightness: Brightness.dark,
    );

    return ThemeData(
      useMaterial3: true,
      colorScheme: colorScheme,
      scaffoldBackgroundColor: colorScheme.surface,
      appBarTheme: AppBarTheme(
        backgroundColor: colorScheme.surface,
        foregroundColor: colorScheme.onSurface,
        elevation: 0,
        scrolledUnderElevation: 1,
      ),
      cardTheme: CardTheme(
        elevation: 1,
        color: colorScheme.surfaceContainerHigh,
        shape: RoundedRectangleBorder(
          borderRadius: BorderRadius.circular(12),
        ),
      ),
      dividerTheme: DividerThemeData(
        color: colorScheme.outlineVariant,
      ),
      inputDecorationTheme: InputDecorationTheme(
        filled: true,
        fillColor: colorScheme.surfaceContainerHighest.withOpacity(0.3),
        border: OutlineInputBorder(
          borderRadius: BorderRadius.circular(8),
        ),
      ),
    );
  }
}
Design Tip: In dark themes, elevation is communicated through lighter surface colors rather than shadows. Material 3’s ColorScheme automatically provides surface container colors at different elevation levels: surfaceContainerLowest, surfaceContainerLow, surfaceContainer, surfaceContainerHigh, surfaceContainerHighest.

Detecting System Brightness

Sometimes you need to know the current brightness outside of the theme system -- for example, to load different assets or apply conditional logic.

Reading System Brightness

class AdaptiveScreen extends StatelessWidget {
  const AdaptiveScreen({super.key});

  @override
  Widget build(BuildContext context) {
    // Method 1: Check the current theme brightness
    final isDark = Theme.of(context).brightness == Brightness.dark;

    // Method 2: Check the platform's brightness setting directly
    final platformBrightness = MediaQuery.platformBrightnessOf(context);
    final isSystemDark = platformBrightness == Brightness.dark;

    // Method 3: Check ColorScheme brightness
    final isColorSchemeDark =
      Theme.of(context).colorScheme.brightness == Brightness.dark;

    return Column(
      children: [
        // Use different images based on theme
        Image.asset(
          isDark ? 'assets/logo_dark.png' : 'assets/logo_light.png',
        ),

        // Adaptive shadow
        Container(
          decoration: BoxDecoration(
            color: Theme.of(context).colorScheme.surface,
            boxShadow: isDark
              ? []  // No shadows in dark mode
              : [
                  BoxShadow(
                    color: Colors.black.withOpacity(0.1),
                    blurRadius: 8,
                    offset: const Offset(0, 2),
                  ),
                ],
          ),
          child: const Text('Adaptive Container'),
        ),
      ],
    );
  }
}

Runtime Theme Switching

To allow users to toggle between light, dark, and system modes at runtime, you need a state management solution. Here’s a clean implementation using a ValueNotifier:

Theme Controller with ValueNotifier

// lib/theme/theme_controller.dart
class ThemeController extends ChangeNotifier {
  ThemeMode _themeMode = ThemeMode.system;

  ThemeMode get themeMode => _themeMode;

  bool get isDarkMode => _themeMode == ThemeMode.dark;
  bool get isLightMode => _themeMode == ThemeMode.light;
  bool get isSystemMode => _themeMode == ThemeMode.system;

  void setThemeMode(ThemeMode mode) {
    if (_themeMode != mode) {
      _themeMode = mode;
      notifyListeners();
    }
  }

  void toggleTheme() {
    setThemeMode(
      _themeMode == ThemeMode.dark ? ThemeMode.light : ThemeMode.dark,
    );
  }
}

// lib/main.dart
class MyApp extends StatefulWidget {
  const MyApp({super.key});

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

class _MyAppState extends State<MyApp> {
  final _themeController = ThemeController();

  @override
  void dispose() {
    _themeController.dispose();
    super.dispose();
  }

  @override
  Widget build(BuildContext context) {
    return ListenableBuilder(
      listenable: _themeController,
      builder: (context, child) {
        return MaterialApp(
          theme: AppTheme.lightTheme,
          darkTheme: AppTheme.darkTheme,
          themeMode: _themeController.themeMode,
          home: HomeScreen(themeController: _themeController),
        );
      },
    );
  }
}

Theme Toggle UI

class ThemeSettingsSection extends StatelessWidget {
  final ThemeController themeController;

  const ThemeSettingsSection({required this.themeController, super.key});

  @override
  Widget build(BuildContext context) {
    return Column(
      crossAxisAlignment: CrossAxisAlignment.start,
      children: [
        Text(
          'Appearance',
          style: Theme.of(context).textTheme.titleMedium,
        ),
        const SizedBox(height: 8),

        // Segmented button for theme selection
        SegmentedButton<ThemeMode>(
          segments: const [
            ButtonSegment(
              value: ThemeMode.light,
              icon: Icon(Icons.light_mode),
              label: Text('Light'),
            ),
            ButtonSegment(
              value: ThemeMode.system,
              icon: Icon(Icons.settings_brightness),
              label: Text('System'),
            ),
            ButtonSegment(
              value: ThemeMode.dark,
              icon: Icon(Icons.dark_mode),
              label: Text('Dark'),
            ),
          ],
          selected: {themeController.themeMode},
          onSelectionChanged: (selection) {
            themeController.setThemeMode(selection.first);
          },
        ),
      ],
    );
  }
}

Persisting Theme Preference

Users expect their theme choice to be remembered across app restarts. Use SharedPreferences to persist the selection.

Persistent Theme Controller

import 'package:shared_preferences/shared_preferences.dart';

class PersistentThemeController extends ChangeNotifier {
  static const _key = 'theme_mode';
  final SharedPreferences _prefs;
  ThemeMode _themeMode;

  PersistentThemeController(this._prefs)
    : _themeMode = _themeModeFromString(
        _prefs.getString(_key) ?? 'system',
      );

  ThemeMode get themeMode => _themeMode;

  Future<void> setThemeMode(ThemeMode mode) async {
    if (_themeMode != mode) {
      _themeMode = mode;
      await _prefs.setString(_key, _themeModeToString(mode));
      notifyListeners();
    }
  }

  static ThemeMode _themeModeFromString(String value) {
    return switch (value) {
      'light' => ThemeMode.light,
      'dark' => ThemeMode.dark,
      _ => ThemeMode.system,
    };
  }

  static String _themeModeToString(ThemeMode mode) {
    return switch (mode) {
      ThemeMode.light => 'light',
      ThemeMode.dark => 'dark',
      ThemeMode.system => 'system',
    };
  }
}

// Initialize in main()
void main() async {
  WidgetsFlutterBinding.ensureInitialized();
  final prefs = await SharedPreferences.getInstance();
  final themeController = PersistentThemeController(prefs);

  runApp(MyApp(themeController: themeController));
}

Adaptive Colors for Both Modes

Sometimes you need colors that aren’t part of the Material color scheme but still need to adapt to light/dark mode. Use theme extensions for this:

Adaptive Custom Colors

class AdaptiveColors extends ThemeExtension<AdaptiveColors> {
  final Color? codeBackground;
  final Color? codeText;
  final Color? linkColor;
  final Color? highlightColor;
  final Color? shadowColor;
  final Color? shimmerBase;
  final Color? shimmerHighlight;

  const AdaptiveColors({
    this.codeBackground,
    this.codeText,
    this.linkColor,
    this.highlightColor,
    this.shadowColor,
    this.shimmerBase,
    this.shimmerHighlight,
  });

  // Light mode colors
  static const light = AdaptiveColors(
    codeBackground: Color(0xFFF5F5F5),
    codeText: Color(0xFF37474F),
    linkColor: Color(0xFF1565C0),
    highlightColor: Color(0xFFFFF9C4),
    shadowColor: Color(0x1A000000),
    shimmerBase: Color(0xFFE0E0E0),
    shimmerHighlight: Color(0xFFF5F5F5),
  );

  // Dark mode colors
  static const dark = AdaptiveColors(
    codeBackground: Color(0xFF1E1E2E),
    codeText: Color(0xFFCDD6F4),
    linkColor: Color(0xFF82B1FF),
    highlightColor: Color(0xFF3E2723),
    shadowColor: Color(0x40000000),
    shimmerBase: Color(0xFF303030),
    shimmerHighlight: Color(0xFF424242),
  );

  @override
  AdaptiveColors copyWith({
    Color? codeBackground,
    Color? codeText,
    Color? linkColor,
    Color? highlightColor,
    Color? shadowColor,
    Color? shimmerBase,
    Color? shimmerHighlight,
  }) {
    return AdaptiveColors(
      codeBackground: codeBackground ?? this.codeBackground,
      codeText: codeText ?? this.codeText,
      linkColor: linkColor ?? this.linkColor,
      highlightColor: highlightColor ?? this.highlightColor,
      shadowColor: shadowColor ?? this.shadowColor,
      shimmerBase: shimmerBase ?? this.shimmerBase,
      shimmerHighlight: shimmerHighlight ?? this.shimmerHighlight,
    );
  }

  @override
  AdaptiveColors lerp(AdaptiveColors? other, double t) {
    if (other is! AdaptiveColors) return this;
    return AdaptiveColors(
      codeBackground: Color.lerp(codeBackground, other.codeBackground, t),
      codeText: Color.lerp(codeText, other.codeText, t),
      linkColor: Color.lerp(linkColor, other.linkColor, t),
      highlightColor: Color.lerp(highlightColor, other.highlightColor, t),
      shadowColor: Color.lerp(shadowColor, other.shadowColor, t),
      shimmerBase: Color.lerp(shimmerBase, other.shimmerBase, t),
      shimmerHighlight: Color.lerp(shimmerHighlight, other.shimmerHighlight, t),
    );
  }
}

// Register in themes
static ThemeData get lightTheme => ThemeData(
  extensions: const [AdaptiveColors.light],
  // ...
);

static ThemeData get darkTheme => ThemeData(
  extensions: const [AdaptiveColors.dark],
  // ...
);

// Usage
Widget build(BuildContext context) {
  final adaptive = Theme.of(context).extension<AdaptiveColors>()!;

  return Container(
    color: adaptive.codeBackground,
    child: Text(
      'final x = 42;',
      style: TextStyle(
        color: adaptive.codeText,
        fontFamily: 'monospace',
      ),
    ),
  );
}

Animated Theme Transitions

By default, Flutter animates theme changes. You can customize the animation duration and even use AnimatedTheme for more control:

Custom Theme Animation

// MaterialApp automatically animates between themes
// Customize the duration:
MaterialApp(
  theme: AppTheme.lightTheme,
  darkTheme: AppTheme.darkTheme,
  themeMode: currentMode,
  themeAnimationDuration: const Duration(milliseconds: 400),
  themeAnimationCurve: Curves.easeInOut,
)

// For manual animation within a subtree:
class AnimatedThemeExample extends StatelessWidget {
  final bool isDark;
  const AnimatedThemeExample({required this.isDark, super.key});

  @override
  Widget build(BuildContext context) {
    return AnimatedTheme(
      data: isDark ? AppTheme.darkTheme : AppTheme.lightTheme,
      duration: const Duration(milliseconds: 500),
      curve: Curves.easeInOutCubic,
      child: Builder(
        builder: (context) {
          final theme = Theme.of(context);
          return Container(
            color: theme.colorScheme.surface,
            child: Text(
              'This animates smoothly!',
              style: TextStyle(color: theme.colorScheme.onSurface),
            ),
          );
        },
      ),
    );
  }
}

Practical Example: Complete System-Aware App

Let’s build a complete example that ties everything together -- system detection, manual toggle, persistent preference, and adaptive colors:

Complete Dark Mode Implementation

// lib/theme/app_theme.dart
import 'package:flutter/material.dart';

class AppTheme {
  static const _seed = Color(0xFF6750A4);

  static ThemeData get light => _build(
    ColorScheme.fromSeed(seedColor: _seed, brightness: Brightness.light),
    AdaptiveColors.light,
  );

  static ThemeData get dark => _build(
    ColorScheme.fromSeed(seedColor: _seed, brightness: Brightness.dark),
    AdaptiveColors.dark,
  );

  static ThemeData _build(ColorScheme scheme, AdaptiveColors adaptive) {
    return ThemeData(
      useMaterial3: true,
      colorScheme: scheme,
      scaffoldBackgroundColor: scheme.surface,
      appBarTheme: AppBarTheme(
        backgroundColor: scheme.surface,
        foregroundColor: scheme.onSurface,
        elevation: 0,
      ),
      cardTheme: CardTheme(
        elevation: scheme.brightness == Brightness.dark ? 0 : 1,
        color: scheme.brightness == Brightness.dark
          ? scheme.surfaceContainerHigh
          : scheme.surface,
        shape: RoundedRectangleBorder(
          borderRadius: BorderRadius.circular(12),
          side: scheme.brightness == Brightness.dark
            ? BorderSide(color: scheme.outlineVariant.withOpacity(0.3))
            : BorderSide.none,
        ),
      ),
      extensions: [adaptive],
    );
  }
}

// lib/main.dart
import 'package:shared_preferences/shared_preferences.dart';

void main() async {
  WidgetsFlutterBinding.ensureInitialized();
  final prefs = await SharedPreferences.getInstance();
  final themeCtrl = PersistentThemeController(prefs);
  runApp(MyApp(themeController: themeCtrl));
}

class MyApp extends StatelessWidget {
  final PersistentThemeController themeController;
  const MyApp({required this.themeController, super.key});

  @override
  Widget build(BuildContext context) {
    return ListenableBuilder(
      listenable: themeController,
      builder: (context, _) {
        return MaterialApp(
          theme: AppTheme.light,
          darkTheme: AppTheme.dark,
          themeMode: themeController.themeMode,
          themeAnimationDuration: const Duration(milliseconds: 300),
          home: SettingsScreen(themeController: themeController),
        );
      },
    );
  }
}

// lib/screens/settings_screen.dart
class SettingsScreen extends StatelessWidget {
  final PersistentThemeController themeController;
  const SettingsScreen({required this.themeController, super.key});

  @override
  Widget build(BuildContext context) {
    final theme = Theme.of(context);
    final adaptive = theme.extension<AdaptiveColors>()!;

    return Scaffold(
      appBar: AppBar(title: const Text('Settings')),
      body: ListView(
        padding: const EdgeInsets.all(16),
        children: [
          // Theme mode selector
          Card(
            child: Padding(
              padding: const EdgeInsets.all(16),
              child: Column(
                crossAxisAlignment: CrossAxisAlignment.start,
                children: [
                  Text('Theme', style: theme.textTheme.titleMedium),
                  const SizedBox(height: 12),
                  SegmentedButton<ThemeMode>(
                    segments: const [
                      ButtonSegment(
                        value: ThemeMode.light,
                        icon: Icon(Icons.light_mode),
                        label: Text('Light'),
                      ),
                      ButtonSegment(
                        value: ThemeMode.system,
                        icon: Icon(Icons.auto_mode),
                        label: Text('Auto'),
                      ),
                      ButtonSegment(
                        value: ThemeMode.dark,
                        icon: Icon(Icons.dark_mode),
                        label: Text('Dark'),
                      ),
                    ],
                    selected: {themeController.themeMode},
                    onSelectionChanged: (s) =>
                      themeController.setThemeMode(s.first),
                  ),
                ],
              ),
            ),
          ),
          const SizedBox(height: 16),

          // Preview card showing adaptive colors
          Card(
            child: Padding(
              padding: const EdgeInsets.all(16),
              child: Column(
                crossAxisAlignment: CrossAxisAlignment.start,
                children: [
                  Text('Preview', style: theme.textTheme.titleMedium),
                  const SizedBox(height: 12),
                  Container(
                    padding: const EdgeInsets.all(12),
                    decoration: BoxDecoration(
                      color: adaptive.codeBackground,
                      borderRadius: BorderRadius.circular(8),
                    ),
                    child: Text(
                      'void main() => runApp(MyApp());',
                      style: TextStyle(
                        color: adaptive.codeText,
                        fontFamily: 'monospace',
                        fontSize: 14,
                      ),
                    ),
                  ),
                ],
              ),
            ),
          ),
        ],
      ),
    );
  }
}
Common Pitfall: Never use hardcoded colors like Colors.white or Colors.black for backgrounds and text. Always use colorScheme.surface, colorScheme.onSurface, etc. Hardcoded colors will look wrong in the opposite theme mode -- white text on a white background in light mode, or dark icons invisible on dark backgrounds.

Custom Color Palettes Per Mode

For apps that need significantly different color palettes in each mode (not just light/dark variants of the same seed), you can define completely separate color schemes:

Distinct Palettes Per Mode

class AppTheme {
  // Light mode: Warm blue-grey palette
  static final _lightScheme = ColorScheme.fromSeed(
    seedColor: const Color(0xFF546E7A),
    brightness: Brightness.light,
  ).copyWith(
    primary: const Color(0xFF37474F),
    secondary: const Color(0xFFFF8A65),
    tertiary: const Color(0xFF66BB6A),
  );

  // Dark mode: Deep purple-blue palette (different mood)
  static final _darkScheme = ColorScheme.fromSeed(
    seedColor: const Color(0xFF7C4DFF),
    brightness: Brightness.dark,
  ).copyWith(
    primary: const Color(0xFFB388FF),
    secondary: const Color(0xFFFF8A80),
    tertiary: const Color(0xFF69F0AE),
  );

  static ThemeData get lightTheme => ThemeData(
    useMaterial3: true,
    colorScheme: _lightScheme,
    extensions: const [AdaptiveColors.light],
  );

  static ThemeData get darkTheme => ThemeData(
    useMaterial3: true,
    colorScheme: _darkScheme,
    extensions: const [AdaptiveColors.dark],
  );
}

Summary

Key Takeaways:
  • ThemeMode.system follows the OS setting, .light forces light, .dark forces dark.
  • Always define both theme and darkTheme in MaterialApp for proper system-aware behavior.
  • Use MediaQuery.platformBrightnessOf(context) to detect system brightness directly.
  • Implement a ThemeController with ChangeNotifier for runtime theme switching.
  • Persist theme preference with SharedPreferences so users don’t have to set it every launch.
  • Use ThemeExtension for adaptive custom colors that differ between light and dark modes.
  • In dark mode, express elevation through lighter surface tones, not shadows.
  • Never hardcode Colors.white/black -- always use colorScheme semantic colors.
  • Flutter automatically animates theme transitions; customize with themeAnimationDuration.