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.
Part II (You reading it)
To render the first screen, the following data is needed:
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.
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.
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:
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:
Dio
to return the map to us
Table!
Here are my findings from this test:
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.
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.
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.
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);
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.
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:
And this is an introduction to the topic of application localization on Flutter.
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.
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
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.
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 ...
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';
}
It was necessary to leave something for the very end that's really all.