Optional Example - Updating the Global Theme From a Feature

At this point, we've covered the domain layer, features, and setting up the UI layer. Hopefully you're now familiar with the flow of data between the domain and the UI, as well as from use case to use case. However, what if we want to update something global, such as a theme used by the entire app, from within a feature? In this example, we'll cover one possible way to do this. If this doesn't interest you, feel free to move on to the next section and start writing the external interface layer for the Pokemon app, as it's not a required step.

Overview

For this guide, we won't be making any changes to the Pokemon app we've been writing, but instead outlining a separate example feature as a proof of concept. You can find the source code for this section in the theme_example project that is part of clean_framework.

Here's what we'll be setting up for this guide:

  1. A screen that allows the user to choose the app theme (called HomeUI),
  2. Another UI-derived class, within the same feature, that converts the user's theme choice to a flutter ThemeMode object (called ExampleThemeModeWrapper),
  3. A ThemeExampleApp top-level widget that will utilize the ExampleThemeModeWrapper UI to provide the themeMode choice to MaterialApp.router.

Our feature will be called Home, and our sub-feature (that contains ExampleThemeModeWrapper) will be called HomeTheme.

Setting Things Up

Since we've already covered the domain and UI layers for Clean Framework apps in previous sections, we won't go into depth about how they work again here.

Our theme example app will contain a single feature, laid out as follows:

features
    home
        domain
            - home_domain_models.dart
            - home_entity.dart
            - home_use_case.dart
        presentation
            theme
                - home_theme_presenter.dart
                - home_theme_ui.dart
                - home_theme_view_model.dart
            - home_presenter.dart
            - home_ui.dart
            - home_view_model.dart
Domain

First, we need to get our data containers ready. Let's start with writing the HomeEntity for our home feature:

File: lib/features/home/domain/home_entity.dart

import 'package:clean_framework/clean_framework.dart';

class HomeEntity extends Entity {
  const HomeEntity({
    this.appTheme = AppTheme.light,
  });

  final AppTheme appTheme;

  @override
  HomeEntity copyWith({
    AppTheme? appTheme,
  }) {
    return HomeEntity(
      appTheme: appTheme ?? this.appTheme,
    );
  }

  @override
  List<Object?> get props => [
        appTheme,
      ];
}

enum AppTheme {
  light(name: 'Light Theme', value: 'LIGHT'),
  dark(name: 'Dark Theme', value: 'DARK');

  const AppTheme({
    required this.name,
    required this.value,
  });

  final String name;
  final String value;
}

Our entity only has a single field: appTheme. The AppTheme type will be our medium to store the user's theme choice, and will later be translated into a ThemeMode object to be passed into MaterialApp.router().

Next, we are going to need two DomainModels, since we're going to end up with two UI classes: HomeDomainToUIModel, and HomeThemeDomainToUIModel:

File: lib/features/home/domain/home_domain_models.dart

import 'package:clean_framework/clean_framework.dart';
import 'package:theme_example/features/home/domain/home_entity.dart';

class HomeDomainToUIModel extends DomainModel {
  const HomeDomainToUIModel({
    required this.appTheme,
  });

  final AppTheme appTheme;

  @override
  List<Object?> get props => [
        appTheme,
      ];
}

class HomeThemeDomainToUIModel extends DomainModel {
  const HomeThemeDomainToUIModel({
    required this.appTheme,
  });

  final AppTheme appTheme;

  @override
  List<Object> get props => [
        appTheme,
      ];
}

Both of our UI classes will need the selected AppTheme, so we have an appTheme field on both DomainModels. You might be tempted to think, "why not just use a single DomainModel since they contain the same data?" But in our opinion, it's better to stick to having a single DomainToUIModel for each Presenter for consistency.

Finally, now that we have our domain layer data objects, we just need to set up the UseCase:

File: lib/features/home/domain/home_use_case.dart

import 'package:clean_framework/clean_framework.dart';
import 'package:theme_example/features/home/domain/home_domain_models.dart';
import 'package:theme_example/features/home/domain/home_entity.dart';

class HomeUseCase extends UseCase<HomeEntity> {
  HomeUseCase()
      : super(
          entity: const HomeEntity(),
        );

  Future<void> getTheme() async {
    entity = entity.copyWith(
      appTheme: AppTheme.light,
    );
  }

  Future<void> updateTheme(AppTheme? theme) async {
    entity = entity.copyWith(
      appTheme: theme,
    );
  }
}

We're going to have two functions in our UseCase:

  1. getTheme(): This is going to be called on initialization (onLayoutReady()) from our main Presenter.
  2. updateTheme(AppTheme?): This function is what will update the entity each time the user makes a new theme selection from our main UI.

Lastly, we're going to need two DomainModelTransformers that each of our Presenters can subscribe to. We usually put these just below the UseCase in the same file.

File: lib/features/home/domain/home_use_case.dart

class HomeDomainToUIModelTransformer
    extends DomainModelTransformer<HomeEntity, HomeDomainToUIModel> {
  @override
  HomeDomainToUIModel transform(HomeEntity entity) {
    return HomeDomainToUIModel(
      appTheme: entity.appTheme,
    );
  }
}

class HomeThemeDomainToUIModelTransformer
    extends DomainModelTransformer<HomeEntity, HomeThemeDomainToUIModel> {
  @override
  HomeThemeDomainToUIModel transform(HomeEntity entity) {
    return HomeThemeDomainToUIModel(
      appTheme: entity.appTheme,
    );
  }
}

Just like our two DomainModels, our DomainModelTransformers are nearly identical. The only difference being the particular Presenter for which the data is intended. When updating the DomainModels, we just provide them with our entity's appTheme field. That's it!

To allow the Presenters to subscribe to DomainModel updates, we also need to provide the DomainModelTransformers in the constructor of the Use Case. Add the optional transformers argument to the constructor, and pass in the two DomainModelTransformers within a list:

File: lib/features/home/domain/home_use_case.dart

class HomeUseCase extends UseCase<HomeEntity> {
  HomeUseCase()
      : super(
          entity: const HomeEntity(),
          transformers: [
            HomeDomainToUIModelTransformer(),
            HomeThemeDomainToUIModelTransformer(),
          ],
        );

...

Great, now that our domain layer is complete, let's move on to the necessary Presenters and UI classes.

Presentation

To start, if you haven't already, create the six files laid out at the beginning of the guide:

  1. presentation/home_view_model.dart,
  2. presentation/home_presenter.dart,
  3. presentation/home_ui.dart,
  4. presentation/theme/home_theme_view_model.dart,
  5. presentation/theme/home_theme_presenter.dart,
  6. presentation/theme/home_theme_ui.dart,

We'll outline each file one by one below, starting with the main ViewModel.

File: lib/features/home/presentation/home_view_model.dart

import 'package:clean_framework/clean_framework.dart';
import 'package:flutter/material.dart';
import 'package:theme_example/features/home/domain/home_entity.dart';

class HomeViewModel extends ViewModel {
  const HomeViewModel({
    required this.appTheme,
    required this.onThemeChange,
  });

  final AppTheme appTheme;

  final ValueChanged<AppTheme?> onThemeChange;

  @override
  List<Object> get props => [
        appTheme,
      ];
}

For our main view model, we'll need the theme choice and a callback to the domain for updating the theme onThemeChange.

File: lib/features/home/presentation/home_presenter.dart

import 'package:clean_framework/clean_framework.dart';
import 'package:flutter/material.dart';
import 'package:theme_example/features/home/domain/home_domain_models.dart';
import 'package:theme_example/features/home/domain/home_use_case.dart';
import 'package:theme_example/features/home/presentation/home_view_model.dart';
import 'package:theme_example/providers.dart';

class HomePresenter
    extends Presenter<HomeViewModel, HomeDomainToUIModel, HomeUseCase> {
  HomePresenter({
    required super.builder,
    super.key,
  }) : super(provider: homeUseCaseProvider);

  @override
  HomeViewModel createViewModel(
      HomeUseCase useCase, HomeDomainToUIModel domainModel) {
    return HomeViewModel(
      appTheme: domainModel.appTheme,
      onThemeChange: useCase.updateTheme,
    );
  }

  @override
  void onLayoutReady(BuildContext context, HomeUseCase useCase) {
    useCase.getTheme();
  }
}

This will be the presenter for the main UI class (home_ui.dart). We call useCase.getTheme() on initialization onLayoutReady to update the theme with the default choice.

File: lib/features/home/presentation/home_ui.dart

import 'package:clean_framework/clean_framework.dart';
import 'package:flutter/material.dart';
import 'package:theme_example/features/home/domain/home_entity.dart';
import 'package:theme_example/features/home/presentation/home_presenter.dart';
import 'package:theme_example/features/home/presentation/home_view_model.dart';

class HomeUI extends UI<HomeViewModel> {
  HomeUI({
    super.key,
  });

  @override
  HomePresenter create(WidgetBuilder builder) {
    return HomePresenter(builder: builder);
  }

  @override
  Widget build(BuildContext context, HomeViewModel viewModel) {
    final themeData = Theme.of(context);
    return Scaffold(
      appBar: AppBar(
        backgroundColor: Theme.of(context).colorScheme.inversePrimary,
        title: Text(viewModel.appTheme.name),
      ),
      body: Center(
        child: Column(
          mainAxisAlignment: MainAxisAlignment.start,
          children: <Widget>[
            const SizedBox(
              height: 36,
            ),
            Text(
              'Select App Theme',
              style: themeData.textTheme.labelMedium,
            ),
            ...AppTheme.values.map(
              (theme) => Padding(
                padding: const EdgeInsets.symmetric(vertical: 8),
                child: RadioListTile(
                  value: theme,
                  groupValue: viewModel.appTheme,
                  title: Text(
                    theme.name,
                    style: themeData.textTheme.labelMedium!.copyWith(
                      color: theme == viewModel.appTheme
                          ? themeData.colorScheme.primary
                          : themeData.colorScheme.onBackground,
                    ),
                  ),
                  onChanged: viewModel.onThemeChange,
                  contentPadding: const EdgeInsets.all(4),
                  tileColor: theme == viewModel.appTheme
                      ? themeData.colorScheme.primaryContainer
                      : null,
                  shape: RoundedRectangleBorder(
                    borderRadius: BorderRadius.circular(16),
                    side: BorderSide(
                      color: themeData.colorScheme.outline,
                    ),
                  ),
                ),
              ),
            ),
          ],
        ),
      ),
    );
  }
}

This will be our main UI that provides the user with radio button choices for the Dark and Light themes. Notice that for our RadioListTile, we are passing in the value of onThemeChange, ultimately calling useCase.updateTheme() when a radio button selection is made.

Next, we'll move on to creating the theme wrapper widget presentation code, starting with the view model.

File: lib/features/home/presentation/theme/home_theme_view_model.dart

import 'package:clean_framework/clean_framework.dart';
import 'package:theme_example/features/home/domain/home_entity.dart';

class HomeThemeViewModel extends ViewModel {
  const HomeThemeViewModel({
    required this.appTheme,
  });

  final AppTheme appTheme;

  @override
  List<Object> get props => [
        appTheme,
      ];
}

In this case, our theme widget only needs the current theme from the domain layer, so that's all the view model will contain.

File: lib/features/home/presentation/theme/home_theme_presenter.dart

import 'package:clean_framework/clean_framework.dart';
import 'package:flutter/material.dart';
import 'package:theme_example/features/home/domain/home_domain_models.dart';
import 'package:theme_example/features/home/domain/home_use_case.dart';
import 'package:theme_example/features/home/presentation/theme/home_theme_view_model.dart';
import 'package:theme_example/providers.dart';

class HomeThemePresenter extends Presenter<HomeThemeViewModel,
    HomeThemeDomainToUIModel, HomeUseCase> {
  HomeThemePresenter({
    required super.builder,
    super.key,
  }) : super(provider: homeUseCaseProvider);

  @override
  HomeThemeViewModel createViewModel(
      HomeUseCase useCase, HomeThemeDomainToUIModel domainModel) {
    return HomeThemeViewModel(
      appTheme: domainModel.appTheme,
    );
  }

  @override
  void onLayoutReady(BuildContext context, HomeUseCase useCase) {
    useCase.getTheme();
  }
}

Now all that is left is to create the wrapper widget itself, and utilize it in our ThemeExampleApp top-level widget.

File: lib/features/home/presentation/theme/home_theme_ui.dart

import 'package:clean_framework/clean_framework.dart';
import 'package:flutter/material.dart';
import 'package:theme_example/features/home/domain/home_entity.dart';
import 'package:theme_example/features/home/presentation/theme/home_theme_presenter.dart';
import 'package:theme_example/features/home/presentation/theme/home_theme_view_model.dart';

class ExampleThemeModeWrapper extends UI<HomeThemeViewModel> {
  ExampleThemeModeWrapper({
    required this.builder,
    super.key,
  });

  final Widget Function(BuildContext, ThemeMode) builder;

  @override
  HomeThemePresenter create(WidgetBuilder builder) =>
      HomeThemePresenter(builder: builder);

  @override
  Widget build(BuildContext context, HomeThemeViewModel viewModel) {
    return builder(
      context,
      _getThemeMode(
        viewModel.appTheme,
      ),
    );
  }

  ThemeMode _getThemeMode(AppTheme theme) {
    return switch (theme) {
      AppTheme.light => ThemeMode.light,
      AppTheme.dark => ThemeMode.dark,
    };
  }
}

That's it for the UI! In order to use the feature's appTheme choice outside of the app, we are providing a builder function that will convert the user's AppTheme choice to a ThemeMode object and passing it in as an argument to our ExampleThemeModeWrapper widget. Then, we can wrap MaterialApp.router in this widget and provide it with the user's AppTheme choice.

Before creating the ThemeExampleApp class, we'll need to get a few prerequisites added.

Firstly, create a UseCaseProvider for our home feature:

File: lib/providers.dart

import 'package:clean_framework/clean_framework.dart';
import 'package:theme_example/features/home/domain/home_use_case.dart';

final homeUseCaseProvider = UseCaseProvider(
  HomeUseCase.new,
);

And finally, we'll need a router for our app.

File: lib/router.dart

import 'package:clean_framework_router/clean_framework_router.dart';
import 'package:theme_example/features/home/presentation/home_ui.dart';

class ExampleRouter extends AppRouter<Routes> {
  @override
  RouterConfiguration configureRouter() {
    return RouterConfiguration(
      debugLogDiagnostics: true,
      routes: [
        AppRoute(
          route: Routes.home,
          builder: (_, __) => HomeUI(),
        ),
      ],
    );
  }
}

enum Routes with RoutesMixin {
  home('/');

  const Routes(this.path);

  @override
  final String path;
}

Now that that's done, let's create our top-level widget to pull it all together. Create a new folder in lib called app, and create a new file in it called theme_example_app.dart:

File: lib/app/theme_example_app.dart

import 'package:clean_framework/clean_framework.dart';
import 'package:clean_framework_router/clean_framework_router.dart';
import 'package:flutter/material.dart';
import 'package:theme_example/features/home/presentation/theme/home_theme_ui.dart';
import 'package:theme_example/router.dart';

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

  @override
  Widget build(BuildContext context) {
    return AppProviderScope(
      child: AppRouterScope(
        create: ExampleRouter.new,
        builder: (context) {
          return ExampleThemeModeWrapper(
            builder: (context, themeMode) {
              return MaterialApp.router(
                title: 'Clean Framework Theme Example',
                routerConfig: context.router.config,
                theme: ThemeData(
                  colorSchemeSeed: Colors.lightBlueAccent,
                  useMaterial3: true,
                ),
                darkTheme: ThemeData(
                  colorSchemeSeed: Colors.green,
                  useMaterial3: true,
                ),
                themeMode: themeMode,
              );
            },
          );
        },
      ),
    );
  }
}

This is where our theme actually gets applied to the app as a whole. Notice that MaterialApp.router is contained within an anonymous function provided to ExampleThemeModeWrapper. Because of the builder function we added to the wrapper widget, we have access to the user's app theme selection from within this function, and we can use it to apply a theme when creating the router.

Now, if you call runApp(const ThemeExampleApp()) from within lib/main.dart, the app should run and allow the theme to be updated from our example feature.

We're done! Hopefully this gave you an understanding of some of the ways in which you can step outside the confines of a feature to interact with other parts of your app using Clean Framework. In the next part, we'll be continuing on with our Pokemon app and begin writing the external interface layer, allowing features to communicate with external services.