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:

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.

lib/src/unit_widget/app/app_paper.dart
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);
}
lib/src/unit_widget/app/app_script.dart
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));
    }
  }
}
lib/src/unit_widget/app/app.dart
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.

lib/main.dart
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.

pubspec.yaml
...
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.

Bottom Navigation diagram.

NetworkDao

  • NetworkDao contains a function Future<void> fetchCoinsList(NetworkDaoParam param). As mentioned above, consider this function similar to StatelessWidget.build or State.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.

  • fetchCoinsList fetches data from assets. When the process completes with success or failure, the result will be injected into NetworkDaoCoinsListSelection and the NetworkDaoCoinsListSelection will be reported to the parent of NetworkDao, which is App

App

  • When App receives NetworkDaoCoinsListSelection from NetworkDao, it will process it with AppCurrentMarket.
lib/src/unit_widget/app/app.dart
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 CoinOverview is obtained, App "commands" CoinsListing to show the coins via CoinsListingCurrentCoins; or show error text if exceptions arise via CoinsListingErrorText.

CoinsListing

  • CoinsListingCurrentCoins processes the showing of coins.

  • CoinsListingErrorText processes the showing of error texts.

Notes

  • Why do we put i18n.dart inside the data folder?

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 CriteriaUnit located 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.).