Creating and Releasing an App with Flutter: Part I

Written by alphamikle | Published 2022/06/21
Tech Story Tags: flutter | dart | stocks | how-to | creating-flutter-app | releasing-flutter-app | flutter-tutorial | flutter-app-development

TLDRIn this series of articles, I would like to show how applications can be created using Flutter. I use this technology at work, as well as my own projects on an ongoing basis since 2018. I have several Open Source solutions (popular and not so popular) that will be applied in this application. I will touch on almost all aspects of development with Flutter, except explicit interaction with the native part. I already have the top-level idea of ​​the application in my head, and the code for this article and the next one has already been written.via the TL;DR App

Hello! In this series of articles, I would like to show how applications can be created using Flutter. I use this technology at work, as well as my own projects on an ongoing basis since 2018. I have several Open Source solutions (popular and not so popular) that will be applied in this application (not for the sake of a tick, but as a solution to emerging problems).

In the process of working on this application, I will touch on almost all aspects of development with Flutter, except explicit interaction with the native part (when you have to write the native code yourself), but if you have a desire to see this, then please comment. And most importantly, I already have the top-level idea of ​​the application in my head, and the code for this article and the next one has already been written, but if you have ideas that could be implemented in this application within the law corridor of the original ideas - please share them in the comments.

Application Idea

Initially, I did not know what this application would be about when I had the idea of writing these articles. All I wanted was to show the whole process from start to finish, and also, why hide it? - show the use of their Open Source solutions on such a semi-real project, in order to be able to refer to it, and not just to the examples in these packages. Actually, the idea came up quite recently I found a good free API with exchange information (stocks, quotes, etc.), which has both a large amount of data and methods for receiving real-time quote changes (via web-socket). There were thoughts to make something like a catalog of books - but it's too simple, and there were no other thoughts.

Based on such a subject area, I started looking for some interesting UI layout, and found this:

As a basis, I will take the colors, screen layout, and style of the charts from this layout. Screens, as I think so far, there will be two:

The first is a list of all the positions that are on the exchange (right now, having written these lines, I thought it would be nice to do a search on it, because, looking ahead, there will be about 28,000 of these positions).

This piece of design will serve as the basis:

The only thing is that I quickly looked for some API to get index pictures, as they are drawn here for SPOT and MSFT, so there will be no pictures in my implementation. But if you know something that will allow you to solve this problem without resorting to manual image search, please share it.

The second screen is a transition to the page of the position itself. It will be the most interesting - I planing to implement a chart of position quotes, a display of the current price, and a small game element - two buttons Up / Down (as in binary options, but without real money, cheating and only for the pleasure and interest). You will get such a mini-game application where you can not only watch quotes, but also "play" - I will enter a win counter and something related to it (I have not worked out this aspect in detail yet - write ideas).

Implementation

Well, I described the idea, it's time to get down to business. I will not write a detailed manual on how to deploy a new Flutter project, so we immediately start writing code. Based on all of the above, I can describe the structure of the project as follows:

/root
  /service
    /routing
    /di
    /...
  /domain
    /main
      /dto
      /model
      /logic
      /ui
    /position
      /dto
      /model
      /logic
      /ui

We'll start by implementing the service layer:

DI

Here, a developer can come to the aid of a large number of different packages that solve this problem - with or without code generation, with a large number of boilerplates and not, but it seems to me that this is a rather trivial task, and it is very easy to solve it yourself. So let's do it! All logic fits in two files - the container itself, and the logic for adding dependencies to it:

import 'package:flutter/cupertino.dart';

class Di {
  static final Map<String, dynamic> _dependencies = <String, dynamic>{};
  static final Map<String, ValueGetter<dynamic>> _builders = <String, ValueGetter<dynamic>>{};

  static String _generateDiCode<T>([String name = '']) {
    return '$T$name';
  }

  static void reg<T>(ValueGetter<T> builder, {String name = '', bool asBuilder = false}) {
    final String code = _generateDiCode<T>(name);
    if (asBuilder) {
      _builders[code] = builder;
    } else {
      _dependencies[code] = builder();
    }
  }

  static T get<T>({String name = ''}) {
    final String code = _generateDiCode<T>(name);
    late T value;
    if (!_dependencies.containsKey(code) && !_builders.containsKey(code)) {
      throw Exception('Dependency for type $T with code $code not registered');
    } else if (_dependencies.containsKey(code)) {
      value = _dependencies[code];
    } else {
      value = _builders[code]!();
    }
    return value;
  }
}

As in other solutions, we can implement identical entities by adding our own prefixes to them, and create singletons, as well as constantly new class instances.

The second file: adding dependencies to the container itself so that it has something to create and return:

import 'package:flutter/cupertino.dart';
import 'package:high_low/service/di/di.dart';
import 'package:high_low/service/routing/default_router_information_parser.dart';
import 'package:high_low/service/routing/page_builder.dart';
import 'package:high_low/service/routing/root_router_delegate.dart';

void initDependencies() {
  Di.reg<BackButtonDispatcher>(() => RootBackButtonDispatcher());
  Di.reg<RouteInformationParser<Object>>(() => DefaultRouterInformationParser());
  Di.reg<RouterDelegate<Object>>(() => RootRouterDelegate());
  Di.reg(() => PageBuilder());
}

In the future, to add new dependencies, it will be enough to register their factories in this function and everything will work as it should.

Routing

The second aspect is one of the most difficult in any application. I will use the Navigator 2.0 approach (here great article on it if you haven't used it yet).

In fact, everything is not very complicated, and according to this scheme:

We need to implement the following classes:

  • RouteInformationProvider
  • RouteInformationParser
  • RouterDelegate
  • Router

I have already spoiled their implementation in the DI container, let's see what's inside.

RouteInformationProvider

Represents a provider of additional information that will be added to the URL that is being navigated and passed on to RouteInformationParser. In general, this is not a required piece of navigation logic in our case, so its implementation remains in question for now.

RouteInformationParser

It should parse the URL, extract the necessary parameters from it, and pass them further - to RouterDelegate. Here is our implementation code (currently):

import 'package:flutter/cupertino.dart';
import 'package:high_low/service/routing/route_configuration.dart';
import 'package:high_low/service/routing/routes.dart';

class DefaultRouterInformationParser extends RouteInformationParser<RouteConfiguration> {
  @override
  Future<RouteConfiguration> parseRouteInformation(RouteInformation routeInformation) {
    return Future.sync(() => Routes.getRouteConfiguration(routeInformation.location ?? Routes.root()));
  }
}

We are also interested in the RouteConfiguration class, here it is:

import 'package:flutter/cupertino.dart';
import 'package:high_low/service/logs/logs.dart';
import 'package:high_low/service/routing/routes.dart';
import 'package:high_low/service/types/types.dart';
import 'package:json_annotation/json_annotation.dart';

part 'route_configuration.g.dart';

@immutable
@JsonSerializable()
class RouteConfiguration {
  const RouteConfiguration({
    required this.initialPath,
    required this.routeName,
    required this.routeParams,
  });

  const RouteConfiguration.empty({
    required this.initialPath,
    required this.routeName,
  }) : routeParams = const RouteParams(params: <String, String>{}, query: <String, String>{});

  factory RouteConfiguration.unknown() => RouteConfiguration.empty(initialPath: Routes.unknown(), routeName: Routes.unknown());
  factory RouteConfiguration.fromJson(Json json) => _$RouteConfigurationFromJson(json);

  final String initialPath;
  final String routeName;
  final RouteParams routeParams;

  Json toJson() => _$RouteConfigurationToJson(this);

  @override
  String toString() => prettyJson(toJson());
}

@immutable
@JsonSerializable()
class RouteParams {
  const RouteParams({
    required this.params,
    required this.query,
  });

  factory RouteParams.fromJson(Json json) => _$RouteParamsFromJson(json);

  final Json params;
  final Json query;

  Json toJson() => _$RouteParamsToJson(this);
}

Here you can notice the appearance of another package - json_annotation, it is needed to generate constructors and class methods for serialization to JSON and de-serialization from JSON. It must be installed together with a couple more:

dependencies:
  json_annotation: ^4.3.0
  #...

dev_dependencies:
  build_runner: ^2.1.4
  json_serializable: ^6.0.1
  #...

If we talk about the functionality of the class itself, any incoming url is converted into it and from it we will take the parameters of interest to us for the further RouterDelegate logic. For example, for such an incoming deep link flutter run --route="/item/AAPL?interval=day" we get the following RouteConfiguration:

{
  "initialPath": "/item/AAPL?interval=day",
  "routeName": "/item/:itemCode",
  "routeParams": {
    "params": {
      "itemCode": "AAPL"
    },
    "query": {
      "interval": "day"
    }
  }
}

This transformation of the url into a configuration takes place in the Routes.getRouteConfiguration(...) method:

import 'package:high_low/service/routing/route_configuration.dart';

typedef RouteParamName = String;
typedef RouteParamValue = String;

const String itemCode = 'itemCode';

abstract class Routes {
  static String root() => '/';
  static String item(String itemCode) => '/item/$itemCode';
  static String unknown() => '/404';

  static List<String> names = [
    Routes.root(),
    Routes.item(':$itemCode'),
    Routes.unknown(),
  ];

  static RouteConfiguration getRouteConfiguration(String route) {
    if (route == Routes.root()) {
      return RouteConfiguration.empty(initialPath: route, routeName: Routes.root());
    }
    final Uri routeUri = Uri.parse(route);
    final List<String> routeSubPaths = routeUri.pathSegments;
    if (routeSubPaths.isEmpty) {
      return RouteConfiguration.empty(initialPath: route, routeName: Routes.unknown());
    }
    for (final String routeName in names) {
      final List<String> routeNameSubPaths = routeName.split('/').where((String segment) => segment.isNotEmpty).toList();
      if (routeNameSubPaths.length != routeSubPaths.length) {
        continue;
      }
      bool isTargetName = true;
      final Map<RouteParamName, RouteParamValue> params = {};
      for (int i = 0; i < routeSubPaths.length; i++) {
        final String routeSubPath = routeSubPaths[i];
        final String routeNameSubPath = routeNameSubPaths[i];
        final bool isDynamicSubPath = routeNameSubPath.contains(':');
        if (routeSubPath != routeNameSubPath && !isDynamicSubPath) {
          isTargetName = false;
          break;
        } else if (isDynamicSubPath) {
          params[routeNameSubPath.replaceFirst(':', '')] = routeSubPath;
        }
      }
      if (isTargetName) {
        return RouteConfiguration(initialPath: route, routeName: routeName, routeParams: RouteParams(params: params, query: routeUri.queryParameters));
      }
    }
    return RouteConfiguration.empty(initialPath: route, routeName: Routes.unknown());
  }
}

This logic can be extended. For example - now this code will not process array query parameters, like /item/AAPL?interval=month,day, but on another way of specifying array parameters: /item/AAPL?interval=month&interval=day - Flutter does not start with the following error:

ProcessException: Process exited abnormally:
Starting: Intent { act=android.intent.action.RUN flg=0x20000000 (has extras) }

/system/bin/sh: --ez: inaccessible or not found
Error: Activity not started, unable to resolve Intent { act=android.intent.action.RUN flg=0x30000000 (has extras) }
  Command: C:\Users\Me\AppData\Local\Android\sdk\platform-tools\adb.exe -s emulator-5554 shell am start -a android.intent.action.RUN -f 0x20000000 --ez enable-background-compilation true --ez enable-dart-profiling true --es route /item/AAPL?interval=month&interval=day --ez enable-checked-mode true --ez verify-entry-points true --ez start-paused true com.alphamikle.high_low/com.alphamikle.high_low.MainActivity

In general, you can safely take this code as a basis, but you will still need to refine it for the specific URLs of your project.

RouterDelegate

import 'dart:async';

import 'package:flutter/material.dart';
import 'package:high_low/domain/main/ui/main_view.dart';
import 'package:high_low/service/di/di.dart';
import 'package:high_low/service/logs/logs.dart';
import 'package:high_low/service/routing/page_builder.dart';
import 'package:high_low/service/routing/route_configuration.dart';
import 'package:high_low/service/routing/routes.dart';

class RootRouterDelegate extends RouterDelegate<RouteConfiguration> with ChangeNotifier, PopNavigatorRouterDelegateMixin<RouteConfiguration> {
  RootRouterDelegate() : navigatorKey = GlobalKey();

  @override
  final GlobalKey<NavigatorState> navigatorKey;

  PageBuilder get pageBuilder => Di.get();

  final List<Page> pages = [];

  @override
  RouteConfiguration currentConfiguration = RouteConfiguration.empty(initialPath: Routes.root(), routeName: Routes.root());

  bool onPopRoute(Route<dynamic> route, dynamic data) {
    if (route.didPop(data) == false) {
      return false;
    }
    pages.removeLast();
    notifyListeners();
    return true;
  }

  Future<void> mapRouteConfigurationToRouterState(RouteConfiguration configuration) async {
    final String name = configuration.routeName;
    pages.clear();
    if (name == Routes.unknown()) {
      // openUnknownView();
      Logs.warn('TODO: Open Unknown View');
    }
  }

  @override
  Future<void> setNewRoutePath(RouteConfiguration configuration) async {
    Logs.debug('setNewRoutePath: $configuration');
    currentConfiguration = configuration;
    await mapRouteConfigurationToRouterState(configuration);
    notifyListeners();
  }

  @override
  Widget build(BuildContext context) {
    return Navigator(
      key: navigatorKey,
      pages: [
        pageBuilder.buildUnAnimatedPage(const MainView(), name: Routes.root()),
        ...pages,
      ],
      onPopPage: onPopRoute,
    );
  }
}

This is just the basis of the delegate, but what's interesting here is the mapRouteConfigurationToRouterState method, which is called from the setNewRoutePath method - which, in turn, processes the routing configurations coming here from RouteInformationParser. In the future, we will write navigation methods here.

Logging

The last point is logging. Everything is quite simple here - I made a small wrapper on top of the logging library, which, in my opinion, provides some of the best logging capabilities. Now we can pass any arguments to the logging methods.

import 'dart:convert';

import 'package:logger/logger.dart' as logger;

String _getJoinedArguments(dynamic p1, [dynamic p2, dynamic p3]) {
  String result = p1.toString();
  result += p2 == null ? '' : ' ${p2.toString()}';
  result += p3 == null ? '' : ' ${p3.toString()}';
  return result;
}

String prettyJson(Map<String, dynamic> json) {
  const JsonEncoder jsonEncoder = JsonEncoder.withIndent(' ');
  return jsonEncoder.convert(json);
}

final _logger = logger.Logger(
  printer: logger.PrefixPrinter(
    logger.PrettyPrinter(
      colors: true,
      printEmojis: false,
      methodCount: 0,
      errorMethodCount: 3,
      stackTraceBeginIndex: 0,
    ),
  ),
);

abstract class Logs {
  static void debug(dynamic p1, [dynamic p2, dynamic p3]) {
    _logger.d(_getJoinedArguments(p1, p2, p3));
  }

  static void info(dynamic p1, [dynamic p2, dynamic p3]) {
    _logger.i(_getJoinedArguments(p1, p2, p3));
  }

  static void warn(dynamic p1, [dynamic p2, dynamic p3]) {
    _logger.w(_getJoinedArguments(p1, p2, p3));
  }

  static void error(dynamic p1, [dynamic p2, dynamic p3]) {
    _logger.e(_getJoinedArguments(p1, p2, p3));
  }

  static void fatal(dynamic p1, [dynamic p2, dynamic p3]) {
    _logger.wtf(_getJoinedArguments(p1, p2, p3));
  }

  static void trace(dynamic p1, [dynamic p2, dynamic p3]) {
    _logger.v(_getJoinedArguments(p1, p2, p3));
  }

  static void pad(dynamic p1, [dynamic p2, dynamic p3]) {
    print(_getJoinedArguments(p1, p2, p3));
  }
}

Conclusion

At the moment, the most basic logic has been implemented, which is needed for further development of business functionality. The source code of the current part can be viewed here.


Written by alphamikle | Lead software engineer
Published by HackerNoon on 2022/06/21