Flutter – Adaptive Layout

I rotated the screen on the iPhone and it worked, so first strike, no hard coded UI positional issues with the widgets and scaffold so far.

Shows the digest list in landscape.
Shows the digest list in landscape.
Shows the recipe details in landscape.
Shows the recipe details in landscape.

Apple’s human interface guidelines recommend using a split view layout instead of a tab bar on an iPad.

Based on the current phone screens we need something like this:

Split view design for the recipes digest
Split view design for the recipes digest

And some information and logic to detect the device and select the appropriate layout.

Shows the adaptive layout working for iPhone and iPad.
Shows the adaptive layout working for iPhone and iPad.

Ta Da

Three column splitview.
Three column splitview.

In the next post we will add the functionality to the new split view screen and decide on which devices and orientations it will be available in.

XP

For such a seemly simple change adapting the layout to different devices, I had to consider a number of tricky architectural areas and make some structural changes

Hopefully, they will help keep the code base clean and healthy as the application grows.

Device info and layout.

Welcome to the chicken and egg world of relying on data from asynchronous calls.

To decide between tabbed or split view I need to know about the client device and its orientation.

However, the device information is only available after the application loads and builds the screen.

To get around this I created a widget “LayoutSelector”, and a new change notifier provider called ‘Layout’.

When the application first runs the device information is not available and it displays a blank screen.

When the device information call finishes its sets the value on the ‘Layout’ provider, which then notifies the ‘LayoutSelector’ to rebuild.

This time it has the device information and can choose between the tab or split view layout.

The user experience works well because all the layout backgrounds are the same colour, so the screen change between loading and the selected device layout goes un-noticed.

Now we have a loading screen there is an option to improve the Ux when things take longer than usual to complete i.e. add a funky animation that is displayed if the application takes longer than a second to load the layout.

This feels the right way to go, but I welcome comments on this.

One thing to note is I’m trying to keep application state simple and just use providers over a DI package.

Package Imports

I’ve added another requirement to the ‘Done, Done, Done, Done’ check list for development.

  • Relative imports for children and package imports for everything else.

It was tempting to just go with package imports but having relative ones for children makes it easier to move folders around under the \lib dir.

There will be a blog on the ‘done, done, done, done’ checklist later in the series.

Project Structure

It is time for a little bit more structure as the number of files and directories grow.

Up to this point all the screens where under the /lib/tab directory. Now they are shared with the split view they have been separated and we have three directories.

  • \lib\ui\tab
  • \lib\ui\screen (Used by the tabs and split view panels.)
  • \lib\ui\splitview

The number of top level directories had also increased so I added \ui at the top level and moved a number of the top level items under it.

Project structure for /lib & /test directory
Project structure for /lib & /test directory

I follow a general rule of 7s, never more than 7 items at a given level. Not sure who I picked this rule up from.

Links

Sound & Vision

BBC drama: The Tourist

One more thing…

“We do no market research. We don’t hire consultants. We just want to make great products.”

Steve Jobs_

Flutter – Scrolling Content

I didn’t see this post coming.

I had planned to talk about adapting the app for display on the iPad and realised I needed to start by creating a recipe details screen for the iPhone.

Recipe details screen design
Recipe details screen design

Unfortunately, the content didn’t scroll which caused a display issue when the number instructions or ingredients increased.

Overflow error when too many ingredients
Overflow error when too many ingredients

Ta Da

After a bit of research (see links below) I found the way to implement a natural iOS experience where the content scrolls under the tab and navigation bars.

Recipe details before scrolling
Recipe details before scrolling
Recipe details after scrolling to the bottom
Recipe details after scrolling to the bottom

BackBurner

We haven’t spent any time improving the style of the recipe details screen, it’s pretty bland, not something I’d want to release. We can tackle this in a future post, once we have a few more screens up and running.

Large navigation bar title that shrinks when the content is scrolled. This can be achieved when you use the CupertinoSliverNavigationBar as the top widget in the sliver list, it requires largeTitle or it errors, so you cannot use it as the normal navigation bar. The plan is to add it as extra functionality to the new TabScrollableContentScaffold widget.

Large title in the navigation bar.
Large title in the navigation bar.
Nav bar title has shunk and moved up after scrolling
Nav bar title has shunk and moved up after scrolling

XP

We needed to slow down and make sure that we could provide a native experience when scrolling content in a tab.

Which includes content appearing to scroll under the tab and navigation bar and for it to bounce and snap when it reaches the beginning or end. This was abstracted into a widget called TabScrollableContentScaffold so we can easily reproduce the experience on other tabs we build.

@override
Widget build(BuildContext context) {
  
  var appState = Provider.of<DigestableMeState>(context);

  return TabScrollableContentScaffold(
    navigationBar: navBar(),
    children: [
      recipeTitleAndAuthor(appState),
      recipeImage(),
      for (Widget item in ingredients(appState))
        item,
      for (Widget item in instructions(appState))
        item,
      shrug()
    ],
  );
}

Mapping hurts, is costly, never under estimate it, get good at map -> reduce in the languages you use. Had to add some more code and tests to fix the mapping from ‘serpApiResult’ results to the ‘Recipe’ view model.

You may have noticed that the initial screen design changed, instructions and ingredients are not side by side, this sort of small change is the normal during most implementations, just remember to demo and agree them.

Links

Sound & Vision

The shocking Miss Emerald, by Caro Emerald.

One more thing

You go to your TV when you want to turn your brain off. You go to your computer when you want to turn your brain on. Those are not the same.

Steve Jobs_

Flutter – Themes

Lets theme the the application, make it look good, do it in a maintainable way that can be easily rebranded.

I started by adding a ‘light’ theme in its own file:

Cupertino light theme files
Cupertino light theme files
import 'package:flutter/cupertino.dart';

const CupertinoThemeData cupertinoLight = CupertinoThemeData(
  brightness: Brightness.light,
  primaryColor: CupertinoColors.activeBlue
);

And using it in the main.dart file:

return CupertinoApp(
  ...  
  theme: cupertinoLight,
  ...
);

We can then remove the colour settings that were added to get the initial green background for the tab.

Digests tab - Cupertino Light Theme
Digests tab – Cupertino Light Theme
Recipe Search - Cupertino Light Theme
Recipe Search – Cupertino Light Theme

Ok, what about a dark theme ?

import 'package:flutter/cupertino.dart';

const CupertinoThemeData cupertinoLight = CupertinoThemeData(
  brightness: Brightness.dark,
  primaryColor: CupertinoColors.activeGreen
);
Digests tab - Cupertino Dark Theme, missing text
Digests tab – Cupertino Dark Theme, missing text

Black text on a black background a little disappointing.

To fix this I created the text styles needed and added a switch(bool) to the application state file ‘DigestableMeState’ to control the switching between the light and dark modes.

Digests tab - Cupertino Dark Theme
Digests tab – Cupertino Dark Theme
Recipe search - Cupertino Dark Theme
Recipe search – Cupertino Dark Theme

Ok, now we look like some of Apple’s iOS applications, but what if we want to rebrand our application for EE ?

What is a white label?

In mobile applications you don’t usual need to include the logo in the application screens as its identity is the app you install, so we can just concentrate of a swatch of colours and the typography.

EE Swatch & Typography
EE Swatch & Typography

A few more files and constants later…

EE Colours
EE Colours
Provider(
	create: (context) => DigestableMeState(
    	brightness: Brightness.dark,
        whiteLabel: WhiteLabel.ee
    ),
),

and we launch as EE in evening dark mode.

Digests tab - EE Dark Theme
Digests tab – EE Dark Theme
Recipe search - EE Dark Theme
Recipe search – EE Dark Theme

It is really about being flexible to change styles at the application level without having too may specific styles in the widgets themselves.

BackBurner

  • Automatically toggling between light and dark mode based on the current device setting.
  • Handling themes across platform to include Android or the web.
  • Custom widgets themes for custom widgets, not even sure if they are necessary at this point so something to come back to. A custom widget theme would allow you to target the custom widgets with styles i.e. we could target the all ListItem widgets to add border, padding or other styles as the application level.

XP

Getting the dark theme to work required more effort than just having a top level theme.

I had to remove the Card from the ListItem widget and go back to simple Rows, Columns, which is really what I should have used in the first place.

I replaced text styles that I had specified in the widgets

style: const TextStyle(
	color: Colors.black87,
    fontSize: 14,
),

With text styles that I created to support the Apple Typography guidelines:

Apple default text styles
Apple default text styles

Injection:

Provider(
	create: (context) => DigestableMeState(
    	brightness: Brightness.light,
    ),
)

Construction:

DigestableMeState({Brightness? brightness}) {
    if (brightness != null) {
      this.brightness = brightness;
    }
    _cupertinoTypography = CupertinoTypography(this.brightness);
...

Definition:

CupertinoTypography(this.brightness);

TextStyle bodyLight = const TextStyle(
  color: CupertinoColors.black,
  fontSize: 17,
);

TextStyle bodyDark = const TextStyle(
  color: CupertinoColors.white,
  fontSize: 17,
);
TextStyle get body {
  return brightness == Brightness.dark ? bodyDark : bodyLight;
}
...

Usage:

var appState = Provider.of<DigestableMeState>(context);
Text(item.title, style: appState.cupertinoTypography.title3)

Links

Sound & Vision

It’s only love with a little be of rain…

Carnage by Nick Cave & Warren Ellis.

One more thing

“Older people sit down and ask, ‘What is it?’ but the boy asks, ‘What can I do with it?’”

Steve Jobs_ on showing a 9-year-old a Mac

Great quote, reminds me to play with new tech, make mistakes, learn, rather than try to define or control it.

Flutter – Adding an Item

Each year we try to make an improvement to our dinner on Christmas day, this year the focus is on better roasties.

Let’s look at how we can find and add a new roast potato recipe to our digest.

The acceptance for this requirement is to be able to:

  • Search for a new recipe
  • Save it to our digest:
    • Title
    • Author
    • Thumbnail image

To get started I added a top navigation bar with an action that allows us to add a recipe.

@override
Widget build(BuildContext context) {
  return CupertinoPageScaffold(
    navigationBar: const CupertinoNavigationBar(
      heroTag: "RecipeTabHeroTag",
      transitionBetweenRoutes: false,
      middle: Text(
        "Recipies",
      ),
      trailing: Icon(CupertinoIcons.add)
    ),
    child: Container(
      color: CupertinoColors.activeGreen,
      child: recipiesList(),
    ),
  );
}
Shows the Cupertino scafold and App bar used to display our recipes
Shows the Cupertino scafold and App bar used to display our recipes

I renamed the tab back to ‘Digests’ now we have a title of ‘Recipes’ in the top navigation bar.

Now for another quick decision we are going to slide up the screen when the add button is clicked and show a search bar to allow us to find new recipes.

Shows the recipe search bar
Shows the recipe search bar

With that out the way we just need to harness Google search to find some new recipes.

I turned to SerpAPI for this and grabbed some data from a recipe search for roast potatoes with balsamic vinegar.

https://serpapi.com/search.json?q=Roast+potatoes+with+balsamic&location=United+Kingdom&hl=en&gl=uk&google_domain=google.co.uk&api_key=secret_api_key

{
    "search_metadata": {
      "id": "61c3369cc99903747c1e643b",
      "status": "Success",
      "json_endpoint": "https://serpapi.com/searches/bdb5db8a7f4c5593/61c3369cc99903747c1e643b.json",
      "created_at": "2021-12-22 14:30:52 UTC",
      "processed_at": "2021-12-22 14:30:52 UTC",
      "google_url": "https://www.google.co.uk/search?q=Roast+potatoes+with+balsamic&oq=Roast+potatoes+with+balsamic&uule=w+CAIQICIOVW5pdGVkIEtpbmdkb20&hl=en&gl=uk&sourceid=chrome&ie=UTF-8",
      "raw_html_file": "https://serpapi.com/searches/bdb5db8a7f4c5593/61c3369cc99903747c1e643b.html",
      "total_time_taken": 1.99
    },
    "search_parameters": {
      "engine": "google",
      "q": "Roast potatoes with balsamic",
      "location_requested": "United Kingdom",
      "location_used": "United Kingdom",
      "google_domain": "google.co.uk",
      "hl": "en",
      "gl": "uk",
      "device": "desktop"
    },
    "search_information": {
      "organic_results_state": "Results for exact spelling",
      "query_displayed": "Roast potatoes with balsamic",
      "total_results": 12800000,
      "time_taken_displayed": 0.49
    },
    "recipes_results": [
      {
        "title": "Balsamic potatoes",
        "link": "https://www.jamieoliver.com/recipes/potato-recipes/balsamic-potatoes/",
        "source": "Jamie Oliver",
        "total_time": "2 hrs 20 mins",
        "ingredients":
        [
          "Balsamic vinegar",
          "maris piper potatoes",
          "red onions",
          "rocket",
          "olive oil"
        ],
        "thumbnail": "https://serpapi.com/searches/61c3369cc99903747c1e643b/images/bd928f9ef521c02bbdb2df96157011d6a02d619d06f8a6e0e62bb157f48e10e5.jpeg"
      },
      {
        "title": "Balsamic roast potatoes",
        "link": "https://www.taste.com.au/recipes/balsamic-roast-potatoes/1bcb6bd4-2efa-4f01-bc98-f2ce32eaa906",
        "source": "Taste",
        "rating": 4,
        "reviews": 2,
        "total_time": "1 hr 5 mins",
        "ingredients":
        [
          "Balsamic vinegar",
          "kipfler potatoes",
          "olive oil",
          "garlic"
        ],
        "thumbnail": "https://serpapi.com/searches/61c3369cc99903747c1e643b/images/bd928f9ef521c02bbdb2df96157011d625322c9b727d95f76706f0471bbaba31.jpeg"
      },
      {
        "title": "Balsamic roast potatoes",
        "link": "https://www.delicious.com.au/recipes/balsamic-roast-potatoes/1e2f30ad-d9b4-41bf-aa2e-16f1e2accfb7",
        "source": "Delicious",
        "rating": 5,
        "reviews": 1,
        "total_time": "1 hr 5 mins",
        "ingredients":
        [
          "Balsamic vinegar",
          "kipfler potatoes",
          "olive oil",
          "garlic"
        ],
        "thumbnail": "https://serpapi.com/searches/61c3369cc99903747c1e643b/images/bd928f9ef521c02bbdb2df96157011d6ebca3a2868589cb9feab32215a0ba5e8.jpeg"
      }
    ]
}

Added a list to return the results from the search.

The results from a recipe search for roast potatoes with balsamic vinegar
The results from a recipe search for roast potatoes with balsamic vinegar

When a result is selected (onTap) we add it to our recipe digest.

Ta Da

Shows the recipe digest after the new recipe is added.
Shows the recipe digest after the new recipe is added.

Back Burner

Saving the digest.

For now we save the added recipe in memory, later we can persist it locally and remotely to our data stores.

iOS Only – Click tab to reset

Adding functionality to navigate back to the top level/state when tapping on the current tab item, this is a good iOS experience, a design that people expect.

Animated Search Bar

I decided to avoid implementing a more advance search bar similar to the one in Apple’s “App Store” application that has animation on focus.

An example of a search bar with animation on focus used by Apple's 'App Store" app.
An example of a search bar with animation on focus used by Apple’s ‘App Store” app.

We can look at this and attempt to improve the search bar later on once the basics are in shape.

CupertinoSearchTextField

I’m getting some library level errors from Flutter asking me to report a framework bug when hitting enter on the keyboard in the search field using a iOS emulator.

For now I’ve abstracted it into a widget and intend to replace it with a custom version with more features.

I’ve also noticed that you need to enter twice on the keyboard in the emulator before the ‘OnTap’ event fires.

XP

In memory data

We need to store and maintain a list of recipes to allow us to add new recipes.

At this point are using a memory repository class to manage this and injecting it as a ChangeNotifierProvider.

ChangeNotifierProvider<MemoryRepository>(
	create: (_) => MemoryRepository(),
    lazy: false,
),

It sounds complicated but it’s just a list of recipes in memory and some methods to add and retrieve them.

List<Recipe> findAllRecipes();
Recipe addRecipe(Recipe recipe);

See the Flutter documentation for more information on state management.

When we move to a data store e.g. a DB via an API we can improve this code.

List Item Reuse

As part of displaying the search results we need to display a list again, so I abstracted the initial digest list item so we could reuse it and ensure our list items have the same look and feel across the app.

Test results for the list item widget
Test results for the list item widget
Digest list item
Digest list item
Widget recipesList() {
  final repository = Provider.of<MemoryRepository>(context);
  var recipes = repository.findAllRecipes();

  return ListView.builder(
    itemCount: recipes.length,
    itemBuilder: (context, index) {
      var listItemInfo = ListItemInfo(
        id: recipes[index].link,
        author: recipes[index].source,
        title: recipes[index].title,
        titleImage: Image.network(recipes[index].thumbnail),
        showAddIcon: false,
      );

      return ListItem(listItemInfo);
    },
  );
}
Recipe search result list item
Recipe search result list item
Widget recipeSearchResultsList() {
  var searchResultRecipes = _searchResults.data.length > 0
    ? Recipe.fromSerpApiResultJson(_searchResults.data)
    : <Recipe>[];

  final repository = Provider.of<MemoryRepository>(context);

  return ListView.builder(
    itemCount: searchResultRecipes.length,
    itemBuilder: (context, index) {
      var listItemInfo = ListItemInfo(
        id: searchResultRecipes[index].link,
        author: searchResultRecipes[index].source,
        title: searchResultRecipes[index].title,
        titleImage: Image.network(searchResultRecipes[index].thumbnail),
        showAddIcon: true,
        onTap: () => {
          repository.addRecipe(searchResultRecipes[index]),
          Navigator.pop(context)
        },
      );

      return ListItem(listItemInfo);
    },
  );
}

Scope Creep (Beware – YUTNI Your unlikely to need it)

For me this was the most tempting time to try and over engineer the project as it is still missing a number of key things.

I wanted to do more work on:

  • data access, including batched api requests
  • design much better widgets with nice animation
  • add a theme and a flavour
  • logging
  • Automated acceptance testing.
  • Improve the design for Android
  • Look at how it would work as website
  • bring in some state management libraries
  • etc..

But experience is telling me to resist the urge to get side tracked, even some initial research on google, has slowed this post down considerably.

In the end I went back to the demo mentality of get it working, balanced with advice from Uncle Bob, that once it’s working that’s only 50%, you now need to get it right.

That equated to some more time extracting methods and tidying up.

As long as we give each class, method a single responsibility in its own file, we can easily improve them independently later on, as we move from concept to production ready.

Often the scope creep comes in when I get bored with the discipline of the task at hand as disciplines feel slow.

Links

A big thanks to M4trix Dev as his article on the iOS Tab Bar was invaluable and allowed me to quickly fix the problems I had with my first implementation.

People

“The thing is, it’s very easy to be different, but very difficult to be better.”

Jony Ive_

Picture of Jony Ive

Sound & Vision

The Bonny - Gerry Cinnamon

Summary of BBC drama Around the world in 80 days

Title image from BBC drama around the world in 80 days.

One more thing

“I have looked in the mirror every morning and asked myself: “If today were the last day of my life, would I want to do what I am about to do today?” And whenever the answer has been “No” for too many days in a row, I know I need to change something.”

Steve Jobs_