How to encourage app updates with Flutter

Esta entrada del blog fue redactado y publicado inicialmente en otro sitio como parte de un convenio. Si quieres ver el post original, te invito a verlo eneste enlace.

One big advantage when creating mobile apps is to offer new content and features to your users. This means that you will have to release new versions of your app with a higher cadence and, in turn, have many versions of your app out there, some even broken. This is a problem for app development and there's a great Flutter plugin that helps you solve it: upgrader.

In this tutorial you'll learn about the following:

  • Importance of keeping your users in the latest versions of your app.
  • Display an alert if your app is not up to date.
  • Display custom UI elements if your app is not up to date.
  • Enforce minimum app versions.
  • Learn the advantages of using AppCast.

Keep in mind: This tutorial assumes that you are a developer with experience publishing mobile apps to Google Play Store and Apple App Store as well as basic knowledge of Flutter, stateful widgets and package versioning.

Before you begin

You'll work on Days Without Incidents, a simple incident counter app that supports many counters, styles, and a simple user experience. You can follow the tutorial by downloading the open source code available via GitHub. You should use Flutter, 3.0 or above to follow along.

When opening the project with your preferred IDE and remember to get the dependencies with flutter pub get.

Here’s a quick rundown of some important files you should be aware of:

  • lib/main.dart: Standard main file required for Flutter projects.
  • lib/core: Core widgets and utilities shared between two or more features.
  • lib/features: Uses feature grouping to abstract different parts of the UI.
  • packages: Contains the data and domain layers.

In an effort to help you understand the essentials, you can also check this Pull Request. It has all the changes required to handle app updates and version restrictions on your own.

Understanding how upgrader works

Nowadays most people use the auto-update feature of the app stores for Android and iOS, this helps to avoid having to update each app on the phone. Yet, there may be instances in which the device has not yet updated the app, or that the user is not auto-updating apps. This can turn into a big deal if you are looking to make releases with consistency (Release Early, Release Often) because it could cause version fragmentation issues.

Version fragmentation will happen when there are too many versions of your app in the market. Each one could have different features, device support, screen support, and even API versions. This means that your infrastructure and services should support all these variations. This ends up making operations more expensive for the business.

The solution? There are three things you can do to cut down the impact of version fragmentation:

  1. Have a manageable list of versions that you support.
  2. Enforce a minimum version you are supporting.
  3. Guide the user through updates if their app is not running the latest version.

This is where upgrader comes in. It helps you put in place all those mechanisms in your app without too much overhead.

upgrader is a Flutter plugin that helps manage the user's installed version of your app. It allows you to check if the user has the latest version installed and if not, it guides the user to install it via stores with a dialog or widget.

The plugin can also enforce a minimum app version, and has built-in support RSS feeds with the AppCast standard used by Sparkle, and . Using the library helps you reduce version fragmentation and avoid potential bugs or broken app versions.

Enough chit-chat, it's time you dive into some code.

Displaying alerts if the app is outdated

One of the most common use cases for upgrader is to display a dialog when the current installed app is outdated compared to the store listing. Time you try it out, open lib/features/time_counter/pages/counter_page.dart and change CountersPage's build to the following:


Widget build(BuildContext context) {
  return Scaffold(
    appBar: const DWIAppBar(),
    body: UpgradeAlert(
      child: const SafeArea(
        child: _Body(),
      ),
    ),
  );
}

UpgradeAlert can wrap a widget, this allows you to place it as a wrapper of body and not having to worry about manual checks to the version.

Tip: Remember to add this import at the top of the file: import 'package:upgrader/upgrader.dart';.

If you build and run the app right now you'll see the following:

Image about default dialog implementation

The plugin also has limited style customization for the dialog with the dialogStyle property in Upgrader which has two different options: UpgradeDialogStyle.cupertino and UpgradeDialogStyle.material (default). Change build again and change the style to be cupertino likeso:


Widget build(BuildContext context) {
  return Scaffold(
    appBar: const DWIAppBar(),
    body: UpgradeAlert(
      upgrader: Upgrader(dialogStyle: UpgradeDialogStyle.cupertino),
      child: const SafeArea(
        child: _Body(),
      ),
    ),
  );
}

Build and run the app, it should look something like this:

Image about default Cupertino dialog implementation

Now go ahead and leave build as it was in the beginning:


Widget build(BuildContext context) {
  return const Scaffold(
    appBar: DWIAppBar(),
    body: SafeArea(
      child: _Body(),
    ),
  );
}

Another common scenario is having an indicator in the UI to display that the installed version of the app is not up to date. Again, the plugin has a really good implementation for this via UpgradeCard.

Time to add UpgradeCard and see how it looks. Open lib/features/time_counter/widgets/counter_list.dart and change add this code after line 80:

UpgradeCard(),

Build and run the app, this is how it looks like:

Image about default card implementation

As you can see, UpgradeCard displays a card styled with Material Design, it uses the same content as UpgradeAlert but it does it inline instead of a dialog. Since in this case the card does not look great, then go ahead and delete the code you just added by deleting line 81.

Implementing custom upgrade widgets

Now, sometimes those two built-in behaviors are not enough in terms of user experience. An example may be to show an IconButton when there's an update available for the app. Let's give this example a try.

You will need to create a new widget that will work like UpgradeCard and UpgradeAlert. In fact, if you take a closer look to the library's code, you'll notice that both extend UpgradeBase which will make things a bit easier.

Start by creating the file lib/core/widgets/upgrade_widget.dart and add the following code:

import 'dart:developer';

import 'package:flutter/material.dart';
import 'package:upgrader/upgrader.dart';

/// Defines a builder function that allows you to create a custom widget
/// that is displayed in a similar fashion as [UpgradeCard]
typedef UpgradeWidgetBuilder = Widget Function(
  BuildContext context,
  Upgrader upgrader,
);

/// A widget to display by checking upgrader info available.
class UpgradeWidget extends UpgradeBase {
  /// Creates a new [UpgradeWidget].
  UpgradeWidget({
    Key? key,
    Upgrader? upgrader,
    required this.builder,
  }) : super(upgrader ?? Upgrader.sharedInstance as Upgrader, key: key);

  /// Defines how the widget will be built. Allows the implementation of custom
  /// widgets.
  final UpgradeWidgetBuilder builder;

  /// Describes the part of the user interface represented by this widget.
  
  Widget build(BuildContext context, UpgradeBaseState state) {
    if (upgrader.debugLogging) {
      log('UpgradeWidget: build UpgradeWidget');
    }

    return FutureBuilder(
      future: state.initialized,
      builder: (BuildContext context, AsyncSnapshot<bool> processed) {
        if (processed.connectionState == ConnectionState.done &&
            processed.data != null &&
            processed.data!) {
          if (upgrader.shouldDisplayUpgrade()) {
            if (upgrader.debugLogging) {
              log('UpgradeWidget: will call builder');
            }
            return builder.call(context, upgrader);
          }
        }

        return const SizedBox.shrink();
      },
    );
  }
}

Here's a quick overview of the code you just added:

  • UpgradeWidget is a wrapper widget that implements the same basic behavior that UpgradeCard and UpgradeAlert share. It extends UpgradeBase and does basic checks when the customized build is executed. This widget will allow you to wrap any widget and display information about updating the app if needed.
  • UpgradeWidget receives an optional Upgrader implementation but also has the default one. This is the way that both UpgradeCard and UpgradeAlert work. Making the custom widget use the same principles allows you to keep interoperability between your custom widget and the library's API.
  • UpgradeWidgetBuilder is an alias for the builder function used to render your content widget. It has Upgrader as a parameter because that will give you access to the information required for building your custom widget. You'll see more about this next.

Now that you have set up a reusable UpgradeWidget, open lib/core/widgets/dwi_appbar.dart and copy this code at the bottom of the file:

class _UpdateButton extends StatelessWidget {
  const _UpdateButton({
    Key? key,
  }) : super(key: key);

  
  Widget build(BuildContext context) {
    return UpgradeWidget(
      upgrader: Upgrader(
        //! This is a bit of a hack to allow the alert dialog to be shown
        //! repeatedly.
        durationUntilAlertAgain: const Duration(milliseconds: 500),
        showReleaseNotes: false,
        showIgnore: false,
      ),
      builder: (context, upgrader) => CircleAvatar(
        child: IconButton(
          onPressed: () {
            upgrader.checkVersion(context: context);
          },
          icon: const Icon(Icons.upload),
        ),
      ),
    );
  }
}

_UpdateButton is a convenience private widget that will allow you to set the behavior for your custom UpgradeWidget. In this case, the idea is to showcase upgrader's alert dialog when the IconButton is tapped. This can be accomplished by calling checkVersion and passing the context as a parameter; the plugin will take over when the function is called and a new UpgradeAlert should be displayed.

For better readability the flags showReleaseNotes and showIgnore are set to false since both elements take too much space in the UI, and since the DWI users are familiar with simple user experience, then you can skip showing them for now.

One last caveat is that whenever the user taps Later in UpgradeAlert, the library stores a timestamp to avoid showing the dialog too often or at undesired times. It will only reshow the dialog once some time has passed (the default is 3 days).

For DWI you'll need to bypass this feature and show the dialog everytime the user taps on the update IconButton. To accommplish this you are overriding the default durationUntilAlertAgain with a Duration of 500 milliseconds. This way, the library will mark the dialog as ready as soon the user closes the dialog.

This is a hacky solution because it wouldn't be needed if the library trusted you to display the alert dialog at any point you wanted. Unfortunately, at the time of writing this tutorial, upgrader does not support this kind of behavior out of the box.

Now add _UpdateButton(), to actions in line 21. Also make sure you add the corresponding imports for the library at the top of the file:

import 'package:dwi/core/widgets/widgets.dart';
import 'package:upgrader/upgrader.dart';

Build and run, you should see the following:

Image about custom widget implementation

Enforcing a minimum version in your app

Another great feature of upgrader is to enforce a minimum app version simply by adding predefined text to the description in the app stores or by defining the minimum app version inside your Upgrader configuration like so:

Upgrader(
  minAppVersion: '3.0.0',
),

If you are looking to use app store descriptions then keep in mind the following formats:

  • For the Android Play Store, use this format: [Minimum supported app version: 1.2.3]
  • For the iOS App Store, use this format: [:mav: 1.2.3]

Using that text will define the minimum app version as 1.2.3; which means that earlier versions of this app will be forced to update to the latest version available.

Controlling your own version listings

An important limit of upgrader is that the default behavior of upgrader leverages the current App Store and Play Store listings of your app, this means that:

  1. If Google or Apple decide to stop displaying the app version or only allowed certain description formats then this would stop working.
  2. If your app is not distributed via public stores then it will not work as intended out of the box.
  3. You are not in control of the list of versions that you support and there's no history about them.

To solve this, upgrader has support for working with AppCast which is:

an RSS feed with one channel that has a collection of items that each describe one app version. The appcast will describe each app version and will provide the latest app version to upgrader that indicates when an upgrade should be recommended.

It is based on the Sparkle framework by Andy Matuschak. You can read the Sparkle documentation here. It is a widely used standard that lists all the versions of your app with an XML format. Here's an example of how an appcast file could look like taken from Sparkle's documentation:

<rss xmlns:sparkle="http://www.andymatuschak.org/xml-namespaces/sparkle" xmlns:dc="http://purl.org/dc/elements/1.1/" version="2.0">
  <channel>
    <title>Sparkle Test App Changelog</title>
    <link>http://sparkle-project.org/files/sparkletestcast.xml</link>
    <description>Most recent changes with links to updates.</description>
    <language>en</language>
    <item>
      <title>Version 2.0</title>
      <link>https://sparkle-project.org</link>
      <sparkle:version>2.0</sparkle:version>
      <description>
      <![CDATA[ <ul> <li>Lorem ipsum dolor sit amet, consectetur adipiscing elit.</li> <li>Suspendisse sed felis ac ante ultrices rhoncus. Etiam quis elit vel nibh placerat facilisis in id leo.</li> <li>Vestibulum nec tortor odio, nec malesuada libero. Cras vel convallis nunc.</li> <li>Suspendisse tristique massa eget velit consequat tincidunt. Praesent sodales hendrerit pretium.</li> </ul> ]]>
      </description>
      <pubDate>Sat, 26 Jul 2014 15:20:11 +0000</pubDate>
      <enclosure url="https://sparkle-project.org/files/Sparkle%20Test%20App.zip" length="107758" type="application/octet-stream" sparkle:edSignature="7cLALFUHSwvEJWSkV8aMreoBe4fhRa4FncC5NoThKxwThL6FDR7hTiPJh1fo2uagnPogisnQsgFgq6mGkt2RBw=="/>
    </item>
  </channel>
</rss>

This file should be hosted in a server accessible to anyone that uses the app. It also can be autogenerated during the release process, or you can also manually update it after a release is available on the stores.

Using upgrader with an AppCast is relatively simple as well. Here's an example taken from the library's documentation:

import 'package:flutter/material.dart';
import 'package:upgrader/upgrader.dart';

void main() => runApp(const MyApp());

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

  
  Widget build(BuildContext context) {
    final appcastURL =
        'https://raw.githubusercontent.com/larryaasen/upgrader/master/test/testappcast.xml';
    final appcastConfig = AppcastConfiguration(url: appcastURL, supportedOS: ['android', 'ios']);

    return MaterialApp(
      title: 'Upgrader Example',
      home: Scaffold(
          appBar: AppBar(
            title: Text('Upgrader Example'),
          ),
          body: UpgradeAlert(
            Upgrader(appcastConfig: appcastConfig),
            child: Center(child: Text('Checking...')),
          )),
    );
  }
}

Great work, now you know exactly how to keep your user's running the latest version of your app and also the main gotchas that you might find along the way.

What's next?

Remember that you can view a complete list of all the changes needed in this PR of Days Without Incidents.

If you are wondering what's the best data storage solution for your Flutter app, check out this tutorial by Onuoha Ifeanyi in which a comparison is made between different databases.

Looking for adaptive app guidance? Damilare Jolayemi has great insights in this tutorial

I hope you enjoyed this tutorial, if you did, sharing it with others is much appreciated. And if you have any questions or comments, leave them down below and I'll be happy to chat.