Generate screenshots for a Flutter app with golden testing and upload them to the stores (1/2)

Matthieu Regnauld
9 min readMar 6, 2023

--

Mistikee is a Flutter app that allows you to simply manage all your passwords without saving them to the cloud, not even in your own device!

It was originally a simple side project written in Android Java, but at some point, I wanted it to make it to production, for both Android and iOS. So I rewrote it in Flutter and created my CI only using Codemagic to begin with. Then I was able to build and deploy my app on every store.

So what’s the problem?

Later on, I wanted to improve my app, add quite a few features and of course deploy them to my end users. But it was at this moment that I realized that I will have to change my screenshots again, and again, and again. And generate a lot of them.

When it was only an Android app, originally only available in French, managing my screenshots by hand was already a tedious task, but still “doable”. But now:

  • I have 3 screens that I have to show so my users understand the basics of the app
  • Those screens will be illustrated with a shape of a device and a text on top
  • I want to add a 4th illustration before my screenshots that will be my slogan
  • I need to generate screenshots for 3 devices on the Google Play Store (one smartphone, 2 tablets)
  • I need to generate screenshots for 4 devices on the App Store Connect (2 iPhones, 2 iPads)
  • The app comes in 2 flavors (a free version and a full version)
  • The app is now available in 2 languages (English and French)

Which makes a total of… 112 illustrations to generate!!!

Needless to say that generate them by hand is totally out of the question.
Same for uploading them.

On a side note, creating two flavors for a free and a full version might not be the best idea here. It adds a bit of complexity and it’s not always very easy to understand for your users. You may want to use in-app purchase instead. But hey, it’s only a side project and a good way to improve my skills on multi-flavored apps!

What’s the usual solution?

One common approach to the taking screenshots problem is to call a library that will take screenshots for you during integration tests. One of the most popular is screenshots (don’t forget the “s” in the end!). It’s a great tool and you definitely should try it, since it will work on pretty much every use case.

But I personally think that there are a few drawbacks to that approach:

  • You need to run multiple emulators, which can lead to a long process and be quite tricky to implement in a CI
  • Even though you can frame (or decorate) your screenshots, you still need to make the final illustrations by yourself

OK so what’s your approach then?

First, here is what I wanted to achieve:

Fully generated illustrations for the app stores
Everything you see here is fully generated only using Flutter

Taking screenshots and generating illustrations

When I first read about the usual approach, my laziness spoke to me: “Meh… There has to be an easier way!”. So I started thinking of another way to do it.

At the same time, I was finishing my golden tests for my app. And then I was like: “Wait a minute! I can generate screenshots using golden tests! What if….”. So I read a bit more about the Golden Toolkit package that I already used and I found what I was looking for.

So here is basically what I do, for each illustrated screenshot. In one golden test:

  • I first take a screenshot of the screen I want
  • I load the generated image using MemoryImage
  • I generate a new Flutter widget with all the needed decorations, texts, backgrounds… to decorate the screenshot
  • I take a final screenshot of that widget

The advantages here:

  • I can draw anything I want to illustrate my screenshots only using Flutter
  • Since I use Riverpod, I can easily mock my logic and choose to display the fake data I want in my screens
  • I generate all the 112 illustrations in less than 30 seconds locally, using one command line, without any emulator

One important thing to mention here: I make a heavy use of Riverpod in my app, not only for state management, but also for dependency injection. While it might not be necessary for your project, it’s important to keep in mind that, if you want this approach to work, you’ll have to properly separate the UI from the logic in your code, using Riverpod or something else, so you can easily mock anything you want. That would also make your unit tests and golden tests easier to implement.

Also I cannot guarantee that it will work everywhere, especially in big project. That being said, I’m pretty confident that it should be doable if your application is well-structured (with Clean Architecture, for example).

Uploading the illustrations to the stores

At first, I didn’t want to break my CI that I did only using Codemagic. But I quickly came to the conclusion that Codemagic, even though it’s a great service, doesn’t provide a native feature to upload screenshots (and more generally the metadata) to the stores, as I write this article.

So I implemented Fastlane in my CI and first tried to use it to only upload the screenshots (deploying the app was still done natively by Codemagic), using the supply and the deliver commands. It worked like a charm with the Google Play Store, but with the App Store Connect… that was a whole different story. As you might guess, it didn’t work. At all.

The solution was actually to still use Fastlane and the previously mentioned commands, but for the whole deployment process: screenshots, metadata, and the app itself.

So how do you generate your illustrations?

Starting from now, for the sake of simplicity, I won’t speak about the flavors. Also, as a reminder, I use Riverpod in my examples, and I also use intl for internationalization.

Now here are the steps to follow:

STEP 1: Create a wrapper for the screen:

Widget getScreenWrapper({
required Widget child,
required Locale locale,
required bool isAndroid,
List<Override> overrides = const [],
})
{
return ProviderScope(
overrides: overrides,
child: MaterialApp(
debugShowCheckedModeBanner: false,
supportedLocales: L10n.all,
localizationsDelegates: const [
AppLocalizations.delegate,
GlobalMaterialLocalizations.delegate,
GlobalCupertinoLocalizations.delegate,
GlobalWidgetsLocalizations.delegate,
],
locale: locale,
theme: ThemeData(
platform: (isAndroid ? TargetPlatform.android : TargetPlatform.iOS),
),
home: Column(
children: [
Container(color: Colors.black, height: 24), // fake, black and empty status bar
Expanded(child: child),
],
),
),
);
}

A few things to mention here:

  • The child argument is the screen you want to take a screenshot of.
  • The locale argument is the language you want to use for your screenshot.
  • The isAndroid argument is important here to get a rendering specific to each OS.
  • The overrides argument is useful to mock the logic of your app (database or webservices calls for example).
  • To make it easier to understand, I use black for the status bar color, which is actually a basic rectangle.

The getScreenWrapper() function above returns the final screen we want to screenshot. But we first need to prepare the screenshot process.

STEP 2: In order to get your fonts working, you’ll need to add the flutter_test_config.dart file in your test/ directory, with the following content:

import 'dart:async';
import 'package:flutter_test/flutter_test.dart';
import 'package:golden_toolkit/golden_toolkit.dart';

Future<void> testExecutable(FutureOr<void> Function() testMain) async
{
TestWidgetsFlutterBinding.ensureInitialized();
await loadAppFonts();
return testMain();
}

STEP 3 (Optional): Some screens display a back button in the app bar, but with that method above, that button won’t display. So the little trick I use here is to:

  • Create a provider:
    final platformScreenshotProvider = Provider<bool?>((ref) => null);
  • Even though that provider value is null by default, it will be overridden in the golden tests like this: platformScreenshotProvider.overrideWithValue(isAndroid), where isAndroid can be true or false whether you’re on Android or iOS, and which returns an Override that you can pass in the overrides array argument of the getScreenWrapper() function above, like any other override.
  • Create a fake app bar back icon:
class AppBarBackIcon extends ConsumerWidget
{
@override
Widget build(BuildContext context, WidgetRef ref)
{
return (ref.read(platformScreenshotProvider) == true
? Icon(Icons.arrow_back_sharp)
: Icon(Icons.arrow_back_ios_sharp));
}
}
  • Use that icon for the leading argument of the app bar in your app:
leading: (ref.read(platformScreenshotProvider) != null
? const AppBarBackIcon()
: null)

Note that this provider can be use anywhere in your app, to fake entered text in a TextFormField for example.

STEP 4: There are specific requirements for the screenshots sizes. Here are the size and densities (we’ll need that info for later) that I use for both the Google Play Store and the App Store Connect:

  • Android smartphone: 1107 x 1968 (density: 3)
  • 7 inches Android tablet: 1206 x 2144 (density: 2)
  • 10 inches Android tablet: 1449 x 2576 (density: 2)
  • iPad pro 2nd gen: 2048 x 2732 (density: 2)
  • iPad pro 6th gen: 2048 x 2732 (density: 2)
  • iPhone 8 Plus: 1242 x 2208 (density: 3)
  • iPhone Xs Max: 1242 x 2688 (density: 3)

Note that while the sizes for the App Store Connect have to be specifically what I mentioned, the Google Play Store is more permissive. Also, if you want to display what your app looks like on a tablet, prefer the portrait mode (if it still makes sense for your app, of course), so your users can see more screens on the store without any swipe.

STEP 5: When it comes to naming the screenshots files to be uploaded to the stores, you can name them anything you want. But keep in mind that:

  • They will display in the stores in alphabetical order.
  • For the App Store Connect, since the two iPads have exactly the same size, we need to differentiate them by naming the iPad pro 6th gen files with a name that should contain IPAD_PRO_3GEN_129 (other values are possible as you can see in the deliver documentation).

STEP 6: Now it’s time to screenshot! Here is how I do, using the Golden Toolkit package:

Future<void> takeScreenshot({
required WidgetTester tester,
required Widget widget,
required String pageName,
required bool isFinal,
required Size sizeDp,
required double density,
CustomPump? customPump,
}) async
{
await tester.pumpWidgetBuilder(widget);
await multiScreenGolden(
tester,
pageName,
customPump: customPump,
devices: [
Device(
name: isFinal ? "final" : "screen",
size: sizeDp,
textScale: 1,
devicePixelRatio: density,
),
],
);
}

A few important things to mention here:

  • The widget argument is the widget you want to screenshot.
  • The pageName argument is the name of the image file containing your screenshot.
  • Since we take 2 screenshots per screen (one for the screen itself, another one for the final illustration), as you might guess, we’ll pass false for the isFinal argument here for the moment.
  • The density argument is the density of the device screen as specified above.
  • The sizeDp argument is the size of the device screen, where its width and height have to be divided by the density! For example, for the iPhone Xs Max, you’ll pass: Size(1242 / 3, 2688 / 3).
  • The customPump argument, although not mandatory, can be useful in some cases. By default, the Golden Toolkit package uses pumpAndSettle(), which can sometimes block the rendering if, for example, there is an infinite animation. In my case, I pass the following argument (only for the first screenshot) and it works very well: (tester) async => await tester.pump(const Duration(milliseconds: 200)).
  • The reason why I use multiScreenGolden() here is because not only can I use the Device object, which is very handy when it comes to specify the screen size and density, but it also generates only what I need without any extra stuff around the screenshot.

STEP 7: Calling the takeScreenshot() function above generates an image file. Let’s load it in an image widget:

final screenFile = File("test/screenshots/goldens/$pageName.screen.png");
final memoryImage = MemoryImage(screenFile.readAsBytesSync());
final image = Image(image: memoryImage);

STEP 8: Now it’s time to decorate the screenshot! I won’t go into details here since it depends on what you want to achieve, but basically, it will look like the following:

Widget getDecoratedScreen(Widget image, ...)
{
return Container(
child: ... // draw anything you want
);
}

STEP 9: And finally, we can take a screenshot of the widget returned by the getDecoratedScreen() function mentioned above, again with the takeScreenshot() function! Note that this time, you shouldn’t need to pass anything to the customPump argument.

STEP 10: Now you can delete the first screenshot (the one in screenFile above): screenFile.deleteSync().

And for the first illustration which contains my slogan, I used the same technique (but without taking an actual screenshot obviously).

One last thing: in order to keep your screenshots tests class separated from your other golden tests and unit tests, you may want to do as follow:

  • Add a tag at the very top of the test class that generates the screenshots, for example @Tags([“screenshots”]), then generate your illustrations with: flutter test --update-goldens --tags=screenshots
  • In order to launch your other tests without interfering with the screenshots test class, add the following argument to exclude the screenshots tests class: -x screenshots

Now you can continue to part 2.

--

--