Speeding up development with the Mason package on Flutter

Hello, my name is Alexander and I’m a Flutter developer at the InstaDev agency. In the process of work, gradually came the realization of how much time you have to spend writing boilerplate code. Armed with the desire to optimize the process, I found a solution: the current and evolving Mason package. What he can do, how to make friends with him, and what was the path from hello world to a flexible and customizable generator – I tell in this article.

A few introductory

Mason is an open source template generator that can be applied to any programming language. It works using the mustache templating engine, whose simple syntax allows you to flexibly customize the generation, taking into account various conditions and variables set by the user. Mason also provides a console interface for managing created templates (here they are called “bricks” – bricks) and a brickhub ecosystem for finding already implemented templates and publishing your own designs.

Other useful mason features include support for executing scripts before and after generation (currently only in Dart), importing bricks into a project directly from a git repository, building templates into one package for further use by programmatic methods and implementing, for example, your own own console interface for templates.

Issues

Unfortunately, boilerplate code is everywhere. It begins to haunt the poor programmer from the moment the project is initialized (which, of course, is created according to a template), and any new functionality requires its adjustment to the existing structure and architectural solutions. Part of the work will be taken over by code generator libraries, something can be entrusted to the IDE, but there are still a lot of tedious and boring Ctrl+C-Ctrl+V moments. Creating a folder structure, files, correct names, initializing the necessary libraries: all this takes time that can be spent on more demanding and creative tasks. It was here that the idea came to attend to the creation of a supply of bricks for all occasions.

Situation

In the project, we adhere to Clean Architecture, use Riverpod for state management, Hive for local data storage and Chopper for server requests. The structure of an average feature looks something like this:

Feature.  When opening each folder, all this does not fit on the screen
Feature. When opening each folder, all this does not fit on the screen

At the same time, a solid part of the code is inevitably repeated: creating a widget for the page, initializing code for state objects, interfaces with standard get and set methods, and so on and so forth. Since a similar stack and architecture is used in several actively developed projects at once, it was decided to entrust all these simple operations, along with the creation of the necessary folders and files, to mason.

Solution

It is worth noting that brickhub already has quite a few templates of varying degrees of sophistication, including templates for creating new functionality, but taking into account the set of technologies, specific design details and the desire for finer tuning, it was decided to create our own. Just this process, together with all the obstacles encountered, I will try to describe further.

General information about mason

When creating a template, the sequence of actions and basic features look like this:

  1. Run command mason new brick_name to initialize the necessary files.

  2. Setting variables in the generated file brick.yaml with the obligatory indication of the name and type. Right now we can create a variable to store the name of the functionality. She will surely come in handy.

vars:
  feature_name:
    type: string
    description: Feature name
    default: feature
    prompt: What is your feature?
  1. Create a template. Setting the desired folder structure occurs naturally within the folder __brick__. Anywhere, both in the contents of the files and in the names, you can use mustache to substitute the values ​​of variables. To begin with, let’s create a folder with the name of the functionality, where all other files will be added.

    template folder.
    template folder.

The curly braces here indicate the reference to the variable – its value will be substituted as the file name during generation. Calling snakeCase in this case is using mason’s built-in lambda functions, which provide the ability to change string values ​​in accordance with different types of naming, which we will use more than once. Their syntax is simplified compared to the lambda functions in mustache, but you can use the classic version if you wish.

{{#snakeCase}}{{feature_name}}{{/snakeCase}}

Presentation layer

The main task here is to generate a page widget with a suitable name and the ability to choose the type of the widget itself – stateless or stateful. This is somewhat complicated by the fact that riverpod has its own class system for widgets, and it would be nice to choose from more of them.

As a result, we add two more variables to the template.

stateful:
    type: boolean
    description: Whether the page widget is stateful or stateless. True if stateful, false otherwise.
    default: false
    prompt: Is page widget stateful or stateless? True if stateful, false otherwise.

consumer:
    type: boolean
    description: Whether the page widget use providers.
    default: false
    prompt: Is widget using providers?

However, stateless and stateful widgets differ in structure, which makes applying conditions directly inside them rather inconvenient. Here you can use the mechanism of partials: create separate files for both types of widgets, and then “import” the one you need depending on the values ​​of the variables. Unfortunately, at the moment mason only allows the use of those partial files that are in the root of the template, which, although it does not interfere with their use, does not allow them to be distributed closer to the area of ​​responsibility.

According to the rules of mustache, partial is marked with a “~” before the name.

Created partial for stateless widget
Created partial for stateless widget
The file where the widget code should be located
The file where the widget code should be located

The content of the template file is extremely simple: one variable check statefulto determine which partial’s content should be substituted. If a stateful true, then what is enclosed in the tag will be substituted {{#stateful}}...{{/stateful}}. The tag is used for the reverse condition {{^stateful}}...{{/stateful}}

{{#stateful}}
{{> stateful_page }}
{{/stateful}}
{{^stateful}}
{{> stateless_page }}
{{/stateful}}

Inside the partial is the widget code with checking the consumer variable to change the widget class and the build method in case it is supposed to access providers.

class {{feature_name.pascalCase()}}Page extends {{#consumer}}Consumer{{/consumer}}{{^consumer}}Stateless{{/consumer}}Widget {
    const {{feature_name.pascalCase()}}Page({
    super.key,

  });

  @override
  Widget build(BuildContext context{{#consumer}}, WidgetRef ref{{/consumer}}) {
    return Scaffold(
        body: SafeArea(
          child: SizedBox(),
        ),
    );
  }
}

Business logic layer

When creating a template for the state notifier, all the same tricks were used: lambda functions to bring the text inside feature_name in a suitable form, conditions that add a data stream listener from the repository (which is yet to come) to the class, substitution of the desired data type, for which partials are again used.

final {{feature_name.pascalCase()}}NotifierProvider = StateNotifierProvider.autoDispose<{{feature_name.pascalCase()}}Notifier, {{feature_name.pascalCase()}}>(
  (ref) {
    return {{feature_name.pascalCase()}}Notifier(
      repository: ref.read({{feature_name.camelCase()}}RepositoryProvider),
    );
  },
);

class {{feature_name.pascalCase()}}Notifier extends StateNotifier<{{> entity_for_repo_response }}> {
  {{feature_name.pascalCase()}}Notifier({
    required {{feature_name.pascalCase()}}Repository repository,
  })  : _repository = repository, super(const {{feature_name.pascalCase()}}()){
    {{#with_repository_stream}}
    _subscription = _repository.{{feature_name.camelCase()}}Stream.listen((event) {
      event.when(
        left: (e) => null,
        right: (data) {

        },
      );
    });
    {{/with_repository_stream}}
  }

  final {{feature_name.pascalCase()}}Repository _repository;

  {{#with_repository_stream}}
  late final StreamSubscription _subscription;

  @override
  void dispose() {
    _subscription.cancel();
    super.dispose();
  }
  {{/with_repository_stream}}
}

Also on this layer is the repository interface – a small template that defines a method for receiving data and a stream, if necessary.

abstract class {{feature_name.pascalCase()}}Repository {
  {{#with_repository_stream}}abstract final Stream<Either<DataError, {{> entity_for_repo_response }}>> {{feature_name.camelCase()}}Stream;{{/with_repository_stream}}
  Future<Either<DataError, {{> entity_for_repo_response }}>> get{{feature_name.pascalCase()}}();
}

The already mentioned partial entity_for_repo_response substitutes either a single entity or a list of them as a returned object, avoiding piling up conditions in a template file.

{{#single_entity_in_response}}List<{{feature_name.pascalCase()}}>{{/single_entity_in_response}}{{^single_entity_in_response}}{{feature_name.pascalCase()}}{{/single_entity_in_response}}

This layer also contains an entity class template without interesting details – an empty class with a name corresponding to the name of the functionality.

class {{feature_name.pascalCase()}} {
  const {{feature_name.pascalCase()}}();
}

data layer

The repository implementation pattern is fairly straightforward – mason only checks the user’s wishes about what data sources should be available, and given the variables with_service and with_cache adds appropriate dependencies to the class.

final {{feature_name.camelCase()}}RepositoryProvider = Provider<{{feature_name.pascalCase()}}Repository>(
  (ref) {
    return {{feature_name.pascalCase()}}RepositoryImpl(
      errorBus: ref.read(errorBusProvider),
      {{#with_service}}service: {{feature_name.pascalCase()}}ServiceImpl.create(
        client: ref.read(chopperClientProvider),
      ),{{/with_service}}
      {{#with_cache}}cache: ref.read({{feature_name.camelCase()}}CacheProvider),{{/with_cache}}
    );
  },
);

class {{feature_name.pascalCase()}}RepositoryImpl extends DataRepository implements {{feature_name.pascalCase()}}Repository {
  {{feature_name.pascalCase()}}RepositoryImpl({
    required super.errorBus,
    {{#with_service}}required this.service,{{/with_service}}
    {{#with_cache}}required this.cache,{{/with_cache}}
  });

  {{#with_service}}final {{feature_name.pascalCase()}}Service service;{{/with_service}}
  {{#with_cache}}final {{feature_name.pascalCase()}}Cache cache;{{/with_cache}}

  {{#with_repository_stream}}
  @override
  late final {{feature_name.camelCase()}}Stream = _{{feature_name.camelCase()}}Stream.stream;
  final _{{feature_name.camelCase()}}Stream =
      BehaviorSubject<Either<DataError, {{> entity_for_repo_response }}>>();
  {{/with_repository_stream}}

  @override
  Future<Either<DataError, {{> entity_for_repo_response }}>> get{{feature_name.pascalCase()}}() async {
    throw UnimplementedError();
  }

}

It is also possible to make optional files for each data source – for this you need to place the entire name of the generated file inside a conditional tag.

{{#with_cache}}{{feature_name.snakeCase()}}_cache.dart{{/with_cache}}

The generated template will look rather strange, because the system will perceive the slash in the closing tag as a transition into the directory, but when generated, everything will work as expected.

split file
split file

With the with_service tag, the same situation will come out. When generating mappings and DTO classes, nothing new was used, so we will omit their description.

Scripts

As already mentioned, mason can run predefined scripts called hooks when it is built. For them, the entire generator context will be provided: user-defined variables that can be both read and modified, and a logger to display information about the generation process.

We’ll add a simple script that runs after generation completes, reports success, and runs build_runner to build the files generated by Hive, Chopper, and the JsonSerializable library. The script itself must contain a method runreceiving HookContext as an argument.

import 'dart:io';

import 'package:mason/mason.dart';

void run(HookContext context) {
  context.logger.info('${context.vars['feature_name']} created at');
  context.logger.info('${Directory.current.path}');

  Process.start(
    'flutter',
    ['pub', 'run', 'build_runner', 'build', '--delete-conflicting-outputs'],
  ).then((process) => process.stdout.pipe(stdout));
}

launch

Now that all the templates are written and the scripts are ready, it’s time to use the results. The most convenient option is to put the bricks in a separate repository and in the project, in the bricks.yaml file, specify links to them, entrusting the work of downloading to mason. Unfortunately, this approach is extremely system dependent. The fact is that the names of files and directories in the template are quite long due to the active use of tags, which is why, due to Windows system restrictions on the maximum path length, mason simply cannot use files downloaded from the git. This problem is already reflected in one of the issue in the mason repository, but at the time of writing the article has not been fixed.

However, you can point mason to a locally loaded template, and by making it globally available, use it in any project you need.

To start, given the number of available variables, it is convenient to make a JSON configuration file and use it during the build, without wasting time answering about the required values ​​in the console. After that, the entire process of creating template files is reduced to one command.

mason make feature -c config.json -o ./path/to/directory

conclusions

Mason is a surprisingly simple tool with a lot of power to automate the creation of repetitive code. Mastering it does not require serious time costs (compared to the constant need to copy and paste something for sure), which allows you to quickly integrate it into your workflow. This is still a young development, in which there are bugs, and there is plenty of room for new features. For example, the functionality for point modification of files instead of their complete rewriting would not interfere much. So it would be possible to implement the addition of path files available in the application and much more.

But despite all this, it is convenient to use it both for generating small standard files required by some library, and for creating and distributing a brick castle from your own best practices.

Mason on pub.dev
Repository with developed template

Similar Posts

Leave a Reply

Your email address will not be published. Required fields are marked *