Written by: Tornike Kurdadze | Sun Aug 11 2024 Optimistic State Management in Flutter

Introduction

Theming is an essential part of any application. It helps to create a consistent look and feel across the app. In Flutter, theming is done using ThemeData class. However, sometimes you need more control over the theming process. In this article, we will explore advanced theming techniques in Flutter.

Key results

  • Improved User Experience: Consistent look and feel across the app.
  • Increased Customization: More control over the theming process.
  • Easier Maintenance: Centralized theming logic.

Before you start

Theming is tricky to get right without designer input so before you start, make sure you have a clear idea of what you want to achieve. For example, some designers prefer to create their own color palettes, while others prefer to use the Material Design.

As a Flutter developer you should be familiar with Material Design guidelines, because the theming process is based on it.

Implementation

If you are like me and you prefer to see the code directly, you can jump to the GitHub repo.

Folder Structure

Let’s see what you are working with.

styles/
├── themes/
   ├── app_theme_base.dart (abstract class for themes)
   ├── light_theme.dart (implements AppThemeBase)
   ├── dark_theme.dart (implements AppThemeBase)
   └── app_theme.dart (Manager class for themes)
├── colors/
   ├── palette.dart (ThemeExtension class)
   ├── app_dark_colors.dart (extends Palette)
   ├── app_light_colors.dart (extends Palette)
   └── app_gradients.dart (ThemeExtension)
├── style_extensions.dart
└── style.dart (export file)
Colors

We’ll create 4 files for colors, starting with an Theme extension class called Palette that will be extended by AppDarkColors and AppLightColors classes.

ThemeExtension is a relatively new feature in Flutter, it allows you to create a class that extends ThemeData and provides a way to create a custom theme for your app.

Palette contains all the colors used in the Design system, which supposed to come from the design file (Figma, Adobe XD, etc.) You can Add as much color as you want, but make sure to keep it clean and organized. More colors = more confusion.

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

class Palette extends ThemeExtension<Palette> {
  const Palette._({
    required this.primary,
    required this.secondary,
    required this.surface,
    required this.surfaceContainer,
    required this.outline,
    required this.elPrimary,
    required this.elSecondary,
    required this.error,
    required this.success,
    required this.warning,
    required this.gradients,
  });

  const Palette.fromChild({
    required this.primary,
    required this.secondary,
    required this.surface,
    required this.surfaceContainer,
    required this.outline,
    required this.elPrimary,
    required this.elSecondary,
    required this.error,
    required this.success,
    required this.warning,
    required this.gradients,
  });

  //Primary
  final Color primary;

  // Secondary
  final Color secondary;

  // Surface
  final Color surface;
  final Color outline;
  final Color surfaceContainer;

  // Elements (Texts & Icons)
  final Color elPrimary;
  final Color elSecondary;

  // System
  final Color error;
  final Color success;
  final Color warning;

  // Gradients
  final AppGradients gradients;

  @override
  ThemeExtension<Palette> copyWith({
    Color? primary,
    Color? secondary,
    Color? surface,
    Color? surfaceContainer,
    Color? outline,
    Color? elPrimary,
    Color? elSecondary,
    Color? error,
    Color? success,
    Color? warning,
    AppGradients? gradients,
  }) {
    return Palette._(
      primary: primary ?? this.primary,
      secondary: secondary ?? this.secondary,
      surface: surface ?? this.surface,
      surfaceContainer: surfaceContainer ?? this.surfaceContainer,
      outline: outline ?? this.outline,
      elPrimary: elPrimary ?? this.elPrimary,
      elSecondary: elSecondary ?? this.elSecondary,
      error: error ?? this.error,
      success: success ?? this.success,
      warning: warning ?? this.warning,
      gradients: gradients ?? this.gradients,
    );
  }

  @override
  ThemeExtension<Palette> lerp(
    covariant ThemeExtension<Palette>? other,
    double t,
  ) {
    if (other is! Palette) {
      return this;
    }
    return Palette._(
      primary: Color.lerp(primary, other.primary, t)!,
      secondary: Color.lerp(secondary, other.secondary, t)!,
      surface: Color.lerp(surface, other.surface, t)!,
      surfaceContainer: Color.lerp(surfaceContainer, other.surfaceContainer, t)!,
      outline: Color.lerp(outline, other.outline, t)!,
      elPrimary: Color.lerp(elPrimary, other.elPrimary, t)!,
      elSecondary: Color.lerp(elSecondary, other.elSecondary, t)!,
      error: Color.lerp(error, other.error, t)!,
      success: Color.lerp(success, other.success, t)!,
      warning: Color.lerp(warning, other.warning, t)!,
      gradients: gradients.lerp(other.gradients, t) as AppGradients,
    );
  }
}
AppLightColors
import 'package:flutter/material.dart';
import 'app_colors.dart';
import 'app_gradients.dart';

class AppLightColors extends Palette {
  const AppLightColors()
      : super.fromChild(
          primary: const Color(0xFF502510),
          secondary: const Color(0xFF2E2148),
          surface: const Color(0xFFEDF3EF),
          surfaceContainer: const Color(0xFFA4B7A7),
          outline: const Color(0xFF6478C7),
          elPrimary: const Color(0xFF101423),
          elSecondary: const Color(0xFF151923),
          error: const Color(0xFF6E2525),
          success: const Color(0xFF7AEAC9),
          warning: const Color(0xFF5B4016),
          gradients: const AppGradients(
            primary: LinearGradient(
              colors: [
                Color.fromRGBO(191, 156, 65, 0.78),
                Color.fromRGBO(199, 77, 50, 1),
              ],
              begin: Alignment(-1, -0.5),
              end: Alignment(1, -0.5),
            ),
          ),
        );
}
AppDarkColors
import 'package:flutter/material.dart';
import 'app_colors.dart';
import 'app_gradients.dart';

class AppDarkColors extends Palette {
  const AppDarkColors()
      : super.fromChild(
          primary: const Color(0xFFF07E11),
          secondary: const Color(0x94257D45),
          surface: const Color(0xFF0A1121),
          surfaceContainer: const Color(0xFF18254F),
          outline: const Color(0xFF282C39),
          elPrimary: const Color(0xFFF5F6F6),
          elSecondary: const Color(0xFFB5B6BB),
          error: const Color(0xFFB05151),
          success: const Color(0xFF5AFF8B),
          warning: const Color(0xFF4B3717),
          gradients: const AppGradients(
            primary: LinearGradient(
              colors: [
                Color.fromRGBO(134, 78, 65, 1),
                Color.fromRGBO(75, 61, 21, 0.7803921568627451),
              ],
              begin: AlignmentDirectional.bottomStart,
              end: AlignmentDirectional.topEnd,
            ),
          ),
        );
}
AppGradients

This is an optionl file, you can create it if you need control gradients from the design system.

import 'package:flutter/material.dart';

@immutable
class AppGradients extends ThemeExtension<AppGradients> {
  const AppGradients({
    required this.primary,
  });

  final Gradient primary;

  @override
  ThemeExtension<AppGradients> lerp(
    covariant ThemeExtension<AppGradients>? other,
    double t,
  ) {
    if (other is! AppGradients) {
      return this;
    }
    return AppGradients(
      primary: Gradient.lerp(primary, other.primary, t)!,
    );
  }

  @override
  AppGradients copyWith({
    Gradient? primary,
    Gradient? secondary,
    Gradient? tertiary,
  }) {
    return AppGradients(
      primary: primary ?? this.primary,
    );
  }
}
Themes

We’ll create 4 files for themes, starting with an abstract class called AppThemeBase that will be implemented by LightTheme and DarkTheme classes.

abstract class AppThemeBase {
  const AppThemeBase({
    required this.themeData,
  });

  final ThemeData themeData;
}
AppightTheme
import 'package:flutter/cupertino.dart';
import 'package:flutter/material.dart';
import '../app_font_families.dart';
import '../colors/app_light_colors.dart';
import 'app_theme_base.dart';

class AppLightTheme implements AppThemeBase {
  const AppLightTheme();

  @override
  ThemeData get themeData {
    const palette = AppLightColors();
    return ThemeData(
      useMaterial3: true,
      extensions: const [palette],
      brightness: Brightness.light,
      colorScheme: ColorScheme.light(
        primary: palette.primary,
        onPrimary: palette.elPrimary,
        secondary: palette.secondary,
        onSecondary: palette.elPrimary,
        error: palette.error,
        onError: palette.elPrimary,
        outline: palette.outline,
        surface: palette.surface,
        onSurface: palette.elPrimary,
        onSurfaceVariant: palette.elPrimary,
      ),
      textTheme: TextTheme(
        /// Provide as many text styles as you need
        bodyMedium: GoogleFonts.poppins(
          color: palette.elPrimary,
          fontWeight: FontWeight.w400,
          fontSize: 16,
        ),
        labelMedium: GoogleFonts.poppins(
          color: palette.elPrimary,
          fontWeight: FontWeight.w400,
          fontSize: 12,
        ),
      ),
      listTileTheme: ListTileThemeData(
        tileColor: palette.surfaceContainer,
        shape: RoundedRectangleBorder(
          borderRadius: BorderRadius.circular(8),
        ),
      ),
      cupertinoOverrideTheme: CupertinoThemeData(
        // This sets selectionHandleColor on iOS
        primaryColor: palette.primary,
        scaffoldBackgroundColor: palette.surface,
      ),
      textSelectionTheme: TextSelectionThemeData(
        cursorColor: palette.primary,
        selectionColor: palette.primary,
        selectionHandleColor: palette.primary,
      ),
      checkboxTheme: CheckboxThemeData(
        shape: RoundedRectangleBorder(
          borderRadius: BorderRadius.circular(4),
        ),
      ),
      appBarTheme: AppBarTheme(
        backgroundColor: palette.surface,
        elevation: 0,
        surfaceTintColor: palette.primary,
      ),
    );
  }
}
AppDarkTheme
import 'package:flutter/cupertino.dart';
import 'package:flutter/material.dart';
import '../app_font_families.dart';
import '../colors/app_dark_colors.dart';
import 'app_theme_base.dart';

class AppDarkTheme implements AppThemeBase {
  const AppDarkTheme();

  @override
  ThemeData get themeData {
    const palette = AppDarkColors();
    return ThemeData(
      useMaterial3: true,
      extensions: const [palette],
      brightness: Brightness.dark,
      colorScheme: ColorScheme.dark(
        primary: palette.primary,
        onPrimary: palette.elPrimary,
        secondary: palette.secondary,
        onSecondary: palette.elPrimary,
        error: palette.error,
        onError: palette.elPrimary,
        outline: palette.outline,
        surface: palette.surface,
        onSurface: palette.elPrimary,
        onSurfaceVariant: palette.elPrimary,
      ),
      textTheme: TextTheme(
        /// Provide as many text styles as you need
        bodyMedium: GoogleFonts.poppins(
          color: palette.elPrimary,
          fontWeight: FontWeight.w400,
          fontSize: 16,
        ),
        labelMedium: GoogleFonts.poppins(
          color: palette.elPrimary,
          fontWeight: FontWeight.w400,
          fontSize: 12,
        ),
      ),
      listTileTheme: ListTileThemeData(
        tileColor: palette.surfaceContainer,
        shape: RoundedRectangleBorder(
          borderRadius: BorderRadius.circular(8),
        ),
      ),
      cupertinoOverrideTheme: CupertinoThemeData(
        // This sets selectionHandleColor on iOS
        primaryColor: palette.primary,
        scaffoldBackgroundColor: palette.surface,
      ),
      textSelectionTheme: TextSelectionThemeData(
        cursorColor: palette.primary,
        selectionColor: palette.primary,
        selectionHandleColor: palette.primary,
      ),
      checkboxTheme: CheckboxThemeData(
        shape: RoundedRectangleBorder(
          borderRadius: BorderRadius.circular(4),
        ),
      ),
      appBarTheme: AppBarTheme(
        backgroundColor: palette.surface,
        elevation: 0,
        surfaceTintColor: palette.primary,
      ),
    );
  }
}
AppTheme
import 'package:flutter/material.dart';
import 'app_dark_theme.dart';
import 'app_light_theme.dart';
import 'app_theme_base.dart';

class AppTheme extends ChangeNotifier {
  AppTheme({
    required BuildContext context,
  }) {
    _context = context;
    _init();
  }

  late final BuildContext _context;
  late final AppThemeBase _darkTheme = const AppDarkTheme();
  late final AppThemeBase _lightTheme = const AppLightTheme();
  Brightness _brightness = Brightness.dark;

  ThemeMode get themeMode {
    switch (_brightness) {
      case Brightness.light:
        return ThemeMode.light;
      case Brightness.dark:
        return ThemeMode.dark;
    }
  }

  ThemeData get lightTheme => _lightTheme.themeData;

  ThemeData get darkTheme => _darkTheme.themeData;

  /// Initialize the theme based on the platform brightness AKA system theme
  void _init() {
    final view = View.of(_context);
    final brightness = MediaQueryData.fromView(view).platformBrightness;
    onBrightnessChanged(brightness);
  }

  void toggleThemeMode() {
    _brightness =
        _brightness == Brightness.light ? Brightness.dark : Brightness.light;
    notifyListeners();
  }

  /// Called when the user changes the system theme
  void onBrightnessChanged(Brightness value) {
    if (_brightness != value) {
      _brightness = value;
      notifyListeners();
    }
  }
}
RootApp

We’ll create a StatefullWidget called RootApp that will listne to the system theme changes and update the theme accordingly.

import 'package:flutter/material.dart';
import 'package:flutter/services.dart';
import '../styles/styles.dart';
import 'app_view.dart';

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

  @override
  State<RootApp> createState() => _RootAppState();
}

class _RootAppState extends State<RootApp> with WidgetsBindingObserver {
  late final AppTheme appTheme = AppTheme(
    context: context,
  );

  @override
  void initState() {
    super.initState();
    SystemChrome.setEnabledSystemUIMode(SystemUiMode.edgeToEdge);
    WidgetsBinding.instance.addObserver(this);
  }

  @override
  void dispose() {
    WidgetsBinding.instance.removeObserver(this);
    super.dispose();
  }

  MediaQueryData get _mediaQuery => MediaQueryData.fromView(View.of(context));

  @override
  void didChangePlatformBrightness() {
    super.didChangePlatformBrightness();
    appTheme.onBrightnessChanged(_mediaQuery.platformBrightness);
  }

  @override
  Widget build(BuildContext context) {
    return AnimatedBuilder(
      animation: appTheme,
      builder: (BuildContext context, __) {
        return MaterialApp(
          theme: appTheme.lightTheme,
          darkTheme: appTheme.darkTheme,
          themeMode: appTheme.themeMode,
          debugShowCheckedModeBanner: false,
          home: AppView(appTheme: appTheme),
        );
      },
    );
  }
}
AppView

And finally, we’ll create a StatelessWidget called AppView that will display the app content.

import 'package:flutter/material.dart';

import '../styles/styles.dart';

class AppView extends StatelessWidget {
  const AppView({
    required this.appTheme,
    super.key,
  });

  final AppTheme appTheme;

  @override
  Widget build(BuildContext context) {
    return Scaffold(
      appBar: AppBar(
        title: const Text('Advanced Flutter Examples'),
      ),
      body: Padding(
        padding: const EdgeInsets.all(24),
        child: Column(
          children: [
            Padding(
              padding: const EdgeInsets.symmetric(vertical: 24),
              child: ListTile(
                leading: const CircleAvatar(
                  backgroundImage: AssetImage('assets/images/avatar.png'),
                ),
                title: Text('Tornike Kurdadze', style: context.bodyM),
                subtitle: Text('Sr. Flutter Developer', style: context.labelS),
              ),
            ),
            Row(
              children: [
                Text('Light Mode', style: context.labelS),
                Padding(
                  padding: const EdgeInsets.all(8),
                  child: Switch(
                    value: appTheme.themeMode == ThemeMode.dark,
                    onChanged: (_) {
                      appTheme.toggleThemeMode();
                    },
                  ),
                ),
                Text('Dark Mode', style: context.labelS),
              ],
            ),
          ],
        ),
      ),
    );
  }
}

What’s next?

  • Saving the theme mode to the local storage.
  • Adding more themes.

Conclusion

In this article, we explored advanced theming techniques in Flutter. We created a centralized theming logic that allows us to switch between light and dark themes based on the system theme. This approach makes it easier to maintain the theming logic and provides more control over the theming process.

Full code base cane be found at GitHub. Give it a star if you find it useful.

Coffee makes me feel like I have my shit together 😀, so here you go 💙