paint-brush
Creating and Releasing an App with Flutter: Part IIby@alphamikle
2,269 reads
2,269 reads

Creating and Releasing an App with Flutter: Part II

by Mike AlfaAugust 4th, 2022
Read on Terminal Reader
Read this story w/o Javascript
tldt arrow

Too Long; Didn't Read

This article is the second in a series on creating applications with Flutter. In this session I'll go through how to create a network layer, work with localization and work with assets in a useful way, use local search, and create a user interface for one of the two application screens. I'll also show some interesting metrics, including how much data your application can process in a millisecond and the size of the UI at which the UI starts to lag. To render the first screen, the following data is needed:

Companies Mentioned

Mention Thumbnail
Mention Thumbnail
featured image - Creating and Releasing an App with Flutter: Part II
Mike Alfa HackerNoon profile picture


Hello! This article is the second in a series on creating applications with Flutter. In this session, I'll go through how to create a network layer, work with localization, work with assets in a useful way, use local search, and create a user interface for one of the two application screens. I'll also show some interesting metrics, including how much data your application can process in a millisecond and the size of the JSON at which the UI starts to lag.

Table of contents

Part I

Part II (You reading it)

Network

To render the first screen, the following data is needed:

First screen

We can highlight the following elements of each token:

image  
title  
subtitle  
price  
diff  

Based on this, we get the following data structure to describe our token:

import 'package:flutter/foundation.dart';  
import 'package:json_annotation/json_annotation.dart';  
  
import '../../../service/types/types.dart';  
import 'item_prices.dart';  
  
part 'stock_item.g.dart';  
  
// BTC, ETH etc.  
typedef CryptoSymbol = String;  
  
/* Example of data:  
{  
  "id": 1,  
  "name": "Bitcoin",  
  "symbol": "BTC",  
  "max_supply": 21000000,  
  "circulating_supply": 18897568,  
  "total_supply": 18897568,  
  "platform": null,  
  "cmc_rank": 1,  
  "last_updated": "2021-12-11T03:44:02.000Z",  
  "quote": {  
    "USD": {  
      "price": 48394.083464545605,  
      "volume_24h": 32477191827.784477,  
      "volume_change_24h": 7.5353,  
      "percent_change_1h": 0.3400355,  
      "percent_change_24h": 0.05623531,  
      "percent_change_7d": -7.88809336,  
      "percent_change_30d": -25.12367453,  
      "percent_change_60d": -14.67776793,  
      "percent_change_90d": 6.86740691,  
      "market_cap": 914530483068.9261,  
      "market_cap_dominance": 40.8876,  
      "fully_diluted_market_cap": 1016275752755.46,  
      "last_updated": "2021-12-11T03:44:02.000Z"  
    }  
  }  
}  
 */  
@immutable  
@JsonSerializable()  
class StockItem {  
  const StockItem({  
    required this.id,  
    required this.name,  
    required this.symbol,  
    required this.prices,  
  });  
  
  factory StockItem.fromJson(Json json) => _$StockItemFromJson(json);  
  
  final int id;  
  final String name;  
  final CryptoSymbol symbol;  
  
  @JsonKey(name: 'quote')  
  final Map<CryptoSymbol, ItemPrices> prices;  
  
  ItemPrices get usdPrices => prices['USD']!;  
  
  String imageUrl(int size) {  
    assert(size > 128 && size <= 250);  
    return '<https://s2.coinmarketcap.com/static/img/coins/${size}x$size/$id.png>';  
  }  
  
  Json toJson() => _$StockItemToJson(this);  
}  
  

The id field appeared as a necessity for displaying currency logos. Since the original resource provides them just by id.


And one more entity that describes the prices of cryptocurrencies in fiat currency:

import 'package:flutter/foundation.dart';  
import 'package:json_annotation/json_annotation.dart';  
  
import '../../../service/types/types.dart';  
  
part 'item_prices.g.dart';  
  
@immutable  
@JsonSerializable()  
class ItemPrices {  
  const ItemPrices({  
    required this.price,  
    required this.diff1h,  
    required this.diff24h,  
  });  
  
  factory ItemPrices.fromJson(Json json) => _$ItemPricesFromJson(json);  
  
  final double price;  
  
  @JsonKey(name: 'percent_change_1h')  
  final double diff1h;  
  
  @JsonKey(name: 'percent_change_24h')  
  final double diff24h;  
  
  Json toJson() => _$ItemPricesToJson(this);  
}  
  

For serialization, I used json_serializable. So, all that remains is to load the data. And this is where code generation, in the form of retrofit, might be of use. With the help of this approach, we can avoid writing boilerplate altogether, but not at all.


We will place the network logic related to getting the list of crypts in the CryptoProvider class.

import 'package:dio/dio.dart';  
import 'package:high_low/domain/crypto/dto/stock_response.dart';  
import 'package:retrofit/http.dart';  
  
part 'crypto_provider.g.dart';  
  
@RestApi(baseUrl: '<https://pro-api.coinmarketcap.com/v1/>')  
abstract class CryptoProvider {  
  factory CryptoProvider(Dio dio, {String? baseUrl}) = _CryptoProvider;  
  
  @GET('cryptocurrency/listings/latest')  
  Future<StockResponse> fetchLatestData({  
    @Header('X-CMC_PRO_API_KEY') required String token,  
    @Query('limit') int limit = 1000,  
  });  
}  
  

Of course, we added the factory of CryptoProvider and Dio into the Di-registrar:

import 'package:dio/dio.dart';  
import 'package:flutter/widgets.dart';  
  
import '../../domain/crypto/logic/crypto_provider.dart';  
import '../routing/default_router_information_parser.dart';  
import '../routing/page_builder.dart';  
import '../routing/root_router_delegate.dart';  
import 'di.dart';  
  
void initDependencies() {  
  Di.reg<BackButtonDispatcher>(() => RootBackButtonDispatcher());  
  Di.reg<RouteInformationParser<Object>>(() => DefaultRouterInformationParser());  
  Di.reg<RouterDelegate<Object>>(() => RootRouterDelegate());  
  Di.reg(() => PageBuilder());  
  Di.reg(() => Dio(), asBuilder: true); // <--  
  Di.reg(() => CryptoProvider(Di.get()), asBuilder: true); // <--  
}  
  

At this stage, we have the following project structure (I omit some internals of service for now):

|-- domain  
|   `-- crypto  
|       |-- dto  
|       |   |-- item_prices.dart  
|       |   |-- stock_item.dart  
|       |   |-- stock_item_example.json  
|       |   `-- stock_response.dart  
|       `-- logic  
|           `-- crypto_provider.dart  
|-- high_low_app.dart  
|-- main.dart  
`-- service  
    |-- config  
    |-- di  
    |-- logs  
    |-- routing  
    |-- theme  
    |-- tools  
    |-- types  

If you are wondering how to get such a directory image, here is the answer. Well, at this stage, work on the network is completed, and we already have everything we need to display the main screen.

State management

So we are close to the UI with logic. Let's start with the last one. But, before we start describing the state of our application, we need to make a big lyrical digression. It's not a secret for those who develop applications on Flutter that Dart is a single-threaded language with the ability to run multiple, so-called Isolate, isolated threads with their own EventLoop and memory. And most developers often write all of their code "simply on one thread." They do not divide up heavy-cost operations that might obstruct the user interface into independent isolates (although I don't blame anyone; the standard API is incredibly clumsy; compute() is not something that saves; alternatively, various third-party libraries... well, who needs them? isolates-difficult indeed). Over time, unpleasant changes may occur in the application or the data arriving from the backend becomes more and more and everything starts to lag. Because of what? Let's do a little research.

Research

I ran three experiments five times for two environments. First environment: profile built on a flagship device (Samsung Galaxy Note 20 Ultra) in “normal use” mode-that is, I did not reboot the phone before each run, but unloaded the application from memory each time, and there were no other actively running applications. The second environment: a kind of simulation of a weak device that the user of your application may also have, is an emulator with the following settings:

  • 2048Mb RAM
  • 256Mb VM Heap
  • 4 Core CPUs


The emulator itself was running on a laptop with a Ryzen 7 5800H (which just recently burned down 😭). There were no background tasks (just open IDEA).


Now to the essence of the tests: for the main screen, we need to upload data about cryptocurrencies. I uploaded 100, 1000, and 5000 of them per request. At the end of the request, I measured the time required to convert the server response (byte array) into a raw JSON string, which is then deserialized into Map<String, dynamic>, all this is underhood logic Dio to which I added only time logging. The second operation that has been analyzed is the transformation of the map into business classes, with which we work in a real application.


In order to implement logging in Dio, I had to pretty much delve into its internal organs: all these transformations occur through the Transformer class. You can write this class yourself and feed Dio, or you can do nothing - then DefaultTransformer will be used. Here is the part of the standard transformer that is responsible for ensuring that you can get the output map (to the right of each added line there is a comment with the <-- prefix, which describes what is happening here):


Future transformResponse(RequestOptions options, ResponseBody response) async {  
  if (options.responseType == ResponseType.stream) {  
    return response;  
  }  
  var length = 0;  
  var received = 0;  
  var showDownloadProgress = options.onReceiveProgress != null;  
  if (showDownloadProgress) {  
    length = int.parse(response.headers[Headers.contentLengthHeader]?.first ?? '-1');  
  }  
  var completer = Completer();  
  var stream = response.stream.transform<Uint8List>(StreamTransformer.fromHandlers(  
    handleData: (data, sink) {  
      sink.add(data);  
      if (showDownloadProgress) {  
        received += data.length;  
        options.onReceiveProgress?.call(received, length);  
      }  
  },  
  ));  
  // let's keep references to the data chunks and concatenate them later  
  final chunks = <Uint8List>[];  
  var finalSize = 0;  
  
  int totalDuration = 0; // <-- Total computation time in microseconds  
  int networkTime = 0; // <-- Time (microseconds), which will spend to accumulate parts of network response  
  
  StreamSubscription subscription = stream.listen(  
  (chunk) {  
      final start = DateTime  
          .now()  
          .microsecondsSinceEpoch; // <-- Before saving each part of the data we start tracking the current time  
      finalSize += chunk.length;  
      chunks.add(chunk);  
      final now = DateTime  
          .now()  
          .microsecondsSinceEpoch; // <--  
      totalDuration += now - start; // <-- After the chunk of data was saved, we check spent time  
      networkTime += now - start; // <--  
    },  
    onError: (Object error, StackTrace stackTrace) {  
      completer.completeError(error, stackTrace);  
    },  
    onDone: () => completer.complete(),  
    cancelOnError: true,  
  );  
// ignore: unawaited_futures  
  options.cancelToken?.whenCancel.then((_) {  
    return subscription.cancel();  
  });  
  if (options.receiveTimeout > 0) {  
    try {  
      await completer.future.timeout(Duration(milliseconds: options.receiveTimeout));  
    } on TimeoutException {  
      await subscription.cancel();  
      throw DioError(  
        requestOptions: options,  
        error: 'Receiving data timeout[${options.receiveTimeout}ms]',  
        type: DioErrorType.receiveTimeout,  
      );  
    }  
  } else {  
  await completer.future;  
  }  
  final start = DateTime  
      .now()  
      .microsecondsSinceEpoch; // <-- Here we start tracking time before all chunks will be joined into the one Uint8List  
  final responseBytes = Uint8List(finalSize);  
  var chunkOffset = 0;  
  for (var chunk in chunks) {  
    responseBytes.setAll(chunkOffset, chunk);  
    chunkOffset += chunk.length;  
  }  
  totalDuration += DateTime  
      .now()  
      .microsecondsSinceEpoch - start; // <-- And adding the new portion of time  
  
  if (options.responseType == ResponseType.bytes) return responseBytes;  
  
  String? responseBody;  
  if (options.responseDecoder != null) {  
    responseBody = options.responseDecoder!(  
      responseBytes,  
      options,  
      response..stream = Stream.empty(),  
    );  
  } else {  
    final start = DateTime  
        .now()  
        .microsecondsSinceEpoch; // <-- We also tracked the decoding of the bytes into the string (raw JSON)  
    responseBody = utf8.decode(responseBytes, allowMalformed: true);  
    totalDuration += DateTime  
        .now()  
        .microsecondsSinceEpoch - start; // <--  
  }  
  if (responseBody.isNotEmpty && options.responseType == ResponseType.json && _isJsonMime(response.headers[Headers.contentTypeHeader]?.first)) {  
    final callback = jsonDecodeCallback;  
    if (callback != null) {  
      return callback(responseBody);  
    } else {  
      final start = DateTime  
          .now()  
          .microsecondsSinceEpoch; // <-- And finally - we track the decoding of the raw JSON string into the Map<String, dynamic>  
      final result = json.decode(responseBody);  
      totalDuration += DateTime  
          .now()  
          .microsecondsSinceEpoch - start; // <--  
      print('TOTAL PARSING TIME: ${totalDuration / 1000}ms; NETWORK TIME: ${networkTime / 1000}ms'); // <--  
      return result;  
    }  
  }  
  return responseBody;  
}  
  

Well, the second hero is the operation of converting a map into a business entity (for this I wedge logging into the class generated by retrofit, in which all the logic for obtaining data is described):

Future<StockResponse> fetchLatestData({required token, limit = 1000}) async {  
  const _extra = <String, dynamic>{};  
  final queryParameters = <String, dynamic>{r'limit': limit};  
  final _headers = <String, dynamic>{r'X-CMC_PRO_API_KEY': token};  
  _headers.removeWhere((k, v) => v == null);  
  final _data = <String, dynamic>{};  
  final _result = await _dio.fetch<Map<String, dynamic>>(_setStreamType<StockResponse>(Options(method: 'GET', headers: _headers, extra: _extra)  
      .compose(_dio.options, 'cryptocurrency/listings/latest', queryParameters: queryParameters, data: _data)  
      .copyWith(baseUrl: baseUrl ?? _dio.options.baseUrl)));  
  bench.start('STOCK RESPONSE DESERIALIZING'); // <-- At here we used the simple performance-tracker  
  final value = StockResponse.fromJson(_result.data!);  
  bench.end('STOCK RESPONSE DESERIALIZING'); // <--  
  return value;  
}  
  

It is also worth showing the code of the performance tracker itself used above:

class _Benchmark {  
  final Map<String, int> _starts = <String, int>{};  
  
  void start(dynamic id) {  
    final String benchId = id.toString();  
    if (_starts.containsKey(benchId)) {  
      Logs.warn('Benchmark already have comparing with id=$benchId in time');  
    } else {  
      _starts[benchId] = DateTime  
          .now()  
          .microsecondsSinceEpoch;  
    }  
  }  
  
  double end(dynamic id) {  
    final String benchId = id.toString();  
    if (!_starts.containsKey(benchId)) {  
      throw Exception('In Benchmark not placed comparing with id=$benchId');  
    }  
    final double diff = (DateTime  
        .now()  
        .microsecondsSinceEpoch - _starts[benchId]!) / 1000;  
    final String info = '$benchId need ${diff}ms';  
    print(info);  
    _starts.remove(benchId);  
    return diff;  
  }  
}  
  
final _Benchmark bench = _Benchmark();  
  


As someone there said:

It's better to show a table of data than to beat around the bush


Therefore, here is the spreadsheet, with additional field annotation:


  • Count - number of cryptocurrency items downloaded per request (there are at least 5000 types of cryptocurrencies in the world 😟)
  • Rows - number of rows in JSON (if you do Beautify in Postman)
  • Size - data size in kilobytes
  • [P] / [D] - environment prefix, Profile/Debug (described above)
  • JSON - time in milliseconds spent directly for Dio to return the map to us
  • Entity - time in milliseconds spent to convert the map into business entities
  • Total - sum of JSON + Entity
  • kB / ms - metric meaning “how many kilobytes can be converted in one millisecond”

Table with metrics


Table!


Here are my findings from this test:


  1. In the best case, if the user has a device in the upper price segment, we can expect that it will be able to process up to ~18kB/ms (perhaps the newest flagships will be capable of more and we will check it in the next episode of our series).
  2. A remark about the worst case: since the [D] environment was launched on an emulator with JIT compilation, we have some negative extremes associated with the fact that the code has not warmed up yet. This is clearly seen in the data volume of 100 units—an extremely long time was spent, which is outside of the statistics. Therefore, I will not take the value of 2.629kB / ms as the minimum, but I will take 8.603kB / ms as closer to reality. We conclude: we can count on the fact that the user's device will be able to process at least ~9kB/ms
  3. We will proceed from the fact that an increasing number of devices have screens with a refresh rate of 120FPS, which means that we have only 8ms to render one frame. Of these 8ms, the rendering process itself takes some time; approximately, on average, it will be 2ms. In total, we have only 6ms left to do something and not lose a frame. And that means we can count on the fact that the user device will be able to process a request with a response size of (18 + 9) / 2 * (8 - 2) = 81kB, so as not to lose a single frame (ideally, if there are no other negative factors). If the display is 60FPS then (18 + 9) / 2 * (16 - 2) = 189kB


What to do with this information? Well, for example, we can conclude that if we try to parse JSON in 1mb in the main thread of the application, then we are guaranteed to get a lag of 80-160ms, and this will already be evident to the user. If you have a lot of requests with bold responses, the interface will lag much more often. How you can deal with this, I already once told. And it's time to continue this old story.

Isolate

With the release of Dart 2.15, there have been positive changes in the possibilities of using isolates. The main innovation is the new method - Isolate.exit(), which allows you to exit the current third-party isolate by passing in SendPort data that will arrive at the corresponding ReceivePort in constant time. At the same time, deep copying, which happened before, before the appearance of this method, does not occur, which means that we will not block our UI thread when it receives a large portion of data at once from a third-party isolate. All this is available out of the box through the good old compute() function. With its help, you can transfer the calculations performed in individual functions to a third-party isolate and quickly get the results back.


A relatively simple solution would be to create your own Transformer, which will parse the answers in a third-party isolate and return the result.


But, as mentioned in the first article, I also want to show the use of my libraries, and not just the steps for creating an application, and it just so happened that I have the isolator library, created to simplify the work with isolates and allow you to move all the logic in general to third-party long-life-lifecycle isolates. These third-party isolates, in the context of the library, are called Backend. And they are loaded with lightweight reactive companions called Frontend - it can be any class from any state manager—Bloc, Mobx, ChangeNotifier, etc. A mixin Frontend is added to this class and you get the ability to communicate with the corresponding Backend. Before the release of Dart 2.15, this library solved one narrow but the fundamental problem (so that you don’t have to solve it yourself) - the ability to transfer unlimited data from a third-party isolate to the main isolate without blocking the latest. With the advent of the Isolate.exit() method, this problem seems to have gone away by itself, so now this library simply allows you not to load the main thread with anything other than rendering the UI (however, as before).


In addition to all of the above, this library allows you to use the same API for both mobile applications and Web! You don't have to write a single line of code to make all your business logic work on the Web. But keep in mind that in a Web environment, your code will run in a single isolate, unlike mobile applications.

Frontend

To begin with, I will attach the entire code, and then I will analyze each of its blocks separately:

import 'dart:async';  
  
import 'package:flutter/material.dart';  
import 'package:flutter/widgets.dart';  
import 'package:isolator/isolator.dart';  
import 'package:isolator/next/maybe.dart';  
import '../../../service/di/di.dart';  
import '../../../service/di/registrations.dart';  
import '../../../service/tools/localization_wrapper.dart';  
import '../../crypto/dto/stock_item.dart';  
import '../../notification/logic/notification_service.dart';  
import 'main_backend.dart';  
  
enum MainEvent {  
  init,  
  loadStocks,  
  startLoadingStocks,  
  endLoadingStocks,  
  filterStocks,  
  updateFilteredStocks,  
}  
  
class MainFrontend with Frontend, ChangeNotifier {  
  late final NotificationService _notificationService;  
  late final LocalizationWrapper _localizationWrapper;  
  final List<StockItem> stocks = [];  
  bool isLaunching = true;  
  bool isStocksLoading = false;  
  bool errorOnLoadingStocks = false;  
  TextEditingController searchController = TextEditingController();  
  TextEditingController tokenController = TextEditingController();  
  bool _isInLaunchProcess = false;  
  bool _isLaunched = false;  
  String _prevSearch = '';  
  
  Future<void> loadStocks() async {  
    errorOnLoadingStocks = false;  
    final Maybe<StockItem> stocks = await run(event: MainEvent.loadStocks);  
    if (stocks.hasList) {  
      _update(() {  
        this.stocks.clear();  
        this.stocks.addAll(stocks.list);  
      });  
    }  
    if (stocks.hasError) {  
      _update(() {  
        errorOnLoadingStocks = true;  
      });  
      await _notificationService.showSnackBar(content: _localizationWrapper.loc.main.errors.loadingError);  
    }  
  }  
  
  Future<void> launch({  
  required NotificationService notificationService,  
    required LocalizationWrapper localizationWrapper,  
  }) async {  
    if (!isLaunching || _isLaunched || _isInLaunchProcess) {  
      return;  
    }  
    _notificationService = notificationService;  
    _localizationWrapper = localizationWrapper;  
    _isInLaunchProcess = true;  
    searchController.addListener(_filterStocks);  
    await initBackend(initializer: _launch);  
    _isInLaunchProcess = false;  
    _isLaunched = true;  
    _update(() => isLaunching = false);  
  }  
  
  void _filterStocks() {  
    if (_prevSearch != searchController.text) {  
      _prevSearch = searchController.text;  
      run(event: MainEvent.filterStocks, data: searchController.text);  
    }  
  }  
  
  void _setFilteredStocks({required MainEvent event, required List<StockItem> data}) {  
    _update(() {  
      stocks.clear();  
      stocks.addAll(data);  
    });  
  }  
  
  void _startLoadingStocks({required MainEvent event, void data}) {  
    _update(() {  
      isStocksLoading = true;  
    });  
  }  
  
  void _endLoadingStocks({required MainEvent event, void data}) {  
    _update(() {  
      isStocksLoading = false;  
    });  
  }  
  
  void _update(VoidCallback dataChanger) {  
    dataChanger();  
    notifyListeners();  
  }  
  
  static MainBackend _launch(BackendArgument<void> argument) {  
    initDependencies();  
    return MainBackend(argument: argument, cryptoProvider: Di.get());  
  }  
  
  @override  
  void initActions() {  
    whenEventCome(MainEvent.startLoadingStocks).run(_startLoadingStocks);  
    whenEventCome(MainEvent.endLoadingStocks).run(_endLoadingStocks);  
    whenEventCome(MainEvent.updateFilteredStocks).run(_setFilteredStocks);  
  }  
}  
  

The logic of the library is somewhat similar to Bloc - you need to register handlers for messages arriving from Backend. They are registered in the initActions method:

@override  
void initActions() {  
  whenEventCome(MainEvent.startLoadingStocks).run(_startLoadingStocks);  
  whenEventCome(MainEvent.endLoadingStocks).run(_endLoadingStocks);  
  whenEventCome(MainEvent.updateFilteredStocks).run(_setFilteredStocks);  
}  
  


Any entity acts as an event identifier, but the important thing is that the match will be checked through the usual equality ==. Also, you can register a handler for a specific type of identifiers,in which case it will handle all events identified specifically by this type:

class SpecificMessageId {  
  const SpecificMessageId(this.someValue);  
  
  final int someValue;  
}  
  
void initActions() {  
  whenEventCome<SpecificMessageId>().run(_specificHandler);  
}  
  

It is worth adding a few words about the handlers themselves. All handlers must match the following type (non-matching handlers cannot be registered):

typedef FrontendAction<Event, Req, Res> = FutureOr<Res> Function({required Event event, required Req data});  
  

But, at the same time, the value of data does not have to arrive. The event-id event will always arrive. That is, the following handlers will register and be valid:

void _startLoadingStocks({required MainEvent event, void data}) {  
  _update(() {  
    isStocksLoading = true;  
  });  
}  
  
void _endLoadingStocks({required MainEvent event, void data}) {  
  _update(() {  
    isStocksLoading = false;  
  });  
}  
  

An update to the package will be coming out very soon, in which other types of event handlers will be available so that there is no need to constantly implement Event event and Req data, which you may not need.


The point of handlers is that if you only want to react to events fired by the Backend, you need a handler. If you want to call some Backend method, you can do it without handlers at all.


When you call any Backend method from Frontend, you will always get some kind of response “in place”, wrapped in a kind of union-type Maybe<T>. There are currently no union types in Dart, except for one built-in FutureOr<T>, therefore, for the correct typing of these methods, it was necessary to create Maybe<T>, it can simply include T, List<T > or an error, or all three - null if the Backend method returns nothing (but, in fact, Backend methods should always return something, which you will see below).


The following code demonstrates calling the MainBackend method on event = MainEvent.loadStocks and getting the result immediately at the call site:

Future<void> loadStocks() async {  
  errorOnLoadingStocks = false;  
  final Maybe<StockItem> stocks = await run(event: MainEvent.loadStocks);  
  if (stocks.hasList) {  
    _update(() {  
      this.stocks.clear();  
      this.stocks.addAll(stocks.list);  
    });  
  }  
  if (stocks.hasError) {  
    _update(() {  
      errorOnLoadingStocks = true;  
    });  
    await _notificationService.showSnackBar(content: _localizationWrapper.loc.main.errors.loadingError);  
  }  
}  
  

Looking ahead a little, I will also show the MainBackend method corresponding to this event, which will be executed in a third-party isolate:

Future<ActionResponse<StockItem>> _loadStocks({required MainEvent event, void data}) async {  
  await send(event: MainEvent.startLoadingStocks);  
  try {  
    final List<StockItem> stockItems = await _cryptoProvider.fetchLatestData();  
    _stocks.clear();  
    _stocks.addAll(stockItems);  
  } catch (error) {  
	await send(event: MainEvent.endLoadingStocks);  
    rethrow;  
  }  
  await send(event: MainEvent.endLoadingStocks);  
  return ActionResponse.list(_stocks);  
}  
  

While I will not describe its contents, this will be discussed below.


The following launch method is needed to initialize MainFrontend and MainBackend. It calls the initBackend method of the Frontend mixin, which must be passed at least one argument: an initializer function that will run in a third-party isolate, and this function must return an instance of the corresponding Backend.

Future<void> launch({  
  required NotificationService notificationService,  
  required LocalizationWrapper localizationWrapper,  
}) async {  
  if (!isLaunching || _isLaunched || _isInLaunchProcess) {  
    return;  
  }  
  _notificationService = notificationService;  
  _localizationWrapper = localizationWrapper;  
  
  _isInLaunchProcess = true;  
  searchController.addListener(_filterStocks);  
  await initBackend(initializer: _launch);  
  _isInLaunchProcess = false;  
  _isLaunched = true;  
  _update(() => isLaunching = false);  
}  
  

Let's take a closer look at it:

static MainBackend _launch(BackendArgument<void> argument) {  
  initDependencies();
  return MainBackend(argument: argument, CryptoProvider: Di.get());  
}  

In this function, we need to reinitialize the Di-container, since the third-party isolate does not know anything about what happened in the main one and all the factories in the third-party isolate are not registered. The requirements for the initializer function are similar to the requirements for the original entryPoint function used in the Isolate API. And here is its interface:

typedef BackendInitializer<T, B extends Backend> = B Function(BackendArgument<T> argument);  
  

Also, Frontend allows you to register per-message hooks from Backend only on messages that should force Frontend to notify the UI of data changes; you can subscribe (for example, one Frontend to another) using the subscribeOnEvent method. This will be discussed in more detail in the UI section.

Backend

I'll start with the Frontend method, which is called to get data about the crypto. When the main screen is first rendered, the MainFrontend widget is initialized in the initState hook of the MainView widget (see the MainFrontend.launch method). Upon completion, the loadStocks method (which was parsed above) is called:

// main_view.dart  
  
Future<void> _launchMainFrontend() async {  
  final MainFrontend mainFrontend = Provider.of(context, listen: false);  
  await mainFrontend.launch(notificationService: Provider.of(context, listen: false), localizationWrapper: Provider.of(context, listen: false));  
  await mainFrontend.loadStocks();  
}  
  
@override  
void initState() {  
  super.initState();  
  _launchMainFrontend();  
  // ...  
}  
  

One of the MainBackend methods has already been highlighted above, well, now it’s time to introduce the class itself, which will exist in a separate isolate throughout the life of the entire application:

import 'dart:async';  
  
import '../../crypto/logic/crypto_provider.dart';  
import 'package:isolator/isolator.dart';  
import '../../crypto/dto/stock_item.dart';  
import 'main_frontend.dart';  
  
typedef StockItemFilter = bool Function(StockItem);  
  
class MainBackend extends Backend {  
  MainBackend({  
  required BackendArgument<void> argument,  
    required CryptoProvider cryptoProvider,  
  })  
      : _cryptoProvider = cryptoProvider,  
        super(argument: argument);  
  final CryptoProvider _cryptoProvider;  
  final List<StockItem> _stocks = [];  
  Timer? _searchTimer;  
  
  Future<ActionResponse<StockItem>> _loadStocks({required MainEvent event, void data}) async {  
  await send(event: MainEvent.startLoadingStocks, sendDirectly: true);  
    try {  
      final List<StockItem> stockItems = await _cryptoProvider.fetchLatestData();  
      _stocks.clear();  
      _stocks.addAll(stockItems);  
    } catch (error) {  
      await send(event: MainEvent.endLoadingStocks, sendDirectly: true);  
      rethrow;  
    }  
    await send(event: MainEvent.endLoadingStocks, sendDirectly: true);  
    return ActionResponse.list(_stocks);  
  }  
  
  ActionResponse<StockItem> _filterStocks({required MainEvent event, required String data}) {  
    final String searchSubString = data;  
    send(event: MainEvent.startLoadingStocks);  
    _searchTimer?.cancel();  
    _searchTimer = Timer(const Duration(milliseconds: 500), () async {  
      _searchTimer = null;  
      final List<StockItem> filteredStocks = _stocks.where(_stockFilterPredicate(searchSubString)).toList();  
      await send(  
        event: MainEvent.updateFilteredStocks,  
        data: ActionResponse.list(filteredStocks),  
      );  
      await send(event: MainEvent.endLoadingStocks);  
    });  
    return ActionResponse.empty();  
  }  
  
  StockItemFilter _stockFilterPredicate(String searchSubString) {  
    final RegExp filterRegExp = RegExp(searchSubString, caseSensitive: false, unicode: true);  
    return (StockItem item) {  
      if (searchSubString.isEmpty) {  
        return true;  
      }  
      return filterRegExp.hasMatch(item.symbol) || filterRegExp.hasMatch(item.name);  
    };  
  }  
  
  @override  
  void initActions() {  
    whenEventCome(MainEvent.loadStocks).run(_loadStocks);  
    whenEventCome(MainEvent.filterStocks).run(_filterStocks);  
  }  
}  
  

By analogy with Frontend in any Backend it is possible to register event handlers with the same API, but with a slight difference in the handler type:

typedef BackendAction<Event, Req, Res> = FutureOr<ActionResponse<Res>> Function({required Event event, required Req data});  
  

The difference is that while the Frontend handler may return nothing, the Backend handler must either return a result of the form ActionResponse<T> or fail with an error. This is a consequence of certain limitations when working with types in Dart.

Also, the handler is the exit point of any Backend, each of which can call the handlers of any other Backend, this is done through special Interactor entities. here and here is a small example.


Now let's take a closer look at the method of obtaining cryptocurrencies. Before starting the download, we send a message to MainFrontend to indicate to the interface that the download is in progress.

await send(event: MainEvent.startLoadingStocks);  
  

Then, the data itself is loaded and stored in the MainBackend for local search.

  
final List<StockItem> stockItems = await  
_cryptoProvider.fetchLatestData();  
_stocks.clear();  
_stocks.addAll(stockItems);  
  

Local search

I will not dwell on the local search method in more detail, since it seems that the article has already become a longread 🙂. It works like a regular expression search. I can only add that you can get the answer to the main question of the universe with it and even a little more.


We are deeply sorry

UI

After completing this step, the main domain structure will look like this:

|-- domain  
|   `-- main  
|       |-- logic  
|       |   |-- main_backend.dart  
|       |   `-- main_frontend.dart  
|       `-- ui  
|           |-- main_header.dart  
|           |-- main_view.dart  
|           `-- stock_item_tile.dart  
|-- high_low_app.dart  
`-- main.dart  
  

Let's describe the contents of the UI folder:

main_view.dart contains the StatefulWidget of the main screen

import 'package:flutter/material.dart';
import 'package:isolator/next/frontend/frontend_event_subscription.dart';
import 'package:provider/provider.dart';
import 'package:yalo_assets/lib.dart';
import 'package:yalo_locale/lib.dart';

import '../../../service/theme/app_theme.dart';
import '../../../service/tools/utils.dart';
import '../../crypto/dto/stock_item.dart';
import '../../notification/logic/notification_service.dart';
import '../logic/main_frontend.dart';
import 'main_header.dart';
import 'stock_item_tile.dart';

class MainView extends StatefulWidget {
  const MainView({Key? key}) : super(key: key);

  @override
  _MainViewState createState() => _MainViewState();
}

class _MainViewState extends State<MainView> {
  MainFrontend get _mainFrontend => Provider.of(context);
  late final FrontendEventSubscription<MainEvent> _eventSubscription;

  Widget _stockItemBuilder(BuildContext context, int index) {
    final StockItem item = _mainFrontend.stocks[index];
    final bool isFirst = index == 0;
    final bool isLast = index == _mainFrontend.stocks.length - 1;
    return Padding(
      padding: EdgeInsets.only(
        left: 8,
        top: isFirst ? 8 : 0,
        right: 8,
        bottom: isLast ? MediaQuery.of(context).padding.bottom + 8 : 8,
      ),
      child: StockItemTile(item: item),
    );
  }

  void _onSearchEnd(MainEvent event) {
    final MainFrontend mainFrontend = Provider.of<MainFrontend>(context, listen: false);
    final LocalizationMessages loc = Messages.of(context);
    final int stocksCount = mainFrontend.stocks.length;
    final String content = loc.main.search.result(stocksCount);
    Provider.of<NotificationService>(context, listen: false).showSnackBar(
      content: content,
      backgroundColor: AppTheme.of(context, listen: false).okColor,
    );
  }

  Future<void> _launchMainFrontend() async {
    final MainFrontend mainFrontend = Provider.of(context, listen: false);
    await mainFrontend.launch(notificationService: Provider.of(context, listen: false), localizationWrapper: Provider.of(context, listen: false));
    await mainFrontend.loadStocks();
  }

  @override
  void initState() {
    super.initState();
    _launchMainFrontend();
    _eventSubscription = Provider.of<MainFrontend>(context, listen: false).subscribeOnEvent(
      listener: _onSearchEnd,
      event: MainEvent.updateFilteredStocks,
      onEveryEvent: true,
    );
  }

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

  @override
  Widget build(BuildContext context) {
    final Assets assets = Provider.of<Assets>(context, listen: false);
    final AppTheme theme = AppTheme.of(context);
    final MaterialStateProperty<Color> buttonColor = MaterialStateProperty.resolveWith((states) => theme.buttonColor);
    final ButtonStyle buttonStyle = ButtonStyle(
      foregroundColor: buttonColor,
      overlayColor: MaterialStateProperty.resolveWith((states) => theme.splashColor),
      shadowColor: buttonColor,
    );
    final List<String> notFoundImages = [
      assets.notFound1,
      assets.notFound2,
      assets.notFound3,
      assets.notFound4,
    ].map((e) => e.replaceFirst('assets/', '')).toList();
    Widget body;

    if (_mainFrontend.isLaunching) {
      body = Center(
        child: Text(Messages.of(context).main.loading),
      );
    } else if (_mainFrontend.errorOnLoadingStocks) {
      body = Center(
        child: Padding(
          padding: const EdgeInsets.all(16),
          child: Column(
            mainAxisSize: MainAxisSize.min,
            crossAxisAlignment: CrossAxisAlignment.center,
            children: [
              Padding(
                padding: const EdgeInsets.only(bottom: 16),
                child: Image.asset(notFoundImages[Utils.randomIntBetween(0, notFoundImages.length - 1)]),
              ),
              TextButton(
                onPressed: _mainFrontend.loadStocks,
                style: buttonStyle,
                child: Text(Messages.of(context).main.repeat),
              ),
            ],
          ),
        ),
      );
    } else {
      body = CustomScrollView(
        physics: const BouncingScrollPhysics(),
        slivers: [
          const MainHeader(),
          SliverList(
            delegate: SliverChildBuilderDelegate(
              _stockItemBuilder,
              childCount: _mainFrontend.stocks.length,
            ),
          ),
        ],
      );
    }

    return Scaffold(
      body: AnimatedSwitcher(
        duration: const Duration(milliseconds: 250),
        child: body,
      ),
    );
  }
}

What is interesting here? The initialization of MainFrontend has already been discussed, only the event subscriber remains. By the way, here it is:

_eventSubscription = Provider.of<MainFrontend>(context, listen: false).subscribeOnEvent(listener: _onSearchEnd, event: MainEvent.updateFilteredStocks,onEveryEvent: true);
  


Calling this method allows us to be notified that our MainFrontend has received a message of the appropriate type from MainBackend. The subscribeOnEvent method is part of Frontend in principle.

As a result, we receive such notifications every time a piece of data arrives after a search:


Main view


And this is an introduction to the topic of application localization on Flutter.

Interface localization


For quite some time now, I have been wondering how to quickly localize an application on Flutter. If you look at the official guide - then the first impression is “you can’t figure it out without a bottle”. The second, actually - too. And then I thought, what if we get rid of the cumbersome .arb and use .yaml instead? This is how the assets_codegen package was born (I do not attach a link, since it is deprecated). Its idea was as follows - we place the localization files in assets, annotate some class so that the localization code clings to it, run flutter pub run build_runner watch and enjoy.The solution was more than workable, but there were also disadvantages - the logic for tracking changes in localization files was written by hand, and Dart generation does not allow tracking changes in non-Dart files, and the result of combining a standard code generator and a handwritten watcher sometimes depressing. In general, there were a lot of annoying bugs. And then one day, already having some understanding of how often you have to add new localization lines and immediately after that expect them to appear in the code (spoiler - extremely rare), I decided to write a completely new package, also the name of which, born in my head, I liked it very much.


A few moments later


This is how the yalo package was born. With extremely simple logic (described in the documentation) - we place localization files in assets, start the generator with the command flutter pub run yalo:loc, connect the generated local package .yalo_locale to the project, use a couple of variables in the root ...App:


import 'package:flutter/material.dart';
import 'package:yalo_locale/lib.dart';

import 'service/di/di.dart';

class HighLowApp extends StatelessWidget {
  const HighLowApp({Key? key}) : super(key: key);

  @override
  Widget build(BuildContext context) {
    return MaterialApp.router(
      routeInformationParser: Di.get<RouteInformationParser<Object>>(),
      routerDelegate: Di.get<RouterDelegate<Object>>(),
      backButtonDispatcher: Di.get<BackButtonDispatcher>(),
      theme: Theme.of(context).copyWith(brightness: Brightness.dark),
      debugShowCheckedModeBanner: false,
      localizationsDelegates: localizationsDelegates, // <-- 1
      supportedLocales: supportedLocales, // <-- 2
      onGenerateTitle: (BuildContext context) => Messages.of(context).common.appTitle,
    );
  }
}

And we use localized content. With pluralization, prefixes, arbitrarily deep nesting and substitution. You may have already noticed examples of use above, but I will demonstrate them separately.

Application name generation:

(BuildContext context) => Messages.of(context).common.appTitle  

Search input field hint:

Messages.of(context).main.search.hint  

Number of items after searching in SnackBar:

Messages.of(context).main.search.result(Provider.of<MainFrontend>(context, listen: false).stocks.length)

It all comes from this file:

main:  
  loading: Loading...  
  search:  
  hint: Search  
    result:  
  zero: Not found any items  
    one: We found ${howMany} item  
    two: We found ${howMany} items  
    other: We found ${howMany} items  
    secret: It seems - you found a secret (42)  
  errors:  
  loadingError: Cryptocurrency load failed  
  repeat: Reload  
  
common:  
  currency: '\$'  
  percent: '%'  
  appTitle: High Low  

More precisely, files lying like this:

|-- README.md  
|-- analysis_options.yaml  
|-- assets  
|   `-- i18  
|       |-- en_intl.yaml  
|       `-- ru_intl.yaml  
`-- watch.sh  
  

But instead of a file prefix, you can sort them into folders - ../en/intl.dart

Conclusion

This time the article has kept up with the code and everything that is implemented is described here. In the third article, I will make a complete second screen (taking into account the graphics and game mechanics), I will show how to work with assets of a healthy person and implicit animation of any text.


Also, here are the changes that have taken place since the first part. And, code the current state of the project.

Special Section

Like the post-credits scenes in Marvel - this section is for special viewers of readers. Having already completed this article, I was almost ready to publish it. But the feeling of perfectionism diligently bit off pieces from me - at the time of the “readiness” of the article, the isolator was not finalized enough to be used on the web. And I also wanted to show not only pictures of the application but also give the opportunity to “poke” it. And so, in a couple of evenings, I added the ability to work on the web (as before - without multithreading, but maintaining full performance without changes in your code). Then the question arose about publishing the application. I plan to publish it in the stores at the very end, but for now, it would be possible to do it on github.pages (here it is, also). This is where the fun begins.


Launched the web version locally, everything works fine, except for one NO! - the API of the service that I started using initially does not allow CORS requests, “so as not to burn your authorization tokens”, apparently, about reverse Application API they have not heard. Anyway. I started looking for ways to get around this limitation without having to create my own proxy, host it somewhere, etc. I found curl-online, and made a request through it (through the interface of the service itself) - everything worked. I immediately started doing web implementation of CryptoProvider, which would be used in a web assembly and fetch data through web-curl. And again:


Everything works locally at me


Deploy to github.pages → and CORS again, but at the curl itself (why didn't I think of running this request from the browser console from the application page to pages - a very big question). The time is one o'clock in the morning, and with cheerful red eyes I begin to stare at the code of the proxy being written for this. Another half an hour and the eyes say “time to sleep”. Waking up the next day, early in the morning, I again began to look for ways not to write proxies and, apparently, they say the truth - the morning is wiser than the evening, I think of looking for an alternativeto the API itself. And the very first google search gives me beautiful documentation, completely free, without authorization (and with very few restrictions), apish.


On the one hand, I am immensely glad that I don’t have to cut any proxies, and I’m also glad that I can show you how it works on the web without any “buts”, but on the other hand, if I first thought, I searched, and not rushed to cut the code, would have saved 8 hours of life ...

Assets

If it were not for the special section, and all the suffering that is described there, this section would really have to be in the third article. Initially, I wanted to show working with them in this one, but while writing the article, I realized that there are no special places, except artificially invented, where they would be in place. Then, during the implementation of the logic associated with the possibility of exhausting the limit of my authorization token, a place appeared on the first resource where the assets would be in place. The idea was this - if the resource of my token runsout, then when an error is received during the request, an additional screen will be displayed where some cool picture will hang, as well as an input for entering your own authorization token, with which everything would work for you personally. After the transition to the new API, the logic for using your token disappeared by itself, but, potentially, there was an opportunity to stumble upon an error due to API RPS limits.


And now to the actual work with assets! The yalo package mentioned above, not only allows you to generate localization from .yaml files, but also, it allows you to generate code with the names of all assets located in your assets folder (or any other, if it is correctly specified in pubspec.yaml). Now the structure of the assets folder of this project is as follows:


./assets  
|-- i18  
|   |-- en_intl.yaml  
|   `-- ru_intl.yaml  
`-- images  
    |-- notFound_1.png  
    |-- notFound_2.png  
    |-- notFound_3.png  
    `-- notFound_4.png  
  

Provided that you already have this package installed in your project, you can run the following command:

flutter pub run yalo:asset  

The result of such a command will be the generated package .yalo_assets in the root of your project, which, by analogy with .yalo_locale, you need to add to pubspec.yaml:

dependencies:  
  //...  
  yalo_locale:  
    path: ./.yalo_locale  
  yalo_assets:  
    path: ./.yalo_assets  

After these manipulations, you get access to the class with static and regular getters:

class Assets {  
 String get enIntl => enIntlS;  
  static const String enIntlS = 'assets/i18/en_intl.yaml';  
  
  String get ruIntl => ruIntlS;  
  static const String ruIntlS = 'assets/i18/ru_intl.yaml';  
  
  String get notFound1 => notFound1S;  
  static const String notFound1S = 'assets/images/notFound_1.png';  
  
  String get notFound2 => notFound2S;  
  static const String notFound2S = 'assets/images/notFound_2.png';  
  
  String get notFound3 => notFound3S;  
  static const String notFound3S = 'assets/images/notFound_3.png';  
  
  String get notFound4 => notFound4S;  
  static const String notFound4S = 'assets/images/notFound_4.png';  
}


I omitted some additional methods available in this class, since they were not in great demand.

How can this be useful? The main advantage is auto-completion. Optional - you have the ability to track assets at the code level. If any file is deleted or its name is changed, the code will react to this and you will get a static error, instead of catching it at runtime (if you didn’t follow this). The resolution of asset name collisions (for example, two files with the same name located in different folders) is also there, and looks like this:


 String get enIntl => enIntlS;  
  static const String enIntlS = 'assets/i18/en_intl.yaml';  
  
  String get ruIntl => ruIntlS;  
  static const String ruIntlS = 'assets/i18/ru_intl.yaml';  
  
  String get notFound => notFoundS;  
  static const String notFoundS = 'assets/images/blabla/notFound.png';  
  
  String get notFound1 => notFound1S;  
  static const String notFound1S = 'assets/images/notFound_1.png';  
  
  String get notFound2 => notFound2S;  
  static const String notFound2S = 'assets/images/notFound_2.png';  
  
  String get notFound3 => notFound3S;  
  static const String notFound3S = 'assets/images/notFound_3.png';  
  
  String get notFound4 => notFound4S;  
  static const String notFound4S = 'assets/images/notFound_4.png';  
  
  String get notFoundCopy => notFoundCopyS;  
 static const String notFoundCopyS = 'assets/images/old_content/notFound.png';  
  
  String get notFoundCopyCopy => notFoundCopyCopyS;  
  static const String notFoundCopyCopyS = 'assets/images/something_else/notFound.png';  
  
  String get notFoundCopyCopyCopy => notFoundCopyCopyCopyS;  
  static const String notFoundCopyCopyCopyS = 'assets/images/very_important_content/notFound.png';  
  
  String get notFound3Copy => notFound3CopyS;  
  static const String notFound3CopyS = 'assets/images/very_important_content/notFound_3.png';  
} 

Final Conclusion

It was necessary to leave something for the very end that's really all.