Project Miniclient — Navigation
That’s the part of the Project Miniclient tutorial. If you missed the beginning, you may want to check the introduction first.
Two types of navigation
Here is a controversial opinion: navigation is an implementation detail for the UI. Imagine we want to display more information about Marvel characters in Project Miniclient. We could put more data directly on the character list, create a new details page, show details in a bottom sheet, or even combine these methods and adapt them based on the device used.
At Tide, we provide flexibility for sub-teams to control whether to add, merge, split, or even delete pages in their features — without the need to update the application package. To achieve this, we use a special concept called a flow. Think of it as an entry point to a feature. Ideally, it should be the only class visible outside the feature, with a constructor that defines the feature’s contract.
That leads us to two types of navigation: external and internal.
External navigation orchestrates the navigation between features. It’s a good old imperative approach, like pushing and popping screens or using deep links to initiate specific flows.
The internal navigation uses a declarative paradigm and drives the feature’s internal navigation stack.
External navigation
If you’ve built Flutter apps before, you may have used packages like go_router
or auto_route
to navigate between screens. Check out the documentation for these packages to learn how to implement the navigation between features.
As Miniclient has one feature only, in this part of the tutorial, our focus will be on a less common but very powerful approach: internal navigation.
Internal navigation
Internal navigation is a self-contained way to manage navigation within a single feature. Encapsulating navigation logic allows UX changes within a feature without affecting the overall application. This independence enables our Miniclient features to dynamically adjust their flows without needing adjustments elsewhere.
To achieve this, we use a declarative approach to drive internal navigation. Let’s see what this approach may look like in practice.
Implementation
Feature flow
Let’s start with the new utility package, called feature_flow
. Put it under utility
, and create the following pubspec.yaml
:
name: feature_flow
description: Miniclient Feature Flow
publish_to: none
version: 0.0.1
environment:
sdk: ^3.2.0
flutter: ^3.16.3
dependencies:
flutter:
sdk: flutter
flow_builder: ^0.1.0
get_it: ^7.6.4
tide_di:
path: ../tide_di
dev_dependencies:
tide_analysis:
path: ../tide_analysis
flutter:
uses-material-design: true
As you may see, in this implementation, we will use a flow_builder
package by @felangelov.
It’s been a while, so we will once again remind you about the analysis_options.yaml
. If nothing clicks, re-read the second part of the tutorial.
Create a single lib/src/feature_flow.dart
file with the following content:
import 'package:flow_builder/flow_builder.dart';
import 'package:flutter/material.dart';
import 'package:get_it/get_it.dart';
import 'package:tide_di/tide_di.dart';
// Generally speaking, we shouldn't use a global service locator like that.
// However, we are in a utility package, so we are doing this to avoid
// passing the GetIt dependency from the app.
final _getIt = GetIt.I;
/// Signature for the [FeatureFlow.onGeneratePages] function.
typedef OnGeneratePages<T> = List<Widget> Function(T state);
/// A widget that manages the flow of a feature.
class FeatureFlow<T> extends StatefulWidget {
const FeatureFlow({
super.key,
required this.onGeneratePages,
required this.state,
this.flowInitializer,
});
/// The DI initializer for the flow.
final TideDIInitializer? flowInitializer;
/// The function that generates the navigation stack based on the state.
final OnGeneratePages<T> onGeneratePages;
/// The initial state of the flow.
final T state;
@override
State<FeatureFlow<T>> createState() => _FeatureFlowState<T>();
}
class _FeatureFlowState<T> extends State<FeatureFlow<T>> {
late FeatureFlowController<T> _controller;
late final Future<void>? _initFuture;
@override
void initState() {
super.initState();
// We create a new scope for the feature flow. This way, we can have
// multiple instances of the same feature in the app.
_getIt.pushNewScope(scopeName: hashCode.toString());
// We create a controller for the feature flow. And register it in the
// service locator as a singleton. This way, we can access the controller
// from GetIt in the feature.
_controller = FeatureFlowController<T>(widget.state);
_getIt.registerSingleton<FeatureFlowController<T>>(_controller);
// Each feature flow can have a DI initializer. This approach allows us to
// encapsulate the DI configuration of a feature in a feature package.
final initializer = widget.flowInitializer;
_initFuture = initializer == null
? null
: () async {
await initializer.init(_getIt);
}();
}
@override
void dispose() {
// The feature is being disposed, so we need to dispose the controller and
// remove the feature scope from the service locator.
_getIt.dropScope(hashCode.toString());
_controller.dispose();
super.dispose();
}
@override
Widget build(BuildContext context) {
return ColoredBox(
color: Theme.of(context).scaffoldBackgroundColor,
child: FutureBuilder(
future: _initFuture,
builder: (context, snapshot) {
if (snapshot.connectionState != ConnectionState.done) {
return SizedBox();
}
return FlowBuilder<T>(
controller: _controller._controller,
onGeneratePages: (flow, _) => widget
.onGeneratePages(flow)
.map((page) => MaterialPage(child: page))
.toList(),
);
}),
);
}
}
/// A controller for a feature flow.
class FeatureFlowController<T> {
FeatureFlowController(T state) : _controller = FlowController<T>(state);
final FlowController<T> _controller;
T get state => _controller.state;
void update(T Function(T) callback) {
_controller.update(callback);
}
void dispose() {
_controller.dispose();
}
}
To make this class available, create a new lib/feature_flow.dart
barrel file with the following content:
export 'package:feature_flow/src/feature_flow.dart';
Bonus exercise
- After you complete the tutorial, to deepen your understanding, try re-implementing the
feature_flow
package usingauto_route
or challenge yourself further with Navigator 2.0, referencing theflow_builder
source code as an example.
Marvel Characters
It’s time to upgrade the feature we created in the third part of the tutorial. Let’s add a new page to display selected Marvel character details.
This feature will now have two screens: one showing the character list and the other displaying character details.
The character list screen is always visible and therefore isn’t tracked in the flow’s state. The details screen, however, appears only when a user selects a character. Create a new file lib/src/flow/marvel_characters_flow_state.dart
with the following content:
import 'package:freezed_annotation/freezed_annotation.dart';
import 'package:marvel_characters/src/domain/model/marvel_character.dart';
part 'marvel_characters_flow_state.freezed.dart';
@freezed
class MarvelCharactersFlowState with _$MarvelCharactersFlowState {
const factory MarvelCharactersFlowState({
MarvelCharacter? selectedCharacter,
}) = _MarvelCharactersFlowState;
}
Pro-tip on flow state: prefer plain models over unions. Instead of defining an abstract class like
MarvelCharactersFlowState
with subclasses for each screen (e.g.,MarvelCharactersListFlowState
andMarvelCharactersDetailsFlowState
), consider modeling the feature state independently of specific screens. Thinking in terms of screens can tie your state to the UI structure, making refactoring harder. Treat screens as implementation details rather than primary elements driving the navigation stack.
It’s time to define the flow. Create a new file lib/src/flow/marvel_characters_flow.dart
:
import 'package:feature_flow/feature_flow.dart';
import 'package:flutter/material.dart';
import 'package:marvel_characters/src/di/di_initializer.dart';
import 'package:marvel_characters/src/flow/marvel_characters_flow_state.dart';
import 'package:marvel_characters/src/presentation/character_details_page.dart';
import 'package:marvel_characters/src/presentation/marvel_characters_page.dart';
class MarvelCharactersFlow extends StatelessWidget {
const MarvelCharactersFlow({super.key});
@override
Widget build(BuildContext context) => FeatureFlow<MarvelCharactersFlowState>(
flowInitializer: MarvelCharactersDIInitializer(),
state: const MarvelCharactersFlowState(),
onGeneratePages: (state) => [
MarvelCharactersPage(),
if (state.selectedCharacter != null)
CharacterDetailsPage(character: state.selectedCharacter!),
],
);
}
Here the flow is initialized with MarvelCharactersDIInitializer
, and the initial state, defined by MarvelCharactersFlowState
, shows no character selected. A key component here is the onGeneratePages
parameter, which builds a navigation stack based on the current state.
You have an error now as CharacterDetailsPage
is not defined. To fix that, create a new file lib/src/presentation/character_details_page.dart
with the following content:
import 'package:flutter/material.dart';
import 'package:marvel_characters/src/domain/model/marvel_character.dart';
class CharacterDetailsPage extends StatelessWidget {
const CharacterDetailsPage({super.key, required this.character});
final MarvelCharacter character;
@override
Widget build(BuildContext context) {
return Scaffold(
appBar: AppBar(
title: Text(character.name),
),
body: Center(
child: Text('Character Details'), // TODO: add more details here
),
);
}
}
It’s time to implement the navigation. Let’s define a new MarvelCharactersEvent
in the lib/src/presentation/bloc/marvel_characters_event.dart
:
...
sealed class MarvelCharactersEvent {
...
const factory MarvelCharactersEvent.openCharacter(MarvelCharacter character) =
_OpenMarvelCharactersEvent;
}
...
class _OpenMarvelCharactersEvent extends MarvelCharactersEvent {
const _OpenMarvelCharactersEvent(this.character) : super._();
final MarvelCharacter character;
}
Implement the event handler in the lib/src/presentation/bloc/marvel_characters_bloc.dart
:
import 'package:feature_flow/feature_flow.dart';
import 'package:marvel_characters/src/flow/marvel_characters_flow_state.dart';
...
class MarvelCharactersBloc
extends Bloc<MarvelCharactersEvent, MarvelCharactersState> {
MarvelCharactersBloc(
...
this._controller,
) : super(const MarvelCharactersState.loading()) {
on<MarvelCharactersEvent>((event, emit) => switch (event) {
...
_OpenMarvelCharactersEvent() => _onOpen(emit, event),
});
...
}
final FeatureFlowController<MarvelCharactersFlowState> _controller;
...
void _onOpen(
Emitter<MarvelCharactersState> emit,
_OpenMarvelCharactersEvent event,
) {
_controller
.update((state) => state.copyWith(selectedCharacter: event.character));
}
}
To trigger the new bloc event, let’s change the CharacterItem
in the lib/src/presentation/marvel_characters_page.dart
:
class CharacterItem extends StatelessWidget {
const CharacterItem({super.key, required this.character});
final MarvelCharacter character;
@override
Widget build(BuildContext context) => Card(
child: InkWell(
onTap: () => context.read<MarvelCharactersBloc>().add(
MarvelCharactersEvent.openCharacter(character),
),
child: Column(...),
),
);
}
With a proper feature entry point, we don’t need to expose internal details like feature pages or the DI initializer. We only expose the feature flow entry point. Update lib/marvel_characters.dart
to look like this:
export 'package:marvel_characters/src/flow/marvel_characters_flow.dart';
Application
After you change the barrel file, you’ll have two compilation errors in the application package. To fix the di_initializer.dart
, you just need to delete the feature DI initializer. The new app initializer should look like this:
import 'package:api_client/api_client.dart';
import 'package:monitoring/monitoring.dart';
import 'package:tide_di/tide_di.dart';
Future<void> initDi() => initializeDIContainer([
..._utilityDIInitializers(),
]);
List<TideDIInitializer> _utilityDIInitializers() => const [
MonitoringDIInitializer(),
ApiClientDIInitializer(),
];
To fix the BootPage
, you need to set the entry point of a feature to the correct widget:
...
builder: (context, shapshot) {
if (shapshot.connectionState == ConnectionState.done) {
return const MarvelCharactersFlow();
}
return const Loading();
});
}
That’s it! Now you’ve implemented an internal navigation system that encapsulates all the logic within each feature package, making your codebase far more modular and adaptable.
Bonus exercises
- Implement proper external navigation with
go_router
orauto_route
. - Complete the
CharacterDetailsPage
implementation.
In the next post, we will cover the topic that some engineers (like, the author of the tutorial) would put at the very beginning — testing. You’ll learn the difference between solitary and sociable testing paradigms, and how to write tests easily and without tears.
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.