Recently, I had to implement onboarding walkthroughs for ClickUp newcomers! This was a really important task because a lot of new users were about to discover the platform with the incredibly funny ad we premiered at the Super Bowl! ✨
The walkthrough allows our numerous new users, who maybe don’t know ClickUp yet, to quickly understand how to use several functionalities from the application. It is an ongoing effort, just like the new ClickUp University resource we’re pursuing! 🚀
Fortunately, the software architecture behind the ClickUp Flutter mobile application allowed me to implement this functionality quite quickly, even by reusing the real widgets from the application! This means that the walkthrough is dynamic, responsive, and exactly matches the real application screens of the app—and will continue to, even when the widgets will evolve.
I was also able to implement the functionality because of the right separation of concerns.
Let’s see what I mean here. 🤔
Separation of concerns
Designing a software architecture is one of the most complex topics for engineering teams. Among all responsibilities, it is always difficult to anticipate future software evolutions. That’s why creating a well-layered and decoupled architecture can help you and your teammates with a lot of things!
The main benefit of creating small decoupled systems is undoubtedly the testability! And this is what helped me create a demo alternative of the existing screens from the app!
A step-by-step guide
Now, how could we apply those principles to a Flutter application?
We will share a few technics we use to build ClickUp with a simple walkthrough example.
The example is so simple that it may not enlighten all of the advantages behind it, but believe me, it will help you create a lot more maintainable Flutter applications with complex codebases. 💡
The application
As an example, we will create an application that displays the population of the USA for each year.
We have two screens here :
HomeScreen
: simply lists all years from 2000 to now. When the users taps on a year tile, they will navigate to theDetailScreen
with a navigation argument set to the selected year.DetailScreen
: gets the year from the navigation argument, calls the datausa.io API for this year, and parses the JSON data to extract the associated population value. If data is available, a label is displayed with the population.
We will focus on the DetailScreen
implementation since it is the most interesting with its asynchronous call.
Step 1. Naive approach
The most obvious implementation for our app, is by using a single StatefulWidget
for the whole logic.
Accessing the year
navigation argument
To access the requested year, we read the RouteSettings
from the ModalRoute
inherited widget.
void didChangeDependencies() {
super.didChangeDependencies();
final year = ModalRoute.of(context)!.settings.arguments as int;
// ...
}
HTTP call
This example invokes the get
function from the http
package to get the data from the datausa.io API, parses the resulting JSON with the jsonDecode
method from the dart:convert
library, and keep Future
as part of the state with a property named _future
.
late Future<Map<dynamic, dynamic>?> _future;
void didChangeDependencies() {
super.didChangeDependencies();
final year = ModalRoute.of(context)!.settings.arguments as int;
if (_year != year) {
_future = _loadMeasure(year);
}
}
Future<Map<dynamic, dynamic>?> _loadMeasure(int year) async {
_year = year;
final uri = Uri.parse(
'https://datausa.io/api/data?drilldowns=Nation&measures=Population&year=$year');
final result = await get(uri);
final body = jsonDecode(result.body);
final data = body['data'] as List<dynamic>;
if (data.isNotEmpty) {
return data.first;
}
return null;
Rendering
To create the widget tree, we’re using a FutureBuilder
, which rebuilds itself regarding the current state of our _future
asynchronous call.
@override
Widget build(BuildContext context) {
return Scaffold(
appBar: AppBar(
title: Text('Year $_year'),
),
body: FutureBuilder<Map<dynamic, dynamic>?>(
future: _future,
builder: (context, snapshot) {
switch (snapshot.connectionState) {
case ConnectionState.done:
final error = snapshot.error;
if (error != null) {
// return "error" tree.
}
final data = snapshot.data;
if (data != null) {
// return "result" tree.
}
// return "empty" data tree.case ConnectionState.none:
case ConnectionState.waiting:
case ConnectionState.active:
// return "loading" data tree.
}
},
),
);
}
Review
Okay, the implementation is short and uses only built-in widgets, but now think of our initial intention: building demo alternatives (or tests) for this screen. It is very difficult to control the result of the HTTP call to force the application to render in a certain state.
That’s where the concept of inversion of control will help us. 🧐
Step 2. Inversion of control
This principle can be hard to understand for new developers (and also hard to explain), but the overall idea is to extract the concerns outside of our components—so that they aren’t responsible for choosing the behavior—and delegate it instead.
In a more common situation, it simply consists of creating abstractions and injecting implementations into our components so their implementation can be changed later on if needed.
But don’t worry, it will make more sense after our next example! 👀
Creating an API client object
In order to control the HTTP call to our API, we’ve isolated our implementation into a dedicated DataUsaApiClient
class. We’ve also created a Measure
class to make data easier to manipulate and maintain.
class DataUsaApiClient {
const DataUsaApiClient({
this.endpoint = 'https://datausa.io/api/data',
});
final String endpoint;
Future<Measure?> getMeasure(int year) async {
final uri =
Uri.parse('$endpoint?drilldowns=Nation&measures=Population&year=$year');
final result = await get(uri);
final body = jsonDecode(result.body);
final data = body['data'] as List<dynamic>;
if (data.isNotEmpty) {
return Measure.fromJson(data.first as Map<String, Object?>);
}
return null;
}
Provide an API client
For our example, we’re using the well-known provider package to inject a DataUsaApiClient
instance at the root of our tree.
Provider<DataUsaApiClient>(
create: (context) => const DataUsaApiClient(),
child: const MaterialApp(
home: HomePage(),
),
)
Using the API client
The provider allows any descendent widget (like our DetailScreen
) to read the nearest DataUsaApiClient
upper in the tree. We can then use its getMeasure
method to start our Future
, in place of the actual HTTP implementation.
@overridevoid didChangeDependencies() {
super.didChangeDependencies();
final year = ModalRoute.of(context)!.settings.arguments as int;
if (_year != year) {
_year = year;
final api = context.read<DataUsaApiClient>();
_future = api.getMeasure(year);
}
}
Demo API client
Now we can take advantage of this!
In case you didn’t know: any classes in dart also implicitly define an associated interface. This allows us to provide an alternative implementation of DataUsaApiClient
which always returns the same instance from its getMeasure
method calls.
This method
class DemoDataUsaApiClient implements DataUsaApiClient {
const DemoDataUsaApiClient(this.measure);
final Measure measure;
@overrideString get endpoint => '';
@override
Future<Measure?> getMeasure(int year) {
return Future.value(measure);
}
Displaying a demo page
We now have all the keys to display a demo instance of the DetailPage
!
We simply override the currently provided DataUsaApiClient
instance by wrapping our DetailScreen
in a provider that creates a DemoDataUsaApiClient
instance instead!
And that’s it—our DetailScreen
reads this demo instance instead, and uses our demoMeasure
data instead of an HTTP call.
ListTile(
title: const Text('Open demo'),
onTap: () {
const demoMeasure = Measure(
year: 2022,
population: 425484,
nation: 'United States',
);
Navigator.push(
context,
MaterialPageRoute(
settings: RouteSettings(arguments: demoMeasure.year),
builder: (context) {
return Provider<DataUsaApiClient>(
create: (context) =>
const DemoDataUsaApiClient(demoMeasure),
child: const DetailScreen(),
);
},
),
);
},
)
Review
This is a great example of Inversion of control. Our DetailScreen
widget isn’t responsible for the logic of getting the data anymore, but delegates it instead to a dedicated client object. And we’re now able to create demo instances of the screen, or to implement widget tests for our screen! Awesome! 👏
But we can do even better!
Since we’re not able to simulate a loading state, for example, we don’t have full control of any state change at our widget level.
Step 3. State management
This is a hot topic in Flutter!
I’m sure you’ve already read long threads of people who try to elect the best state-management solution for Flutter. And to be clear, that’s not what we will do in this article. In our opinion, as long as you separate your business logic from your visual logic, you’re fine! Creating these layers is really important for maintainability. Our example is simple, but in real applications, logic can quickly become complex and such separation makes it a lot easier to find your pure logic algorithms. This subject is often summarized as state management.
In this example, we’re using a basic ValueNotifier
alongside a Provider
. But we could have also used flutter_bloc or riverpod (or another solution), and it would have worked great, too. The principles stay the same, and as long as you separated your states and your logic, it is even possible to port your codebase from one of the other solutions.
This separation also helps us to control any state of our widgets so we can mock it in every possible way!
Creating a dedicated state
Instead of relying on the AsyncSnapshot
from the framework, we now represent our screen state as a DetailState
object.
It is also important to implement the hashCode
and operator ==
methods to make our object comparable by value. This allows us to detect if two instances should be considered different.
💡 The equatable or freezed packages are great options to help you implement the
hashCode
andoperator ==
methods!
abstract class DetailState {
const DetailState(this.year);
final int year;
@overridebool operator ==(Object other) =>
identical(this, other) ||
(other is DetailState &&
runtimeType == other.runtimeType &&
year == other.year);
@overrideint get hashCode => runtimeType.hashCode ^ year;
Our state is always associated with a year
, but we also have four distinct possible states regarding what we want to show to the user:
NotLoadedDetailState
: the data update hasn’t started yetLoadingDetailState
: the data is currently being loadedLoadedDetailState
: the data has been successfully loaded with an associatedmeasure
NoDataDetailState
: the data has been loaded, but there’s no available dataUnknownErrorDetailState
: the operation failed because of an unknownerror
class NotLoadedDetailState extends DetailState {
const NotLoadedDetailState(int year) : super(year);
}
class LoadedDetailState extends DetailState {
const LoadedDetailState({
required int year,
required this.measure,
}) : super(year);
final Measure measure;
@overridebool operator ==(Object other) =>
identical(this, other) ||
(other is LoadedDetailState && measure == other.measure);
@overrideint get hashCode => runtimeType.hashCode ^ measure.hashCode;
}
class NoDataDetailState extends DetailState {
const NoDataDetailState(int year) : super(year);
}
class LoadingDetailState extends DetailState {
const LoadingDetailState(int year) : super(year);
}
class UnknownErrorDetailState extends DetailState {
const UnknownErrorDetailState({
required int year,
required this.error,
}) : super(year);
final dynamic error;
@overridebool operator ==(Object other) =>
identical(this, other) ||
(other is UnknownErrorDetailState &&
year == other.year &&
error == other.error);
@overrideint get hashCode => Object.hash(super.hashCode, error.has
Those states are clearer than an AsyncSnapshot
, since it really represents our use cases. And again, this makes our code more maintainable!
💡 We highly recommend the Union types from the freezed package to represent your logic states! It adds a lot of utilities like the
copyWith
ormap
methods.
Putting the logic in a Notifier
Now that we have a representation of our state, we need to store an instance of it somewhere—that’s the purpose of the DetailNotifier
. It will keep the current DetailState
instance in its value
property and will provide methods to update the state.
We provide a NotLoadedDetailState
initial state, and a refresh
method to load data from the api
and update the current value
.
class DetailNotifier extends ValueNotifier<DetailState> {
DetailNotifier({
required int year,
required this.api,
}) : super(DetailState.notLoaded(year));
final DataUsaApiClient api;
int get year => value.year;
Future<void> refresh() async {
if (value is! LoadingDetailState) {
value = DetailState.loading(year);
try {
final result = await api.getMeasure(year);
if (result != null) {
value = DetailState.loaded(
year: year,
measure: result,
);
} else {
value = DetailState.noData(year);
}
} catch (error) {
value = DetailState.unknownError(
year: year,
error: error,
);
}
}
}
Provide a state for the view
To instantiate and observe the state of our screen, we also rely on the provider and its ChangeNotifierProvider
. This kind of provider automatically looks for any ChangeListener
created and will trigger a rebuild from the Consumer
each time it is notified of a change (when our notifier value is different from the previous one).
class DetailScreen extends StatelessWidget {
const DetailScreen({
Key? key,
}) : super(key: key);
@override
Widget build(BuildContext context) {
final year = ModalRoute.of(context)!.settings.arguments as int;
return ChangeNotifierProvider<DetailNotifier>(
create: (context) {
final notifier = DetailNotifier(
year: year,
api: context.read<DataUsaApiClient>(),
);
notifier.refresh();
return notifier;
},
child: Consumer<DetailNotifier>(
builder: (context, notifier, child) {
final state = notifier.value;
// ...
},
),
);
}
}
Review
Great! Our application architecture is starting to look pretty good. Everything is separated into well-defined layers, with specific concerns! 🤗
One thing is still missing for testability though, we want to define the current DetailState
to control the state of our associated DetailScreen
.
Step 4. Visual dedicated layout widget
In the last step, we gave a bit too much responsibility to our DetailScreen
widget: it was responsible for instantiating the DetailNotifier
. And like what we’ve seen previously, we try to avoid any logic responsibility at the view layer!
We can easily solve this by creating another layer for our screen widget: we will split our DetailScreen
widget into two:
DetailScreen
is responsible for setting up the various dependencies of our screen from the current application state (navigation, notifiers, state, services, …),DetailLayout
simply converts aDetailState
into a dedicated tree of widgets.
By combining the two, we will be able to simply create DetailLayout
demo/test instances, but having DetailScreen
for the real use case in our application.
Dedicated layout
To achieve a better separation of concerns, we moved everything under the Consumer
widget to a dedicated DetailLayout
widget. This new widget only consumes data and isn’t responsible for any instantiation. It just converts the read state to a specific widget tree.
The ModalRoute.of
call and ChangeNotifierProvider
instance remains in the DetailScreen
, and this widget simply returns the DetailLayout
with a pre-configured dependency tree!
This minor improvement is specific to provider usage, but you will notice that we’ve also added a ProxyProvider
so that any descendant widget can directly consume a DetailState
. This will make it easier to mock data.
class DetailScreen extends StatelessWidget {
const DetailScreen({
Key? key,
}) : super(key: key);
@override
Widget build(BuildContext context) {
final year = ModalRoute.of(context)!.settings.arguments as int;
return ChangeNotifierProvider<DetailNotifier>(
create: (context) {
final notifier = DetailNotifier(
year: year,
api: context.read<DataUsaApiClient>(),
);
notifier.refresh();
return notifier;
},
child: child: ProxyProvider<DetailNotifier, DetailState>(
update: (context, value, previous) => value.value,
child: const DetailLayout(),
),
);
}
}
class DetailLayout extends StatelessWidget {
const DetailLayout({
Key? key,
}) : super(key: key);
@override
Widget build(BuildContext context) {
return Consumer<DetailState>(
builder: (context, state, child) {
return Scaffold(
appBar: AppBar(
title: Text('Year ${state.year}'),
),
body: () {
// ...
}(),
);
},
);
}
Extracting widgets as dedicated classes
Never hesitate to extract a widget tree into a dedicated class! It will improve performance, and make the code more maintainable.
In our example we created one visual layout widget for each one of the associated state types:
if (state is NotLoadedDetailState || state is LoadingDetailState) {
return const LoadingDetailLayout();
}
if (state is LoadedDetailState) {
return LoadedDetailLayout(state: state);
}
if (state is UnknownErrorDetailState) {
return UnknownErrorDetailLayout(state: state);
}
return const NoDataDetailLayout();
Demo instances
Now we have full control over what we can mock and display onto our screen!
We just have to wrap a DetailLayout
with a Provider<DetailState>
to simulate the state of the layout.
ListTile(
title: const Text('Open "loaded" demo'),
onTap: () {
Navigator.push(
context,
MaterialPageRoute(
builder: (context) {
return Provider<DetailState>.value(
value: const DetailState.loaded(
year: 2022,
measure: Measure(
year: 2022,
population: 425484,
nation: 'United States',
),
),
child: const DetailLayout(),
);
},
),
);
},
),
ListTile(
title: const Text('Open "loading" demo'),
onTap: () {
Navigator.push(
context,
MaterialPageRoute(
builder: (context) {
return Provider<DetailState>.value(
value: const DetailState.loading(2022),
child: const DetailLayout(),
);
},
),
);
},
),
Conclusion
Creating a maintainable software architecture definitively isn’t easy! Anticipating future scenarios can demand a lot of effort, but I hope the few tips I shared will help you in the future!
The examples may look simple—it might even seem like we’re over-engineering—but as your app’s complexity grows, having those standards will help you a lot! 💪
Have fun with Flutter, and follow the blog to get more technical articles like this one! Stay tuned!