Data Fetching
This is a very long and important section. It not only guides you on how to handle asynchronous data fetching, but also helps you understand more about how to properly design units and set up their communication flows.
Concept
In available state management approaches, the app's structure is usually designed to have multiple layers, and objects of a layer will communicate with ones in other layers via dependency injection and change subscriptions. For example, in the Bloc pattern, Blocs (UI or Presentation layer) can get data from Repositories (Domain layer), which fetch that data from Data Providers (Data layer) - Bloc's Architecture
In this pattern, there would be no layers, just a unit tree; and classifying objects into layers is not ideal. Instead, it is to design them with operations that fit the characteristics of the objects and the app's structure. A widget can play the role of both UI and data provider at the same time if the app is small and does not have plans to expand, like a ToDo app. A Home screen in a ToDo app can fetch data from a local database and display it in the UI. It should not be bad because a ToDo app is pretty small. Dividing the app's architecture into layers would be unnecessary.
In the Paper pattern, all objects have to be placed in a tree-layout architecture as introduced in Concept, including the Repositories, Data Providers if they are created. It also means that they have states. Yes, Data Providers, Repositories, or even Databases on the local or server-side have states.
Let's say a Data Provider has a function: Future<String> fetch(String param).
This function is treated as an attribute and constructs the state of the Data
Provider. Because it is asynchronous, it will have 2 states: standby and
running. For more details - Function.
To be more interesting, consider the Data Provider as a UI. It may sound strange but think of it this way. When fetch is called, imagine the Data Provider builds a UI displayed to a "back-end person". In this imagined UI, there is a text field waiting for the "back-end" to enter data. When the input is ready, the "back-end" presses a submit button. The Data Provider notifies its parent and closes the UI.
Now let's get into the code. First, create a Data Provider to fetch a list of coins in the market. We name it NetworkDao. Download and place the following files into the correct paths:
-
lib/src/data/i18n.dart
-
lib/src/model/coin_overview.dart
-
lib/src/model/currency_symbol_mapper.dart
-
lib/src/model/figure.dart
-
lib/src/unit/network_dao/model/coins_list.dart
-
lib/src/unit/network_dao/model/exception.dart
-
lib/src/unit/network_dao/model/param.dart
-
lib/src/unit/network_dao/network_dao_paper.dart
-
lib/src/unit/network_dao/network_dao_script.dart
-
lib/src/unit/network_dao/network_dao.dart
-
lib/src/unit_passive/criteria_unit.dart
-
lib/src/unit_widget/coins_listing/coins_listing_paper.dart
-
lib/src/unit_widget/coins_listing/coins_listing_script.dart
-
lib/src/unit_widget/coins_listing/coins_listing.dart
-
lib/src/widget/coin_summary_card.dart
Next, copy the following content to the corresponding files respectively. You should initialize Git and commit the implemented code of the Bottom Navigation section for better tracking.
part of 'app.dart';
abstract class AppPaper extends Paper {}
class AppInitialize extends AppPaper {}
class AppCurrentTab extends AppPaper {
final int index;
AppCurrentTab(this.index);
}
class AppCurrentMarket extends AppPaper {
final Result<JsonCoinsList, NetworkDaoExceptionCode> coinsResult;
AppCurrentMarket(this.coinsResult);
}
part of 'app.dart';
class AppScript extends Script<AppPaper, AppState> {
void map() => on<AppCurrentTab>(onCurrentTab)
?.on<AppInitialize>(onInitialize)
?.on<AppCurrentMarket>(onCurrentMarket);
void onInitialize(
AppInitialize p,
AppState s,
SourceVerifier ifFrom,
) async {
final page = 1;
final category = s.criteria.selectedCategory;
final currency = s.criteria.selectedCurrency;
final timeFrame = s.criteria.selectedTimeFrame;
s.networkDao.process(
NetworkDaoDataFetching(
NetworkDaoCoinsListParam(
page: page,
category: category,
currency: currency,
timeFrame: timeFrame,
),
),
);
}
void onCurrentTab(
AppCurrentTab p,
AppState s,
SourceVerifier ifFrom,
) async {
ifFrom(s.bottomBarController)?.setIndex = p.index;
s.setPage(ifFrom(s.pageController), p.index);
}
void onCurrentMarket(
AppCurrentMarket p,
AppState s,
SourceVerifier ifFrom,
) {
final result = p.coinsResult;
if (result is Success<JsonCoinsList>) {
final coins = <CoinOverview>[];
for (var i = 0; i < result.data.length; i++) {
final current = result.data.item(i);
final id = current?.id();
final name = current?.name();
final symbol = current?.symbol();
final currentPrice = current?.currentPrice()?.toDouble();
final priceChange24hPercentage =
current?.priceChange24hPercentage()?.toDouble();
final currentCurrency = s.criteria.selectedCurrency;
final currencySign = CurrencySymbolMapper().symbol(currentCurrency);
if (id == null ||
name == null ||
symbol == null ||
currentPrice == null ||
priceChange24hPercentage == null ||
currencySign == null) {
continue;
}
coins.add(
CoinOverview(
key: id,
name: name,
symbol: symbol,
currentPrice: Figure(currentPrice),
changePercentage: Figure(priceChange24hPercentage),
currencySign: currencySign,
),
);
}
s.coinsListing.process(
CoinsListingCurrentCoins(
ListModel(coins, keyGen: (_) => result.data.key),
),
);
} else if (result is Failure<NetworkDaoExceptionCode>) {
final String text;
switch (result.exp) {
case NetworkDaoExceptionCode.timeout:
text = s.l10n.s_TimeoutErrorFetchingAlert;
break;
case NetworkDaoExceptionCode.wrongFormat:
case NetworkDaoExceptionCode.resourcesNotFound:
text = s.l10n.s_GeneralErrorFetchingAlert;
break;
}
s.coinsListing.process(CoinsListingErrorText(text));
}
}
}
import 'package:flutter/material.dart';
import 'package:flutter_localizations/flutter_localizations.dart';
import 'package:paper/paper.dart';
import '../../../l10n/app_localizations.dart';
import '../../model/coin_overview.dart';
import '../../model/currency_symbol_mapper.dart';
import '../../model/figure.dart';
import '../../unit/network_dao/network_dao.dart';
import '../../unit_passive/criteria_unit.dart';
import '../../widget/bottom_bar.dart';
import '../../widget/keep_alive_page.dart';
import '../coins_listing/coins_listing.dart';
part 'app_paper.dart';
part 'app_script.dart';
class App extends UnitWidget<AppPaper> {
// ignore: use_key_in_widget_constructors
App(super.parent, {required super.agent, super.listener});
UnitWidgetState<AppPaper, App> createState() => AppState();
Script<AppPaper, AppState> createScript() => AppScript();
}
class AppState extends UnitWidgetState<AppPaper, App> {
final networkDao = UnitAgent<NetworkDaoPaper>();
final coinsListing = WidgetAgent<CoinsListingPaper>();
final bottomBarController = BottomBarController();
final pageController = PageController();
late CriteriaUnit criteria;
late EdgeInsets safeAreaPadding;
late AppLocalizations l10n;
Set<Agent>? register() => {
networkDao.log((a) => NetworkDao(a, listener: networkDaoListener)),
};
void initState() {
super.initState();
criteria = CriteriaUnit(
selectedCategory: 'all',
selectedCurrency: 'usd',
selectedTimeFrame: '24h',
);
}
void didChangeDependencies() {
super.didChangeDependencies();
safeAreaPadding = MediaQuery.viewPaddingOf(context);
}
Widget build(BuildContext context) {
return MaterialApp(
debugShowCheckedModeBanner: false,
localizationsDelegates: [
AppLocalizations.delegate,
GlobalMaterialLocalizations.delegate,
GlobalWidgetsLocalizations.delegate,
GlobalCupertinoLocalizations.delegate,
],
supportedLocales: [
Locale('en'), // English
Locale('es'), // Spanish
],
home: Builder(
builder: (context) {
l10n = AppLocalizations.of(context)!;
return Material(
child: Column(
children: [
Expanded(
child: PageView(
controller: pageController,
onPageChanged: onPageChange,
children: [
KeepAlivePage(
child: CoinsListing(
this,
agent: coinsListing,
listener: coinsListingListener,
l10n: l10n,
topPadding: safeAreaPadding.top,
),
),
KeepAlivePage(child: Center(child: Text('Second Page'))),
],
),
),
BottomBar(
controller: bottomBarController,
onChanged: onBottomTabChange,
index: 0,
width: double.infinity,
height: 60,
bottomPadding: safeAreaPadding.bottom,
),
],
),
);
},
),
);
}
bool _isSetPageByController = false;
void setPage(PageController? controller, int index) {
_isSetPageByController = controller != null;
controller?.animateToPage(
index,
duration: const Duration(milliseconds: 300),
curve: Curves.easeOut,
);
}
void onPageChange(int index) {
if (_isSetPageByController) {
_isSetPageByController = false;
return;
}
report(AppCurrentTab(index), from: pageController);
}
void onBottomTabChange(int index) =>
report(AppCurrentTab(index), from: bottomBarController);
PaperListener<NetworkDaoPaper, AppPaper> get networkDaoListener => reporter(
(r) =>
r.on<NetworkDaoCoinsListSelection>((p) => AppCurrentMarket(p.result)),
);
PaperListener<CoinsListingPaper, AppPaper> get coinsListingListener =>
reporter((r) => r);
}
The App needs to know when to start fetching data. In our example, it would be
when the app launches. So we set up a Paper for the App to process all necessary
work. And we "command" the App to process that Paper in the main.dart.
Finally, "command" the App to process the AppInitialize Paper.
import 'package:paper/paper.dart';
import 'src/unit_widget/app/app.dart';
void main() {
final app = WidgetAgent<AppPaper>();
PaperFrameworkEstablisher().initializeWidgetUnit((s) => App(s, agent: app));
app.process(AppInitialize());
}
Run the app:
After loading, the CoinsListing widget should a error text. Look at the
handler of AppCurrentMarket in AppScript, we can see the App has commanded
the CoinsListing to show text when the result from AppCurrentMarket is
NetworkDaoExceptionCode.wrongFormat or
NetworkDaoExceptionCode.resourcesNotFound. If you debug, you will see the
exception is NetworkDaoExceptionCode.resourcesNotFound. It is because we did
not have the data resource in the project yet. Let's add it.
-
assets/data/coins_list.json
...
flutter:
assets:
- assets/data/
Restart the app:
Breakdown
Why did we put the NetworkDao under the App but not somewhere else? The App
controls all UI components under it, so it is more suitable and logical to
place the NetworkDao under the control of App. Whenever a request for data is
needed from the UI, the App will ask the NetworkDao to fetch it. It is quite like the
Repository in the Bloc pattern.
Let us remind you that it is NOT our convention that a Data Provider object should be placed at a specific location. The decision should be made based on the nature of the project, size of the app, etc. As mentioned before, in a ToDo app, a Data Provider can be placed under a moderate (not necessarily a screen) widget because the ToDo app is relatively small and unlikely to expand with big features.

NetworkDao
-
NetworkDaocontains a functionFuture<void> fetchCoinsList(NetworkDaoParam param). As mentioned above, consider this function similar toStatelessWidget.buildorState.build, which builds a UI to communicate with users. Imagine the function builds a UI with a field for the "back-end-person" to enter a list of coins. After completing entering the data, the "back-end" clicks a button to submit the final data and closes the UI. -
fetchCoinsListfetches data from assets. When the process completes with success or failure, the result will be injected intoNetworkDaoCoinsListSelectionand theNetworkDaoCoinsListSelectionwill be reported to the parent ofNetworkDao, which isApp
App
- When
AppreceivesNetworkDaoCoinsListSelectionfromNetworkDao, it will process it withAppCurrentMarket.
PaperListener<NetworkDaoPaper, AppPaper> get networkDaoListener => reporter(
(r) => r.on<NetworkDaoCoinsListSelection>(
(p) => AppCurrentMarket(p.result),
),
);
We can see how App processes AppCurrentMarket via its handler in
app_script.dart.
If a coin list - JsonCoinsList is successfully fetched, it will be parsed to a
model called CoinOverview. So why don't we just use JsonCoinsList or parse
the raw JSON to a list of CoinOverview like we usually do, like
CoinOverview.from(json),... We took inspiration from an aspect of Clean
Architecture by Robert C. Martin. It is the separation of Model from Data Layer
and Entity from Presentation layer. Consider JsonCoinsList as a model from the Data
Layer and CoinOverview as an entity from the Presentation Layer; and App can be
somewhat treated as a Repository.
Back to our project, during the parsing, you can see that we've checked if each
field in the raw coin data is null, those coins will be ignored and skipped,
which are not included in the list of coins shown in the UI. It is just our
requirement for this example. How to handle issues from parsing should be
clarified by your project.
Moreover, regarding the parsing, why don't we implement the more traditional way like Model.fromJson(json),... As our experience, parsing via constructor is not ideal because there are probably many exceptions during the parsing (null values, invalid types,...). Handling those exceptions should not be encapsulated inside the constructor but explicitly handled like we do in the example. A constructor should only successfully initialize an object without any exception or error.
- After a list of
CoinOverviewis obtained,App"commands"CoinsListingto show the coins viaCoinsListingCurrentCoins; or show error text if exceptions arise viaCoinsListingErrorText.
CoinsListing
-
CoinsListingCurrentCoinsprocesses the showing of coins. -
CoinsListingErrorTextprocesses the showing of error texts.
Notes
- Why do we put
i18n.dartinside thedatafolder?
NumberFormat is treated as a model as it is immutable. And because we create a
top-level instance final numUSFormat = NumberFormat("#,##0.00");, it should be
located as data.
- Why is
CriteriaUnitlocated in unit_passive?
What is a passive_unit? It is obviously a unit, which is a stateful object but does not have the ability to notify state changes.
class CriteriaUnit {
String selectedCategory;
String selectedCurrency;
String selectedTimeFrame;
CriteriaUnit({
required this.selectedCategory,
required this.selectedCurrency,
required this.selectedTimeFrame,
});
}
CriteriaUnit has at least one variable property - selectedCategory,
selectedCurrency and selectedTimeFrame rather than constants. And it does not
have any mechanism to notify its subscribers (callbacks, etc.).