← Back to posts Cover image for Mastering Theming in Flutter: From Basic Dark Mode to Advanced Dynamic Themes

Mastering Theming in Flutter: From Basic Dark Mode to Advanced Dynamic Themes

· 5 min read
Weekly Digest

The Flutter news you actually need

No spam, ever. Unsubscribe in one click.

Chris
By Chris

Stop Rewriting Theme Logic: A Flutter Developer’s Guide

If you’ve built more than one Flutter app, you’ve likely felt the déjà vu. You need a theme system—light mode, dark mode, maybe system sync—and you find yourself writing the same boilerplate again: setting up a state management solution for the current ThemeMode, persisting the user’s choice with SharedPreferences, and ensuring it loads correctly on app start. It’s tedious, repetitive, and error-prone.

Let’s break this cycle. This guide walks through building a robust, reusable theme management system that scales from basic dark mode to advanced dynamic theming.

The Core Problem: Scattered Logic

The issue isn’t complexity; it’s fragmentation. Your theme logic often ends up split between:

  1. A ChangeNotifier or Bloc to hold the current ThemeMode.
  2. SharedPreferences calls scattered in initState and setter methods.
  3. Manual MaterialApp configuration.

This pattern is hard to maintain and even harder to reuse. Let’s consolidate it.

Step 1: Building a Centralized Theme Manager

We’ll create a single, dedicated class using ChangeNotifier (for simplicity, but this pattern works with any state management tool). This class will be our single source of truth.

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

class AppThemeManager extends ChangeNotifier {
  ThemeMode _currentThemeMode = ThemeMode.system;
  static const String _prefsKey = 'selected_theme';

  ThemeMode get currentThemeMode => _currentThemeMode;

  AppThemeManager() {
    _loadSavedTheme();
  }

  Future<void> _loadSavedTheme() async {
    final prefs = await SharedPreferences.getInstance();
    final savedIndex = prefs.getInt(_prefsKey);
    if (savedIndex != null) {
      _currentThemeMode = ThemeMode.values[savedIndex];
      notifyListeners();
    }
  }

  Future<void> updateThemeMode(ThemeMode newMode) async {
    if (_currentThemeMode == newMode) return;
    _currentThemeMode = newMode;
    notifyListeners();

    // Persist the choice
    final prefs = await SharedPreferences.getInstance();
    await prefs.setInt(_prefsKey, newMode.index);
  }

  void cycleTheme() {
    final List<ThemeMode> cycle = [
      ThemeMode.light,
      ThemeMode.dark,
      ThemeMode.system,
    ];
    final currentIndex = cycle.indexOf(_currentThemeMode);
    final nextIndex = (currentIndex + 1) % cycle.length;
    updateThemeMode(cycle[nextIndex]);
  }
}

Key Points:

  • Encapsulation: Loading, saving, and updating logic lives in one place.
  • Initialization: The constructor calls _loadSavedTheme, so the saved preference loads automatically.
  • Persistence: updateThemeMode immediately saves the user’s choice.

Step 2: Integrating with Your App

Provide this manager high up in your widget tree, typically above MaterialApp.

import 'package:provider/provider.dart';

void main() async {
  WidgetsFlutterBinding.ensureInitialized();
  final themeManager = AppThemeManager();
  runApp(MyApp(themeManager: themeManager));
}

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

  @override
  Widget build(BuildContext context) {
    return ChangeNotifierProvider<AppThemeManager>.value(
      value: themeManager,
      child: Consumer<AppThemeManager>(
        builder: (context, manager, child) {
          return MaterialApp(
            title: 'Mastering Themes',
            // Your defined light/dark ThemeData
            theme: ThemeData.light(useMaterial3: true),
            darkTheme: ThemeData.dark(useMaterial3: true),
            themeMode: manager.currentThemeMode, // The magic link
            home: const HomeScreen(),
          );
        },
      ),
    );
  }
}

In your UI, switching themes becomes trivial:

FloatingActionButton(
  onPressed: () => context.read<AppThemeManager>().cycleTheme(),
  child: const Icon(Icons.brightness_medium),
)

Step 3: Going Beyond Light & Dark (Advanced Dynamic Theming)

What if your app needs more than just light and dark? Think of custom color schemes (e.g., “Sunset”, “Ocean”, “Forest”). This is where we evolve our manager.

First, define a custom theme class:

class AppTheme {
  final String name;
  final Color primaryColor;
  final Color secondaryColor;
  final Color surfaceColor;

  const AppTheme({
    required this.name,
    required this.primaryColor,
    required this.secondaryColor,
    required this.surfaceColor,
  });

  ThemeData toThemeData({required Brightness brightness}) {
    final isDark = brightness == Brightness.dark;
    return ThemeData(
      useMaterial3: true,
      brightness: brightness,
      colorScheme: ColorScheme.fromSeed(
        seedColor: primaryColor,
        brightness: brightness,
      ),
    );
  }
}

Next, extend the AppThemeManager to handle these custom themes while still supporting the classic ThemeMode.light/dark/system fallback.

class AdvancedThemeManager extends ChangeNotifier {
  ThemeMode _baseThemeMode = ThemeMode.system;
  AppTheme? _selectedCustomTheme;
  // ... persistence logic for both values ...

  ThemeData getCurrentThemeData(BuildContext context) {
    final platformBrightness = MediaQuery.platformBrightnessOf(context);
    final effectiveBrightness = _baseThemeMode == ThemeMode.system
        ? platformBrightness
        : (_baseThemeMode == ThemeMode.dark ? Brightness.dark : Brightness.light);

    // If a custom theme is selected, use it.
    if (_selectedCustomTheme != null) {
      return _selectedCustomTheme!.toThemeData(brightness: effectiveBrightness);
    }
    // Otherwise, fall back to Material default light/dark.
    return effectiveBrightness == Brightness.dark
        ? ThemeData.dark(useMaterial3: true)
        : ThemeData.light(useMaterial3: true);
  }
}

In your MaterialApp, replace the theme and darkTheme parameters with a single theme builder:

themeMode: manager.baseThemeMode, // Still controls light/dark/system
theme: (context) => manager.getCurrentThemeData(context),

Common Mistakes to Avoid

  1. Not Awaiting SharedPreferences: Always await the getInstance() and setInt() calls. Use async/await properly to avoid race conditions on app start.
  2. Forgetting notifyListeners(): If your UI isn’t updating, this is the first thing to check.
  3. Overcomplicating Initial State: Ensure your theme manager loads the saved preference before MaterialApp builds. Our constructor approach solves this.
  4. Hard-Coding Theme Values: Avoid defining color hex codes directly in UI widgets. Always reference Theme.of(context).colorScheme.primary or your custom theme properties.

Conclusion

By building a dedicated theme manager, you encapsulate all the messy logic—state, persistence, system integration—into a single, testable, and reusable class. The basic version handles 90% of use cases, while the advanced pattern opens the door to fully dynamic branding. Copy this pattern into your next project, and you’ll never write the same theme-switching logic again.

This blog is produced with the assistance of AI by a human editor. Learn more

Related Posts

Cover image for Flutter App Size Optimization: Strategies to Shrink Your APK/IPA Below 10MB

Flutter App Size Optimization: Strategies to Shrink Your APK/IPA Below 10MB

Flutter apps are often criticized for their larger-than-native binary sizes. This post will dive into practical, actionable strategies to significantly reduce your Flutter app's footprint, including build configurations, asset optimization, and dependency management, demonstrating how to achieve app sizes under 10MB.

Cover image for Mastering In-App Subscriptions in Flutter: A Guide to Avoiding Common Pitfalls

Mastering In-App Subscriptions in Flutter: A Guide to Avoiding Common Pitfalls

Implementing in-app subscriptions in Flutter can be surprisingly complex, especially when dealing with cross-platform nuances, receipt validation, and managing different store configurations. This post will delve into the common challenges developers face, compare solutions like RevenueCat with custom implementations, and provide best practices for setting up and maintaining a robust subscription system.

Cover image for Taming Android Emulator RAM: A Developer's Guide to Reducing Memory Usage for Flutter

Taming Android Emulator RAM: A Developer's Guide to Reducing Memory Usage for Flutter

Android emulators are notorious for hogging RAM, often pushing developer machines into swap and slowing down Flutter development. This post will explore the reasons behind high emulator memory consumption and provide practical, actionable strategies and tools (including a custom-built solution) to significantly reduce RAM usage, ensuring a smoother development experience without sacrificing functionality.