avatarGiovanni Accetta

Summary

The provided content is a comprehensive guide on implementing dynamic theming in a Flutter app using Material 3 design principles and the Provider library for state management.

Abstract

The article "A Complete Guide to a Scalable App in Flutter — Part 5— Theming and AppSettings — Dynamic settings and theming with Material 3 using Provider" delves into the significance of dynamic theming for user engagement and brand consistency in Flutter applications. It emphasizes the importance of allowing users to personalize themes and modes to enhance user experience and potentially increase user retention. The guide walks through the process of creating a dynamic theme using Flutter's Material 3 theming options, ColorScheme, and Typography, while also demonstrating how to use the Provider library to manage app settings. The author provides a detailed implementation of an AppTheme class, which centralizes theme management, and an AppSettingsProvider to handle user preferences for theme and color settings. The article also touches on the architecture for managing app settings, including contracts, interactors, and services, and ensures that the theming system is maintainable and scalable. The full implementation and tests are available on the author's GitHub repository.

Opinions

  • The author believes that maintaining a consistent brand identity through dynamic theming is crucial for user engagement.
  • They suggest that hyper-customization, including theme and mode adjustments, is a game-changer in app development.
  • The author advocates for a centralized approach to theme management using the AppTheme class to ensure consistency and ease of maintenance.
  • They emphasize the use of Material 3's ColorScheme and Typography to align with the latest design principles and ensure good contrast and readability.
  • The author values the importance of a robust state management system, such as Provider, in handling dynamic app settings.
  • They intentionally omit direct references to providers in the architecture to maintain its broad applicability beyond Flutter-specific patterns.
  • The author highlights the benefits of using enums like AppColor and AppThemeMode to abstract dependencies on Material ThemeMode and Color classes, allowing for future customization.
  • They stress the importance of comprehensive testing, including the use of mock objects for SharedPreferences, to achieve complete test coverage.
  • The author encourages reader support through clapping and feedback, acknowledging the effort put into creating the guide and motivating future content creation.
  • They disclose that parts of the article were formatted using ChatGPT, indicating a collaborative effort between human expertise and AI assistance.

A Complete Guide to a Scalable App in Flutter — Part 5— Theming and AppSettings — Dynamic settings and theming with Material 3 using Provider

Hello, dear readers! If you’ve been following my guide on building scalable Flutter apps, you’ve now the basic stepstone to build any app. I finally found the time of writing part 5 which is one of my favorite: dynamic theming.

In today’s dynamic app landscape, maintaining a consistent brand identity isn’t just important; it’s the key to user engagement. With the surge in hyper-customization, allowing users to fine-tune themes and modes becomes a game-changer, enhancing the overall app experience and potentially skyrocketing user retention. 🎨✨ Picture this: your app, fully embracing dynamic theming, effortlessly adapting to the user’s preference. Contrast that with an app that clings to a single theme — imagine a user, deeply immersed in dark mode, only to be abruptly yanked into a glaring light mode when using an app that doesn’t support dynamic theming. It’s these subtle nuances that can make or break user experience. 🌙☀️

By the end of this article, our template app will have the ability to offer a customized experience to our user that fits their needs and their taste and obtain something that will look like this:

Recap of the guide so far:

  • Part 1 laid the foundation with an introduction to scalable app development in Flutter, focusing on Clean Architecture principles.
  • Part 2 delved into the Data Layer, where we learned about Repositories and harnessed the power of Service Locator using getIt.
  • Part 3 guided us through the UI Layer, where we used provider built the UI state management architecture embracing the MVVM pattern and understood the significance of contracts in defining interactions between layers.
  • Part 4 took us on a journey through complex navigation scenarios and responsive layouts, employing tools like flutter_adaptive_scaffold and go_router.

What to Expect in Part 5:

In this installment, we explore how dynamic theming can breathe life into your app, adapting its visual identity dynamically. Flutter, being a UI toolkit with a focus on expressive and flexible designs, allows us to create stunning and personalized user experiences.

Material 3, the evolution of Material Design, brings fresh aesthetics and new design principles to Flutter. Coupled with the powerful provider library.

It is important when writing Flutter widgets to keep things clean and tidy in order to benefit of the full capabilities offered by the flutter theming options. Unfortunately, this is something often neglected in the early stages of an app that can greatly increase the complexity of handling theme changes in the future.

Photo by Steve Johnson on Unsplash

Flutter ThemeData in Flutter 3.16 use Material 3 by default

With the release of Flutter 3.16 in November, Material 3 has become the default theming strategy in Flutter. Prior to this, developers had to opt-in manually when creating their theme. Flutter ThemeData offers a centralized way to theme all Material widgets in the app with the guarantee of maintaining good contrasts for accessibility and readability. When creating UI widgets, it's essential to avoid overriding theme options that most widgets offer to ensure the effectiveness of ThemeData.

For instance, using a Text widget and defining a custom color or textStyle can override the theme, making it challenging to maintain consistency across the app or ensure correct readability when transitioning between light and dark themes.

ThemeMode (system, light or dark)

Flutter Material App is able to accept 3 parameters: a ThemeData for the default light theme, one for the dark theme, and the theme mode that app should be using:

MaterialApp.router(
  title: 'GBAccetta Portfolio',
  theme: lightTheme,
  darkTheme: darkTheme,
  themeMode: ThemeMode.system,
  routerConfig: AppRouter.simpleRouter,
);

ColorScheme (Material color palette)

Material 3 uses ColorScheme. Flutter provides a way to generate a complete ColorScheme adapted to the light or dark theme by providing a color seed (although note that the color seed is not guaranteed to be part of the resulting color palette). This ColorScheme is optimized to ensure readability and good contrast across the entire app. If you need to override some colors to align with a brand identity, ensure that the color maintains enough contrast. The Material Design documentation is an excellent resource for understanding color roles: Material Design Color Roles.

In Flutter, you can generate a complete palette like this:

In Flutter we can generate a complete palette such as this one by simply calling ColorScheme.fromSeed. Any color is also overridable from this constructor:

  ColorScheme _colorScheme(Brightness brightness) => ColorScheme.fromSeed(
        seedColor: seedColor,
        brightness: brightness,
      );

Minor differences exist with the full Material palette. For instance, the Surface Container colors suggested for navigation containers do not exist in colorScheme. However, there is a SurfaceVariant color suitable for app bars and navigation bars.

Typography and TextTheme

Typography is another comprehensive part of Material theming that, when used correctly, ensures a beautifully organized UI. Material documentation on typography is an excellent resource: Material Design Typography.

In Flutter, I found out that the best approach to Material 3 theming, at least for me, is to use Typography to define the style and target platform, and TextTheme to override the fontWeight and size. To use Material 3 defaults, you can use theTypography.material2021 constructor as this as not been made the default constructor yet as of Flutter 3.16. In our example, it will look like this:

In our example we will have somthing like this. I will be using all default textTheme weight and sizes except for the titleLarge used in the appBar to which I’m assigning an higher weight:

ThemeData(
    ...
    typography: Typography.material2021(
      colorScheme: colorScheme,
      platform: TargetPlatform.iOS,
    ),
    textTheme: const TextTheme(
      titleLarge: TextStyle(fontWeight: FontWeight.w500),
    ),
    ...
);

Implementation: Our AppTheme class

We are set to implement themeData in our sample app. As previously mentioned, our aim is to minimize the usage of constructor parameters in Material widgets, avoiding overrides that could disrupt our theme data. This approach ensures a centralized location for modifying the application's aesthetics in a uniform and consistent manner.

For example for the AppBar, in our views, we will simply use this :

      appBar: AppBar(
        title: Text('GBAccetta Portfolio', style: textTheme.titleLarge),
      ),

While all the styling will go in the AppBarTheme property of the AppTheme:

      appBarTheme: AppBarTheme(
        scrolledUnderElevation: 0,
        color: colorScheme.surfaceVariant,
        shape: const RoundedRectangleBorder(
          borderRadius: BorderRadius.only(
            bottomRight: Radius.elliptical(120, 50),
          ),
        ),
      ),

The provided code will be applied to all appBars within the application, ensuring a consistent appearance with a rounded bottom-right corner. We will also remove the elevation when content scrolls beneath them, using scrolledUnderElevation: 0. We’ll employ a similar approach for all Material widgets in our sample app, including but not limited to:

  • Text Widgets
  • AppBar Widgets
  • Card Widgets
  • Chip Widgets
  • Segmented Button Widgets
  • NavigationRail Widgets
  • BottomNavigationBar Widgets
  • ProgressIndicator Widgets

Here is the complete AppTheme class I created. It’s essential to observe that we exclusively utilize colors derived from the colorScheme and never directly apply a color within this class. This practice guarantees a consistent visual experience and alignment with the overall Brightness of the app. As outlined in the comments, when employing colorScheme.surfaceVariant as a background, we consciously select colorScheme.onSurfaceVariant or colorScheme.inverseSurfaceVariant for text and icon rendering. This meticulous approach follows Material 3 guidelines and ensures optimal contrast, contributing to an aesthetically pleasing and accessible user interface:

import 'package:flutter/material.dart';

/// The [AppTheme] class is responsible for defining the app's theme based on a
/// [seedColor] provided during app startup. The [seedColor] is used to initialize
/// the app color scheme dynamically for both light and dark themes when calling
/// the [getTheme] method with [Brightness.light] or [Brightness.dark].
class AppTheme {
  final MaterialColor seedColor;

  /// Initializes the [AppTheme] with an optional [seedColor], defaulting to
  /// [Colors.green].
  AppTheme({this.seedColor = Colors.green});

  /// Generates a [ColorScheme] based on the specified [brightness].
  ColorScheme _colorScheme(Brightness brightness) => ColorScheme.fromSeed(
        seedColor: seedColor,
        brightness: brightness,
      );

  /// Returns a [ThemeData] instance tailored to the chosen [brightness].
  ThemeData getTheme(Brightness brightness) {
    final colorScheme = _colorScheme(brightness);
    return ThemeData(
      brightness: brightness,
      colorScheme: colorScheme,
      // Use Material 3 typography, not yet the default in Flutter 3.16
      typography: Typography.material2021(
        colorScheme: colorScheme,
        platform: TargetPlatform.iOS,
      ),
      textTheme: const TextTheme(
        titleLarge: TextStyle(fontWeight: FontWeight.w500),
      ),
      appBarTheme: AppBarTheme(
        // Remove colored overlay when content scroll below the app bar
        scrolledUnderElevation: 0,
        // We will use surfaceVariant as background for all navigation component
        color: colorScheme.surfaceVariant,
        // Adds a bottomRight rounded corner to the appBar
        shape: const RoundedRectangleBorder(
          borderRadius: BorderRadius.only(
            bottomRight: Radius.elliptical(120, 50),
          ),
        ),
      ),
      cardTheme: const CardTheme(
        // Provides a nice customized shape for all cards in the app
        shape: RoundedRectangleBorder(
          borderRadius: BorderRadius.only(
            topLeft: Radius.elliptical(20, 32),
            bottomLeft: Radius.elliptical(20, 120),
            topRight: Radius.elliptical(20, 32),
            bottomRight: Radius.elliptical(20, 120),
          ),
        ),
      ),
      chipTheme: ChipThemeData(
        // Colors the chips and makes them more prominent
        backgroundColor: colorScheme.primaryContainer,
      ),
      progressIndicatorTheme: ProgressIndicatorThemeData(
        color: colorScheme.secondary,
      ),
      bottomNavigationBarTheme: BottomNavigationBarThemeData(
        // Assigns [surfaceVariant] as the background color
        backgroundColor: colorScheme.surfaceVariant,
        // Assigns [onSurfaceVariant] to the unselectedItemColor
        unselectedItemColor: colorScheme.onSurfaceVariant,
        // Assigns [inverseSurface] to the selectedIcon for more contrast
        selectedIconTheme: IconThemeData(color: colorScheme.inverseSurface),
      ),
      navigationRailTheme: NavigationRailThemeData(
        // Uses [surfaceVariant] as the background color
        backgroundColor: colorScheme.surfaceVariant,
        // Uses [onSurfaceVariant] for the unselected icon and indicator
        indicatorColor: colorScheme.onSurfaceVariant,
        unselectedIconTheme: IconThemeData(color: colorScheme.onSurfaceVariant),
        // Uses [surfaceVariant] for the selected icon as this will be inside
        // the indicator and will give a nice hole in the indicator effect
        selectedIconTheme: IconThemeData(color: colorScheme.surfaceVariant),
      ),
    );
  }
}

Let’s make the app theme dynamic

Now, let’s integrate our freshly crafted AppTheme into our application.

We want to provide users with the flexibility to choose their preferred theme on a dedicated settings page.

The AppSettingsProvider

To achieve this, we’ll turn to the reliable provider package once more. Introducing the AppSettingsProvider — a central hub for all app configurations, including the ThemeSettings property. Below is a glimpse of how this class could be structured.

While outlining the comprehensive architecture in part 1, I intentionally omitted any references to providers for two distinct reasons. Firstly, to streamline the scheme and minimize its complexity. Secondly, to maintain a generic architecture that is not exclusively bound to Flutter or the provider state management pattern. Nonetheless, in a simplified context, it’s apt to conceptualize provider as a view model for the entire application. In certain exceptional scenarios, the provider itself might need direct access to the data layer through an interactor. In this specific instance, for example, as we utilize the provider to trigger a complete app rebuild before any view or viewModel is rendered, we find it necessary to introduce a dependency on a SettingsInteractor to fetch current settings during app startup. Here is the code :

class AppSettingsProvider extends ChangeNotifier {
  late ThemeSettings themeSettings;

  AppSettingsProvider() {
    // Typically, following the architecture outlined in Part 1, interactors are exclusively
    // utilized by the view model. While providers may resemble a view model in certain
    // scenarios, they don't have an associated view, given that their consumer spans
    // the entire app. I opted not to include them in the architecture diagram to maintain
    // its general validity across different contexts, as the diagram aims for broader
    // applicability beyond Flutter and provider-specific concepts.
    final settingsInteractor = getIt<SettingsUseCases>();
    // To streamline the exposure of app settings in the provider, we encapsulate
    // theme-related settings within a dedicated property called themeSetting.
    // This approach enhances the usability of selectors for optimizing UI updates
    // based on changes in settings, ensuring more performance-effective rebuilds.
    themeSettings = ThemeSettings(
      appColor: settingsInteractor.currentAppColor,
      appThemeMode: settingsInteractor.currentAppThemeMode,
    );
  }

  void switchColor(AppColor appColor) {
    if (themeSettings.appColor == appColor) return;
    // copying settings to a new themeSettings object ensure provider selector
    // to rebuild when themeSettings changes without having to specify a logic
    // for the rebuild.
    themeSettings = themeSettings.copyWith(appColor: appColor);
    notifyListeners();
  }

  void switchMode(AppThemeMode appThemeMode) {
    if (themeSettings.appThemeMode == appThemeMode) return;
    // copying settings to a new themeSettings object ensure provider selector
    // to rebuild when themeSettings changes without having to specify a logic
    // for the rebuild.
    themeSettings = themeSettings.copyWith(appThemeMode: appThemeMode);
    notifyListeners();
  }
}

class ThemeSettings {
  final AppThemeMode appThemeMode;
  final AppColor appColor;

  ThemeSettings({required this.appThemeMode, required this.appColor});

  ThemeSettings copyWith({AppThemeMode? appThemeMode, AppColor? appColor}) {
    return ThemeSettings(
      appColor: appColor ?? this.appColor,
      appThemeMode: appThemeMode ?? this.appThemeMode,
    );
  }
}

AppColor and AppThemeMode are two simple enum class that we will create to freed any dependency of any app component from the material ThemeMode and Color classes. This may prove very useful if further customization is needed in the future.

enum AppColor { green, blue, red, deepPurple }

extension AppColorExtension on AppColor {
  MaterialColor get color {
    switch (this) {
      case AppColor.green:
        return Colors.green;
      case AppColor.blue:
        return Colors.blue;
      case AppColor.red:
        return Colors.red;
      case AppColor.deepPurple:
        return Colors.deepPurple;
    }
  }
}
enum AppThemeMode { system, light, dark }

extension AppThemeModeExtension on AppThemeMode {
  ThemeMode get themeMode {
    switch (this) {
      case AppThemeMode.system:
        return ThemeMode.system;
      case AppThemeMode.light:
        return ThemeMode.light;
      case AppThemeMode.dark:
        return ThemeMode.dark;
    }
  }
  IconData get icon {
    switch (this) {
      case AppThemeMode.system:
        return Icons.auto_mode;
      case AppThemeMode.light:
        return Icons.light_mode;
      case AppThemeMode.dark:
        return Icons.dark_mode;
    }
  }
}

Next, we’ll incorporate this AppSettingsProvider into our MaterialApp and leverage a selector on the themeSettings property to trigger an app rebuild whenever this setting undergoes a change. While, in our sample app, using a consumer would have given the same result, just keep in mind that we are constructing a template app designed to serve as the foundation for various applications. As the AppSettingsProvider evolves to accommodate additional settings, the selector proves invaluable by facilitating a targeted UI rebuild solely in response to changes in themeSettings, preventing unnecessary rebuilds for other settings changes:

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

  // This widget is the root of your application.
  @override
  Widget build(BuildContext context) {
    return MultiProvider(
      providers: [
        ChangeNotifierProvider(create: (_) => AppSettingsProvider()),
        ChangeNotifierProvider(create: (_) => User(id: 'id', name: 'name')),
        ChangeNotifierProvider(create: (_) => ArticleListProvider())
      ],
      child: Selector<AppSettingsProvider, ThemeSettings>(
        selector: (_, settings) => settings.themeSettings,
        builder: (_, themeSettings, __) {
          final appTheme = AppTheme(seedColor: themeSettings.appColor.color);
          return MaterialApp.router(
            title: 'GBAccetta Portfolio',
            theme: appTheme.getTheme(Brightness.light),
            darkTheme: appTheme.getTheme(Brightness.dark),
            themeMode: themeSettings.appThemeMode.themeMode,
            routerConfig: AppRouter.simpleRouter,
          );
        },
      ),
    );
  }
}

Integrating App Settings Management in Accordance with Our Architecture

To seamlessly wrap up this integration and align it with our established architecture, three vital components need to be created:

  1. A SettingsUseCases contract and its corresponding interactor, a SettingsInteractor. These components define the use cases for managing changes in app themes and colors chosen by the user.
  2. A SharedPrefsService to interact with a local storage source: this storage management service is designed to handle user choices, utilizing SharedPreferences. Despite the apparent simplicity of creating a service solely for SharedPreferences, two notable advantages arise: consistency in the testing strategy, aligning with our mock pattern, and future-proofing the application. If the need arises to substitute SharedPreferences with an alternative storage solution, such as a local database, modifications would be confined only to this service.
  3. A new UserSettingsView with its State, and ViewModel where we can render the SegmentedButtons for choosing the user preferred color and themeMode

Given the focus of this article on dynamic theming with Material 3, the detailed implementation of these components will not be explored here. Part 2 and 3 of this course have equipped you with the skills to comprehend and develop these components. For clarity, I will only present here the contracts that these components utilize. Contracts serve the purpose of elucidating the component’s functionality without delving into the intricacies of the code.

abstract class SettingsUseCases {
  void saveAppThemeMode(AppThemeMode mode);
  void saveAppColor(AppColor appColor);
  AppThemeMode get currentAppThemeMode;
  AppColor get currentAppColor;
}
/// user_settings_contracts.dart

class UserSettingsVMState extends BaseViewModelState {
  late final AppSettingsProvider appSettings;
}

abstract class UserSettingsViewContract extends BaseViewContract {}

abstract class UserSettingsVMContract extends BaseViewModelContract<
    UserSettingsVMState, UserSettingsViewContract> {
  void onAppColorChanged(AppColor color);
  void onThemeModeChanged(AppThemeMode themeMode);
}

We can maybe also show the UserSettingsView as here you can notice that no theming is assigned to any of the widgets as the AppTheme will do his job and also how our architecture makes it easy to work with a provider such as the AppSettingsProvider that will be populated in the onInitState of our view:

class UserSettingsView extends StatefulWidget {
  const UserSettingsView({super.key});

  @override
  State<UserSettingsView> createState() => _UserSettingsViewWidgetState();
}

class _UserSettingsViewWidgetState extends BaseViewWidgetState<
    UserSettingsView,
    UserSettingsVMContract,
    UserSettingsVMState> implements UserSettingsViewContract {
  @override
  void onInitState() {
    vmState.appSettings = context.read<AppSettingsProvider>();
  }

  @override
  Widget contentBuilder(BuildContext context) {
    return LayoutBuilder(builder: (context, constraints) {
      return Scaffold(
        appBar: AppBar(
          title: Text('User Settings', style: textTheme.titleLarge),
        ),
        body: Column(
          mainAxisAlignment: MainAxisAlignment.center,
          children: [
            Center(child: Text('Theme Mode:', style: textTheme.headlineSmall)),
            const SizedBox(height: 16),
            SegmentedButton<AppThemeMode>(
              segments: AppThemeMode.values
                  .map((appThemeMode) => ButtonSegment(
                      value: appThemeMode,
                      label: constraints.maxWidth > 300
                          ? Text(appThemeMode.name)
                          : null,
                      icon: Icon(appThemeMode.icon)))
                  .toList(),
              selected: {vmState.appSettings.themeSettings.appThemeMode},
              onSelectionChanged: (set) =>
                  vmContract.onThemeModeChanged(set.first),
            ),
            const SizedBox(height: 24),
            Center(child: Text('App Color:', style: textTheme.headlineSmall)),
            const SizedBox(height: 16),
            SegmentedButton<AppColor>(
              segments: AppColor.values
                  .map((appColor) => ButtonSegment(
                      value: appColor,
                      icon: Icon(Icons.circle, color: appColor.color)))
                  .toList(),
              selected: {vmState.appSettings.themeSettings.appColor},
              onSelectionChanged: (set) =>
                  vmContract.onAppColorChanged(set.first),
            ),
          ],
        ),
      );
    });
  }
}

Finally, for the same reason, I won’t delve into the testing details. However, you can find all the newly added tests on the GitHub repository for part 5, ensuring a complete test coverage of 100%. It’s important to note that for effective testing of the SharedPreferences library, you should initialize the preferences by calling SharedPreferences.setMockInitialValues({});.

Conclusion

In conclusion, we’ve successfully implemented dynamic theming in our Flutter app using Material 3 guidelines and the powerful Provider library. The AppTheme class serves as a centralized hub for managing the visual aspects of our application, promoting uniformity and consistency across various Material widgets. Leveraging the ThemeData features and adhering to Material 3’s design principles, we’ve crafted a flexible and maintainable theming system.

Moreover, the integration of theme settings with the Provider library and the creation of an AppSettings provider have empowered our app to dynamically respond to user preferences. Through the implementation of contracts, interactors, and services, we’ve established a robust structure that not only accommodates dynamic theming but also sets the stage for expanding app settings seamlessly. The article has provided insights into the construction of the AppSettingsProvider, offering a clear pathway for managing and adapting various application settings, a crucial aspect of creating a user-friendly and customizable Flutter application.

You will find the part 5 complete github project here.

Support

If you found this guide helpful and insightful, your appreciation would mean a lot! Clapping is a great way to show your support. If you enjoyed and benefited from the content, consider giving it up to 50 claps. Your feedback helps in recognizing and acknowledging the effort and time put into creating this guide. Your recognition inspires continuous improvement and motivates for more comprehensive and valuable content creation.

Disclaimer

Parts of this article where formatted using chatGPT

Flutter
Material Design
Clean Architecture
App Development
Flutter App Development
Recommended from ReadMedium