Project Miniclient — Feature level

Oleksandr Leushchenko
Tide Engineering Team
8 min readJul 17, 2024

In this third part of the Miniclient tutorial, we will start working on a feature. If you missed the beginning, you may want to check the introduction first.

At last, our foundation is mature enough to start building with it! We will start with a simple screen that shows a list of Marvel characters:

Create a package called “marvel_characters” inside “feature” folder with the following “pubspec.yaml” file:

name: marvel_characters
description: List of Marvel characters
version: 0.0.1
publish_to: none

environment:
sdk: ^3.2.0
flutter: ^3.16.3

dependencies:
api_client:
path: ../../utility/api_client
tide_design_system:
path: ../../utility/tide_design_system
dio: ^5.4.0
flutter:
sdk: flutter
flutter_bloc: ^8.1.3
freezed_annotation: ^2.4.1
injectable: ^2.3.2
tide_monitoring:
path: ../../utility/tide_monitoring
tide_prelude:
path: ../../utility/tide_prelude
retrofit: ^4.0.3
tide_di:
path: ../../utility/tide_di

dev_dependencies:
build_runner: ^2.4.7
freezed: ^2.4.5
injectable_generator: ^2.4.1
json_serializable: ^6.7.1
retrofit_generator: ^8.0.5
tide_analysis:
path: ../../utility/tide_analysis

Now, let’s define a feature structure. Our typical feature package consists of the following layers:

  • di — a place for DI initializer and DI modules
  • domain — holds all domain-specific classes related to the feature. Domain models, API services, repositories, and use cases go here.
  • navigator — no feature should know about another feature. When we need to navigate from one feature to another, we create a navigator class that will be defined in the feature, and implemented on the application level.
  • presentation — holds the UI for the feature. Notice, that we treat bloc as an implementation detail for the UI, that’s why we put bloc in the presentation layer.

That’s how the feature’s structure will look like in the code:

lib/
|- src/
|---- di
|---- domain/
|-------- api/
|-------- model/
|-------- repository/
|---- navigator
|---- presentation/
|-------- bloc/
|- marvel_characters.dart

DI

Let’s write some boilerplate for DI initialization. In a real project, we generate this code, but for our purely educational purpose, let’s write the DI initializer manually.

Create a “lib/src/di/di_initializer.dart” file:

import 'dart:async';

import 'package:get_it/get_it.dart';
import 'package:injectable/injectable.dart';
import 'package:marvel_characters/src/di/di_initializer.config.dart';
import 'package:tide_di/tide_di.dart';

class MarvelCharactersDIInitializer extends TideDIInitializer {
const MarvelCharactersDIInitializer() : super(_init);
}

@injectableInit
FutureOr<GetIt> _init(GetIt getIt, String? environment) =>
getIt.init(environment: environment);

As you might notice, this code is almost identical to the one we wrote for the “api_client” package. It will always be like that in every package, that’s why we rarely write this class manually and rely on the mason generator here.

Currently, you have a compilation error as “di_initializer.config.dart” has not been generated yet. Run code generation to fix the error.

Domain layer

Freezed helps reduce boilerplate code for defining immutable classes and unions. It provides copyWith, fromJson/toJson, pattern matching, and many other things you’d expect to see in your models.

It’s time to generate a model for the API. Create a file “lib/src/domain/model/marvel_character.dart” with the following content:

import 'package:freezed_annotation/freezed_annotation.dart';

part 'marvel_character.freezed.dart';
part 'marvel_character.g.dart';

@freezed
class MarvelCharacter with _$MarvelCharacter {
const factory MarvelCharacter({
@JsonKey(name: 'name') required String name,
@JsonKey(name: 'description') required String description,
@JsonKey(name: 'thumbnail') required Thumbnail thumbnail,
}) = _MarvelCharacter;

factory MarvelCharacter.fromJson(Map<String, dynamic> json) =>
_$MarvelCharacterFromJson(json);
}

@freezed
class Thumbnail with _$Thumbnail {
const factory Thumbnail({
@JsonKey(name: 'path') required String path,
@JsonKey(name: 'extension') required String extension,
}) = _Thumbnail;

factory Thumbnail.fromJson(Map<String, dynamic> json) =>
_$ThumbnailFromJson(json);
}

extension ThumbnailX on _Thumbnail {
String get url => '$path.$extension';
}

Most APIs wrap the result in a response model. Create a new file “lib/src/domain/model/marvel_characters_response.dart”. For our response, the model will look like this:

import 'package:freezed_annotation/freezed_annotation.dart';
import 'package:marvel_characters/src/domain/model/marvel_character.dart';

part 'marvel_characters_response.freezed.dart';
part 'marvel_characters_response.g.dart';

@freezed
class MarvelCharactersResponse with _$MarvelCharactersResponse {
const factory MarvelCharactersResponse({
@JsonKey(name: 'results') required List<MarvelCharacter> characters,
}) = _MarvelCharactersResponse;

factory MarvelCharactersResponse.fromJson(Map<String, dynamic> json) =>
_$MarvelCharactersResponseFromJson(json);
}

We put the response model near the character model, even though they have different semantics — one is the domain model, and the other is just a detail of the communication between the app and the server. For big features, that’s a problem and we would need to split them. For most features — it is not, as our features are granular.

Run code generation and make sure that you don’t have any errors in the package.

We rely on code generation for networking (you may dive into details on this topic by reading Basic and advanced networking in Dart and Flutter series).

Create API service class in the “lib/src/domain/api/marvel_characters_api.dart” file:

import 'package:dio/dio.dart';
import 'package:marvel_characters/src/domain/model/marvel_characters_response.dart';
import 'package:retrofit/retrofit.dart';
import 'package:injectable/injectable.dart';
import 'package:api_client/api_client.dart';

part 'marvel_characters_api.g.dart';

@injectable
@RestApi()
abstract class MarvelCharactersApi {
@factoryMethod
factory MarvelCharactersApi(Dio dio) = _MarvelCharactersApi;

@GET('v1/public/characters')
Future<MarvelResponse<MarvelCharactersResponse>> getCharacters();
}

Usually, it is a bad practice to interact with the API service directly as some data transformation might be required. For that reason, we create a Repository class. Here its responsibility would be to unwrap the list of Marvel characters from the networking model, so that the rest of the app would not know about models intended to be used inside the domain layer only, and to deal with networking errors.

Create “lib/src/domain/repository/mavel_characters_repository.dart” file with the following content:

import 'package:injectable/injectable.dart';
import 'package:marvel_characters/src/domain/api/marvel_characters_api.dart';
import 'package:marvel_characters/src/domain/model/marvel_character.dart';
import 'package:tide_prelude/tide_prelude.dart';

@injectable
class MarvelCharactersRepository {
const MarvelCharactersRepository(this._api);

final MarvelCharactersApi _api;

Future<Result<List<MarvelCharacter>, Exception>> getCharacters() async {
final result = await Result.fromAsync(_api.getCharacters);
return result.map((s) => s.data.characters);
}
}

Notice, how elegant the error handling is with the Result monad!

Presentation

There are various ways of handling state management in Flutter, and the right choice often depends on the complexity of the application and the size of the team working on it.

The flutter_bloc package helps to separate the presentation from the business logic (BLoC stands for Business Logic Component). The BLoC receives events, handles them, and, optionally, changes its internal state. The UI subscribes to the stream of state changes and updates accordingly.

We need to load the data from the API and display it on the screen. For that purpose, we create an event (lib/src/presentation/bloc/marvel_characters_event.dart):

part of 'marvel_characters_bloc.dart';

sealed class MarvelCharactersEvent {
const MarvelCharactersEvent._();

const factory MarvelCharactersEvent.load() = _LoadMarvelCharactersEvent;
}

class _LoadMarvelCharactersEvent extends MarvelCharactersEvent {
const _LoadMarvelCharactersEvent() : super._();
}

The implementation mimics freezed unions. Notice that _LoadMarvelCharactersEvent is private and the MarvelCharactersEvent constructor is private too. We want the compiler to check that all bloc events are nicely scoped.

To make pattern matching work, we need to have access to the exact types in the bloc, that’s why we specified that this file is “part of” bloc file, which we will create a bit later.

State class would be more complex as we need proper copyWith, equals, toString, and other useful methods implementations, so we will use freezed.

In most cases, the state is a union that describes all possible page conditions (lib/src/presentation/bloc/marvel_characters_state.dart):

part of 'marvel_characters_bloc.dart';

@freezed
class MarvelCharactersState with _$MarvelCharactersState {
const factory MarvelCharactersState.loading() = _LoadingMarvelCharactersState;
const factory MarvelCharactersState.success(
List<MarvelCharacter> marvelCharacters,
) = _SuccessMarvelCharactersState;
const factory MarvelCharactersState.error(
Exception error,
) = _ErrorMarvelCharactersState;
}

Notice that this file is a part of the bloc too. We need that to make internal state classes visible from the bloc.

It is time to implement the UI logic. For that, let’s focus on MarvelCharactersBloc. This class listens to MarvelCharactersEvent events and outputs MarvelCharactersState states. Create a “lib/src/presentation/bloc/marvel_characters_bloc.dart” file with the following content:

import 'package:flutter_bloc/flutter_bloc.dart';
import 'package:freezed_annotation/freezed_annotation.dart';
import 'package:injectable/injectable.dart';
import 'package:marvel_characters/src/domain/model/marvel_character.dart';
import 'package:marvel_characters/src/domain/repository/marvel_characters_repository.dart';

part 'marvel_characters_bloc.freezed.dart';
part 'marvel_characters_event.dart';
part 'marvel_characters_state.dart';

@injectable
class MarvelCharactersBloc
extends Bloc<MarvelCharactersEvent, MarvelCharactersState> {
MarvelCharactersBloc(
this._charactersRepository,
) : super(const MarvelCharactersState.loading()) {
on<MarvelCharactersEvent>((event, emit) => switch (event) {
_LoadMarvelCharactersEvent() => _onLoad(emit, event),
});

add(const MarvelCharactersEvent.load());
}

final MarvelCharactersRepository _charactersRepository;

Future<void> _onLoad(
Emitter<MarvelCharactersState> emit,
_LoadMarvelCharactersEvent event,
) async {
emit(const MarvelCharactersState.loading());

final characters = await _charactersRepository.getCharacters();

emit(
characters.fold(
MarvelCharactersState.success,
MarvelCharactersState.error,
),
);
}
}

Notice that we add the `load` event immediately after we subscribe the bloc to the events.

It is always a bad idea to give a real application to the user without any monitoring in place. Modify the bloc like that:

...
import 'package:tide_monitoring/tide_monitoring.dart';
...
MarvelCharactersBloc(
...
this._monitoring,
)...

final Monitoring _monitoring;

@override
Future<void> onTransition(
Transition<MarvelCharactersEvent, MarvelCharactersState> transition,
) async {
super.onTransition(transition);

final state = transition.nextState;
if (state is _ErrorMarvelCharactersState)
await _monitoring.log(
'MarvelCharactersBloc Error: ${state.error}',
);
}
...

The important thing here is that we never mess up logging/monitoring with the business logic itself. In the “_onLoad” handler we set the error state, and then we monitor the state change in “onTransition”. That would allow us to keep monitoring in one place, which simplifies both logging and business logic functions.

We’ve come a long way, but at last, we may work on the UI. Typically we create a page with just a BlocProvider that puts the appropriate bloc into the widget tree. Inside the bloc provider factory, we use `diContainer`. We have an agreement that is the only valid scenario for DI usage in the UI layer. The page content itself is a mapper from bloc states into different UIs.

Create “lib/src/presentation/marvel_characters_page.dart” file:

import 'package:tide_design_system/tide_design_system.dart';
import 'package:flutter/material.dart';
import 'package:flutter_bloc/flutter_bloc.dart';
import 'package:marvel_characters/src/domain/model/marvel_character.dart';
import 'package:marvel_characters/src/presentation/bloc/marvel_characters_bloc.dart';
import 'package:tide_di/tide_di.dart';

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

@override
Widget build(BuildContext context) => BlocProvider(
create: (_) => diContainer<MarvelCharactersBloc>(),
child: const _MarvelCharactersPageContent(),
);
}

class _MarvelCharactersPageContent extends StatelessWidget {
const _MarvelCharactersPageContent();

@override
Widget build(BuildContext context) => Scaffold(
body: BlocBuilder<MarvelCharactersBloc, MarvelCharactersState>(
builder: (context, state) => state.when(
loading: () => const Loading(),
error: (e) => Error(message: e.toString()),
success: (characters) => CharacterList(characters: characters),
),
),
);
}

class CharacterList extends StatelessWidget {
const CharacterList({super.key, required this.characters});

final List<MarvelCharacter> characters;

@override
Widget build(BuildContext context) => GridView.builder(
gridDelegate: const SliverGridDelegateWithFixedCrossAxisCount(
crossAxisCount: 3,
),
itemCount: characters.length,
itemBuilder: (_, index) => CharacterItem(character: characters[index]),
);
}

class CharacterItem extends StatelessWidget {
const CharacterItem({super.key, required this.character});

final MarvelCharacter character;

@override
Widget build(BuildContext context) => Card(
child: Column(
crossAxisAlignment: CrossAxisAlignment.stretch,
children: <Widget>[
Expanded(
child: Image.network(character.thumbnail.url),
),
Padding(
padding: const EdgeInsets.all(12),
child: Text(character.name),
),
],
),
);
}

The entry point to the feature package is always a widget. In our simplified example, that would be the page. Create a barrel file (“lib/marvel_characters.dart”) for the package and export the di initializer and the page from there:

export 'package:marvel_characters/src/di/di_initializer.dart';
export 'package:marvel_characters/src/presentation/marvel_characters_page.dart';

Bonus exercise

  1. Notice that we hardcoded padding in the UI. That might be not the best idea. Why? How to fix it?
  2. Make the UI toggle between grid and list depending on the device width. While you are doing this, think of reusability and the design system.
  3. Such an important topic as testing is left behind in this tutorial. We follow BDD in combination with widget testing. You may familiarize yourself with the approach by watching the following playlist.
  4. The other “must have” topic, that is uncovered in the tutorial, is navigation. Here we are building the simplest app with one screen only, so it can live without navigation, however in real life we would use two navigation mechanisms: internal (we implement it via flow_builder) and external (implemented via auto_route).

We are almost done with the implementation. All that is left is to use the feature in the app.

About Tide

Founded in 2015 and launched in 2017, Tide is the leading business financial platform in the UK. Tide helps SMEs save time (and money) in the running of their businesses by not only offering business accounts and related banking services, but also a comprehensive set of highly usable and connected administrative solutions from invoicing to accounting. Tide has 600,000 SME members in the UK (more than 10% market share) and more than 275,000 SMEs in India. Tide has also been recognised with the Great Place to Work certification.
Tide has been funded by Anthemis, Apax Partners, Augmentum Fintech, Creandum, Salica Investments, Jigsaw, Latitude, LocalGlobe, SBI Group and Speedinvest, amongst others. It employs around 1,800 Tideans worldwide. Tide’s long-term ambition is to be the leading business financial platform globally.

LinkedIn

--

--