Note

What is Note? It is a declaratively cached value which is used between papers and papers without worrying about modifying the value paper by paper. This sounds very unclear. Let's code for better understanding.

Add files:

Copy file contents:

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);
}

class AppCoinDetailsSelection extends AppPaper {
  final String key;

  AppCoinDetailsSelection(this.key);
}

class AppCurrentCoinDetails extends AppPaper {
  final Result<JsonCoinDetails, NetworkDaoExceptionCode> detailsResult;

  AppCurrentCoinDetails(this.detailsResult);
}

class AppTopWidgetPopping extends AppPaper {}

class AppFilterChoiceSelection extends AppPaper {
  final FilterType type;

  AppFilterChoiceSelection(this.type);
}

class AppCriteriaApplying extends AppPaper {}

class AppCriteriaSelection extends AppPaper {
  final Item category;
  final Item currency;
  final Item timeFrame;

  AppCriteriaSelection({
    required this.category,
    required this.currency,
    required this.timeFrame,
  });
}

class AppFilterValueSelection extends AppPaper {
  final FilterType type;
  final String key;

  AppFilterValueSelection({required this.type, required this.key});
}

class AppLoginSelection extends AppPaper {
  final String email;
  final String password;

  AppLoginSelection({
    required this.email,
    required this.password,
  });
}

class AppRegisterSelection extends AppPaper {
  final String name;
  final String email;
  final String password;

  AppRegisterSelection({
    required this.name,
    required this.email,
    required this.password,
  });
}

class AppSignOutSelection extends AppPaper {}

class AppAuthenticationDemand extends AppPaper {}
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)
      ?.on<AppCoinDetailsSelection>(onCoinDetailsSelection)
      ?.on<AppCurrentCoinDetails>(onCurrentCoinDetails)
      ?.on<AppTopWidgetPopping>(onTopWidgetPopping)
      ?.on<AppCriteriaSelection>(onCriteriaSelection)
      ?.on<AppFilterChoiceSelection>(onFilterChoiceSelection)
      ?.on<AppCriteriaApplying>(onCriteriaApplying)
      ?.on<AppFilterValueSelection>(onFilterValueSelection)
      ?.on<AppLoginSelection>(onLoginSelection)
      ?.on<AppRegisterSelection>(onRegisterSelection)
      ?.on<AppSignOutSelection>(onSignOutSelection)
      ?.on<AppAuthenticationDemand>(onAuthenticationDemand);

  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));
    }
  }

  void onCoinDetailsSelection(
    AppCoinDetailsSelection p,
    AppState s,
    SourceVerifier ifFrom,
  ) async {
    s.showLoading();
    s.networkDao.process(
      NetworkDaoDataFetching(NetworkDaoCoinDetailsParam(coinId: p.key)),
    );
  }

  void onCurrentCoinDetails(
    AppCurrentCoinDetails p,
    AppState s,
    SourceVerifier ifFrom,
  ) {
    final result = p.detailsResult;

    if (result is Success<JsonCoinDetails>) {
      final data = result.data;

      final id = data.id();
      final name = data.name();
      final symbol = data.symbol();
      final marketCapRank = data.marketCapRank();
      final marketCap = data.marketCap();
      final totalVolume = data.totalVolume();
      final high24h = data.high24h();
      final low24h = data.low24h();
      final totalSupply = data.totalSupply();
      final allTimeHigh = data.allTimeHigh();
      final athChangePercentage = data.athChangePercentage();
      final allTimeLow = data.allTimeLow();
      final atlChangePercentage = data.atlChangePercentage();

      if (id == null ||
          name == null ||
          symbol == null ||
          marketCapRank == null ||
          marketCap == null ||
          totalVolume == null ||
          high24h == null ||
          low24h == null ||
          totalSupply == null ||
          allTimeHigh == null ||
          athChangePercentage == null ||
          allTimeLow == null ||
          atlChangePercentage == null) {
        throw Exception();
      }

      final details = CoinDetails(
        key: id,
        name: name,
        marketCapRank: '${numUSFormat.format(marketCapRank)} \$',
        marketCap: '${numUSFormat.format(marketCap)} \$',
        tradingVolume: '${numUSFormat.format(totalVolume)} \$',
        low24h: '${numUSFormat.format(low24h)} \$',
        high24h: '${numUSFormat.format(high24h)} \$',
        totalSupply: numUSFormat.format(totalSupply),
        allTimeHigh: '${numUSFormat.format(allTimeHigh)} \$',
        sinceAllTimeHigh: '${numUSFormat.format(athChangePercentage)} %',
        allTimeLow: '${numUSFormat.format(allTimeHigh)} \$',
        sinceAllTimeLow: '${numUSFormat.format(atlChangePercentage)} %',
      );

      s.navigator.currentState?.pop();
      s.showCoinDetailsPage(details: details);
    } else if (result is Failure<NetworkDaoExceptionCode>) {
      s.navigator.currentState?.pop();
      s.showErrorDialog(s.l10n.s_CoinDetailsNotFoundAlert);
    }
  }

  void onTopWidgetPopping(
    AppTopWidgetPopping p,
    AppState s,
    SourceVerifier ifFrom,
  ) async {
    s.navigator.currentState?.pop();
  }

  Future<void> onCriteriaApplying(
    AppCriteriaApplying p,
    AppState s,
    SourceVerifier ifFrom,
  ) async {
    final category = s.criteriaCardController.getCategory!.key;
    final currency = s.criteriaCardController.getCurrency!.key;
    final timeFrame = s.criteriaCardController.getTimeFrame!.key;

    s.criteria = CriteriaUnit(
      selectedCategory: category,
      selectedCurrency: currency,
      selectedTimeFrame: timeFrame,
    );

    s.navigator.currentState?.pop();
    await s.coinsListing.process(CoinsListingLoading());
    s.networkDao.process(
      NetworkDaoDataFetching(
        NetworkDaoCoinsListParam(
          page: 1,
          category: category,
          currency: currency,
          timeFrame: timeFrame,
        ),
      ),
    );
  }

  void onCriteriaSelection(
    AppCriteriaSelection p,
    AppState s,
    SourceVerifier ifFrom,
  ) {
    s.showCriteriaDialog(
      selectedCategory: p.category,
      selectedCurrency: p.currency,
      selectedTimeFrame: p.timeFrame,
    );
  }

  void onFilterChoiceSelection(
    AppFilterChoiceSelection p,
    AppState s,
    SourceVerifier ifFrom,
  ) {
    s.showFiltersSelectionPage(type: p.type);
  }

  void onFilterValueSelection(
    AppFilterValueSelection p,
    AppState s,
    SourceVerifier ifFrom,
  ) {
    s.navigator.currentState?.pop();
    switch (p.type) {
      case FilterType.category:
        s.criteriaCardController.setCategory = s.categories[p.key]!;
        break;
      case FilterType.currency:
        s.criteriaCardController.setCurrency = s.currencies[p.key]!;
        break;
      case FilterType.timeFrame:
        s.criteriaCardController.setTimeFrame = s.timeFrames[p.key]!;
        break;
    }
  }

  Future<void> onLoginSelection(
    AppLoginSelection p,
    AppState s,
    SourceVerifier ifFrom,
  ) async {
    await Future.delayed(Duration(seconds: 1));
    final box = await s.hive.openBox('user');
    final response = await box.get(p.email);

    if (response == null) {
      s.authentication.process(AuthPageEmailNotExistError());
      return;
    }

    if (response['password'] != p.password) {
      s.authentication.process(AuthPageWrongPasswordError());
      return;
    }

    s.authentication.process(AuthPageCurrentProfile(name: response['name']!));

    if (s.tabAfterLoginNote.value != null) {
      s.setPage(ifFrom(s.pageController), s.tabAfterLoginNote.value!);
      s.bottomBarController.setIndex = s.tabAfterLoginNote.value!;
    }

    s.coinsListing.process(CoinsListingProfileState(true));
  }

  Future<void> onRegisterSelection(
    AppRegisterSelection p,
    AppState s,
    SourceVerifier ifFrom,
  ) async {
    await Future.delayed(Duration(seconds: 1));
    final box = await s.hive.openBox('user');
    await box.put(
      p.email,
      {'name': p.name, 'email': p.email, 'password': p.password},
    );
    s.authentication.process(AuthPageLoginCurrentInfo());
  }

  Future<void> onSignOutSelection(
    AppSignOutSelection p,
    AppState s,
    SourceVerifier ifFrom,
  ) async {
    await Future.delayed(Duration(seconds: 1));
    s.authentication.process(AuthPageLoginCurrentInfo());

    if (s.tabAfterLoginNote.value != null) {
      s.setPage(ifFrom(s.pageController), s.tabAfterLoginNote.value!);
    }

    s.coinsListing.process(CoinsListingProfileState(false));
  }

  void onAuthenticationDemand(
    AppAuthenticationDemand p,
    AppState s,
    SourceVerifier ifFrom,
  ) {
    s.setPage(ifFrom(s.pageController), 1);
    s.bottomBarController.setIndex = 1;
    if (ifFrom(s.coinsListing) == null) {
      s.tabAfterLoginNote.value = 0;
    }
  }
}
lib/src/unit_widget/app/app.dart
import 'package:flutter/material.dart';
import 'package:flutter_localizations/flutter_localizations.dart';
import 'package:hive/hive.dart';
import 'package:paper/paper.dart';

import '../../../l10n/app_localizations.dart';
import '../../data/i18n.dart';
import '../../model/coin_details.dart';
import '../../model/coin_overview.dart';
import '../../model/currency_symbol_mapper.dart';
import '../../model/decor.dart';
import '../../model/figure.dart';
import '../../model/item.dart';
import '../../unit/network_dao/network_dao.dart';
import '../../unit_passive/criteria_unit.dart';
import '../../widget/bottom_bar.dart';
import '../../widget/coin_details_page.dart';
import '../../widget/criteria_card.dart';
import '../../widget/filter_selection_page.dart';
import '../../widget/keep_alive_page.dart';
import '../authentication/auth_page.dart';
import '../coins_listing/coins_listing.dart';

part 'app_paper.dart';
part 'app_script.dart';
part 'data/filter_items.dart';
part 'model/filter_type.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 authentication = WidgetAgent<AuthPagePaper>();

  final bottomBarController = BottomBarController();
  final pageController = PageController();
  final criteriaCardController = FilterCardController();

  final navigator = GlobalKey<NavigatorState>();
  final navigatorStack = CurrentNavigationObserver();

  final hive = Hive;

  late final categories = {for (var e in categoryItems) e.key: e};
  late final currencies = {for (var e in currencyItems) e.key: e};
  late final timeFrames = {for (var e in timeFrameItems) e.key: e};
  late CriteriaUnit criteria;

  late EdgeInsets safeAreaPadding;
  late AppLocalizations l10n;

  late final tabAfterLoginNote = Note<int>(this);

  
  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,
      navigatorKey: navigator,
      navigatorObservers: [navigatorStack],
      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: AuthPage(
                          this,
                          agent: authentication,
                          listener: authenticationPageListener,
                          l10n: l10n,
                          topPadding: safeAreaPadding.top,
                          type: AuthPageLogin(email: '', password: ''),
                        ),
                      ),
                    ],
                  ),
                ),
                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 showLoading() {
    navigator.currentState?.push(DialogRoute(
      settings: RouteSettings(name: 'loading'),
      context: navigator.currentContext!,
      builder: (context) => Center(
          child: CircularProgressIndicator(
        color: ColorDecor().x8BC540,
      )),
      barrierDismissible: false,
      traversalEdgeBehavior: TraversalEdgeBehavior.closedLoop,
    ));
  }

  void showCoinDetailsPage({required CoinDetails details}) {
    navigator.currentState?.push(PageRouteBuilder(
      settings: RouteSettings(name: 'coin-details?id=${details.key}'),
      pageBuilder: (context, animation, secondaryAnimation) {
        return CoinDetailsPage(
          l10n: l10n,
          topPadding: safeAreaPadding.top,
          bottomPadding: safeAreaPadding.bottom,
          details: details,
          onBackButtonTapped: onNeedToPop,
        );
      },
      transitionsBuilder: (context, animation, secondaryAnimation, child) {
        const begin = Offset(0.0, 1.0);
        const end = Offset.zero;
        const curve = Curves.ease;

        var tween =
            Tween(begin: begin, end: end).chain(CurveTween(curve: curve));

        return SlideTransition(position: animation.drive(tween), child: child);
      },
    ));
  }

  void showErrorDialog(String errorText) {
    navigator.currentState?.push(DialogRoute(
      settings: RouteSettings(name: 'error-$errorText'),
      context: navigator.currentContext!,
      builder: (context) => Center(
        child: Material(
          color: Colors.transparent,
          child: Container(
            decoration: BoxDecoration(
              color: ColorDecor().xFFFFFF,
              borderRadius: BorderRadius.circular(10),
            ),
            padding: EdgeInsets.all(16),
            child: Text(
              errorText,
              style: TextDecor().s15w600(color: ColorDecor().xEF5350),
              textAlign: TextAlign.center,
            ),
          ),
        ),
      ),
      traversalEdgeBehavior: TraversalEdgeBehavior.closedLoop,
    ));
  }

  void showCriteriaDialog({
    required Item selectedCategory,
    required Item selectedCurrency,
    required Item selectedTimeFrame,
  }) {
    navigator.currentState?.push(DialogRoute(
      settings: RouteSettings(name: 'filter-dialog'),
      context: navigator.currentContext!,
      builder: (context) => Center(
        child: Padding(
          padding: EdgeInsets.symmetric(horizontal: 16),
          child: FilterCard(
            key: criteriaCardController,
            categoryLabel: l10n.f_CATEGORY,
            currencyLabel: l10n.f_CURRENCY,
            timeFrameLabel: l10n.f_TIMEFRAME,
            category: selectedCategory,
            currency: selectedCurrency,
            timeFrame: selectedTimeFrame,
            onCategoryTapped: onCategoryChoiceSelected,
            onCurrencyTapped: onCurrencyChoiceSelected,
            onTimeFrameTapped: onTimeFrameChoiceSelected,
            onDismissTapped: onNeedToPop,
            onApplyTapped: onCriteriaApplied,
          ),
        ),
      ),
      traversalEdgeBehavior: TraversalEdgeBehavior.closedLoop,
    ));
  }

  void showFiltersSelectionPage({required FilterType type}) {
    final String label;
    final ListModel<Item> items;

    switch (type) {
      case FilterType.category:
        label = l10n.f_CATEGORY;
        items = ListModel(
          categories.values.toList(),
          keyGen: (_) => 'category',
        );
        break;
      case FilterType.currency:
        label = l10n.f_CURRENCY;
        items = ListModel(
          currencies.values.toList(),
          keyGen: (_) => 'currency',
        );
        break;
      case FilterType.timeFrame:
        label = l10n.f_TIMEFRAME;
        items = ListModel(
          timeFrames.values.toList(),
          keyGen: (_) => 'time-frame',
        );
        break;
    }

    navigator.currentState?.push(MaterialPageRoute(
      settings: RouteSettings(name: 'filters-selection'),
      builder: (context) => FilterSelectionPage(
        title: label,
        filters: items,
        onFilterTapped: (key) => onFilterValueSelected(type, key),
      ),
    ));
  }

  void onPageChange(int index) {
    if (_isSetPageByController) {
      _isSetPageByController = false;
      return;
    }
    report(AppCurrentTab(index), from: pageController);
  }

  void onBottomTabChange(int index) => report(
        AppCurrentTab(index),
        from: bottomBarController,
      );

  void onNeedToPop() => report(AppTopWidgetPopping());

  void onCategoryChoiceSelected() => report(
        AppFilterChoiceSelection(FilterType.category),
      );

  void onCurrencyChoiceSelected() => report(
        AppFilterChoiceSelection(FilterType.currency),
      );

  void onTimeFrameChoiceSelected() => report(
        AppFilterChoiceSelection(FilterType.timeFrame),
      );

  void onCriteriaApplied() => report(AppCriteriaApplying());

  void onFilterValueSelected(FilterType type, String value) => report(
        AppFilterValueSelection(type: type, key: value),
      );

  PaperListener<NetworkDaoPaper, AppPaper> get networkDaoListener => reporter(
        (r) => r
            .on<NetworkDaoCoinsListSelection>(
              (p) => AppCurrentMarket(p.result),
            )
            ?.on<NetworkDaoCoinDetailsSelection>(
              (p) => AppCurrentCoinDetails(p.result),
            ),
      );

  PaperListener<CoinsListingPaper, AppPaper> get coinsListingListener =>
      reporter(
        (r) => r.on<CoinsListingCoinSelection>((p) async {
          return AppCoinDetailsSelection(p.key);
        })?.on<CoinsListingCriteriaSelection>((p) {
          return AppCriteriaSelection(
            category: categories[criteria.selectedCategory]!,
            currency: currencies[criteria.selectedCurrency]!,
            timeFrame: timeFrames[criteria.selectedTimeFrame]!,
          );
        })?.on<CoinsListingProfileDemand>((p) async {
          return AppAuthenticationDemand();
        }),
      );

  PaperListener<AuthPagePaper, AppPaper> get authenticationPageListener =>
      reporter(
        (r) => r.on<AuthPageLoginSelection>((p) async {
          final info = await authentication.report<AuthPageLoginCurrentInfo>();

          return AppLoginSelection(
            email: info!.email,
            password: info.password,
          );
        })?.on<AuthPageRegisterSelection>((p) async {
          final info =
              await authentication.report<AuthPageRegisterCurrentInfo>();

          return AppRegisterSelection(
            name: info!.name,
            email: info.email,
            password: info.password,
          );
        })?.on<AuthPageSignOutSelection>((p) => AppSignOutSelection()),
      );
}

/// The observer is for keep track the state of the navigation stack,
/// which is not able to be accessed by current Flutter Navigator.
///
/// It is treated as a listener, and retrieve the state of stack; nothing else.
class CurrentNavigationObserver extends NavigatorObserver {
  final List<String> stack = [];

  
  void didPush(Route<dynamic> route, Route<dynamic>? previousRoute) {
    stack.add(route.settings.name!);
  }

  
  void didReplace({Route<dynamic>? newRoute, Route<dynamic>? oldRoute}) {
    if (oldRoute == null) {
      if (newRoute != null) {
        // Add the route to the top?
        stack.add(newRoute.settings.name!);
      }
      return;
    }

    if (newRoute == null) {
      stack.remove(oldRoute.settings.name!);
      return;
    }

    if (stack.contains(oldRoute.settings.name)) {
      stack[stack.indexOf(oldRoute.settings.name!)] = newRoute.settings.name!;
    } else {
      // Add the route to the top?
      stack.add(newRoute.settings.name!);
    }
  }

  
  void didRemove(Route<dynamic> route, Route<dynamic>? previousRoute) {
    stack.remove(route.settings.name);
  }

  
  void didPop(Route<dynamic> route, Route<dynamic>? previousRoute) {
    stack.remove(route.settings.name);
  }
}
lib/src/unit_widget/coin_listing/coins_listing_paper.dart
part of 'coins_listing.dart';

abstract class CoinsListingPaper extends Paper {}

class CoinsListingCurrentCoins extends CoinsListingPaper {
  final ListModel<CoinOverview>? coins;

  CoinsListingCurrentCoins(this.coins);
}

class CoinsListingErrorText extends CoinsListingPaper {
  final String text;

  CoinsListingErrorText(this.text);
}

class CoinsListingCoinSelection extends CoinsListingPaper {
  final String key;

  CoinsListingCoinSelection(this.key);
}

class CoinsListingLoading extends CoinsListingPaper {}

class CoinsListingCriteriaSelection extends CoinsListingPaper {}

class CoinsListingProfileState extends CoinsListingPaper {
  final bool isEnabled;

  CoinsListingProfileState(this.isEnabled);
}

class CoinsListingProfileDemand extends CoinsListingPaper {}
lib/src/unit_widget/coin_listing/coins_listing_script.dart
part of 'coins_listing.dart';

class CoinsListingScript extends Script<CoinsListingPaper, CoinsListingState> {
  
  void map() => on<CoinsListingCurrentCoins>(onCurrentCoins)
      ?.on<CoinsListingErrorText>(onErrorText)
      ?.on<CoinsListingLoading>(onLoading)
      ?.on<CoinsListingProfileState>(onProfileState);

  void onCurrentCoins(
    CoinsListingCurrentCoins p,
    CoinsListingState s,
    SourceVerifier ifFrom,
  ) {
    s.coins = p.coins?.toList();
    s.isFilterButtonEnabled = true;
    s.render();
  }

  void onErrorText(
    CoinsListingErrorText p,
    CoinsListingState s,
    SourceVerifier ifFrom,
  ) {
    s.coins = null;
    s.errorText = p.text;
    s.render();
  }

  void onLoading(
    CoinsListingLoading p,
    CoinsListingState s,
    SourceVerifier ifFrom,
  ) {
    s.coins = null;
    s.errorText = null;
    s.render();
  }

  void onProfileState(
    CoinsListingProfileState p,
    CoinsListingState s,
    SourceVerifier ifFrom,
  ) {
    s.isProfileEnabled.value = p.isEnabled;
  }
}
lib/src/unit_widget/coin_listing/coins_listing.dart
import 'package:flutter/material.dart';
import 'package:paper/paper.dart';

import '../../../l10n/app_localizations.dart';
import '../../model/coin_overview.dart';
import '../../model/decor.dart';
import '../../widget/coin_summary_card.dart';

part 'coins_listing_paper.dart';
part 'coins_listing_script.dart';

class CoinsListing extends UnitWidget<CoinsListingPaper> {
  // ignore: use_key_in_widget_constructors
  CoinsListing(
    super.parent, {
    required super.agent,
    super.listener,
    required this.l10n,
    this.topPadding,
    this.height,
    this.width,
    this.coins,
    this.errorText,
  });

  final AppLocalizations l10n;
  final double? topPadding;
  final double? height;
  final double? width;

  final ListModel<CoinOverview>? coins;

  final String? errorText;

  
  UnitWidgetState<CoinsListingPaper, CoinsListing> createState() =>
      CoinsListingState();

  
  Script<CoinsListingPaper, CoinsListingState> createScript() =>
      CoinsListingScript();
}

class CoinsListingState
    extends UnitWidgetState<CoinsListingPaper, CoinsListing> {
  late List<CoinOverview>? coins;

  late String? errorText;

  late bool isFilterButtonEnabled;
  late final ValueNotifier<bool> isProfileEnabled;

  
  void initState() {
    super.initState();

    coins = widget.coins?.toList();

    errorText = widget.errorText;
    isFilterButtonEnabled = coins != null;
    isProfileEnabled = ValueNotifier(false);
  }

  
  Widget build(BuildContext context) {
    return ConstrainedBox(
      constraints: BoxConstraints.tightFor(
        width: widget.width,
        height: widget.height,
      ),
      child: Column(
        children: [
          SizedBox(height: widget.topPadding),
          SizedBox(
            height: 60,
            child: Stack(
              children: [
                Center(
                  child: Text(
                    widget.l10n.f_MagicCoins,
                    style: TextDecor().s25w700(color: ColorDecor().x000000),
                  ),
                ),
                Positioned(
                  right: 16,
                  top: 0,
                  bottom: 0,
                  child: ValueListenableBuilder<bool>(
                    valueListenable: isProfileEnabled,
                    builder: (context, value, child) {
                      return GestureDetector(
                        onTap: onProfileButtonTapped,
                        child: Icon(
                          Icons.account_circle,
                          size: 40,
                          color: value
                              ? ColorDecor().x8BC540
                              : ColorDecor().x9E9E9E,
                        ),
                      );
                    },
                  ),
                ),
              ],
            ),
          ),
          SizedBox(
            height: 52,
            child: Padding(
              padding: EdgeInsets.symmetric(horizontal: 16, vertical: 8),
              child: Row(
                children: [
                  Expanded(
                    child: Container(
                      height: double.infinity,
                      decoration: BoxDecoration(
                        color: Colors.white,
                        borderRadius: BorderRadius.circular(10),
                        border: Border.all(
                          width: 2,
                          color: ColorDecor().x8BC540,
                        ),
                      ),
                      padding: EdgeInsets.all(5),
                      child: Row(
                        children: [
                          const Icon(
                            Icons.search_outlined,
                            color: Colors.black,
                            size: 19,
                          ),
                          const SizedBox(width: 5),
                          Expanded(
                            child: GestureDetector(
                              onTap: () {},
                              child: Text(widget.l10n.s_ClickSearch),
                            ),
                          ),
                        ],
                      ),
                    ),
                  ),
                  const SizedBox(width: 5),
                  SizedBox(
                    height: double.infinity,
                    width: 40,
                    child: ElevatedButton(
                      onPressed:
                          isFilterButtonEnabled ? onFilterButtonTapped : null,
                      style: ElevatedButton.styleFrom(
                        shape: RoundedRectangleBorder(
                          side: BorderSide(
                            color: isFilterButtonEnabled
                                ? ColorDecor().x8BC540
                                : Colors.transparent,
                            width: 2,
                          ),
                          borderRadius: BorderRadius.circular(10),
                        ),
                        padding: const EdgeInsets.all(0),
                      ),
                      child: Icon(
                        Icons.filter_alt,
                        color: isFilterButtonEnabled
                            ? ColorDecor().x8BC540
                            : ColorDecor().x9E9E9E,
                      ),
                    ),
                  ),
                ],
              ),
            ),
          ),
          if (coins == null && errorText == null)
            Expanded(child: Center(child: CircularProgressIndicator())),
          if (coins != null && coins!.isNotEmpty)
            Expanded(
              child: ListView.separated(
                padding: EdgeInsets.symmetric(horizontal: 16),
                itemCount: coins!.length,
                itemBuilder: (context, index) {
                  final coin = coins![index];
                  return CoinSummaryCard(
                    name: coin.name,
                    ticker: coin.symbol,
                    price: coin.currencyFormattedCurrentPrice,
                    change: coin.changePercentage.trailingSpacingPercentage,
                    isChangeUp: coin.changePercentage.num >= 0,
                    onTap: () => onCardTapped(coin.key),
                  );
                },
                separatorBuilder: (context, index) {
                  return Divider(
                    thickness: 1,
                    height: 5,
                    color: ColorDecor().x9E9E9E,
                  );
                },
              ),
            ),
          if (coins != null && coins!.isEmpty)
            Expanded(
              child: Center(
                child: Text(
                  widget.l10n.s_NoCoinsFoundAlert,
                  textAlign: TextAlign.center,
                ),
              ),
            ),
          if (coins == null && errorText != null)
            Expanded(
              child: Center(
                child: Text(errorText!, textAlign: TextAlign.center),
              ),
            ),
        ],
      ),
    );
  }

  void onCardTapped(String key) => report(CoinsListingCoinSelection(key));

  void onFilterButtonTapped() => report(CoinsListingCriteriaSelection());

  void onProfileButtonTapped() => report(CoinsListingProfileDemand());
}
lib/main.dart
import 'package:flutter/foundation.dart';
import 'package:flutter/material.dart';
import 'package:hive/hive.dart';
import 'package:paper/paper.dart';
import 'package:path_provider/path_provider.dart';

import 'src/unit_widget/app/app.dart';

void main() async {
  WidgetsFlutterBinding.ensureInitialized();

  if (!kIsWeb) {
    final dir = await getApplicationDocumentsDirectory();
    Hive.init(dir.path);
  }

  final app = WidgetAgent<AppPaper>();
  PaperFrameworkEstablisher().initializeWidgetUnit((s) => App(s, agent: app));
  app.process(AppInitialize());
}
pubspec.yaml
...

dependencies:
  ...
  hive: ^2.2.3
  path_provider: ^2.1.5

Run:

Breakdown

For the Authentication feature, you can refer to previous sections to debug and understand how it works.

After understanding the flow, now we focus on tabAfterLoginNote of App.

The feature is: if users tap the profile icon in CoinsListing, the app should navigate to AuthPage for the user to login. After successfully logging in, the app will bring the user back to CoinsListing.

CoinsListing

  • When users tap the profile icon on the top-right of CoinsListing, CoinsListing reports CoinsListingProfileDemand.

App

  • When App receives CoinsListingProfileDemand, it processes AppAuthenticationDemand:
lib/src/unit_widget/app_script.dart
void onAuthenticationDemand(
    AppAuthenticationDemand p,
    AppState s,
    SourceVerifier ifFrom,
  ) {
    s.setPage(ifFrom(s.pageController), 1);
    s.bottomBarController.setIndex = 1;
    if (ifFrom(s.coinsListing) == null) {
      s.tabAfterLoginNote.value = 0;
    }
  }

After navigating to the authentication page, App sets tabAfterLoginNote's value to 0. This is because the changes come from CoinsListing, which is the first page of PageView. The value of tabAfterLoginNote should be set according to the index of the page that triggers AppAuthenticationDemand.

  • After granting the authentication of the users, App will check if tabAfterLoginNote.value is null. If it is null, it means users navigate to the Authentication Page by themselves (swiping, tapping the tab in the bottom bar); and App could do something else. Otherwise, based on the value of tabAfterLoginNote, App commands BottomBar to navigate to the appropriate pages. In our case, it is 0 - CoinsListing.

So how about we use a simple cached value in App instead of Note like

int tabAfterLogin;

It works absolutely but is less convenient.

Adding more to the above feature, let's say after navigating to AuthPage due to tapping the profile icon, users change their mind and swipe back to CoinsListing. That means tabAfterLogin should be set to null in AppCurrentTab's handler in AppScript.

void onCurrentTab(
    AppCurrentTab p,
    AppState s,
    SourceVerifier ifFrom,
  ) async {
    ifFrom(s.bottomBarController)?.setIndex = p.index;
    s.setPage(ifFrom(s.pageController), p.index);
    s.tabAfterLogin = null;
  }

It is also obvious that s.tabAfterLogin = null; should be placed in other handlers as well. It still works though but is annoying. This is where Note comes in. When constructing a Note, the constructor allows you to inject a callback via keepWhen, which will trigger whenever a paper of a unit is processed. If the result returned from the callback is true, the value of Note remains untouched. Otherwise, it will be set to null. For a better example, go to the next section - Streaming data from server-side.

Notes

As you may observe in AppScript.onLoginSelection, we handle the login by retrieving authentication data saved from Hive. This is different from what we recommended in Data Fetching.

As suggested, there should be a class to wrap Hive, for example named AuthService, which would provide APIs for authentication. Similar to the concept in Data Fetching, the App just needs to trigger the AuthService.login without awaiting it to complete. In the implementation of AuthService.login, after completing the retrieval of data, a callback will be triggered to notify the App.

But also as explained in Data Fetching Discussion, the simple implementation is accepted for better common understanding and practice. You also want to be aware of the drawbacks as stated in that section as well.