Chapters

Hide chapters

Real-World Flutter by Tutorials

First Edition · Flutter 3.3 · Dart 2.18 · Android Studio or VS Code

Real-World Flutter by Tutorials

Section 1: 16 chapters
Show chapters Hide chapters

10. Dynamic Theming & Dark Mode
Written by Vid Palčar

Heads up... You're reading this book for free, with parts of this chapter shown beyond this point as scrambled text.

When thinking about mobile apps — or even apps in general — dark mode might instantly cross your mind. It’s one of the most expected features for every new app developed. However, it’s hard to imagine supporting either dark or light mode without using some type of theming. For a beginner who’s just started the journey of developing mobile apps, it’s usually most intuitive to specify colors and styles on the fly when they’re needed. After gaining some experience developing apps, however, you’ll quickly realize that this approach is unsustainable and hard to maintain. Imagine switching a specific color in the app for a darker shade — you’d have to go through all the appearances of the color previously used and switch it to the new one. This is where theming saves the day!

Theming in Flutter apps allows you to share colors and styles throughout whole or specific parts of the app. You can set up the theme in your Flutter app in a few ways. You’ll look at different methods of setting up the theme in just a moment.

The way you’ll choose to create a theme in most cases relies heavily on the InheritedWidget class. Therefore, check the key concepts and theory behind InheritedWidget in the previous chapter, Chapter 9, “Internationalizing & Localizing”. In this chapter, you’ll:

  • Take a quick look at different approaches to setting up a theme in your Flutter app.
  • Use your knowledge on InheritedWidget to add dynamic theming to your app.
  • Learn about best practices and how to define colors and styles for the theme.
  • Learn how to implement dynamic theming based on user preferences.

Throughout this chapter, you’ll work on the starter project from this chapter’s assets folder.

It’s time to get started by looking at different ways to theme your app.

Ways to Theme Your App

As already mentioned, you have numerous ways to set up the theme for your project. For this chapter, you’ll only look at a few ways with a bit more focus on the one that’s the most appropriate for the WonderWords app.

The purpose of this chapter is to provide you with options so that when you’re facing different feature requirements and app architectures, you can choose the option that works the best for your case. Since there isn’t much sense in using multiple theming solutions for your app, this chapter is more theoretical. You’ll use only one theming solution for WonderWords and then look at examples of other options.

Basic App Theming

Look at the following code snippet:

@override
Widget build(BuildContext context) {
  return MaterialApp(
    title: 'Flutter Demo',
    theme: ThemeData(
      primarySwatch: Colors.blue,
    ),
    home: const MyHomePage(title: 'Flutter Demo Home Page'),
  );
}
theme: ThemeData(
  // 1
  primaryColor: Colors.blueGrey,
  // 2
  fontFamily: 'Georgia',
  // 3
  textTheme: const TextTheme(
    headline1: TextStyle(
      color: Colors.black,
      fontSize: 36.0,
      fontWeight: FontWeight.bold,
    ),
  ),
),
Text(
  'Title',
  style: Theme.of(context).textTheme.headline1,
),
theme: ThemeData(
  // definition of light theme
),
darkTheme: ThemeData(
 // definition of dark theme
),
themeMode: ThemeMode.light,

Using a Third-party Package

Thanks to the very strong developer community, quite a few excellent third-party solutions exist to handle theming for your Flutter app. The various packages might be more or less appropriate for your use case. One such package is adaptive_theme, which is fairly popular in the developer community. It represents a holistic theming solution for your app by covering all the important features connected to theming. In the following section, you’ll dive deeper into the usage of this package.

adaptive_theme: ^3.1.0
@override
Widget build(BuildContext context) {
  // 1
  return AdaptiveTheme(
    // 2
    light: ThemeData(
      // implementation of light theme
    ),
    dark: ThemeData(
      // implementation of light theme
    ),
    // 3
    initial: AdaptiveThemeMode.light,
    // 4
    builder: (theme, darkTheme) => MaterialApp(
      title: 'Flutter Demo',
      // 5
      theme: theme,
      darkTheme: darkTheme,
      home: const MyHomePage(title: 'Flutter Demo Home Page'),
    ),
  );
}
// 1
AdaptiveTheme.of(context).setDark();

// 2
AdaptiveTheme.of(context).setLight();

// 3
AdaptiveTheme.of(context).setSystem();
void main() async {
  WidgetsFlutterBinding.ensureInitialized();
  final themeMode = await AdaptiveTheme.getThemeMode();
  runApp(MyApp(themeMode: themeMode));
}
initial: themeMode ?? AdaptiveThemeMode.light,
AdaptiveTheme.of(context).setTheme(
  light: ThemeData(
    // new specification of light theme
  ),
  dark: ThemeData(
    // new specification of dark theme
  ),
);

Using Inherited Widget for Theming

Finally, it’s time to implement dynamic theming for your WonderWords app. As mentioned in the introduction, it will depend on InheritedWidget. As theming is an essential part of the app’s architecture, quite a few things are already prepared for you.

WonderTheme as InheritedWidget

You’ll start by opening wonder_theme.dart located in packages/component_library/lib/src/theme:

class WonderTheme extends InheritedWidget {
  const WonderTheme({
    required Widget child,
    required this.lightTheme,
    required this.darkTheme,
    Key? key,
  }) : super(
          key: key,
          child: child,
        );

  final WonderThemeData lightTheme;
  final WonderThemeData darkTheme;

  // TODO: replace with correct implementation of updateShouldNotify
  @override
  bool updateShouldNotify(WonderTheme oldWidget) {
    return false;
  }

  // TODO: replace with correct implementation of service locator function
  static WonderThemeData of(BuildContext context) {
    return LightWonderThemeData();
  }
}
@override
bool updateShouldNotify(WonderTheme oldWidget) =>
    oldWidget.lightTheme != lightTheme || oldWidget.darkTheme != darkTheme;
static WonderThemeData of(BuildContext context) {
  // 1
  final WonderTheme? inheritedTheme =
      context.dependOnInheritedWidgetOfExactType<WonderTheme>();
  // 2
  assert(inheritedTheme != null, 'No WonderTheme found in context');
  // 3
  final currentBrightness = Theme.of(context).brightness;
  // 4
  return currentBrightness == Brightness.dark
      ? inheritedTheme!.darkTheme
      : inheritedTheme!.lightTheme;
}
// TODO: remove changes after testing
@override
Widget build(BuildContext context) {
  return MaterialApp.router(
    theme: ThemeData(),
    darkTheme: ThemeData(),
    themeMode: ThemeMode.light,
    supportedLocales: const [
      Locale('en', ''),
      Locale('pt', 'BR'),
    ],
    localizationsDelegates: const [
      GlobalCupertinoLocalizations.delegate,
      GlobalMaterialLocalizations.delegate,
      AppLocalizations.delegate,
      ComponentLibraryLocalizations.delegate,
      ProfileMenuLocalizations.delegate,
      QuoteListLocalizations.delegate,
      SignInLocalizations.delegate,
      ForgotMyPasswordLocalizations.delegate,
      SignUpLocalizations.delegate,       UpdateProfileLocalizations.delegate,
    ],
    routerDelegate: _routerDelegate,
    routeInformationParser: const RoutemasterParser(),
  );
}

Defining Custom Theme Data

In wonder_theme_data.dart, under the themes folder, you’ll see an abstract class and its two implementations: LightWonderThemeData and DarkWonderThemeData. Quite a few things are already prepared for you. WonderThemeData has declarations of the theming elements, such as colors and fonts, but there are two different implementations of this class with different values assigned to those declarations. You’ll override these declarations in the implementation classes.

ThemeData get materialThemeData;
@override
ThemeData get materialThemeData => ThemeData(
  // 1
  brightness: Brightness.light,
  // 2
  primarySwatch: Colors.black.toMaterialColor(),
  // 3
  dividerTheme: _dividerThemeData,
);
@override
ThemeData get materialThemeData => ThemeData(
  // 1
  brightness: Brightness.dark,
  // 2
  primarySwatch: Colors.white.toMaterialColor(),
  // 3
  dividerTheme: _dividerThemeData,
  // 4
  toggleableActiveColor: Colors.white,
);

Setting up Colors

Similar to materialThemeData, other WonderThemeData attributes are also defined. One, for example, is colors. Notice the multiple Color getters in the WonderThemeData abstract class. You use these to declare all the various sets of colors you use in the app. Look at the example that’s already been prepared for you. The color is declared in the WonderThemeData abstract class as follows:

// 1
Color get roundedChoiceChipBackgroundColor;
// 2
@override
Color get roundedChoiceChipBackgroundColor => Colors.white;
// 3
@override
Color get roundedChoiceChipBackgroundColor => Colors.black;

Switching Themes

You’ve already learned about switching themes in both of the previously mentioned theming approaches. When doing it with the help of an inherited widget, it’s not much different.

// TODO: wrap with stream builder
// 1
return WonderTheme(
  lightTheme: _lightTheme,
  darkTheme: _darkTheme,
  // 2
  child: MaterialApp.router(
    theme: _lightTheme.materialThemeData,
    darkTheme: _darkTheme.materialThemeData,
    // TODO: change to dark mode
    themeMode: ThemeMode.light,
    supportedLocales: const [
      Locale('en', ''),
      Locale('pt', 'BR'),
    ],
    localizationsDelegates: const [
      GlobalCupertinoLocalizations.delegate,
      GlobalMaterialLocalizations.delegate,
      AppLocalizations.delegate,
      ComponentLibraryLocalizations.delegate,
      ProfileMenuLocalizations.delegate,
      QuoteListLocalizations.delegate,
      SignInLocalizations.delegate,
      ForgotMyPasswordLocalizations.delegate,
      SignUpLocalizations.delegate,
      UpdateProfileLocalizations.delegate,
    ],
    routerDelegate: _routerDelegate,
    routeInformationParser: const RoutemasterParser(),
  ),
);
// TODO: change for dynamic theme changing
themeMode: ThemeMode.dark,

Switching Themes With User Intervention

So far, you’ve manually switched themes by changing themeMode and rebuilding the app. That’s not ideal, as your users won’t rebuild the app once they install it. Instead, they want to select a light or dark theme according to their preference. Now, you’ll add this capability to WonderWords.

Different Theme Modes

Just as Flutter’s theme provides three different modes for theming, your app will also support three theme modes. They’re predefined for you as DarkModePreference enumeration in dark_mode_preference.dart located in the lib/src folder of the domain_models package:

Upserting and Retrieving Theme Mode

To set and use the currently set theme mode, you’ll use BehaviorSubject, which you learned about in Chapter 6, “Authenticating Users”. To refresh your memory on the topic, look back at that chapter.

final BehaviorSubject<DarkModePreference> _darkModePreferenceSubject =
      BehaviorSubject();
// 1
await _localStorage.upsertDarkModePreference(
  preference.toCacheModel(),
);
// 2
_darkModePreferenceSubject.add(preference);
Stream<DarkModePreference> getDarkModePreference() async* {
  // 1
  if (!_darkModePreferenceSubject.hasValue) {
    final storedPreference = await _localStorage.getDarkModePreference();
    _darkModePreferenceSubject.add(
        storedPreference?.toDomainModel() ??
          DarkModePreference.useSystemSettings,
    );
  }
  // 2
  yield* _darkModePreferenceSubject.stream;
}

Changing Theme Through UI

At this point, you can access the theme preference selection UI in the Profile Menu Screen by switching to the Profile tab from the home screen. There, you’ll notice a list of radio buttons specifying the three Dark Mode Preferences.

bloc.add(
  const ProfileMenuDarkModePreferenceChanged(
    DarkModePreference.alwaysDark,
  ),
);
bloc.add(
  const ProfileMenuDarkModePreferenceChanged(
    DarkModePreference.alwaysLight,
  ),
);
bloc.add(
  const ProfileMenuDarkModePreferenceChanged(
    DarkModePreference.useSystemSettings,
  ),
);

groupValue: currentValue,

// 1
return StreamBuilder<DarkModePreference>(
  stream: _userRepository.getDarkModePreference(),
  builder: (context, snapshot) {
    // 2
    final darkModePreference = snapshot.data;
    return WonderTheme(
      lightTheme: _lightTheme,
      darkTheme: _darkTheme,
      child: MaterialApp.router(
        theme: _lightTheme.materialThemeData,
        darkTheme: _darkTheme.materialThemeData,
        // 3
        themeMode: darkModePreference?.toThemeMode(),
        supportedLocales: const [
          Locale('en', ''),
          Locale('pt', 'BR'),
        ],
        localizationsDelegates: const [
          GlobalCupertinoLocalizations.delegate,
          GlobalMaterialLocalizations.delegate,
          AppLocalizations.delegate,
          ComponentLibraryLocalizations.delegate,
          ProfileMenuLocalizations.delegate,
          QuoteListLocalizations.delegate,
          SignInLocalizations.delegate,
          ForgotMyPasswordLocalizations.delegate,
          SignUpLocalizations.delegate,
          UpdateProfileLocalizations.delegate,
        ],
        routerDelegate: _routerDelegate,
        routeInformationParser: const RoutemasterParser(),
      ),
    );
  },
);

Key Points

  • Flutter offers a built-in solution for theming your app.
  • Many other third-party theming solutions might work well for your project.
  • You can implement a custom theme with the help of InheritedWidget.
  • Usually, you want to support three different theme modes: light, dark and system.
  • To hold the current theme mode preference, use BehaviorSubject. This provides you with a stream you can listen to for changes.
  • To provide a great user experience, save the current theme mode preference in the local storage with the help of the Hive package.

Where to Go From Here?

As already mentioned, it’s important to be aware that you always have multiple options to achieve a specific goal or functionality. As you gain more and more knowledge, and therefore, become more and more experienced in Flutter development, it’s important to consider this fact. Based on the requirements of your specific problem, you should use the solution that’ll work best for you. Therefore, this chapter offers you a few different options for dealing with theming in your Flutter app. In the case of WonderWords, theming with the help of InheritedWidget works best, as it offers the most adjustability. In some other scenario, this might be too complicated of a solution, and therefore, it would just waste your time to implement it.

Have a technical question? Want to report a bug? You can ask questions and report bugs to the book authors in our official book forum here.
© 2024 Kodeco Inc.

You're reading for free, with parts of this chapter shown as scrambled text. Unlock this book, and our entire catalogue of books and videos, with a Kodeco Personal Plan.

Unlock now