<![CDATA[The DexterX Developer]]>https://dexterx.dev/https://dexterx.dev/favicon.pngThe DexterX Developerhttps://dexterx.dev/Ghost 6.22Sat, 21 Mar 2026 01:19:55 GMT60<![CDATA[Ideas on how to organise the UI code in a Flutter application]]>This post is aimed at capturing thoughts I have had on how to approach organising UI code for a Flutter application. With some other frameworks, the UI may be written through another syntax such as through markup language. However, toolkits like Flutter have developers  write "UI as code&

]]>
https://dexterx.dev/ideas-on-how-to-organise-the-ui-code-in-a-flutter-application/5dee26229ad9f30038493121Mon, 09 Dec 2019 12:08:54 GMTThis post is aimed at capturing thoughts I have had on how to approach organising UI code for a Flutter application. With some other frameworks, the UI may be written through another syntax such as through markup language. However, toolkits like Flutter have developers  write "UI as code". Here the code for building user interfaces is written using the same programming language as the business logic.

When writing code in general, you and/or your team may be have a rule whereby the body of a method must fit within the bounds of your screen. Yet, a problem that developers may encounter during the course of building out their user interfaces is that code for a page can become large and difficult to follow, resulting in the infamous "bracket hell" problem.  To combat this, you can refactor out your code into separate widgets as covered in the previous link that leads a post by Iiro Krankka. With the "UI as code" approach, the IDE will be able to assist refactoring very easily. Whilst refactoring though, it may be easy to lose sense of the big picture and miss out on the fact that there are repeatable patterns within an application that are larger than a component like a button.

One way to look at the UI to identify these patterns is to apply the Atomic Design methodology by Brad Frost. I won't be describing it here but it applies a scientific analogy to break user interfaces into hierarchies and you can open the link for more details. This aligns with Flutter's approach of creating user interfaces via composition. If you're working with dedicated designers, there's a chance they're already building a design system or a style guide of sorts for your application. Applying the methodology could thus help designers and developers communicate with the same terminology. Having identified the patterns at various levels (with or without the help of a designer), developers could then structure the application such that there are folders for the atoms, molecules, organisations and templates. This can help accelerate the addition of new pages to an application by looking at the building blocks that already exist and mixing them together to get the desired outcome.

Note that what I'm proposing here isn't new like as seen in examples like this React Native boilerplate. However, what would be interesting to see is if teams use Flutter for not only building applications but to also document their design system through something like a pattern lab. Given that Flutter can target the web and DartPad can be used as an online editor to run Flutter code, it's not entirely unreasonable to think that will happen in the foreseeable future. Each pattern in a lab is typically accompanied with some sample code. If a pattern lab was built using Flutter and embedded DartPad within it, tweaks could be made on the fly if needed as well. Tools like Supernova Studio could also assist with this process by translating designs into code. If anyone ends up doing this, I would love to hear about it. If you're interested in reading more about design systems, I suggest reading Brad Frost's book on Atomic Design and Design Systems by Alla Kholmatov. Feel free to drop a comment if you have other resources that you recommend as well.

]]>
<![CDATA[Creating a responsive Flutter application using Material Design using a navigation drawer]]>https://dexterx.dev/creating-a-responsive-flutter-application-with-a-navigation-drawer/5d8b3ab5d443690038659f0bWed, 25 Sep 2019 13:49:54 GMT

The navigation drawer is one of the most common ways to provide a user with access to various destinations with an application. As Flutter can now be used to target other platforms like the web, it is important to consider how your application handles responsive design. Successfully doing so enables your application to make more effective use of the amount of real estate available on larger devices. In this post, we'll see how can achieve this goal.

For responsive applications that use a navigation drawer (sometimes referred to as an app with master-detail layout), the following scenarios would likely need to be handled:

  • on a device with a smaller form factor (e.g. mobile phone), the application bar should have a button (often referred to a hamburger button) that presents a modal navigation drawer (aka hamburger menu). For convenience, we'll refer to this as the mobile layout of your application
  • on a device with a larger form factor (e.g. browser, tablet, desktop), the navigation drawer is permanently displayed on the screen. This is typically on the left of the screen. The right side will be a details pane that contains information related to what the user has selected in the navigation drawer. I'll refer to this as the tablet/desktop layout.
  • if the application is running on a large device and can be resized, it should be able to seamlessly transition between the mobile and tablet/desktop layout as the user resizes the application window.

Let's see how this can be done. If you need to be able to run your application on the web, make sure you've turned on web support as per the guide here. Note that I did this on the dev channel where the version of the SDK was 1.10.5 at the time of writing this. Once done, create a brand new application. We'll start by editing the main.dart file

import 'package:flutter/material.dart';

import 'constants/route_names.dart';
import 'pages/gallery_page.dart';
import 'pages/home_page.dart';
import 'pages/settings_page.dart';
import 'pages/slideshow_page.dart';
import 'widgets/app_route_observer.dart';

void main() => runApp(DemoApp());

class DemoApp extends StatelessWidget {
  @override
  Widget build(BuildContext context) {
    return MaterialApp(
      title: 'Responsive app with navigation drawer',
      theme: ThemeData(
        primarySwatch: Colors.blue,
        pageTransitionsTheme: PageTransitionsTheme(
          // makes all platforms that can run Flutter apps display routes without any animation
          builders: Map<TargetPlatform,
                  _InanimatePageTransitionsBuilder>.fromIterable(
              TargetPlatform.values.toList(),
              key: (dynamic k) => k,
              value: (dynamic _) => const _InanimatePageTransitionsBuilder()),
        ),
      ),
      initialRoute: RouteNames.home,
      navigatorObservers: [AppRouteObserver()],
      routes: {
        RouteNames.home: (_) => const HomePage(),
        RouteNames.gallery: (_) => const GalleryPage(),
        RouteNames.slideshow: (_) => const SlideshowPage(),
        RouteNames.settings: (_) => const SettingsPage()
      },
    );
  }
}

/// This class is used to build page transitions that don't have any animation
class _InanimatePageTransitionsBuilder extends PageTransitionsBuilder {
  const _InanimatePageTransitionsBuilder();

  @override
  Widget buildTransitions<T>(
      PageRoute<T> route,
      BuildContext context,
      Animation<double> animation,
      Animation<double> secondaryAnimation,
      Widget child) {
    return child;
  }
}

In the main.dart file we've done the following

  • specified that we're building an application using Material Design via the MaterialApp widget
  • we have customised our theme to change the default page transition animations that occur when navigation to various routes using the PageTransitionsTheme class. In this case, we have created a _InanimatePageTransitionsBuilder class. It extends the PageTransitionsBuilder class that is used by defining page transition animations. The buildTransitions method that has been overridden is responsible for determining how to animate the widget (the child parameter) that is upon navigating to a specified route. By returning the child, this means no animation occurs. The builders property of the PageTransitionsTheme, is a dictionary where we can set what page transition animation is for each platform. Here, we've mapped each target platform to the _InanimatePageTransitionsBuilder class so that navigating will not result in a page transition animation for any platform
  • named routing has been configured. For more details on navigating with named routes, refer to this cookbook recipe. The HomePage, GalleryPage, SlideshowPage and SettingsPage represent the pages that the app can present to the user. The code for these will be shown later. To allow the code to be more organised and facilitate code reuse, the name of the routes been defined in a constants file called route_names.dart
class RouteNames {
  static const String home = '/';
  static const String gallery = '/gallery';
  static const String slideshow = '/slideshow';
  static const String settings = '/settings';
}
  • the navigatorObservers property of the MaterialApp widget has been set so that parts of the app can be notified when navigation has occurred within the app. The AppRouteObserver class looks like this
import 'package:flutter/material.dart';

class AppRouteObserver extends RouteObserver<PageRoute> {
  factory AppRouteObserver() => _instance;

  AppRouteObserver._private();

  static final AppRouteObserver _instance = AppRouteObserver._private();
}

It may have been possible to simply define global variable of that is of type   RouteObserver<PageRoute>. However I try to avoid having global variables as it can make the code harder to maintain amongst many other reasons. Creating the AppRouteObserver class with a factory constructor ensures that there is only a single instance that gets created. This class is used so that the navigation drawer knows which page is currently being presented so that it can be the corresponding navigation link is highlighted. Doing this requires use to create a widget that will render the navigation drawer

import 'package:flutter/material.dart';

import '../constants/page_titles.dart';
import '../constants/route_names.dart';
import 'app_route_observer.dart';

/// The navigation drawer for the app.
/// This listens to changes in the route to update which page is currently been shown
class AppDrawer extends StatefulWidget {
  const AppDrawer({@required this.permanentlyDisplay, Key key})
      : super(key: key);

  final bool permanentlyDisplay;

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

class _AppDrawerState extends State<AppDrawer> with RouteAware {
  String _selectedRoute;
  AppRouteObserver _routeObserver;
  @override
  void initState() {
    super.initState();
    _routeObserver = AppRouteObserver();
  }

  @override
  void didChangeDependencies() {
    super.didChangeDependencies();
    _routeObserver.subscribe(this, ModalRoute.of(context));
  }

  @override
  void dispose() {
    _routeObserver.unsubscribe(this);
    super.dispose();
  }

  @override
  void didPush() {
    _updateSelectedRoute();
  }

  @override
  void didPop() {
    _updateSelectedRoute();
  }

  @override
  Widget build(BuildContext context) {
    return Drawer(
      child: Row(
        children: [
          Expanded(
            child: ListView(
              padding: EdgeInsets.zero,
              children: [
                const UserAccountsDrawerHeader(
                  accountName: Text('User'),
                  accountEmail: Text('[email protected]'),
                  currentAccountPicture: CircleAvatar(
                    child: Icon(Icons.android),
                  ),
                ),
                ListTile(
                  leading: const Icon(Icons.home),
                  title: const Text(PageTitles.home),
                  onTap: () async {
                    await _navigateTo(context, RouteNames.home);
                  },
                  selected: _selectedRoute == RouteNames.home,
                ),
                ListTile(
                  leading: const Icon(Icons.photo_library),
                  title: const Text(PageTitles.gallery),
                  onTap: () async {
                    await _navigateTo(context, RouteNames.gallery);
                  },
                  selected: _selectedRoute == RouteNames.gallery,
                ),
                ListTile(
                  leading: const Icon(Icons.slideshow),
                  title: const Text(PageTitles.slideshow),
                  onTap: () async {
                    await _navigateTo(context, RouteNames.slideshow);
                  },
                  selected: _selectedRoute == RouteNames.slideshow,
                ),
                const Divider(),
                ListTile(
                  leading: const Icon(Icons.settings),
                  title: const Text(PageTitles.settings),
                  onTap: () async {
                    await _navigateTo(context, RouteNames.settings);
                  },
                  selected: _selectedRoute == RouteNames.settings,
                ),
              ],
            ),
          ),
          if (widget.permanentlyDisplay)
            const VerticalDivider(
              width: 1,
            )
        ],
      ),
    );
  }

  /// Closes the drawer if applicable (which is only when it's not been displayed permanently) and navigates to the specified route
  /// In a mobile layout, the a modal drawer is used so we need to explicitly close it when the user selects a page to display
  Future<void> _navigateTo(BuildContext context, String routeName) async {
    if (widget.permanentlyDisplay) {
      Navigator.pop(context);
    }
    await Navigator.pushNamed(context, routeName);
  }

  void _updateSelectedRoute() {
    setState(() {
      _selectedRoute = ModalRoute.of(context).settings.name;
    });
  }
}

The following has been done in this class

  • It's been made a StatefulWidget so that the when the app presents a new page, the appropriate navigation link (i.e. ListTile) is highlighted
  • It has a property called permanentlyDisplay. When set to true, the navigation drawer should always be kept on-screen. This will occur in size of the application window is large enough. In other cases, the navigation drawer would be presented modally
  • It renders a Drawer widget that has been specifically created for displaying navigation drawers. It contains a list of navigation links (represented by the ListView) and each one corresponds to a ListTile widget. Tapping on a navigation link will close the drawer if it's been shown modally (i.e. the mobile layout of the app has been rendered) and will take the user to the selected destination/page.
  • If the navigation drawer is being permanently displayed, the VerticalDivider widget is used to help provider a clearer separation between the navigation drawer and details pane. For readability I've made the child of the Drawer widget a Row to display the list of navigation links alongside the vertical divider that is conditionally displayed. An alternative to this would have been the look at the value of the permanentlyDisplay property and if the value is true, then child is set to be the Row widget whose children remain the same as above. If the value is false then the child is the ListView widget that contains our navigation links. This would have reduced the number of elements rendered in the widget tree as we won't always render a row. However, in my opinion this is a minor optimisation that comes at the cost of reducing the readability of the code
  • The RouteAware mixin has been added. The widget then subscribes to an instance of the AppRouteObserver class so it can be notified when routes have been pushed or popped. The didPush and didPop respectively have been overridden to handle these events and call the _updateSelectedRoute method. The latter method will cause the AppDrawer widget to be rebuilt so that the correct navigation link is highlighted via the ListTile's selected property

Now that we have our navigation drawer, let's see how we have our application automatically adjust to changes in the screen/window size. Flutter applications that use Material Design will generally use the Scaffold widget for rendering the various pages as it contains the structure for elements like the application bar and the page content itself. As we're looking to build responsive layouts, this class needs to be extended and the result is as follows

import 'package:flutter/material.dart';

import 'app_drawer.dart';

/// A responsive scaffold for our application.
/// Displays the navigation drawer alongside the [Scaffold] if the screen/window size is large enough
class AppScaffold extends StatelessWidget {
  const AppScaffold({@required this.body, @required this.pageTitle, Key key})
      : super(key: key);

  final Widget body;

  final String pageTitle;

  @override
  Widget build(BuildContext context) {
    final bool displayMobileLayout = MediaQuery.of(context).size.width < 600;
    return Row(
      children: [
        if (!displayMobileLayout)
          const AppDrawer(
            permanentlyDisplay: true,
          ),
        Expanded(
          child: Scaffold(
            appBar: AppBar(
              // when the app isn't displaying the mobile version of app, hide the menu button that is used to open the navigation drawer
              automaticallyImplyLeading: displayMobileLayout,
              title: Text(pageTitle),
            ),
            drawer: displayMobileLayout
                ? const AppDrawer(
                    permanentlyDisplay: false,
                  )
                : null,
            body: body,
          ),
        )
      ],
    );
  }
}

Here we have a "responsive" scaffold that will display the navigation drawer (the AppDrawer class we saw earlier) alongside the actual Scaffold widget that represents our details pane if the screen/window size is large enough. This was achieved by

  • Using the MediaQuery.of method within the build method of our widget. It will give us the size of the screen/window. If the user resizes the application window, the build method of our AppDrawer widget will be called, allowing us to know what the new window size is. In this case, we check the width and decided that if it's less than 600 logical pixels then we will present the mobile layout of our application is presented to the user. The value selected was based on what's in the Android docs here, where that is the width for a 7 inch tablet. The Material Design guidelines also has documentation on breakpoints here
  • If we have determined that a mobile layout should be used (based on the displayMobileLayout variable) we let the AppBar (application bar) deduce that it should display the hamburger button via the automaticallyImplyLeading property. This occurs as would've set the Scaffold's drawer property to an instance of our AppDrawer class. If the application is tablet/desktop mode so to speak, then our application bar won't have the hamburger button displayed as automaticallyImplyLeading would be false

With that done, we can now build our pages. For brevity, I've only included the code of the HomePage class below

import 'package:flutter/material.dart';

import '../constants/page_titles.dart';
import '../widgets/app_scaffold.dart';

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

  @override
  Widget build(BuildContext context) {
    return const AppScaffold(
      pageTitle: PageTitles.home,
      body: Center(
        child: Text('This is the home page'),
      ),
    );
  }
}

The HomePage class makes use of our responsive scaffold (AppScaffold) whilst passing in the title to display in the application bar and the main content of the page. This is it looks running on a Pixel 2 emulator and Pixel C emulator

Creating a responsive Flutter application using Material Design using a navigation drawer

Thanks to Codemagic, you can play around with it online here. If you'd like to look at the code, it's available on GitHub via this link. If you have any comments leave them below

]]>
<![CDATA[Moved to new domain!]]>As you may have noticed upon trying to visit this site, I've now changed the domain to dexterx.dev and updated the name of the site as well. Apologies if you were affected by the downtime.  I've tried to add redirects from the old domain

]]>
https://dexterx.dev/moved-to-new-domain/5d52a507381b6c0038a47c4bTue, 13 Aug 2019 11:55:49 GMTAs you may have noticed upon trying to visit this site, I've now changed the domain to dexterx.dev and updated the name of the site as well. Apologies if you were affected by the downtime.  I've tried to add redirects from the old domain but suggest updating your bookmarks accordingly

]]>
<![CDATA[Navigation drawer template with MobX]]>The navigation drawer is a common way for applications to provide users with access to various destinations in the application. Following on my post on the bottom navigation bar, I've created two new templates that make use of the provider and MobX packages that structure the application around

]]>
https://dexterx.dev/navigation/5d187c5c4f973e003841c77cSun, 30 Jun 2019 09:36:56 GMT

The navigation drawer is a common way for applications to provide users with access to various destinations in the application. Following on my post on the bottom navigation bar, I've created two new templates that make use of the provider and MobX packages that structure the application around using the navigation drawer. There are two as one involves using routes to take the user to the selected destination while the other makes swaps out what's displayed in the Scaffold's body akin to Android fragments. The latter is also similar to what was done for the bottom navigation bar. I won't be dwelving how state management was done As the approach to state management is pretty much identical to what was done for the template with a bottom navigation bar. Rather, this post is to let those that follow my blog know that the templates have been added and see what the results look like.

First let's see the navigation drawer that is common to both templates

Navigation drawer template with MobX

These are screenshots for the template that swaps out the page content in the Scaffold's body. These are what the user will see after selecting one of the destinations in the navigation drawer

Navigation drawer template with MobX
The slideshow page
Navigation drawer template with MobX
The gallery page
Navigation drawer template with MobX
The home page
Navigation drawer template with MobX
The settings page

The other template is similar but will trigger a page transition and a back button takes the user back to the home page if they picked a page other than the home page. Here's an example where the gallery page was picked

Navigation drawer template with MobX
After navigating to the gallery page

If these are useful to use, check out the GitHub repository that has all of the templates I've created. The template with the navigation drawer that takes users to each page through routes can be found here. The other template that swaps out the main page content is here. If you find these templates, let others know so that it can assist their development process.

]]>
<![CDATA[Flutter application templates and bottom navigation using provider and MobX]]>https://dexterx.dev/flutter-application-templates/5d04e5c8ca4e890038fe4aafSat, 15 Jun 2019 13:45:43 GMT

UPDATE 5/6/2019: the code snippets have been updated to make use of const where possible with some notes added

UPDATE 19/6/2019: this post has been updated since it was originally written. The DestinationsStore class was previously called AppStore. The renaming is to better align with domain vocabulary

UPDATE 30/6/2019: I've updated some of the code snippets and screenshots here to match the latest updates

Application developers are accustomed to having using an IDE to create a brand new application using one of the provide templates as a starting point. For example, when creating an Android application, developers can choose from options like a bottom navigation activity, master/detail flow etc. Some ecosystems also provide the ability create an application using templates created by the community. These are generally done so that developers get to use their preferred library of choice for structuring their application. For example, if you're developing a Xamarin.Forms application in Visual Studio and prefer to use Prism to implement the MVVM pattern, then one can install the Prism Template Pack that would add project templates to their Visual Studio installation. This would allow developers to pick the template that would create a Xamarin.Forms applications with Prism installed and view models are then implemented using Prism's APIs. Currently, Flutter doesn't provide similar functionality though it is possible to use the command to create an app from one the samples in the docs. In this post, I'll be introducing my attempt to create an application template that uses material design and combines using the provider and MobX for state management. The application will have a bottom navigation bar that allows users to change destinations (note: destinations are what they're referred to material design specification but I will use this interchangeably refer to them as pages as well)

The following are screenshots from the application

Flutter application templates and bottom navigation using provider and MobX
The home page
Flutter application templates and bottom navigation using provider and MobX
The dashboard page
Flutter application templates and bottom navigation using provider and MobX
The notifications page
Flutter application templates and bottom navigation using provider and MobX

The application is similar to the one created by Android Studio when creating an Android application with a bottom navigation activity. The difference here is that each page has its own counter that should be familiar to Flutter developers as the tooling will default to creating a counter application out of the box.

Each page is associated with a data store to keep track of the counter and an action to increment the counter. A data store for the destinations is used to track the available destinations that the user can select and which one is currently selected

import 'package:mobx/mobx.dart';
import '../constants/enums.dart';
part 'destinations_store.g.dart';

class DestinationsStore = DestinationsStoreBase with _$DestinationsStore;

abstract class DestinationsStoreBase with Store {
  static const List<Destination> destinations = Destination.values;

  @observable
  int selectedDestinationIndex = destinations.indexOf(Destination.Home);

  @computed
  Destination get selectedDestination => destinations[selectedDestinationIndex];

  @action
  void selectDestination(int index) {
    selectedDestinationIndex = index;
  }
}

Within the data store, the selected destination defaults to the home page. The data store provides an action that updates the currently selected destination and is invoked when the user picks one of the items that are part of bottom navigation bar. The code for the application that relates to this is as follows

import 'package:flutter/material.dart';
import 'package:provider/provider.dart';
import 'package:flutter_mobx/flutter_mobx.dart';
import 'package:shared_preferences/shared_preferences.dart';
import 'constants/enums.dart';
import 'constants/keys.dart';
import 'pages/dashboard_page.dart';
import 'pages/home_page.dart';
import 'pages/notifications_page.dart';
import 'pages/settings_page.dart';
import 'services/preferences_service.dart';
import 'stores/dashboard_store.dart';
import 'stores/destinations_store.dart';
import 'stores/home_store.dart';
import 'stores/notifications_store.dart';
import 'stores/settings_store.dart';

void main() async {
  final sharedPreferences = await SharedPreferences.getInstance();
  runApp(App(sharedPreferences));
}

class App extends StatelessWidget {
  const App(this.sharedPreferences, {Key key}) : super(key: key);

  final SharedPreferences sharedPreferences;

  @override
  Widget build(BuildContext context) {
    return MultiProvider(
      providers: [
        Provider<DestinationsStore>(
          builder: (_) => DestinationsStore(),
        ),
        Provider<HomeStore>(
          builder: (_) => HomeStore(),
        ),
        Provider<DashboardStore>(
          builder: (_) => DashboardStore(),
        ),
        Provider<NotificationsStore>(
          builder: (_) => NotificationsStore(),
        ),
        Provider<PreferencesService>(
          builder: (_) => PreferencesService(sharedPreferences),
        ),
        ProxyProvider<PreferencesService, SettingsStore>(
          builder: (_, preferencesService, __) =>
              SettingsStore(preferencesService),
        ),
      ],
      child: Consumer<SettingsStore>(
        builder: (context, store, _) {
          return Observer(
            builder: (_) {
              return MaterialApp(
                title: 'App title',
                theme: store.useDarkMode ? ThemeData.dark() : ThemeData.light(),
                home: Consumer<DestinationsStore>(
                  builder: (context, store, _) {
                    return Observer(
                      builder: (_) {
                        return Scaffold(
                          appBar: AppBar(
                            title: AppBarTitle(store.selectedDestination),
                          ),
                          body: SafeArea(
                            child: PageContainer(
                              store.selectedDestination,
                            ),
                          ),
                          bottomNavigationBar: AppBottomNavigationBar(store),
                          floatingActionButton: store.selectedDestination ==
                                  Destination.Settings
                              ? null
                              : FloatingActionButton(
                                  key: Keys.incrementButtonKey,
                                  onPressed: () {
                                    switch (store.selectedDestination) {
                                      case Destination.Home:
                                        Provider.of<HomeStore>(context)
                                            .increment();
                                        break;
                                      case Destination.Dashboard:
                                        Provider.of<DashboardStore>(context)
                                            .increment();
                                        break;
                                      case Destination.Notifications:
                                        Provider.of<NotificationsStore>(context)
                                            .increment();
                                        break;
                                      case Destination.Settings:
                                        break;
                                    }
                                  },
                                  child: const Icon(Icons.add),
                                ),
                        );
                      },
                    );
                  },
                ),
              );
            },
          );
        },
      ),
    );
  }
}

class AppBarTitle extends StatelessWidget {
  const AppBarTitle(this.destination, {Key key}) : super(key: key);

  final Destination destination;

  @override
  Widget build(BuildContext context) {
    switch (destination) {
      case Destination.Dashboard:
        return const Text('Dashboard', key: Keys.dashboardPageTitleKey);
      case Destination.Notifications:
        return const Text('Notifications', key: Keys.notificationsPageTitleKey);
      case Destination.Settings:
        return const Text('Settings', key: Keys.settingsPageTitleKey);
      default:
        return const Text('Home', key: Keys.homePageTitleKey);
    }
  }
}

class AppBottomNavigationBar extends StatelessWidget {
  const AppBottomNavigationBar(
    this.store, {
    Key key,
  }) : super(key: key);

  final DestinationsStore store;

  @override
  Widget build(BuildContext context) {
    return BottomNavigationBar(
      key: const Key('bottomNavigationBar'),
      selectedItemColor: Colors.blue,
      unselectedItemColor: Colors.grey,
      currentIndex: store.selectedDestinationIndex,
      items: DestinationsStoreBase.destinations.map(
        (option) {
          switch (option) {
            case Destination.Home:
              return const BottomNavigationBarItem(
                icon: Icon(Icons.home),
                title: Text('Home'),
              );
            case Destination.Dashboard:
              return const BottomNavigationBarItem(
                icon: Icon(Icons.dashboard),
                title: Text('Dashboard'),
              );
            case Destination.Notifications:
              return const BottomNavigationBarItem(
                icon: Icon(Icons.notifications),
                title: Text('Notifications'),
              );
            case Destination.Settings:
              return const BottomNavigationBarItem(
                icon: Icon(Icons.settings),
                title: Text('Settings'),
              );
          }
        },
      ).toList(),
      onTap: (index) => store.selectDestination(index),
    );
  }
}

class PageContainer extends StatelessWidget {
  const PageContainer(this.destination, {Key key}) : super(key: key);

  final Destination destination;

  @override
  Widget build(BuildContext context) {
    switch (destination) {
      case Destination.Dashboard:
        return const DashboardPage(key: Keys.dashboardPageKey);
      case Destination.Notifications:
        return const NotificationsPage(key: Keys.notificationsPageKey);
      case Destination.Settings:
        return const SettingsPage(key: Keys.settingsPageKey);
      default:
        return const HomePage(key: Keys.homePageKey);
    }
  }
}

The child of the Scaffold widget is what will display the current page via the PageContainer widget. The Scaffold widget has a BottomNavigationBar widget specified and this widget handles when the user taps on an menu item. When this happens, the action to update the selected destination is invoked. If the destination changes, the PageContainer widget is redrawn at which point since there is an Observer widget that will react to when the selected destination changes (the selectedDestination property in the DestinationsStore class). When the destination is observed to have changed, the selected destination is checked to determine the page that should be rendered. Some approaches like to define all of the pages for the tabs and store them in a list beforehand compared to what's shown above. I prefer the approach I've shown as it keeps widget construction in the build method, avoids initialising widgets that might not actually be displayed and in my opinion, makes the code is easier to follow.

Now that I've explained the structure behind the application, if this template is of interest to you, check out the GitHub repository here. There's a readme that explains the steps required to use the template to create an application. Updates may be made to the template in the future so if you find it useful, I'd suggest watching the repository for updates.

]]>
<![CDATA[Combining Flutter's AnimatedList with MobX]]>https://dexterx.dev/combining-flutters-animatedlist-with-mobx/5cfcf4966cae9a0038a1135bSun, 09 Jun 2019 15:18:30 GMTUPDATE 24/6/2019: A bug was originally found that I submitted PR for into the MobX repository that has been merged in and released. This blog post and the code has been updated since it's been originally written to make use of that fix

In my previous post, I demonstrated how MobX is a great library for doing state management with Flutter applications (at least I hope I did!). It's quite common for applications to present a list of items to the user and animating these lists can help your app to life. Fortunately, Flutter has the AnimatedList widget that can help us achieve that goal and I'll be demonstrating how we can make use of it along with MobX. Note that I'll be skipping the parts around installing MobX and how to use code generation. I'll also be assuming that readers have a basic understanding of MobX's core concepts covered here and in my previous post. The complete sample is hosted in this GitHub repository

Initial state of the sample app

The above screenshot shows what the initial state of our sample app looks like. The floating action button will allow us to add new items and there is text on the bottom to help keep track of the total number of items. As an item is added, it should slide in from the right and the total down the bottom should be updated.

Let's first define what the model of the item will look like

class Item {
  final DateTime dateAdded;

  Item(this.dateAdded);
}

Here, each item is expected to contain date and time information on when it was added. An item is represented by the ItemCard widget

import 'package:flutter/material.dart';
import 'package:provider/provider.dart';
import '../models/item.dart';
import '../stores/data_store.dart';

/// Used for rendering each item in the list.
/// Displays information on when each item was added along with a remove button.
class ItemCard extends StatelessWidget {
  final Item _item;
  final bool _removable;

  const ItemCard(this._item, this._removable, {Key key}) : super(key: key);

  @override
  Widget build(BuildContext context) {
    return Padding(
      padding: const EdgeInsets.all(8.0),
      child: Row(
        children: [
          Expanded(
              child:
                  Text('Item added at ${_item.dateAdded.toIso8601String()}')),
          RaisedButton(
              child: Text('Remove'),
              onPressed: _removable
                  ? () {
                      Provider.of<DataStore>(context).removeItem(_item);
                    }
                  : null),
        ],
      ),
    );
  }
}

This is fairly straightforward, it displays the date and time the item was added and has a button that allows the user to remove the item. Which is an action exposed via the DataStore class that will hold the list of items

import 'package:mobx/mobx.dart';
import '../models/item.dart';

part 'data_store.g.dart';

class DataStore = _DataStore with _$DataStore;

abstract class _DataStore with Store {
  @observable
  ObservableList<Item> items = ObservableList<Item>();

  @computed
  String get itemsFooter =>
      "${items.length} ${(items.length == 1) ? 'item' : 'items'}";

  @action
  void addItem() {
    items.add(Item(DateTime.now().toUtc()));
  }

  @action
  void removeItem(Item item) {
    items.remove(item);
  }
}

This store maintains the list of items we need to display through the items property. This is an ObservableList so that we can be notified on when the list has been modified. This happens through two methods that are treated as MobX actions. One is for adding an item addItem and the other for removing a specific item through the removeItem method. Note that we have also defined the footer itemsFooter to be will displayed at the bottom the of the page. If there is one item, it should the text to be displayed should be"1 item" and in all other case it will be "x items" where x is the total number of items. By defining it as a computed observable, modifications to the items property will make sure that itemsFooter is kept in sync.

This store is connected with the DataPage class as shown below

import 'package:flutter/material.dart';
import 'package:flutter_mobx/flutter_mobx.dart';
import '../widgets/item_card.dart';
import '../stores/data_store.dart';

class DataPage extends StatefulWidget {
  final DataStore store;
  DataPage(this.store, {Key key}) : super(key: key);

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

class _DataPageState extends State<DataPage> {
  final GlobalKey<AnimatedListState> _listKey = GlobalKey<AnimatedListState>();
  // This tween with the slide transition will cause the item to slide from right to left into the list
  final Tween<Offset> _tween = Tween<Offset>(
    begin: Offset(1.0, 0.0),
    end: Offset.zero,
  );

  @override
  void initState() {
    super.initState();
    widget.store.items.observe((listChange) {
      if (listChange.added?.isNotEmpty ?? false) {
        // an item has been added, synchronise the items displayed within the AnimatedList with the items within an our store
        _listKey.currentState.insertItem(listChange.index);
      }
      if (listChange.removed?.isNotEmpty ?? false) {
        // an item has been removed, synchronise the items displayed within the AnimatedList with the items within an our store.
        // note that when removing the AnimatedList will play the animation in reverse (left to right) for us so can reuse the
        // same tween for adding in items
        _listKey.currentState.removeItem(
            listChange.index,
            (context, animation) => SlideTransition(
                  position: animation.drive(_tween),
                  child: ItemCard(listChange.removed.first, false),
                ));
      }
    });
  }

  @override
  Widget build(BuildContext context) {
    return Scaffold(
      appBar: AppBar(
        title: Text('Animated List demo'),
      ),
      body: Column(children: [
        Expanded(
          child: AnimatedList(
            key: _listKey,
            initialItemCount: 0,
            itemBuilder: (context, index, animation) => SlideTransition(
                  position: animation.drive(_tween),
                  child: ItemCard(widget.store.items[index], true),
                ),
          ),
        ),
        Padding(
          padding: EdgeInsets.fromLTRB(0.0, 0.0, 0.0, 8.0),
          child: Observer(
            builder: (_) => Text('${widget.store.items.length} items(s)'),
          ),
        ),
      ]),
      floatingActionButton: FloatingActionButton(
        onPressed: () {
          widget.store.addItem();
        },
        child: Icon(Icons.add),
      ),
    );
  }
}

The DataPage is a StatefulWidget that takes an instance of the DataStore class (not shown here but I've passed it using the provider package) and is associated with the _DataPageState class. Together, they are responsible for displaying our list of items as per the screenshot above. If we look at the build method that is responsible for rendering, we notice a FloatingActionButton that will cause items to be added, an AnimatedList and aText widget. The latter is responsible for display the total amount of items in the list at the bottom of the screen. It has the Observer widget as parent so that it can be notified of when the items have been modified so that the total can be updated on-screen.

The AnimatedList is responsible for rendering the list of items. It starts off being an empty list, hence the initialItemCount is set to zero. The itemBuilder property defines how we want to render each item and will pass to us an Animation<double> object (I've named it animation) that represents our animation and ots value will go from zero to one. To indicate that we want the item represented by the ItemCard widget (more on this later) to slide in, we wrap it with the SlideTransition widget. The SlideTransition widget has a position property that defines the animation that controls the position of its child (the ItemCard widget). This requires an Animation<Offset> value that we can obtain by chaining the animation object passed by the AnimatedList's itemBuilder with a Tween<Offset> object that we've named as _tween in the _DataPageState class. This tween is responsible for the defining beginning and end Offset values, which determines the beginning and end position of the ItemCard as it slides in. The Offset constructor has two arguments, the first represents the horizontal component whilst the second is the vertical component. By passing Offset(1.0, 0.0) as the beginning and Offset.zero as the end of our tween, this will enable the animation to slide the ItemCard from right to left as it'll start horizontally off-screen.

To actually insert and remove items from the AnimatedList widget and have animations occur though, we need to make use of the AnimatedListState class. Following the advice from the API docs, we have created a GlobalKey<AnimatedListState> object that is passed as the key of our AnimatedList to associated them with each other. Note that the API docs for AnimatedListState also state that its insertItem method is what's used to insert an item into an AnimatedList with an animation. Items can be removed using the removeItem method. Given that the modification of our items is done via the DataStore class, the question then is how we can invoke the AnimatedListState's insertItem or removeItem as an item is added based on if the DataStore's addItem() method or removeItem has been invoked. Fortunately, as we have the items property an ObservableList, MobX provides a way for us to observe when our list of items has been changed. We configure this within the initState method shown again below for convenience

@override
  void initState() {
    super.initState();
    widget.store.items.observe((listChange) {
      if (listChange.added?.isNotEmpty ?? false) {
        // an item has been added, synchronise the items displayed within the AnimatedList with the items within an our store
        _listKey.currentState.insertItem(listChange.index - 1);
      }
      if (listChange.removed?.isNotEmpty ?? false) {
        // an item has been removed, synchronise the items displayed within the AnimatedList with the items within an our store.
        // note that when removing the AnimatedList will play the animation in reverse (left to right) for us so can reuse the
        // same tween for adding in items
        _listKey.currentState.removeItem(
            listChange.index,
            (context, animation) => SlideTransition(
                  position: animation.drive(_tween),
                  child: ItemCard(listChange.removed.first, false),
                ));
      }
    });
  }

We make use of the observe method exposed by the ObservableList class. This callback is invoked when the list has been modified. It passes a ListChange object that allows us to know if items had been added or removed, and the index of the item that has been added/removed. This is the point where we can invoke the insertItem/ removeItem method exposed by the AnimatedListState. When removing an item via the AnimatedListState's removeItem method, note that it allows us to define the appearance and animation of the item being removed. This is where we can disable any kind of user interaction with the item being removed since it'll remain visible while the removal animation is occurring. The animation in this case will play the animation used to slide the animation in, but in reverse. In other words, the item will slide from left to right (off-screen). Now let's see it all in action

Our animated list in action

The items slide in and out when added or removed, and we can see the text showing the total items at the bottom updates. Feel free to check the repository for a closer a look if you want to play around with. The Flutter team also has a brief video on the AnimatedList as part of their widget of the week series

If you need your lists to be more lively, consider using the AnimatedList widget in your apps. It might just be the thing your app needs to give it a little extra oomph.

]]>
<![CDATA[Managing UI state in Flutter with MobX and provider - Dissecting a Hacker News app]]>https://dexterx.dev/managing-ui-state-in-flutter-with-mobx-and-provider/5cf797f56a94d2003783569bThu, 06 Jun 2019 12:33:16 GMT

Note: at the time of writing this article, the ProxyProvider class wasn't available in the provider package. I may look at updating the app to make use of it at a later stage. Readers should also check https://mobx.netlify.app/guides/stores as I've contributed to the official documentation on how to use ProxyProvider. This includes a change to how I now recommend structuring applications whereby dependencies are passed directly to via constructor. Besides that, the rest of the article should still be of use to developers looking to use MobX. I have updated the code to use the latest version of provider etc

When looking at building applications for Flutter, state management has become a hot topic that there's now a dedicated section on it on the official Flutter website. Having come from a background where I've worked on building mobile and desktop .NET applications, MVVM has become the standard architectural pattern. The UI would written in XAML and with the ability to wire up the UI with a view model through data binding. Whilst researching for ways that could help manage updating the UI within Flutter apps that were similar how I would normally do so in the past, two approaches that stood out to me were the BLoC pattern and MobX.

There are quite a few resources that have gone through the BLoC pattern so I won't dwelve into it much detail. To summarise though, it requires using the StreamBuilder widget that is wired up to listen to a stream (which that may reside in, say, a view model. The stream, which may be implemented using RxDart, will contain information on if the state has changed (e.g. app has progressed from loading data to finish loading) and the StreamBuilder widget will rebuild the UI in response. Some developers may consider working with streams a complex topic and that setting up the BLoC pattern involves too much effort although there are packages that can help get around that.

MobX provides similar capabilities that doesn't require dealing with streams. Code generation can also make it easier for developers to work by reducing the amount of code developers need to write to wire everything up. It makes use of three core concepts as described here

  1. Observables - represents part of your application's state
  2. Actions - responsible for mutating state
  3. Reactions - responsible for reacting to changes in state

To illustrate, lets dissect an application I've written called SUpNews. This is a basic application that connects to the Hacker News API to display stories and is hosted on GitHub here.

Overview and architecture

Breaking down the app, it essentially consists of four pages

  1. New stories page - for displaying new stories
  2. Top stories page - for displaying top stories
  3. Favourites page - for keep track of favourite stories
  4. Settings page - for toggling on and off dark mode and how stories should be opened
Managing UI state in Flutter with MobX and provider - Dissecting a Hacker News app
Screenshot of the new stories page

Tapping on a story will be default, open it "in-app". There is also the option it on the device's browser, ability to share a story, or have it added to the user's list of favourites. If the story exists in their list of favourites already then the user will be presented with the option of removing the story from their favourites. Scrolling the list of stories will also cause more stories to be loaded

Managing UI state in Flutter with MobX and provider - Dissecting a Hacker News app
Options available for a story
Managing UI state in Flutter with MobX and provider - Dissecting a Hacker News app
Settings page

If you read the documentation on the website on MobX for Dart here, it talks about having a widget-store-service hierarchy, where the widget represents the state you want to depict in your UI, a store contains information about the state and a service is used to perform work like fetching data from an API that would then be kept in your store. If we apply this to SUpNews, the architecture looks somewhat like the following

Managing UI state in Flutter with MobX and provider - Dissecting a Hacker News app
Architecture of the application. The arrows represent the flow of direct interaction between the various components

This looks more complicated than it actually is but each page will be associated with a store that will in turn make use of one or more services. The available services are

  • Hacker News API client - this will retrieve the stories need to be displayed. Makes use of the hnpwa_client package, though I'm actually using a fork due to an issue I've found
  • Sharing service - provides a way to share stories. This makes use of the share package
  • Story service - responsible for opening stories for users to read in-app or in a separate browser. This is done using the launcher package
  • Shared preferences service - used to help manage the users preferences within the app via the shared_preferences plugin

The sharing service, story service and shared preferences service may be a bit overkill but helps provide an abstraction in case the implementation of the methods need to differ on the various platforms. For example, whilst this app only supports Android and iOS right now, on the web you could story service navigate the user to the story via the browser that they'd already be using to run the app.

The news stories and top stories page also communicate with the favourites store since the user can manage their list of favourite stories through these pages. In general, each page will be associated with a store and the stores themselves don't have a reference to the page. This should be familiar to those have experience with doing MVVM applications. In Xamarin.Forms, your pages are likely be done in XAML as mentioned before and wired up with the associated view model. An IoC container is used to manage dependencies and your view models would get the dependencies (i.e. the services) it needs through constructor injection. A similar approach can be taken here with Flutter as we shall soon see.

Organising stores and services

Now that we've seen all the components within the application, we need to look at how we can manage the stores and services. The provider package is a popular choice that for making using of dependency injection with widgets. In fact, the Flutter team has recently spoken on a talk on Pragmatic State Management in Flutter. The MobX for Dart documentation also suggests using provider. Note that with Flutter, there is a notion of "lifting state up" whereby state is kept above the widgets. If there are two widgets need to make use of the same state, then the state should be kept above the nearest ancestor to both widgets. This helps avoid (1) unnecessary rebuilding of the UI and (2) widening the scope that state more than necessary.

Looking at the main.dart file that contains the entry point of the app, we see the following

import 'package:flutter/material.dart';
import 'package:shared_preferences/shared_preferences.dart';
import 'package:flutter_statusbarcolor/flutter_statusbarcolor.dart';
import 'services/preferences_service.dart';
import 'widgets/app.dart';

Future<void> main() async {
  WidgetsFlutterBinding.ensureInitialized();
  var sharedPreferences = await SharedPreferences.getInstance();
  await FlutterStatusbarcolor.setStatusBarColor(Colors.teal);
  if (useWhiteForeground(Colors.teal)) {
    await FlutterStatusbarcolor.setStatusBarWhiteForeground(true);
  } else {
    await FlutterStatusbarcolor.setStatusBarWhiteForeground(false);
  }
  runApp(App(PreferencesService(sharedPreferences)));
}

Here we try to get an instance of the SharedPreferences before running the app as it's in asynchronous operation that only needs to be done once and makes changing the values of the preferences much easier later on since those involve synchronous operations.

The app class looks like

import 'package:flutter/material.dart';
import 'package:hnpwa_client/hnpwa_client.dart';
import 'package:provider/provider.dart';
import '../services/sharing_service.dart';
import '../services/story_service.dart';
import '../stores/favourites_store.dart';
import '../stores/settings_store.dart';
import '../services/preferences_service.dart';
import 'themeable_app.dart';

class App extends StatelessWidget {
  final PreferencesService _preferencesService;

  const App(this._preferencesService);

  @override
  Widget build(BuildContext context) {
    return MultiProvider(
      providers: [
        Provider<PreferencesService>(
          create: (_) => _preferencesService,
        ),
        Provider<HnpwaClient>(
          create: (_) => HnpwaClient(),
        ),
        Provider<SettingsStore>(
          create: (_) => SettingsStore(_preferencesService),
        ),
        Provider<FavouritesStore>(
          create: (_) => FavouritesStore(_preferencesService),
        ),
        Provider<SharingService>(
          create: (_) => SharingService(),
        ),
        Provider<StoryService>(
          create: (_) => StoryService(_preferencesService),
        )
      ],
      child: Consumer<SettingsStore>(
        builder: (context, value, _) => ThemeableApp(value),
      ),
    );
  }
}

The aptly named Provider widget helps provides descendents an instance of a specified class when needed. The Provider's builder method is invoked once to instantiate and return an instance of a type that has been by requested by a descendent widget. These descendent widgets would be attached to child property of the Provider widget. We've leveraged the MultiProvider widget us to define a collection of Providers so we that don't have to deal with nesting Provider widgets (see the package's readme for more details) . Here we've defined the dependencies that are common amongst the pages. The SettingsStore is a notable exception to this as it contains the state on if the user has toggled dark mode on or off. This is done by consuming an instance of the SettingsStore so that it can be passed to the ThemeableApp widget via the Consumer widget. This enables the app to change the theme when the user toggles on or off the dark mode setting as seen below.

import 'package:flutter/cupertino.dart';
import 'package:flutter/material.dart';
import 'package:flutter_mobx/flutter_mobx.dart';
import 'package:hnpwa_client/hnpwa_client.dart';
import 'package:provider/provider.dart';
import '../screens/favourites_page.dart';
import '../stores/favourites_store.dart';
import '../screens/new_stories_page.dart';
import '../screens/settings_page.dart';
import '../screens/top_stories_page.dart';
import '../services/preferences_service.dart';
import '../stores/new_stories_store.dart';
import '../stores/top_stories_store.dart';
import '../stores/settings_store.dart';

class ThemeableApp extends StatelessWidget {
  final SettingsStore settingsStore;
  ThemeableApp(this.settingsStore, {Key key}) : super(key: key);

  @override
  Widget build(BuildContext context) {
    return Observer(
      builder: (_) {
        var themeData = ThemeData(
          fontFamily: 'Lato',
          brightness: settingsStore.useDarkMode == true
              ? Brightness.dark
              : Brightness.light,
          primarySwatch: Colors.teal,
          textTheme: TextTheme(
            subtitle1: TextStyle(fontSize: 16, fontWeight: FontWeight.w500),
            subtitle2: TextStyle(fontWeight: FontWeight.w300),
          ),
        );
        return MaterialApp(
          title: 'SUpNews',
          theme: themeData,
          home: SafeArea(
            child: CupertinoTabScaffold(
              tabBar: CupertinoTabBar(
                items: [
                  BottomNavigationBarItem(
                    icon: Icon(Icons.new_releases),
                    title: Text('New'),
                  ),
                  BottomNavigationBarItem(
                    icon: Icon(Icons.trending_up),
                    title: Text('Top'),
                  ),
                  BottomNavigationBarItem(
                    icon: Icon(Icons.favorite),
                    title: Text('Favourites'),
                  ),
                  BottomNavigationBarItem(
                    icon: Icon(Icons.settings),
                    title: Text('Settings'),
                  ),
                ],
              ),
              tabBuilder: (BuildContext context, int index) {
                switch (index) {
                  case 0:
                    return Consumer2<HnpwaClient, PreferencesService>(
                      builder: (context, hnpwaClient, preferencesService, _) =>
                          Provider(
                        create: (_) =>
                            NewStoriesStore(hnpwaClient, preferencesService),
                        child: Consumer<NewStoriesStore>(
                          builder: (context, value, _) => Material(
                            child: NewStoriesPage(
                              value,
                            ),
                          ),
                        ),
                      ),
                    );
                  case 1:
                    return Consumer2<HnpwaClient, PreferencesService>(
                      builder: (context, hnpwaClient, preferencesService, _) =>
                          Provider(
                        create: (_) => TopStoriesStore(
                          hnpwaClient,
                          preferencesService,
                        ),
                        child: Consumer<TopStoriesStore>(
                          builder: (context, value, _) => Material(
                            child: TopStoriesPage(
                              value,
                            ),
                          ),
                        ),
                      ),
                    );
                  case 2:
                    return Consumer<FavouritesStore>(
                      builder: (context, value, _) => Material(
                        child: FavouritesPage(value),
                      ),
                    );
                  case 3:
                    return Consumer<SettingsStore>(
                      builder: (context, value, _) => Material(
                        child: SettingsPage(value),
                      ),
                    );
                }
                return null;
              },
            ),
          ),
        );
      },
    );
  }
}
The ThemeableApp class where each tab is defined

Within the ThemeableApp class, we can see that we have defined the pages within the app are displayed within four tabs. Looking at the new stories page as an example (within the case 0 block), we make use of Consumer2 widget as it allows us to get instances of two different class types. More specifically, we're trying to get hold of the HnpwaClient (the Hacker News API client) and an instance of the PreferencesService. We consume these services so that we can provide an instance of the NewStoriesStore that is dependent on them, which in turn would get consumed by the NewStoriesPage. We can see that the code follows what was just described through the nesting of Consumer and Provider widgets.

Connecting stores to widgets to update the UI

Now that we have stores passed to our pages, let's see how they are wired up together so that the UI responds to changes in state. Once again, we'll be using the new stories page as an example. When the new stories page appears, a call to the Hacker News API should be done to fetch the newest stories. Whilst that is happening, we can indicate to to the user that the app is busy fetching data. Once the results have come back from the API, the new stories page should display the list of stories.

Our new stories page is defined as followed

import 'package:flutter/foundation.dart';
import '../screens/stories_page.dart';
import '../stores/new_stories_store.dart';

class NewStoriesPage extends StoriesPage<NewStoriesStore> {
  NewStoriesPage(NewStoriesStore store, {Key key}) : super(store, key: key);
}
The new stories page class

It extends a StoriesPage class as both the new stories page and top stories page have identical UI but have difference sources of data.

import 'package:flutter/material.dart';
import 'package:flutter_mobx/flutter_mobx.dart';
import 'package:mobx/mobx.dart';
import 'package:incrementally_loading_listview/incrementally_loading_listview.dart';
import '../stores/stories_store.dart';
import '../widgets/placeholder_stories.dart';
import '../widgets/placeholder_story.dart';
import '../widgets/story.dart';

class StoriesPage<T extends StoriesStore> extends StatefulWidget {
  final T store;
  StoriesPage(this.store, {Key key}) : super(key: key);

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

/// Notes: use of [AutomaticKeepAliveClientMixin] with the [wantKeepAlive] override will effectively allow Flutter to retain the page state, including the scroll position.
/// Without it, switching back and forth between tabs would cause the data to tab to be rebuilt, which in turn causes data to be fetched etc
class _StoriesPageState<T extends StoriesStore> extends State<StoriesPage>
    with AutomaticKeepAliveClientMixin {
  @override
  void initState() {
    super.initState();
    widget.store.loadInitialStories();
  }

  @override
  Widget build(BuildContext context) {
    super.build(context);
    return Observer(
      builder: (_) {
        switch (widget.store.loadFeedItemsFuture.status) {
          case FutureStatus.rejected:
            return Center(
              child: Column(
                mainAxisAlignment: MainAxisAlignment.center,
                children: [
                  Text('Oops something went wrong'),
                  RaisedButton(
                    child: Text('Retry'),
                    onPressed: () async {
                      await widget.store.retry();
                    },
                  ),
                ],
              ),
            );
          case FutureStatus.fulfilled:
            return RefreshIndicator(
              child: IncrementallyLoadingListView(
                loadMore: () async {
                  await widget.store.loadNextPage();
                },
                hasMore: () => widget.store.hasNextPage,
                itemCount: () => widget.store.feedItems.length,
                itemBuilder: (context, index) {
                  if (index == widget.store.feedItems.length - 1 &&
                      widget.store.hasNextPage &&
                      !widget.store.loadingNextPage) {
                    return Column(
                      children: [
                        Story(widget.store.feedItems[index]),
                        PlaceholderStory(),
                      ],
                    );
                  }
                  return Story(widget.store.feedItems[index]);
                },
              ),
              onRefresh: () async {
                await widget.store.refresh();
              },
            );
          case FutureStatus.pending:
          default:
            return PlaceholderStories();
        }
      },
    );
  }

  @override
  bool get wantKeepAlive => true;
}
The generic stories page class

The Observer widget is provided by the flutter_mobx package and is part of the reacts to changes in state within the NewStoriesStore. So let's see what's defined within the store

import 'package:hnpwa_client/hnpwa_client.dart';
import '../common/enums.dart';
import '../services/preferences_service.dart';
import 'stories_store.dart';

class NewStoriesStore extends StoriesStore {
  NewStoriesStore(
      HnpwaClient hnpwaClient, PreferencesService preferencesService)
      : super(StoryFeedType.New, hnpwaClient, preferencesService);
}
The NewStoriesStore class

The store extends StoriesStore class much like how the NewStoriesPage extends StoriesPage

import 'package:hnpwa_client/hnpwa_client.dart';
import 'package:mobx/mobx.dart';
import 'package:url_launcher/url_launcher.dart';
import '../common/enums.dart';
import '../services/preferences_service.dart';

part 'stories_store.g.dart';

class StoriesStore = StoriesStoreBase with _$StoriesStore;

abstract class StoriesStoreBase with Store {
  final StoryFeedType _storyFeedType;
  final HnpwaClient _hnpwaClient;
  final PreferencesService _preferencesService;

  int _currentPage = 1;
  bool _isLoadingNextPage = false;

  @observable
  bool hasNextPage = false;

  @observable
  ObservableList<FeedItem> feedItems = ObservableList<FeedItem>();

  @observable
  ObservableFuture loadFeedItemsFuture;

  @observable
  bool loadingNextPage = false;

  StoriesStoreBase(
      this._storyFeedType, this._hnpwaClient, this._preferencesService);

  @action
  Future<void> refresh() {
    return _loadFirstPageStories();
  }

  @action
  Future<void> retry() {
    return loadFeedItemsFuture = ObservableFuture(_loadFirstPageStories());
  }

  @action
  Future<void> loadInitialStories() {
    return loadFeedItemsFuture = ObservableFuture(_loadFirstPageStories());
  }

  Future<void> open(String url) {
    final defaultOpenInAppPreference = _preferencesService.openInApp;
    return launch(url,
        forceSafariVC: defaultOpenInAppPreference,
        forceWebView: defaultOpenInAppPreference);
  }

  @action
  Future<void> loadNextPage() async {
    try {
      if (_isLoadingNextPage || (_currentPage > 1 && !hasNextPage)) {
        return;
      }
      _isLoadingNextPage = true;
      var feed = _storyFeedType == StoryFeedType.Top
          ? (await _hnpwaClient.news(page: _currentPage))
          : (await _hnpwaClient.newest(page: _currentPage));
      // some items from the official API don't have a URL but the HNPWA API will put "item?={id}" as the URL so need to filter those out
      feedItems.addAll(feed.items.where((fi) {
        var uri = Uri.tryParse(fi.url);
        return uri != null && uri.hasScheme;
      }));
      hasNextPage = feed.hasNextPage;
      _currentPage++;
    } finally {
      _isLoadingNextPage = false;
    }
  }

  @action
  Future<void> _loadFirstPageStories() async {
    feedItems.clear();
    _currentPage = 1;
    await loadNextPage();
  }
}

Here a number of properties have the @observable annotation attached. The annotation is used to indicate the state that the UI will react to when the value changes

  • hasNextPage - indicates if the Hacker News API has another set of stories that the app can fetch
  • feedItems - this is the actual list of stories that can be displayed. This is an instance of the ObservableList<T> class defined within MobX. It is similar to the ObservableCollection defined in .NET where collection-based UI elements (e.g. a ListView) would bind to an ObservableCollection. If the collection gets modified then the UI be updated to reflect that
  • loadFeedItemsFuture - this is used to represent the asynchronous work for fetching the initial stories to display. The work being performed may be different as we may be retrying or loading the initial set of stories upon landing on the page. The property as defined as instance of the ObservableFuture type defined within MobX. It is similar to the Future class already defined within the Flutter framework but has extra functionality when using MobX with Flutter.
  • loadingNextPage - this is used to indicate if the app is fetching the next set of stories

Methods that can change state decorated with the @observable annotation will have an @action annotation attached to them

  • refresh - used when the the user has requested to refresh the stories displayed
  • retry - used when an error has occurred whilst fetching stories (e.g. app is offline when the app starts) and the user has requested to retry fetching the stories
  • loadInitialStories - used to load the initial set of stories upon loading the page
  • loadNextPage - used fetch the next set of stories when the user scrolls to the bottom of the page. The page will check the value of hasNextPage and loadingNextPage to determine if it can load the next set of stories
  • _loadFirstPageStories - used to fetch the first set/page of stories. This is a common method called by retry and loadInitialStories. Of note here is that even private methods that modify observables need to have the @action annotation as well.

All stories are fetched using the HnpwaClient class that is a service consumed by the store. Another thing to note is the is the line part 'stories_store.g.dart'; that is part of the store's code. This is because making use of annotations requires the MobX code generation to run, which will create a stories_store.g.dart file that contains more code on the underlying implementation of how the annotated code actually works without us having to worry about that ourselves. More details can be found in MobX's getting started guide.

So now that we see what's within the store, let's go through how the page and store are connected. Upon loading the new stories page, the initState method is invoked that will call the loadInitialStories method defined within the store. The build method defined is responsible for rendering the page and has the Observer widget as mentioned earlier. The widget is responsible for monitoring if the underlying observables used further in get changed and trigger the UI to be rebuilt when this occurs.  From the perspective the page, the follow reactions can occur

  • If the status of the loadFeedItemsFuture property is  FutureStatus.pending,  a loading indicator is displayed as stories are being fetched (status would be FutureStatus.pending)
  • If an error occurs whilst fetching stories (the status of loadFeedItemsFuture is FutureStatus.rejected), then an error message is displayed to notify the user about this and a button is presented then allows them to retry. Retrying would assign a new ObservableFuture value (this underlying value is the asynchronous work to retry) to the store's loadFeedItemFuture property that would cause the loading indicator to be displayed again.
  • If the operation to fetch stories has successfully completed (i.e. the status of loadFeedItemsFuture is FutureStatus.fulfilled), we present the list of stories to the user via the IncrementallyLoadingListView widget. This widget is a drop-in replacement for the standard ListView widget from a package I developed. When the user scrolls to the bottom, it can invoke the loadNextPage() method to retrieve the next set of stories from the API and trigger the list to be updated
  • The stories presented by the IncrementallyLoadingListView widget are based on the feedItems defined within the store. Each story is represented by the Story widget
  • Pull to refresh functionality is available that will call the refresh method defined in the store when triggered, which we've seen is a MobX action that can mutate state. The feedItems collection will be repopulated that will in turn update the list of stories displayed

If you've used the FutureBuilder widget then you'll notice that the how app makes use of the status of an ObservableFuture (the loadFeedItemsFuture property) to determine what should be rendered.

The favourites page (FavouritesPage) and its store (FavouritesStore) are connected in a similar way. However, the FavouritesStore is also used when the user interacts with a story via a long press gesture (i.e. tap and hold).

import 'package:flutter/material.dart';
import 'package:flutter_mobx/flutter_mobx.dart';
import 'package:hnpwa_client/hnpwa_client.dart';
import 'package:intl/intl.dart' show DateFormat;
import 'package:provider/provider.dart';
import '../services/story_service.dart';
import '../services/sharing_service.dart';
import '../stores/favourites_store.dart';

import 'styles.dart';

class Story extends StatelessWidget {
  final FeedItem _item;
  Story(this._item, {Key key}) : super(key: key);

  @override
  Widget build(BuildContext context) {
    final storyService = Provider.of<StoryService>(context);
    final sharingService = Provider.of<SharingService>(context);
    final favouritesStore = Provider.of<FavouritesStore>(context);
    return InkWell(
      child: Padding(
        padding: const EdgeInsets.fromLTRB(8, 8, 8, 16),
        child: Row(
          children: [
            Center(
              child: CircleAvatar(
                child: Center(
                  child: Text(
                    _item.points.toString(),
                  ),
                ),
              ),
            ),
            Expanded(
              child: Padding(
                padding: const EdgeInsets.fromLTRB(8, 0, 0, 0),
                child: Column(
                  crossAxisAlignment: CrossAxisAlignment.start,
                  children: [
                    TextSpacer(
                      Text(_item.title,
                          style: Theme.of(context).textTheme.subtitle1),
                    ),
                    TextSpacer(
                      Text(
                        _item.url,
                        overflow: TextOverflow.ellipsis,
                        style: Theme.of(context).textTheme.subtitle2,
                      ),
                    ),
                    TextSpacer(
                      Text(
                        '${_item.user} - ${DateFormat().format(
                          DateTime.fromMillisecondsSinceEpoch(
                              _item.time * 1000),
                        )}',
                        style: Theme.of(context).textTheme.subtitle2,
                      ),
                    ),
                    Text(
                      '${_item.commentsCount} ${_item.commentsCount == 1 ? 'comment' : 'comments'}',
                      style: Theme.of(context).textTheme.subtitle2,
                    ),
                  ],
                ),
              ),
            ),
          ],
        ),
      ),
      onTap: () async {
        await storyService.open(_item.url);
      },
      onLongPress: () {
        showModalBottomSheet(
            context: context,
            builder: (BuildContext bc) {
              return Observer(
                builder: (_) => Container(
                  child: new Wrap(
                    children: <Widget>[
                      new ListTile(
                        leading: new Icon(Icons.favorite),
                        title: new Text(favouritesStore.isInFavourites(_item)
                            ? 'Remove from favourites'
                            : 'Add to favourites'),
                        onTap: () {
                          favouritesStore.isInFavourites(_item)
                              ? favouritesStore.removeFavourite(_item)
                              : favouritesStore.addFavourite(_item);
                          Navigator.of(context).pop();
                        },
                      ),
                      new ListTile(
                        leading: new Icon(Icons.open_in_browser),
                        title: new Text('Open in browser'),
                        onTap: () async {
                          await storyService.openInBrowser(_item.url);
                          Navigator.of(context).pop();
                        },
                      ),
                      if (_item.url != null)
                        new ListTile(
                          leading: new Icon(Icons.share),
                          title: new Text('Share'),
                          onTap: () async {
                            await sharingService.share(_item.url);
                            Navigator.of(context).pop();
                          },
                        ),
                    ],
                  ),
                ),
              );
            });
      },
    );
  }
}

Here we've made use of the Provider.of<T> method that the provider package has as an alternate approach compared to the Consumer when trying to resolve an instance of a class. By sharing the FavouritesStore with the Story widget, we can ensure the page associated with it will also be updated in response to the user managing their list of favourites outside of the FavouritesPage. Now if the user were to go to the favourites page to remove a story from their list of favourites that was added the new stories page, when they go back to the new stories page and tap and hold on the story, they will see that they get the option to add the story to their list of favourites again. We can see how MobX has made state management much easier as all of observers of the same state have been notified of the changes.

Conclusion

Hopefully this post has provided some useful information on how MobX can be used within a Flutter application in case you're interested in making use of it as well. Feel free to check the code in more detail (e.g. how the settings page is done as it hasn't been shown here) by checking out the repository on GitHub. It has been used by some notable companies for building React Native applications as seen here and can expected to work for Flutter applications.

For those that are familiar with building .NET mobile or desktop applications with the MVVM pattern (e.g. through Xamarin.Forms), a quick guide on how translate your experience is

  • To use stores as your view models
  • Properties within your view model/store that need to raise a property change notification so the changes are reflected in the UI should be decorated with the @observable annotation
  • Commands (which would implement the ICommand interface) that represent the actions the user can perform would be mapped to methods within your view model/store. If the method needs to update a property within your view model/store that would require the UI to be updated to reflect these changes then decorate the methods with the @action annotation
  • If you've been using an ObservableCollection in your view models to representation a collection of data that needs to be displayed, you can use an ObservableList with MobX. This tracks when items have been added, updated or removed from the collection as well
  • Connect your UI with your store so it can react to changes in state with the Observer widget

If you have any questions or comments, you can find me on Twitter. For more information about MobX for Dart, check the official site

]]>
<![CDATA[Advice and resources on writing plugins for Flutter]]>

Flutter is a great framework for developing cross-platform applications, enabling developers to implement expressive and beautifully-designed user interfaces. A lot of developers may find that they only need to deal with working at the framework level where all of their code is written in Dart. However, things become more complicated

]]>
https://dexterx.dev/advice-and-resources-on-writing-plugins-for-flutter/5c7652e7add69800c0f1d2e8Wed, 27 Feb 2019 12:26:16 GMTAdvice and resources on writing plugins for Flutter

Flutter is a great framework for developing cross-platform applications, enabling developers to implement expressive and beautifully-designed user interfaces. A lot of developers may find that they only need to deal with working at the framework level where all of their code is written in Dart. However, things become more complicated when applications need to make use of native platform APIs (e.g. bluetooth) or platform-specific libraries/SDKs (e.g AppAuth). In the event that a plugin hasn't been published by another member in the community, the solution is to start creating a plugin ourselves. There are a lot of articles on Flutter but they tend to focus on areas that are more on the framework level (e.g. state management, animations etc). Having written a few plugins myself (see here), I thought I'd share some advice and learnings. This is by no means an exhaustive list but should help collect some of the various resources I have made use of in a more centralised location

  • Read this guide on writing platform-specific code. Accessing platform-specific APIs makes use of platform channels. It's important to understand how they work and the associated APIs. There's also a table that describes how Dart values and types are interpreted in Android and iOS, and vice versa. This highlights some of the limitations on the types that can be transferred over the wire.

    • It also demonstrates how an error can be returned from the native platform back to the Flutter side. Determine the scenarios where it makes sense to do so with error codes and messages that indicate what went wrong. These will trigger a platform exception that can be caught by consumers of your plugin
  • Read the introduction on how to develop plugins available here. It walks through how to use create a new plugin via the tooling, including the optional parameters that can be specified e.g. what language should be used to write the Android and iOS code.

  • Determine what languages you'll be using on the Android and iOS side. By default, the tooling will require developers to write their code in Java for the Android side whilst the iOS side requires implementing the plugin in Objective-C. Kotlin and Swift are more modern languages for Android and iOS respectively that developers may find easier to learn. Furthermore, the syntax is more similar to each other. However, bear in mind that this may result in some friction when developers need to make use of your plugin (e.g. see this issue tracked on the Flutter repository)

  • Read this article about writing a good plugin that was written by one of the engineers at Google

  • Migrate the Android side of the plugin and example application (one is created automatically by the tooling) to AndroidX. Google has deprecated the Android support libraries that are used to allow older versions of Android to access new functionality. This can be done by opening the Android head project (i.e. the Android side under /<your_plugin>/example/android) of the example application in Android Studio and following the migration guide. I would imagine that future updates to the Flutter SDK would mean the plugin and applications created by the tooling will support AndroidX out of the box, thereby eliminating the need to follow this step. If you have an existing plugin that is being migrated to AndroidX, increment the version following semantic versioning to indicate it's a breaking change (more on this a bit below)

  • Make it easier for developers (including yourself) to test their application and your plugin. If you look at the generated code for a new plugin, you'll see that the methods within the plugin's class are static. Change this so that it's no longer static so that developers can mock your plugin (e.g. with the mockito package) when writing unit tests. Once this is done, you could also look at creating unit tests for your plugin. A guide on how to do write unit tests in Flutter can be found here

  • Structure your plugin so that consumers of your plugin will only need add a single import statement to access it once it's been added as a dependency. A guide on how to do this can be found here

  • Follow the semantic versioning guidelines when you need to increment the version when releasing updates. Incrementing the correct version element (major, minor and patch) will aid developers in identifying breaking changes besides having them mentioned in the changelog. This is admittedly something I had missed and so did some of the Flutter team from the looks of it. Guess we all make mistakes :)

  • Avoid exposing platform-specific methods and platform-specific plugins where possible. If Flutter has been picked as the framework you're using to create your applications then that is likely because you're creating a cross-platform application. Consider the following code

    if (Platform.isIOS) {
      batteryLevelPlugin.getBatteryLeveliOS();
    } else if (Platform.isAndroid) {
      batteryLevelPlugin.getBatteryLevelAndroid();
    }
    ...
    

    This doesn't make for a pleasant "developer experience" as branches in the code are introduced for each platform. This will be harder to maintain when more platforms are supported as well. Expose a single method that abstracts what needs to happen for each platform. In the case that there is functionality that may require platform-specific configuration/details, allow these to be passed through the parameters passed when invoking a method. In the local notifications plugin that I've written, there is a method for initialising the plugin and each platform can be configured in different ways. For example, one can specify the default icon for notifications on Android while on iOS one can specify if a sound should be played by default when a notification appears. I've separated this by creating a class that holds platform-specific information, which I've been calling these "platform-specifics". This has a different meaning in the Xamarin.Forms ecosystem but I think it has a nice ring to it

    class InitializationSettings {
      /// Settings for Android
      final AndroidInitializationSettings android;
    
      /// Settings for iOS
      final IOSInitializationSettings ios;
    
      const InitializationSettings(this.android, this.ios);
    }
    

    These are then passed by calling the method for initializing the plugin

    var flutterLocalNotificationsPlugin = new FlutterLocalNotificationsPlugin();
    var initializationSettingsAndroid =
        new AndroidInitializationSettings('app_icon');
    var initializationSettingsIOS = new IOSInitializationSettings(
        onDidReceiveLocalNotification: onDidRecieveLocalNotification);
    var initializationSettings = new InitializationSettings(
        initializationSettingsAndroid, initializationSettingsIOS);
    flutterLocalNotificationsPlugin.initialize(initializationSettings);
    

    Doing so allows you target the lowest common denominator whilst providing the extensionability of able to do more platform-specific work.

  • Comment the Dart code for your plugin using three forward slashes. Upon publishing your plugin, API docs are generated and these comments will appear in the docs to help developers understand the functionality available in your plugin

  • Create an example that demonstrates the functionality available in your plugin. An example application is generated out of the box for your plugin to encourage developers follow this practice. In the absence of having detailed documentation for every scenario, it'll help others with being able to have access to sample code and see the results when they run the application

  • Use the code the Flutter team has written for their plugins at this repository as a reference. These provide a good reference on practices to follow and native APIs that you may need to make use of whilst developing your plugin. This is especially useful if you are a new to doing either Android or iOS development when it comes to the using the native APIs and understanding the how each platform works. A couple of examples include

  • If you are developing a plugin that will act as a wrapper around an Android and/or iOS library, familiarise yourself with how a library/dependency can be installed on each platform. On Android, Gradle is used to handle dependencies and add them your build.gradle file as per this guide. On iOS, CocoaPods are used and dependencies are added in your plugin's Podspec file. In the plugin I've written as a wrapper around the AppAuth SDKs, you can see how I've added it as a dependency in Android and iOS. Once done, you should be able to make sure of the APIs provided by the library being consumed as a dependency

Hopefully, these collection of tips and links would be of use to other developers. Whilst I have had some exposure with each platform due to my experience with Xamarin, writing plugins for Flutter has provided me with a good opportunity to learn more about each platform, pick up new programming languages and learn more Flutter itself. Flutter's plugin ecosystem is still growing and by writing plugins you can help contribute to its growth and to the community.

]]>
<![CDATA[Loading paginated data with list views in Flutter]]>https://dexterx.dev/loading-paginated-data-with-list-views-in-flutter/5b62fb47e5738f00bfa547ffThu, 02 Aug 2018 13:40:34 GMT

It is common for applications to retrieve a collection of data and render the results in the form of a list. In Flutter, this is achieved using the ListView widget. However, some APIs may deal with large volumes of data that when a request to retrieve that data is made, only a small subset will be returned. Typically, the response will including information to retrieve the next subset i.e. the application is dealing with paginated data. For applications that display the data in a list view, a common approach is to initiate a request to load the next set of data once the user scrolls to the bottom e.g. once the last item scrolls into view.

To do this in Flutter, we can make use of the ListView.builder constructor, which exposes an itemBuilder callback that will pass the item's index to your application so that it can render the appropriate UI for that item. For developers with experience doing Windows or Xamarin.Forms application development, this would be similar to the item/data template selector. This callback is triggered when an item's corresponding widget is visible in the list view. We could use this to trigger a request to load more items when the last item scrolls into view. Using this approach allows us to avoid doing things like calculating the scroll position, the height of the items etc to determine if the application should load the next set of data items. The resulting code would look similar to the following

/// In your state class
Future _loadMoreItems() async {
  setState(() {
    _loadingMore = true;
  });
  // make asynchronous API request to load more items here
  ...
  setState(() {
    // update _hasMoreItems if there are more
    _hasMoreItems = ...;
    _loadingMore = false;
  });
}

/// Within your state class' build function

return ListView.builder(itemCount: items.length,
                        itemBuilder: (context, index) {
                        final item = items[index];
                        // check if the last item in the list view has scrolled into view and if there are more items
                        if ((_hasMoreItems ?? false) &&
                            index == items.length - 1) {
                          if (!(_loadingMore ?? false)) {
                            // load more items
                            _loadMoreItems();
                          }
                          // display the item and possibly an indicator of some sort to show that the app is loading more items
                          ...
                        }
                        // return a widget that renders how the item looks
                        return new ItemCard(..);
});

This is pretty straightforward and the Gist above has been modified for brevity. This is an example of how it could look in action

An example of an incrementally loading list view

Upon scrolling to the last item within the current collection, it will render the item and an additional placeholder item to indicate that there are more items being loaded. This is a bit similar to what you'd see in Facebook when scrolling through your feed. A working example application from which the gif was captured from can be found here on GitHub.

Although there's not much code needed to implement the behaviour, if you find it a bit tedious to write this code for each project, I've also created a package that uses the same logic as above and can be obtained from Pub. It's essentially an extension the ListView.builder constructor with additional properties for determining if there are more items to be loaded, the function to be invoked to when there are more items and the ability to customise when more items should be loaded i.e. rather than triggering a request to load more items when the last item scrolls into view, this can be done when the 3rd last item scrolls into view.

Update: Have found that this approach doesn't work on the current version in the dev channel (0.5.7). However, I've kept this post for historical purposes. This appears to be due to the logic trying to trying to re-build whilst it's already in progress. This has resulted in the pub package needing updates to resolve issue such that the approach used now differs than what was originally proposed above. Essentially a StreamBuilder now wraps around a ListView.builder. Upon scrolling to, say, the last item, we can emit an item onto a stream that would trigger an async generator function that calls our code to load more items and emit an event when this is done. The StreamBuilder will respond to this to rebuild the ListView to show the new items. If you are interested in the code. Feel free to look at it on GitHub here

]]>