Sitemap
Flutter Community

Articles and Stories from the Flutter Community

The Ultimate Guide: Flutter Architecture Template — II

--

Many years ago, I created an end-to-end application development template using Flutter as Flutter Architecture Template. Currently, I’m publishing a new series with better ideas and implementations. Let’s learn together!

This article helps you understand how to build a custom business application. It is important to consider an important point when working on a large project or enterprise. I can actually discuss this topic in more detail, but for now I’d like to build this architecture from zero to hero. Come on, let’s do this!

Architecture template..

Yes, that’s my main goal. Actually, I have two goals. The first is to advance my coding skills (more structural); the second is to create a template. Architecture coding provides a better coding environment, solution, testing, modularization, etc. Additionally, templates will encourage team collaboration, differentiation, and many more things.

https://vb10.dev/#/en-us/

That sounds good, right?

-Yes, but remember this is not suitable for only one person or a side project. Teamwork is better for understanding and implementing this.

How many components are in this article?

In this project, core ideas have been created for an advanced mobile project. The following are included:

1.Create Project with Structure Folders
2.Vscode extensions with Pubspec Details
3.Localization support
4.Project requirements while app staring and Environment management
5.Theme & Code Generation
6.Navigation structure, Scripts for development, 3.party package effective usage
7.Kartal package, Responsive design, Custom widget design
8.Stateless widget, Stateful widget and Mixin Usage
9.Network Manager with Vexana and GetIt Manager (dependency injection)
10.State Management with BLoC Package
11.Cache Operation with Hive
12.Unit test, Integration test, Widget test
13.Pigeon, Fastlane, App Screen generator.

As you can see, we are waiting for a difficult time today, but after reading this article you will be ready to make a good architecture approach, think, and code with confidence. Let’s get started.

I have created a video about this article. Check out my video

1. Project Structure

When developing an architecture, this is the main idea. It provides a place to write code. This will help organize the code base in the project. When new feature requests come, keep the related files in the same folder as you knew when they would be added to your project.

There can be more reasons, but the thought is quite important. So what is my solution?

A module-based solution based on feature-based development. There is a separate most important logic for example. Databases/networks, etc.

Projet General Structre

You can find every request here or you can find a chance to upgrade. It is for this reason that there is a good structure. It is always possible to add more solutions.

Also you can check this structure from my repository.

2.Code Settings, Extension and Snippet

I use many extensions for this architecture to help me code, debug, and develop quickly. I configure my VSCode settings for better coding on the other hand. You can find and install directly with this script:
- setup_extension.sh -> https://gist.github.com/VB10/2050d098dbaae1e591dee35f13ffa3f5

After extension installed let’s config!
It helps with styling, fixing, and general maintenances. The following is my general settings json file.

Last part is snippet. I create many snippet for speed up coding. For example I create “hwaFsm” it is mean “HardwareAndro Flutter statefull Model. The other snippets:

  • Magic number (hwaMagicNumber)
  • Singleton Eager (hwaEager)
  • Base View Model (hwaBaseViewModel)
  • Base View (hwaBaseView)
  • Base Model (hwaBaseModel)
  • Base Test (hwaTest)
  • Hwa Extension (hwaExtension)
  • Flutter from json factory (hwaflutterFromJson)
  • Flutter change loading (fcl)
  • Flutter scaffold base (hwaFSM)

Regarding pubspec.yaml, on the other hand. You need to add many packages while developing a project. In this case, it’s all about the “annotation”. After adding many pakages, it is hard to read what is point of usage. Annotation will help to sum of similar item.
For example:

#firebase
firebase_messaging: 4.5.0
firebase_core: 3.1.0

3.Localization support

In the last year, localization has become very important for mobile apps. There’s localization in almost every application. So I’m going to show you how to add localization.

The language feature will be made by the localization manager. On the other hand, you have to generate the key for localization. I’m using the “easy_localization” package. This package has been very stable for me for a long time.

{
"home": {
"title": "Home Page"
},
"general": {
"button": {
"save": "Save {}"
}
}
}

Here’s an example of localization. Defining your key and value in JSON is the first step. It’s all about the key. If you want a dynamic value, you can use an argument. You want the button to say “Save 10000.”. Use [count] with your key.

You can use this class to define your language support and change the language in the app. Make sure you add a translations folder and json file before using this class.

Translations: — asset/translations

Finally, it is necessary to work with the build runner to generate the code. In the next section, I will explain how code is generated. This time just call this script before using value: (You can find this script in repository as a lang.sh)

#!/bin/bash

dart run easy_localization:generate -O lib/product/init/language -f keys -o locale_keys.g.dart --source-dir asset/translations

This package must be installed before it can be used. The first one is build_runner, and the second is localization generator.

Localization is ready for use. Use LocaleKeys to call for example:

Text(LocaleKeys.home_title.tr());

Finally, we are almost ready to use our localization support, the last step is to manage multiple languages within our app. This class will help you with everything related to localization. The following are our needs:
-Path to the file
-Item support
-General update on the project

final class ProductLocalization extends EasyLocalization {
/// ProductLocalization need to [child] for a wrap locale item
ProductLocalization({
required super.child,
super.key,
}) : super(
supportedLocales: _supportedItems,
path: _translationPath,
useOnlyLangCode: true,
);

static final List<Locale> _supportedItems = [
Locales.tr.locale,
Locales.en.locale,
];

static const String _translationPath = 'asset/translations';

/// Change project language by using [Locales]
static Future<void> updateLanguage({
required BuildContext context,
required Locales value,
}) =>
context.setLocale(value.locale);
}

You can use this class for general usage localization. (Before use this solution do not forget the requirements for easy localization )

4.Project requirements while app staring and Environment management

Requirements are the starting point of the application. Before running the application, many things can be checked. The permission to access the internet, the permission to use a camera, the permission to connect to a database, etc.

Here’s how to handle this situation.

final class ApplicationInitialize {
/// project basic required initialize
Future<void> make() async {
WidgetsFlutterBinding.ensureInitialized();

await runZonedGuarded<Future<void>>(
_initialize,
(error, stack) {
Logger().e(error);
},
);
}

/// This method is used to initialize the application process
Future<void> _initialize() async {
await EasyLocalization.ensureInitialized();
EasyLocalization.logger.enableLevels = [LevelMessages.error];
await SystemChrome.setPreferredOrientations([DeviceOrientation.portraitUp]);
// TODO: Splash
await DeviceUtility.instance.initPackageInfo();

FlutterError.onError = (details) {
/// crashlytics log insert here
/// custom service or custom logger insert here
/// Todo: add custom logger
Logger().e(details.exceptionAsString());
};

// Dependency initialize
// envied

_productEnvironmentWithContainer();

await ProductStateItems.productCache.init();
}

/// DO NOT CHANGE THIS METHOD
void _productEnvironmentWithContainer() {
AppEnvironment.general();

/// It must be call after [AppEnvironment.general()]
ProductContainer.setup();
}
}

During app startup, this class manages all requirements. This manager is called in the main file as follows:

Future<void> main() async {
await ApplicationInitialize().make();
runApp(ProductLocalization(child: const StateInitialize(child: _MyApp())));
}

The management of the environment is very important for mobile applications. There are many possible values, such as changing the base URL, changing the API key, etc. It’s how to keep this in the project that’s the problem.

The Envied package is very useful in this situation. With obfuscate, you can manage environment variables. In this case, it isn’t very secure, but it’s very useful.Check the implementation when the application starts.

The Envied Package can be found at https://pub.dev/packages/envied
< Don’t forget to add envied generator for making a code from .env file >

This package should be implemented in this project:

  • Add a .env file to .gitignore by creating a new file
  • Enter your base URL and API key.
  • Create a dev_env.dart for storing environment variables.
  • Declare your envied path for top of class and add your variable like “static const baseUrl = \_DevEnv.baseUrl;”
  • Run the builder to generate the code from the environment file.

My envied file is look like this.

BASE_URL=https://api.sample.com

The other hand is dev_env.dart file.

import 'package:envied/envied.dart';
part 'dev_env.g.dart';
@Envied(path: '.env')
class DevEnv {
@EnviedField(varName: 'BASE_URL')
static const baseUrl = _DevEnv.baseUrl;
}

That’s all I have to say. You can now use your env variable with secure for all your variables. As a reminder, you will learn to generate codes for best usage in the next section, so you can find the right code.

My general code generation is complete in another submodule.

5.Theme & Code Generation

The project supports modular design. Create a flutter library project for managing modular designs.

flutter create --template=package your_package_name

1.Code generation

The most important thing for a project is code generation. This helps to make a code easier, more readable, and easier to understand. The tool is very useful for projects. I am using many packages for code generation. An asset generator makes it easier to create an asset. For example, an image, a lottie, a json, a font, etc. Envied gen manages environments securely. We will manage this code generation algorithm from the new module within this article. Implementing and managing it is very simple.

“Gen” is the name of the new module, and it is very easy to implement and manage.

There are three submodules in this folder.

1- Asset Gen -> https://pub.dev/packages/flutter_gen
2- Envied Gen -> https://pub.dev/packages/envied_generator
3- Model generation -> https://pub.dev/packages/json_serializable

1.1- Asset Gen

Asset generation will use lottie, svg, fonts, images, etc. Using the flutter_gen package, it works. It is very easy to use and very powerful. In pubspec.yaml, the package must be declared.

flutter_gen:
output: lib/src/asset/
integrations:
lottie: true
flutter_svg: true

colors:
inputs:
- assets/color/colors.xml

After that you need to run build runner for generating a code.

flutter pub run build_runner build

Your asset generated. Just call Asset file with relative path.

ColoredBox(color: Assets.color.black)
/// package: gen mean that find the asses in sub module
Assets.lottie.animZombieda.lottie(package:"gen")
Assets.images.imageName.image(package:"gen")

1.2- Envied Gen

Envied Gen is used for managing environments with security. This is covered in the previous section. You can find more details about this here. This is the module I just moved to.

1.3- Model Generation

Model generation is for model. It is very useful for model. I’m using json_serializable package for this case. This package is very easy to use and it is very powerful. The package needs to declare in the pubspec.yaml file.

@JsonSerializable()
class User extends INetworkModel<User> with EquatableMixin {
User({this.userId, this.id, this.title, this.body});

/// Get user from json
factory User.fromJson(Map<String, dynamic> json) => _$UserFromJson(json);
final int? userId;
final int? id;
final String? title;
final String? body;
..

The generation almost complete this project. This time project can access for generation module from index file.

export 'asset/index.dart';
export 'environment/index.dart';
export 'model/index.dart';

2.Theme

The theme of this article is the most important thing. I’ll create a theme with “Theme Manager”, which is very easy to use. You can use this to make a theme light or dark. Theme builder can be used from material. There is also a text theme class for helping text themes. I wrote a good article about this. You can check it out. The main idea of the theme is

How to make a color etc. from mainly” and “how to implement a new theme easily”.

A CustomTheme is the main idea behind the theme structure. Making multiple schemes like light and dark is made easier with it.

abstract class CustomTheme {
ThemeData get themeData;

FloatingActionButtonThemeData get floatingActionButtonThemeData;
}

After that you can implement your theme like this.

final class CustomLightTheme implements CustomTheme {
@override
ThemeData get themeData => ThemeData(
useMaterial3: true,
fontFamily: GoogleFonts.roboto().fontFamily,
colorScheme: CustomColorScheme.lightColorScheme,
floatingActionButtonTheme: floatingActionButtonThemeData,
);

@override
FloatingActionButtonThemeData get floatingActionButtonThemeData =>
const FloatingActionButtonThemeData();
}

Here is a file to help you make a theme based on font family, color scheme, etc. Alternatively, you can create a custom theme like FloatingActionButtonThemeData. It needs to be called from inside a MaterialApp.

 Widget build(BuildContext context) {
return MaterialApp.router(
routerConfig: _appRouter.config(),
builder: CustomResponsive.build,
theme: CustomLightTheme().themeData,
darkTheme: CustomDarkTheme().themeData,
themeMode: context.watch<ProductViewModel>().state.themeMode,
localizationsDelegates: context.localizationDelegates,
supportedLocales: context.supportedLocales,
locale: context.locale,
);
}

The following example shows how to use a color from colorScheme.

context.general.colorScheme.primary (this estension coming from kartal package)

During the writing of code, my package contains many extensions for simplifying the process. Check out my kartal package for more details:

6.Navigation structure, Scripts for helping development, 3.party package best usage

Using this module, you can create a general structure for your project and integrate it with 3rd-party scripts and other tools.

Scripts for helping development

While developing like this bigger project, I need to make a script for helping development. I’ll show you how to make a script for helping development. These scripts are very useful for development.

Making a script will be useful while developing such a big project. Here’s how to make a script to help development. It is very useful to have scripts like these for development purposes.

The first script is “full clean”. In most cases, it helps to clean up the entire project when a package needs to be updated, etc.

The other script is called “lang sh”. In addition, it helps create language classes after adding a new key.

#!/bin/bash

flutter pub run easy_localization:generate -O lib/product/init/language -f keys -o locale_keys.g.dart --source-dir asset/translations

Here are a couple of ready-to-use scripts:

  • build.sh => It’s run build runner
  • clean.sh => It’s clean your main project (flutter, ios clean)
  • icon_build.sh => It’s make a icon for android and ios
  • ios_clean.sh => It’s clean a ios build and pod file
  • android_build.sh => It will make apk or app bundle

There are scripts in the root scripts folder. You can find it in the repository.

Navigation structure

Navigation is the main concept. Mobile applications require it very much. Let me show you how to make a navigation structure. For this case, I am using the auto_route package. It is a very powerful and easy-to-use package. It is especially useful when managing a route with auto-generate. There are many navigation features in the Auto Router package. In this project, let’s implement this package.

First need to install the package to your project:

dependencies:
auto_route: [latest-version]

dev_dependencies:
auto_route_generator: [latest-version]
build_runner:

With the app router file, you can create a route with auto-generation. Depending on how many pages you have, you can add this flow:

First add ‘@RoutePage()’ annotain in your realated pages.

// Home View
@RoutePage()
final class HomeView extends StatefulWidget {
const HomeView({super.key});

@override
State<HomeView> createState() => _HomeViewState();
}

/// Home Detail View
@RoutePage()
final class HomeDetailView extends StatelessWidget {
const HomeDetailView({required this.id, super.key});
final String id;
@override
Widget build(BuildContext context) {
return Scaffold(
appBar: AppBar(
title: const Text('Home Detail View'),
),
body: Center(
child: Text('Home Detail View $id'),
),
);
}
}

Add these pages to the app router. Your root navigation structure is created by it.

part 'app_router.gr.dart';

@AutoRouterConfig(replaceInRouteName: AppRouter._replaceRouteName)

/// Project router information class
final class AppRouter extends RootStackRouter {
static const _replaceRouteName = 'View,Route';
@override
List<AutoRoute> get routes => [
AutoRoute(page: HomeRoute.page, initial: true),
AutoRoute(page: HomeDetailRoute.page),
];
}

After this configuration call your build runner, package will create auto route requirements for navigation.

As a last point, the app router needs to be implemented in the material app. Once this is done, you can use auto router everywhere.

final class _MyApp extends StatelessWidget {
const _MyApp();
static final _appRouter = AppRouter();
@override
Widget build(BuildContext context) {
return MaterialApp.router(
routerConfig: _appRouter.config(),
);
}
}

The module is almost complete. As a result of this implementation, you can use it like this. You have many navigation options, such as push, pop, replace, etc., with context.router. More detail https://pub.dev/packages/auto_route#navigating-between-screens

And it is sample usage from there:

 context.router.push(HomeDetailRoute(id: user.userId.toString()));

Bonus:

Sometimes, code generation takes a long time for larger projects. It is possible to customize your build yaml for only a specific file. In this case, I only need to work on auto_route_gen for the view file. For generating page instances, there is only one view file.

3.party package best usage

Many projects use third-party packages. Using this package in the future is very important. Here’s how to make the most of this package. I have been using Cached network image package until now. It is very useful for loading images. For this package, let’s make an example.

You must add a dependency to pubspec.yaml before installing this package.

cached_network_image: ^latest_version

As a next step, I will create a new module as a common. This will make it easier to use this package in the project. To use this package, I would like to create a new widget. It does not require any configuration of the package to work.

It just depends on my primitive class. In addition, it can be changed or improved every time without affecting my main project. I know my new widget cannot read or reach my cached network image in the main project.

/// It will provide to image caching and image loading from network
final class CustomNetworkImage extends StatelessWidget {
/// The line `const CustomNetworkImage({super.key});` is defining a constructor
/// for the `CustomNetworkImage` class.
const CustomNetworkImage({
super.key,
this.imageUrl,
this.emptyWidget,
this.memCache = const CustomMemCache(height: 200, width: 200),
this.boxFit = BoxFit.cover,
this.loadingWidget,
this.size,
});
}

It’s a very simple one. There is no need to configure the package. My primitive class is all it depends on. MemCache is a memory cache used in core packages. It requires no extra configuration and can be used anywhere in the project with parameters.

The main idea is to not give out any package source code to anyone. It only depends on my primitive class. This will make it easier to improve or change in the future.

7. Kartal package, Responsive design, Custom widget design

I am developing my hand with the kartal package. Coding with extensions is made easier with this tool. The package contains many extensions of primitive or advanced types. Coders will find it very useful.

For examples:

context.general.colorScheme.primary
context.sized.low (0.1 percent of screen size)
GlobalKey.rendererBox
List.ext.nullOrEmpty
'https://medium.com/@vbacik-10'.ext.launchUrl

Here is more detail about this package:

Responsive design

Flutter grew up on mobile, tablet and web. For a project, responsive design is pretty important. For this case, I’m using the Responsive package. It is very easy to use and very powerful. In pubspec.yaml, the package must be declared.

responsive_framework: ^latest_version

This package is very useful for responsive design. There are many breakpoints for responsive design. The details can be found on pub.dev. https://pub.dev/packages/responsive_framework

Creating responsive design breakpoints requires a custom responsive class.

This class allows you to declare your breakpoints. You can easily make your project responsive after implementing it. If you make your project a single design for example Iphone 14, then you don’t need to worry about other screen sizes. All screen sizes will be responsive.

Using this package as an example

Custom widget design

You will probably create new widgets many times. It is important to make widgets that are basic, useful, and expandable. Therefore, I will show you how to make a dialog component. Success_dialog is the name of my dialog. This widget shows a success message in a very basic way.

final class SuccessDialog extends StatelessWidget {
/// Constructor for dialog
const SuccessDialog({required this.title, super.key});

/// Title for the dialog
final String title;


@override
Widget build(BuildContext context) {
return AlertDialog.adaptive(
title: Text(title),
actions: [
IconButton(
onPressed: () {
Navigator.of(context).pop(true);
},
icon: const Icon(Icons.check),
),
],
);
}
}

That’s the basic widget for version one. To display this dialog, I have to call the “ShowDialog” code everywhere. It is not good for quality, usability, and maintainability. A new function will be added as a show function.

  /// Show the dialog for success
/// This will always return [true]
static Future<bool> show({
required String title,
required BuildContext context,
}) async {
await DialogBase.show<bool>(
context: context,
builder: (context) => SuccessDialog(title: title),
);
return true;
}

This alert can be used very easily by my team. The “SuccessDialog.show” function is called.

When you close your widget, add a private constructor, so anyone can’t directly call it.

8. Stateless widget, Stateful widget and Mixin Usage

Every flutter project is related to this topic. This is valuable for understanding your coding life. Here is an example of how to use it. Actually, I didn’t explain the core concept of these widgets. I’ll try to demonstrate how these widgets can be used.

Here is a basic overview of this topic:
https://docs.flutter.dev/ui/interactivity#stateful-and-stateless-widgets

The first point is that inheritance can be used for some widgets. Padding is the most common usage. It is probably used in every project. The idea is to make a padding widget class for standardizing the project.

final class ProjectPadding extends EdgeInsets {
const ProjectPadding._() : super.all(0);

/// All Padding
///

/// [ProjectPadding.allSmall] is 8
const ProjectPadding.allSmall() : super.all(8);

/// [ProjectPadding.allMedium] is 16
const ProjectPadding.allMedium() : super.all(16);

/// [ProjectPadding.allNormal] is 20
const ProjectPadding.allNormal() : super.all(20);

/// [ProjectPadding.allLarge] is 32
const ProjectPadding.allLarge() : super.all(32);

/// Symmetric
/// Only left,right,bottom
}

The purpose of this is to make the component usable for the project. There is no need to add an extra stateless widget for padding. This class can be used for padding. An example:

Padding(
padding: const ProjectPadding.allSmall(),
...

Another example is a stateless widget. I am creating a widget that is a NormalButton. This is a simple button with a radius border.

/// radius is 20
final class NormalButton extends StatelessWidget {
const NormalButton({required this.title, required this.onPressed, super.key});

/// title text
final String title;

/// button on pressed
final VoidCallback onPressed;

@override
Widget build(BuildContext context) {
return InkWell(
/// todo:
radius: ProjectRadius.normal.value,
onTap: onPressed,
child: Text(title),
);
}
}

There are two parameters in the class. The first title is for showing a text. The second one is onPressed for a callback. We are trying to create a stateless widget. This makes a widget more reusable.

This is how you can make your sub widget. Sub widgets are based on this concept. The ProjectRadius is used for border radiuses. Radius is a basic enum.

enum ProjectRadius {
/// 8.
small(8),

/// 16.
medium(16),

/// 20.
normal(20),

/// 32.
large(32);

final double value;
const ProjectRadius(this.value);
}

Another example is a stateful widget. My widget is a CustomLoginButton. The button shows a loading state without any extra configuration.

final class CustomLoginButton extends StatefulWidget {
const CustomLoginButton({required this.onOperation, super.key});
final AsyncValueGetter<bool> onOperation;
@override
State<CustomLoginButton> createState() => _CustomLoginButtonState();
}

class _CustomLoginButtonState extends State<CustomLoginButton>
with MountedMixin, _CustomLoginButtonMixin {
@override
Widget build(BuildContext context) {
return ValueListenableBuilder<bool>(
valueListenable: _isLoadingNotifier,
builder: (BuildContext context, bool value, Widget? child) {
if (value) return const SizedBox();
return NormalButton(
title: 'Login',
onPressed: () async {
await _onPressed(context);
},
);
},
);
}
}

I’m getting an async value from the widget parameter. It helps manage loading state while sending a request. You can use stateful widgets to perform any operation related to the UI screen.

The last point is mixin. In widgets, mixins help manage states. This widget is very useful for stateful widgets. Stateful or stateless widgets just display design code. In view code, you do not write any operations like button presses or service requests. Mixins can be written in. Maintainability is improved by keeping clean code.

mixin _CustomLoginButtonMixin
on MountedMixin<CustomLoginButton>, State<CustomLoginButton> {
final ValueNotifier<bool> _isLoadingNotifier = ValueNotifier<bool>(false);

@override
void initState() {
super.initState();
_isLoadingNotifier.value = false;
}

@override
void dispose() {
super.dispose();
}

Future<void> _onPressed(BuildContext context) async {
_isLoadingNotifier.value = true;
final response = await widget.onOperation.call();
await safeOperation(() async {
if (response) Navigator.of(context).pop();
_isLoadingNotifier.value = false;
});
}
}

This mixin is only used in CustomLoginButton. Due to the fact that this is dependent on the CustomLoginButton State object. In addition, I have closed operations, verifiables, etc. if they are not needed. It is very helpful to use mixins and state classes for managing basic state information.

Ui issues can only be found in view classes, logic issues can be found in mixins.

There are some mixins that depend only on the view, and there are others that depend on the state. You need to use mountain check before every UI update, for instance. In this case, MountedMixin can be used.

mixin MountedMixin<T extends StatefulWidget> on State<T> {
Future<void> safeOperation(AsyncCallback callback) async {
if (!mounted) return;
await callback.call();
}
}

9. Network Manager with Vexana and GetIt Manager (Dependency injection)

The Vexana mine package for the network. I wrote this package with dio. It adds extra features for business life. For more information, please visit my pub.dev page.

This package has the following capabilities:

  • Model of generic response
  • Refresh token mechanism
  • The caching process
  • Manage without a network connection
  • And other features, you can check my package from pub.dev.

This package is pretty useful for enterprise projects. You can give a hint, issue, or PR at any time. Let’s implement this package for our project.

For project configuration, we need a custom network manager.

Here is a code explanation.

  • NetworkManager<EmptyModel> is used in this project as a generic model. The EmptyModel can be defined as an error model based on your custom definition. Most of the time, many projects have the same response for any error, so this usage makes it easier to assess error models directly.
  • This constructor is used to define your root network manager class. It is common for projects to have some urls that define this layer. (When you have different base urls, you need to find a network manager)
  • The option contains a few requirements about general usage. A header (e.g.)
  • Last point for handling error situations when something goes wrong.
/// [EmptyModel] is a model class that is used to
/// general model or primitive type.
final class EmptyModel extends INetworkModel<EmptyModel> with EquatableMixin {
/// [EmptyModel] constructor is used to create a new [EmptyModel]
const EmptyModel({this.name});

/// [name] is a getter method that returns the name of the model.
final String? name;

@override
Map<String, Object>? toJson() => null;

@override
EmptyModel fromJson(Map<String, dynamic>? json) {
return EmptyModel(name: json?['name'] as String? ?? '');
}

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

Example usage of this network manager.

final class LoginService extends AuthenticationOperation {
LoginService(INetworkManager<EmptyModel> networkManager)
: _networkManager = networkManager;

final INetworkManager<EmptyModel> _networkManager;

@override
Future<List<User>> users() async {
final response = await _networkManager.send<User, List<User>>(
ProductServicePath.posts.value,
parseModel: User(),
method: RequestType.GET,
);

return response.data ?? [];
}
}

Login page is used by this service. I just said two types of parameters with a send method. The first is the response model. The second one is what is the type of response. In addition, the send request needs to have a path, a parse model, and a type.

Users returns a List<User>. Finally, you can get the response data.

There are two parameters in the response object:

- data: response data (List<User>)

- error: error model (IErrorModel<EmptyModel>)

This error model can be returned to the UI screen. An error message is displayed to the user as a result of this.

There are many projects that require the same object to be used everywhere. We have a ProductNetworkManager, for example. It is used for product service. You must create the same object if you want to use this network manager in another service. It is very difficult to maintain. In this case, we can use the GetIt package.

Dependency Injection with GetIt

Most of the time, this package is used to design service locators. You can use watch_it solution to listen to every change, and it also helps with a couple of integrations.

final class ProductContainer {
const ProductContainer._();
static final _getIt = GetIt.I;

/// Product core required items
static void setup() {
_getIt
..registerSingleton(ProductCache(cacheManager: HiveCacheManager()))
..registerSingleton<ProductNetworkManager>(ProductNetworkManager.base())
..registerLazySingleton<ProductViewModel>(
ProductViewModel.new,
);
}
}

It is in my core manager file. It helps manage dependency injections. This file allows you to register your service. It is not my idea to give a git link directly to the client. I’ll only display the items. We will be able to determine how many services are implemented in our project with the help of this class.

 final class ProductStateItems {
const ProductStateItems._();

static ProductNetworkManager get productNetworkManager =>
ProductContainer.read<ProductNetworkManager>();

static ProductViewModel get productViewModel =>
ProductContainer.read<ProductViewModel>();

static ProductCache get productCache => ProductContainer.read<ProductCache>();
}

These parameters can be called in any service or view model. It helps manage dependency injections. I need to start the network service when I start the home view, for example. This one is mine to call.

  @override
void initState() {
super.initState();
_productNetworkErrorManager = ProductNetworkErrorManager(context);
ProductStateItems.productNetworkManager.listenErrorState(
onErrorStatus: _productNetworkErrorManager.handleError,
);

_homeViewModel = HomeViewModel(
operationService: LoginService(productNetworkManager),
userCacheOperation: ProductStateItems.productCache.userCacheOperation,
);
}

Any service or view model can call these parameters. Dependency injections can be managed with it. When I start the home view, for example, I need to start the network service. I’m the one who gets to call this one.

10. State Management with BLoC Package

We have reached the golden point. A project’s state management is its most important component. For this case, I am using the BLoC package. My goal is to separate my state items from my UI screen. In view — view model, it helps manage a state. I’ll add a section showing my best methods for managing state in a project. It is very popular for me to use blocs.

As an example:

  • A BlocListener listens for changes in state.
  • BlocBuilder — It helps build UI screens.
  • BlocConsumer — It listens for state changes and builds UI screens.
  • BlocSelector — Selects a state value for a block.

It is also very easy to test and manage state. I will add a base class for utility. As opposed to “bloc” usage, I prefer “cubit” usage. It is very easy to use.

Let’s take this case as an example. For the home page, I am creating a home view model.

final class HomeViewModel extends BaseCubit<HomeState> {
/// [AuthenticationOperation] service
HomeViewModel({
required AuthenticationOperation operationService,
required HiveCacheOperation<UserCacheModel> userCacheOperation,
}) : _authenticationOperationService = operationService,
_userCacheOperation = userCacheOperation,
super(const HomeState(isLoading: false));

.... others...

}

This is the beginning of my project. I am creating a view-model class to manage state. I am getting some verifiable results from using the business function. Another point state class for notifying state changes.

final class HomeState extends Equatable {
const HomeState({required this.isLoading, this.users});

final bool isLoading;
final List<User>? users;

@override
List<Object?> get props => [isLoading, users];

HomeState copyWith({bool? isLoading, List<User>? users}) {
return HomeState(
isLoading: isLoading ?? this.isLoading,
users: users ?? this.users,
);
}
}

This state class has many methods. This is how I prefer to use it. I am making only one state class immutable. By using Equtable, only the changed state will be notified. By using the copyWith method, you can copy a state with a new value. The design is basic, but it is pretty enough for a screen.

The last point is the BaseCubit class. The base cubit uses it to manage a state. For the whole project, you can add your logic.

abstract class BaseCubit<T extends Object> extends Cubit<T> {
BaseCubit(super.initialState);

@override
void emit(T state) {
if (isClosed) return;
super.emit(state);
}
}

When your state tries to emit, but the page is closed, the state won’t be emitted. This class can be used to implement your logic.

With this home view model, let’s make an example.

  /// Get users
Future<void> fetchUsers() async {
CustomLogger.showError<User>(usersFromCache);
final response = await _authenticationOperationService.users();
_saveItems(response);
emit(state.copyWith(users: response));
}

Data will be fetched from the backend and a state will be updated. The /_saveItems method makes a cache and a log. Let’s look at the home view model and how it should be handled.

       const Column(
mainAxisAlignment: MainAxisAlignment.center,
children: [
Expanded(
child: _UserBlocList(),
),
],
),

My user bloc list is a constant widget, as you can see. Building a user interface screen is important. The point needs to be on every screen, which means you have to separate your UI screen from your widget screen.

Take a look at this widget:

final class _UserBlocList extends StatelessWidget {
const _UserBlocList();

@override
Widget build(BuildContext context) {
return BlocListener<HomeViewModel, HomeState>(
listener: (context, state) {},
child: BlocSelector<HomeViewModel, HomeState, List<User>>(
selector: (state) {
return state.users ?? [];
},
builder: (context, state) {
if (state.isEmpty) return const SizedBox.shrink();

return HomeUserList(users: state);
},
),
);
}
}

BlocSelector is used in this widget. It is very useful for using complex states. A UI screen can be built by selecting a state value.

This is a screen-by-screen example. You can also create a bigger object to call every screen. You can create a widget that initializes the state, for example.

final class StateInitialize extends StatelessWidget {
const StateInitialize({required this.child, super.key});
final Widget child;
@override
Widget build(BuildContext context) {
return MultiBlocProvider(
providers: [
BlocProvider<ProductViewModel>(
create: (context) => ProductStateItems.productViewModel,
),
],
child: child,
);
}
}

Every screen can access this ProductViewModel object. This widget can be used to add logical operations. It helps manage a project’s state. I put it in my network manager, cache manager, and view model. Last but not least, I’m calling this widget in main.dart.

Future<void> main() async {
await ApplicationInitialize().make();
runApp(ProductLocalization(child: const StateInitialize(child: _MyApp())));
}

I think that’s all. Initiate the state for managing a global state in the project. For example, you can put a theme change operation for calling everywhere.

   return MaterialApp.router(
routerConfig: _appRouter.config(),
builder: CustomResponsive.build,
theme: CustomLightTheme().themeData,
darkTheme: CustomDarkTheme().themeData,
themeMode: context.watch<ProductViewModel>().state.themeMode,
localizationsDelegates: context.localizationDelegates,
supportedLocales: context.supportedLocales,
locale: context.locale,
);

You can use like this (context.watch) or you can wrap with bloc builder etc.

11. Cache Operation with Hive

Cache operation is another important aspect. For this case, I’m using Hive. There are already a number of database solutions available for mobile devices. Sqflite, shared, realm, etc. As a business person, I prefer to use Hive with my experience. The system works quickly, safely, and is easy to use. I’m creating a cache manager in a submodule this time. If the dependency is not related to the main module, I always choose a submodule.

I’m using the new version of Hive. It is more effective and easier to use. Isaar core is used for writing. There are many operations that work without async operations. There is no need to add an extra parameter to add a model. This project needs a manager.

I am working on implementing a strategy design for the cache manager. Using it will make it easier to operate a cache with different packages.

abstract class CacheManager {
const CacheManager({this.path});
Future<void> init({required List<CacheModel> items});
void remove();
final String? path;
}

With this base, let’s create a hive cache manager. Using my cache manager to initialize and remove hive databases.

I’m registering a hive adapter for my cache model while initializing a HIVE database. This operation requires cache model items.

Cache models extend BaseModel classes. It helps to create a cache model using JSON. While initializing a Hive database, you must add your core operation model. This class creates a cache for user models. In this class, I wrote my unit test.

class UserCache with CacheModel {
UserCache({required this.id, required this.name});
UserCache.empty() : this(id: '', name: '');
@override
final String id;
final String name;
@override
UserCache fromDynamicJson(dynamic json) {
final itemMap = json as Map<String, dynamic>;
return UserCache(
id: itemMap['id'] as String,
name: itemMap['name'] as String,
);
}

Last but not least, cache operation is required. The logic will make it easier to keep data in general. Generally, every database package follows these steps.

  • Add an item to the cache
  • Add all items to Cache
  • Remove an item from the cache
  • Get an item from the cache
  • As well as more…

I am making a cache operation for each cache model. The logic is making a general cache operation logic.

I will create a hive cache operation after the core layer. Hive uses it to make a cache operation.

class HiveCacheOperation<T extends CacheModel> extends CacheOperation<T> {
/// Initialize hive box
HiveCacheOperation() {
_box = Hive.box<T>(name: T.toString());
}
late final Box<T> _box;
@override
void add(T item) {
_box.put(item.id, item);
}

@override
void addAll(List<T> items) {
_box.putAll(Map.fromIterable(items));
}

@override
void clear() {
_box.clear();
}

@override
T? get(String id) {
return _box.get(id);
}

@override
List<T> getAll() {
return _box
.getAll(_box.keys)
.where((element) => element != null)
.cast<T>()
.toList();
}

@override
void remove(String id) {
_box.delete(id);
}
}

That’s all there is to cache operation. This is a very simple method. Any cache model can be used after implementation. Example: HiveCacheOperation<UserCacheModel>. The operations are as follows:

  • HiveCacheOperation<UserCacheModel>().add(UserCacheModel(id: ‘1’, name: ‘John’));
  • HiveCacheOperation<UserCacheModel>().getAll();
  • HiveCacheOperation<UserCacheModel>().get(‘1’);
  • HiveCacheOperation<UserCacheModel>().remove(‘1’);
  • HiveCacheOperation<UserCacheModel>().clear();

The core operation has been completed. Implementation time for the project. I have added this cache manager to my product container. Every service, view model, etc., can access it.

static void setup() {
_getIt
..registerSingleton(ProductCache(cacheManager: HiveCacheManager()))
..registerSingleton<ProductNetworkManager>(ProductNetworkManager.base())
..registerLazySingleton<ProductViewModel>(
ProductViewModel.new,
);
}

// application initialize call from main.
await ProductStateItems.productCache.init();

Let’s take a look at my product cache class. It is making an initialization with other required operations. Last but not least, I am adding my cache object to the product cache. With it, you will be able to use it anywhere.

final class ProductCache {
ProductCache({required CacheManager cacheManager})
: _cacheManager = cacheManager;

final CacheManager _cacheManager;

Future<void> init() async {
await _cacheManager.init(
items: [
UserCacheModel.empty(),
],
);
}

late final HiveCacheOperation<UserCacheModel> userCacheOperation =
HiveCacheOperation<UserCacheModel>();
}

It is ready for use. In hive cache manager, you can add your cache model. Let’s use it in the home view. I make a cache operation when fetching a user list.

  final HiveCacheOperation<UserCacheModel> _userCacheOperation;

/// Save users to cache
void _saveItems(List<User> user) {
for (final element in user) {
_userCacheOperation.add(UserCacheModel(user: element));
}
}

/// Get users from cache
List<User> get usersFromCache =>
_userCacheOperation.getAll().map((e) => e.user).toList();

That’s all I have to say. This cache operation can be used with any cache model. You can also implement your own cache manager for other database packages.

12. Unit test, Integration test, Widget test

Testing is a very important part of any project. There are generally three concepts. Here is the official documentation: https://docs.flutter.dev/testing/overview

  • Tests of core logic — Unit tests
  • Full UI testing — Integration test
  • Atomic widget test — Widget test

My project uses these tests because Flutter directly supports them. Let’s implement the first unit test point.

Unit test

Here is a unit test for the HomeViewModel. The main part of my project is shown below. Other parts will be used as examples.

There will be two main classes for cache and network in this view model. However, this is not relevant to unit tests. Just wondering if my method is working. Let’s create a mock login service and user cache.

```dart
/// Normal usage
_homeViewModel = HomeViewModel(
operationService: LoginService(productNetworkManager),
userCacheOperation: ProductStateItems.productCache.userCacheOperation,
);
/// Mock usage
_homeViewModel = HomeViewModel(
operationService: LoginServiceMock(),
userCacheOperation: UserCacheMock(),
);
```

By using mocks, you can test without relying on any extra dependencies. Unit tests benefit greatly from it. Let’s take a look at my mock class.

For mocking, I’m using mockito. I will only write some methods for using my main method. Direct mock data will be returned by the service layer. Caching only works in memory. There is no need for any additional requirements. This is required for package dependencies, etc.

Finally, I created this project with Bloc. Bloc_test is included in the package. Unit tests benefit greatly from it. For unit testing, I use this package.

The following is a sample of the same test from the file home_view_model_test.dart.

group('Home View Model Test', () {
test('inital state is loading', () {
expect(homeViewModel.state.isLoading, false);
});

blocTest<HomeViewModel, HomeState>(
'fetch users',
build: () => homeViewModel,
act: (bloc) async => bloc.fetchUsers(),
expect: () => [
isA<HomeState>().having((state) => state.users, 'users', isNotEmpty),
],
);

The first test validates the loading state. In the second case, we validate to fetch users using a bloc test. For looking at this, you can write many more tests.

Integration test

This is the second step of the integration test. The UI is being validated. It is very useful for testing user interfaces. For the home page, I’m using integration test.

For integration tests, I prefer to use the patrol package. It simplifies the process of making UI tests. There are many useful features in this package, and it supports Flutter natively.

You can learn more about integration tests from [patrol](https://patrol.dev/docs/getting-started) with [flutter website.](https://docs.flutter.dev/cookbook/testing/integration/introduction)

My home screen looks like this. Here’s a test we can write.

My steps:

  • Wait to load home page
  • Tap to button
  • Wait to load users
  • Check to users

My test file for this case is as follows:

patrolTest(
'Open home page and press button',
($) async {
await app.main();
await $.pumpAndSettle();
await $(FloatingActionButton).tap();
await $.pumpAndSettle();
await $('1').waitUntilVisible();
$(Scrollable).containing(Text);
expect($('q'), findsWidgets);
},
);

It is easy to find the same widget for $() function in the patrol. For looking at this, you can write many more tests.

Widget test

This is an idea for testing minimal components. It is possible to find any problem directly related to the behavior of the UI component.

   /// my user list for using widget test
final users = [
User(userId: 1, body: 'body 1'),
User(userId: 2, body: 'body 2'),
User(userId: 3, body: 'body 3'),
];

await tester.pumpWidget(
MaterialApp(
home: Scaffold(
appBar: AppBar(
actions: [
Builder(
builder: (context) {
return IconButton(
onPressed: () {
context.route
.navigateToPage(const HomeDetailView(id: 'id'));
},
icon: const Icon(Icons.details),
);
},
),
],
),
body: HomeUserList(users: users),
),
),
);


/// test steps
for (final item in users) {
expect(find.text(item.userId.toString()), findsOneWidget);
}

await tester.tap(find.byType(IconButton));
await tester.pumpAndSettle();
expect(find.text('Home Detail View'), findsOneWidget);

13. Pigeon, Fastlane, App Screen generator.

We discussed many topics regarding this architecture approach at least. For this project, I’m going to show you another topic.

  • Pigeon — This makes native communication between Flutter and Native easier.
  • A Fastlane is a tool that helps to build and release iOS and Android apps.
  • The App Screen generator helps make a screen for iOS and Android apps.

They are helping to fix some issues at any time. It is sometimes necessary to communicate with the native layer of iOS Swift and Android Kotlin. You will be able to solve your problem very easily with this package.

We are helping to make a build and release for iOS and Android. For releases, it is very useful.

The App Screen Generator helps you make an app screen for iOS and Android. Design professionals find it very useful.

Pigeon: Generate native code more effective and easy

First, let’s talk about pigeons.By looking at your dart code, this package writes to native code. You create logic in Dart and Pigeon will generate native Swift and Kotlin code for you. It is just a matter of putting it in your project and using it.

enum Code { one, two }

class MessageData {
MessageData({required this.code, required this.data});
String? name;
String? description;
Code code;
Map<String, String> data;
}

@HostApi()
abstract class ExampleHostApi {
String getHostLanguage();

// These annotations create more idiomatic naming of methods in Objc and Swift.
@ObjCSelector('addNumber:toNumber:')
@SwiftFunction('add(_:to:)')
int add(int a, int b);

@async
bool sendMessage(MessageData message);
}

Check out [pigeon](https://github.com/flutter/packages/tree/main/packages/pigeon/example)

Fastlane: Build your own delivery pipeline

Fastlane is an excellent tool for making CDs work locally or remotely. I have been involved with Fastlane for many years. You can use Fastlane packages or write your own scripts.

VERSION_NUMBER = "3.0.0"

platform :ios do
before_all do
end
desc "Push a new beta build to TestFlight"
lane :beta do
enable_automatic_code_signing

increment_version_number(
version_number: VERSION_NUMBER
)

increment_build_number({
build_number: latest_testflight_build_number + 1
})

gym(scheme: "Runner",
xcargs: "-allowProvisioningUpdates",
)
upload_to_testflight
end
end

This script is very simple, as you can see. The first thing I’m doing is incrementing the version number and build number. After that, I will build and upload to TestFlight.

Store Screen Generator

Making a screen for iOS and Android to access the play store and app store is the last topic. For making a screen, this website is very useful.

https://studio.app-mockup.com/

It offers a variety of free templates for creating app store screens. Making a screen is as simple as selecting a template.

Conclusion

This article will help you create your own path by exploring various technical ideas. After reading and understanding this article, along with reviewing the source code, you’ll gain several ideas for your new project. You can check out my source code and submit a pull request if needed.

The repository includes a template option that you can use when starting a new project.

Additionally, I’ve created a video series in Turkish, spanning over 9 hours, to complement this article. I’ve included the link to the video below.

Thank you for reading!
See you in the next article.

--

--

Flutter Community
Flutter Community
Veli Bacık
Veli Bacık

Responses (7)