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:
- A screen that allows the user to choose the app theme (called
HomeUI
), - Another UI-derived class, within the same feature, that converts the user's theme choice to a flutter
ThemeMode
object (calledExampleThemeModeWrapper
), - A
ThemeExampleApp
top-level widget that will utilize theExampleThemeModeWrapper
UI to provide the themeMode choice toMaterialApp.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 DomainModel
s, 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 DomainModel
s. 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
:
getTheme()
: This is going to be called on initialization (onLayoutReady()
) from our mainPresenter
.updateTheme(AppTheme?)
: This function is what will update the entity each time the user makes a new theme selection from our mainUI
.
Lastly, we're going to need two DomainModelTransformer
s 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 DomainModel
s, our DomainModelTransformer
s are nearly identical. The only difference being the particular Presenter
for which the data is intended. When updating the DomainModel
s, we just provide them with our entity's appTheme
field. That's it!
To allow the Presenter
s to subscribe to DomainModel
updates, we also need to provide the DomainModelTransformer
s in the constructor of the Use Case. Add the optional transformers
argument to the constructor, and pass in the two DomainModelTransformer
s 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 Presenter
s and UI
classes.
Presentation
To start, if you haven't already, create the six files laid out at the beginning of the guide:
presentation/home_view_model.dart
,presentation/home_presenter.dart
,presentation/home_ui.dart
,presentation/theme/home_theme_view_model.dart
,presentation/theme/home_theme_presenter.dart
,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.