Skip to content

13. Flutter

Estimated time to read: 115 minutes

Flutter is an open-source framework developed by Google to simplify the development of apps for Android and iOS. With Flutter, you can develop and test your app quickly, without worrying about the details of the underlying platforms.

learning path

Here is the suggested learning path of the Flutter team.

13.1 Libraries and Samples

By now, Flutter has a large ecosystem. Don’t reinvent the wheel, but also avoid relying on outdated code.

And samples

13.2 Architecture

Flutter is a cross-platform UI toolkit that enables developers to create applications that can run on iOS, Android, web, and desktop while sharing most of their code. The system is built in layers, with the Flutter engine (written in C++) at the bottom handling core functionality like graphics, text layout, and input/output operations. Above the engine sits the Flutter framework (written in Dart), which provides developers with pre-built components called widgets that are used to construct the application's user interface. To make Flutter work on different devices, platform-specific "embedders" serve as intermediaries between Flutter and the operating system, managing essential tasks like rendering surfaces and processing input events.

Flutter architectural overview Dart App
Flutter architectural overview

13.3 Dart

Flutter uses Dart as its programming language. While Dart shares many similarities with C++, there are some key differences you should be aware of.

I recommend starting with my basic Dart material and completing the tasks in the Object-Oriented Programming section to get comfortable with the language. Once you're familiar with the fundamentals, you can move on to the more advanced topics in the following sub sections.

Dart Specialties:

  • Everything you can store in a variable is an object, and every object is an instance of a class. This includes numbers, functions, and even null.
  • No need for the new keyword to instantiate objects.
  • Dart uses single inheritance.
  • All classes implicitly define an interface.
  • Supports optional parameters.
  • Built-in null safety.
  • No protected; privacy is indicated by a naming convention (_), with everything else public by default.
  • Lists are similar to arrays.
  • switch can be used with all types.

13.3.2 Lambdas - Arrow Operator - =>

Lambdas are anonymous functions and are best thought as a mathematical map to, i.e.

(a,b,c) → f(a,b,c)

Here is an example

bool hasEmpty = aListOfStrings.any((s) => s.isEmpty);

13.3.3 Generics

Generics are widely used and make the type of lists or classes and methods in general exchangeable without the need of redefinition. In C++ you have templates. However, there are some differences Comparing Templates and Generics. The following snippets are from Generics.

abstract class Cache<T> {
  T getByKey(String key);
  void setByKey(String key, T value);
}
var names = <String>['Seth', 'Kathy', 'Lars'];
var uniqueNames = <String>{'Seth', 'Kathy', 'Lars'};
var views = Map<int, View>();
T first<T>(List<T> ts) {
  // Do some initial work or error checking, then...
  T tmp = ts[0];
  // Do some additional checking or processing...
  return tmp;
}

13.3.4 General Snippets

// Iterable collections
var listOfInts = [1, 2, 3]; // A simple list of integers
// Using a collection for loop to create a list of strings with interpolated values
var listOfStrings = ['#0', for (var i in listOfInts) '#$i']; 
// Filter words longer than 6 characters and count them
var longWords = allWords.where((w) => w.length > 6).toList().length; 
var points = <Point>[]; // Empty list of Point objects (List<Point>)
var addresses = <String, Address>{}; // Empty map with String keys and Address values (Map<String, Address>)
var counts = <int>{}; // Empty set of integers (Set<int>)

// const is for hardcoded, compile-time constants
const items = ['Salad', 'Popcorn', 'Toast', 'Lasagne']; 
// Check if any item in the list contains the letter 'a'
if (items.any((item) => item.contains('a'))) {
  print('At least one item contains "a"');
}

// Check if all items have a length of 5 or more characters
if (items.every((item) => item.length >= 5)) {
  print('All items have length >= 5');
}

// Find the first item that has a length greater than 5
var found = items.firstWhere((item) => item.length > 5);

// Filter out even numbers from the list of numbers
var evenNumbers = numbers.where((number) => number.isEven);

// String interpolation
// Prints a message with interpolated variables
print('Hello, $name! You are ${year - birth} years old.'); 

// Ternary operator (short form of if/else)
var visibility = isPublic ? 'public' : 'private'; // If isPublic is true, visibility will be 'public', else 'private'

// Optional parameters (optional positional parameter and default value for the named parameter)
int sumUpToFive(int a, [int? b, String title = '']) {
  // Implementation goes here
}

const Scrollbar({super.key, required Widget? child}); // Constructor for Scrollbar with named parameters

// Named parameters - optional unless marked as required
void enableFlags({bool? bold, bool? hidden}) {
  // Enable or disable flags based on parameters
}

// Calling the function with named parameters
enableFlags(bold: true, hidden: false);

// Classes with constructors and methods
class Point {
  double x, y;

  // Constructor with positional parameters
  Point(this.x, this.y);

  // Static method to calculate distance between two points
  static double distanceBetween(Point a, Point b) {
    // Return the distance between points using the Pythagorean theorem
    return ((b.x - a.x).abs() + (b.y - a.y).abs());
  }
}

class Person {
  // A private field, visible only within this class/library
  // private by naming convention _
  final String _name; //final is for read-only variables that are set just once

  // Constructor accepting the name
  Person(this._name);

  // A public method to greet someone
  String greet(String who) => 'Hello, $who. I am $_name.';
}

13.3.5 Mixin, Interfaces, Base Classes, Extension Methods

Mixins, interfaces, base classes, and extension methods are all mechanisms that allow you to add or modify functionality in classes.

  • A mixin in Dart is a way to add functionality to classes without using inheritance.
  • An interface defines a contract that a class must adhere to. A class that implements an interface must provide implementations for the methods and properties defined by that interface. Unlike other programming languages, in Dart, all classes implicitly define an interface (see Implicit interfaces). However, Dart has some distinct rules regarding interfaces
    • If you want to explicitly define an interface, you must use the interface class syntax, e.g., interface class Foo.
    • When using the interface keyword without abstract, the methods in the interface must have implementations.
    • To define a pure interface (an interface without any implementation), you need to use the combination of abstract and interface, i.e., abstract interface class.
  • Extension methods add functionality to existing libraries and types.

    extension <extension name>? on <type> {
      (<member definition>)*
    }
    

Try to understand the following examples.

// Mixin for logging user activities
mixin ActivityLogger {
  void logActivity(String activity) {
    print('Activity Log: $activity');
  }
}

// Mixin for sending notifications
mixin Notifier {
  void sendNotification(String message) {
    print('Sending notification: $message');
  }
}

// A basic User class that mixes in both ActivityLogger and Notifier
class User with ActivityLogger, Notifier {
  final String name;

  User(this.name);

  void performAction(String action) {
    logActivity('$name performed $action');
    sendNotification('$name has completed an action: $action');
  }
}

void main() {
  var user = User('John Doe');
  user.performAction('login');
  user.performAction('update profile');
}

/// extension method
extension on String {
  bool get isBlank => trim().isEmpty;
}
if (v.isBlank){...}

/// interface
abstract class JournalRepository {
  Future<Result<List<Journal>>> getJournals();
  Future<Result<void>> delete(int id);
  Future<Result<void>> create(Journal journal);
}

interface class Vehicle {
  void moveForward(int meters) {
    // without the keyword abstract an implementation is required
  }
}

/// mixin
mixin LoadingStateMixin {
  bool isLoading = false;

  void setLoading() {
    isLoading = true;
  }
}
class _HomeScreenState extends State<HomeScreen> with LoadingStateMixin {
  // may use isLoading and setLoading
}

13.3.6 Special Operators

String? notAString = null; // the type String? indicates notAString may be null
// ? execute right hand side only if not null
print(notAString?.length?.isEven);

// ?? operator: uses the lefthandside if not null and the righthandside otherwise
print(nullableString ?? 'alternate');

// and ??= assignment operator, which assigns a value to a variable only if that variable is currently null:
int? a; // a = null
a ??= 3; // a = 3
a ??= 5; // a is still 3

// .. cascade: perform a sequence of operations on the same object, instead of typing querySelector?.text, querySelector?.onclick ...
querySelector('#confirm')
  ?..text = 'Confirm'
  ..onClick.listen((e) => window.alert('Confirmed!'))
  ..scrollIntoView();

Task

  • Implement an extension method for String, that makes the first letter of the string a capital letter and returns the given string.
  • Use the null aware operator to set a default value for a variable x.
  • Define a class (and hence implicitly an interface) for a student.

13.4 Interactive Programming Paradigms

Depending on your learning style, you can either start by building your first Flutter applications following my basic instructions and the codelab and then read the following chapters to understand the bigger picture and concepts, or you can take the opposite approach, starting with the broader concepts and then diving into programming.

13.4.1 Event-Driven Programming

As a C++ programmer, you're likely familiar with writing code that runs sequentially — one line after the other. However, when building applications with graphical user interfaces (GUIs), we often need to handle user input (like clicks, key presses, etc.) and other events asynchronously. This is where event-driven programming comes in.

Event-driven programming is a paradigm where the flow of the program is determined by events, such as user actions or messages from other programs. Instead of executing instructions in a fixed order, your application waits for and reacts to these events.

In GUI development, we typically have an event loop — a mechanism that continuously waits for events (like button clicks or keyboard input) and dispatches them to the appropriate parts of the application. In Dart and Flutter, the event loop is an essential part of how apps run. The event loop is part of the framework that keeps your app responsive. It listens for events like user interactions (taps, gestures, etc.) and system events (like timers or network responses). When an event happens, the event loop triggers the relevant code (called a callback) to handle the event.

event loop
Event Loop

In Dart, you work with asynchronous operations (like network requests or timers) using Futures and Streams, which are key concepts for handling non-blocking tasks in the event loop. We will discuss this in more detail in asynchronous programming

13.4.1.1 Simple Example: Button with Event Handler

Imagine creating a simple app with a button. In Flutter, you'd write something like this:

import 'package:flutter/material.dart';

void main() {
  runApp(MyApp()); // Runs the MyApp widget and initializes the app
}

class MyApp extends StatelessWidget {
  @override
  Widget build(BuildContext context) {//this method renders the UI
    return MaterialApp(
      // The MaterialApp widget sets up basic material design elements for the app
      home: Scaffold(
        body: ElevatedButton(
          onPressed: () {
            // This function is called when the button is pressed
            print("Button Pressed!"); // Prints a message to the console
          },
          child: Text('Press Me'), // Text displayed on the button
        ),
      ),
    );
  }
}
  • The onPressed is the event handler. It reacts to the button press, and the event loop calls it when the button is pressed.
  • The event loop is constantly running in the background, awaiting actions like this and triggering the appropriate callback functions.

This is a simple example, but the core idea of event-driven programming in Flutter remains the same: you define what should happen when certain events (like user actions) occur, and the framework takes care of the event loop to trigger those actions.

Widget

In Flutter everything is a widget. Widgets are immutable, meaning they don't change themselves. So when the UI needs to be updated (for example, after a user interaction), Flutter calls the build method to rebuild the widget and display the updated UI.

13.4.2 State

To fully understand the rest, you will likely need some basic instructions and to complete the codelab.

App state
State management

The state of an app refers to everything that exists in memory while the app is running. State is the data necessary to rebuild the UI at any given moment.

Flutter differs between ephemeral state and app state:

  • Ephemeral state (sometimes referred to as UI state or local state) refers to state that is confined within a single widget. This is where StatefulWidgets come into play, such as for managing the state of a TextField, a checkbox, or a progress indicator.

  • When an app consists of multiple widgets and screens, the question arises: How do you synchronize the state of different widgets and manage the data across the app? This is where app state — in the narrower sense — becomes important, as it involves how data is shared, stored, and managed across different parts of the application.

The following figure illustrates the difference of app state and ephemeral state. And may be used as a decision tree, what is needed in which case.

Ephemeral vs App state
Ephemeral vs App state

"A widget declares its user interface by overriding the build() method, which is a function that converts state to UI:

UI = f(state)

The build() method is by design fast to execute and should be free of side effects, allowing it to be called by the framework whenever needed (potentially as often as once per rendered frame)."1

13.4.2.1 Simple Counter with setState

We'll start with the basic counter example using setState to manage the counter's state. This is the most straightforward way of managing local state in Flutter. In this example, we have a button that, when pressed, increments a counter displayed on the screen. The state of the counter is updated using setState(), which triggers a rebuild (calls the build method) of the widgetd (in this case the complete CounterScreen) to reflect the new counter value.

screenshot counter example

// Import the Flutter material package for UI elements
import 'package:flutter/material.dart';

// The entry point of the application
void main() {
  runApp(MyApp());
}

// MyApp is a stateless widget, which means its state doesn't 
// change once it's built, it's like showing a picture
class MyApp extends StatelessWidget {
  @override
  Widget build(BuildContext context) {
    return MaterialApp(
      home: CounterScreen(), 
    );
  }
}

// CounterScreen is a stateful widget, which means it can have a 
// state that changes over time, e.g. when we change the counter by 
// pressing the button
class CounterScreen extends StatefulWidget {
  @override
  _CounterScreenState createState() => _CounterScreenState();
}

// _CounterScreenState manages the state of the CounterScreen widget
class _CounterScreenState extends State<CounterScreen> {
  int _counter = 0;

  // This function is called when the button is pressed, 
  // and it increments the counter value
  void _incrementCounter() {
    // Change the state of the _CounterScreenState instance
    // and thus trigger a repainting of the screen, 
    // i.e. trigger a call of the build method
    setState(() {
      _counter++;  
    });
  }

  @override
  Widget build(BuildContext context) {
    // Scaffold provides a default layout structure, such as app bar and body
    return Scaffold(
      appBar: AppBar(
        title: Text('Counter Example'), 
      ),
      body: Center(
        // Column widget arranges its children vertically
        child: Column(
          mainAxisAlignment: MainAxisAlignment.center, 
          children: <Widget>[
            Text('Counter: $_counter'),  
            ElevatedButton(
              onPressed: _incrementCounter, 
              child: Text('Increment'), 
            ),
          ],
        ),
      ),
    );
  }
}
  • setState() in the private method _incrementCounter triggers a rebuild of the widget, reflecting changes in the counter value.

This is fine for a single page application. But it will not work for app state data, i.e. data needed in different widgets or screens of your app. We will discuss this in more detail in chapter .

13.4.3 Reactive Programming vs. Data Binding

Many frameworks like Maui (C#), React and Vue face similar problems.

Reactive Programming is a programming paradigm focused on asynchronous data streams and change propagation. It treats data as streams of events that can be reacted to, emphasizing how changes flow through a system.

Data Binding is a specific technique for automatically synchronizing UI elements with data sources. It can be unidirectional or bidirectional and often serves as an implementation of reactive principles.

Flutter uses a declarative-reactive approach:

  • Unidirectional data flow: Data flows top-down through the widget tree
  • State management: Changes trigger rebuilds of affected widgets
  • Immutable widgets: Widgets are recreated on changes, not modified
  • Reactive streams: Uses Streams, Futures, and ValueNotifier for reactive programming

13.4.3.1 Flutter vs MAUI, React, Vue

  • Flutter: "UI is a function of state" - rebuilds on changes
  • MAUI: "UI and data are coupled" - bidirectional automatic propagation
  • React: "Data flows down, events flow up" - explicit unidirectional flow
  • Vue 3: "Fine-grained reactivity" - automatic dependency tracking with optional two-way binding

13.5 New Project

For every new project start with the following. I have prepared this for you in your repository under the 'app' directory; you just need to

  • run flutter upgrade and flutter doctor to check and upgrade your flutter installation
  • run flutter pub get (in the folder app) and
  • adapt the steps 7, 8 and 14
  • do steps 2 and 11.

Here are the basic steps for a new project

  1. create a new project, see also codelab first app
    • give it a good name
    • test rename if you want to rename an existing app.
    • run it on all relevant platforms (web, Android, mac, ...), i.e. test the configurations
  2. choose the namespace, typically your domain backwards with your project, e.g. de.h_da.fbi.hci.fitness -- no - allowed, i.e. replace all occurrences of com.example with your namespace
  3. adapt VS Code for flutter, e.g. use settings
  4. create a folder structure similar to package structure of the sample compass_app
    • consider copying the folders utils, ui/core of the sample compass_app
    • at least create the folders
      • data
      • domain
      • utils
      • routing
      • ui
        • core
          • localization
            • arb
        • home
        • settings
  5. add internationalization
  6. add logging
  7. define your theme
  8. Add a NavigationBar for primary destinations. And use Navigator for nested navigation. With Navigator it is possible to pass arguments and return values while navigating.
  9. Add a test screen (playground) to you primary navigation. Here you can add single widgets or group of widgets and use hot reload to test your design.
  10. Add an about screen and show the licenses, your app uses licenses
  11. Add your launcher icon
  12. Add freezed to easier implement domain models
  13. Define your App State, see Simple App State or Advanced App State
  14. Add a settings screen and store your settings using shared_preferences

freezed

If you use freezed with the annotation, make sure to run dart run build_runner build after each change in the domain model. Or run run dart run build_runner watch to watch for changes and run the build runner automatically.

Material Theme Builder
Material Theme Builder

Next start to implement the UI of your screens and Figma components.

13.6 User Interface

In this chapter I want to guide you through the process to realize a given Figma prototype into Flutter code.

There are some tools supporting a visual design with drag & drop of UI elements and generating code from this visual design, there are even some plugins for Figma to generate Flutter code. However, the generated code is mostly complicated, difficult to understand and to adapt or extend. In many frameworks the approach of hot reload is used instead.

Case Study

A case study may be found in the article How Flutter facilitates collaboration between designers and developers.

13.6.1 Layout and Widgets

For each screen and component identify rows and cols. Try to work with as less as possible rows and cols.

Layout rows and cols
Layout

Spacing Attribute

The new flutter version supports

Row(
  spacing: 15.0,
  children: [

Now add the widgets according to your design, check the widget catalog. Define custom widgets for each component of your Figma design. Before you start to copy & paste widgets in your code, extract them to a custom widget.

dev tools

In addition to using breakpoints, try the 📹 devtools

Use the Flutter inspector (devtools or the icon on the right of your debugger) to play with settings of your widgets

devtools
DevTools

See also

property-editor
Property Editor

Further reading

13.6.1.1 Constraints and Overflowed problems

Make sure, you understand the constraints options and make them work for you.

constraints
Understanding constraints

Overflowed problems "The error often occurs when a Column or Row has a child widget that isn’t constrained in its size."2

It might be sufficient to wrap the control with Expanded.

13.6.2 Cupertino - Material

By default, Flutter implements Material Design since it's a Google framework. This means it follows Android's design guidelines out of the box. When you create a new Flutter project, you typically start with MaterialApp as your root widget, which provides the Material Design structure and styling.

However, Flutter recognizes that iOS users expect a different look and feel - the distinctive Apple design language. This is where the Cupertino package comes in. By using CupertinoApp instead of MaterialApp, along with other Cupertino widgets throughout your app, you can create an iOS-native feel. For example, you would use CupertinoButton instead of MaterialButton, or CupertinoNavigationBar instead of AppBar.

Now, this might raise a question: "What happened to the 'write once, run anywhere' promise of cross-platform development?" This is where the flutter_platform_widgets package provides an elegant solution. This package acts as an abstraction layer that automatically determines which platform the app is running on and serves the appropriate widget - Material for Android or Cupertino for iOS. As a developer, you can write code using these platform-aware widgets, and the package handles the platform-specific implementation details.

Nevertheless, Flutter provides an automatic adaptation for things that are behaviors of the OS environment (such as text editing and scrolling) and that would be 'wrong' if a different behavior took place.

13.6.3 User Input

buttons TextField SegmentedButton Chip

In Flutter, the Form widget is an essential tool when you are dealing with multiple input fields that require validation, saving, and resetting the form data, see Cookbook - Forms.

  • Form: Using a Form provides access to a FormState, which lets you save, reset, and validate each FormField that descends from this Form. You can also provide a GlobalKey to identify a specific form.
    • final GlobalKey<FormState> _formKey = GlobalKey<FormState>();
    • if (_formKey.currentState!.validate())
  • FormField: Each individual form field should be wrapped in a FormField widget with the Form widget as a common ancestor.
  • TextFormField: TextFormField wraps a TextField and integrates it with the enclosing Form.

The example below is from Create a Form with a GlobalKey.

class MyCustomForm extends StatefulWidget {
  const MyCustomForm({super.key});

  @override
  MyCustomFormState createState() {
    return MyCustomFormState();
  }
}

class MyCustomFormState extends State<MyCustomForm> {
  // Create a global key that uniquely identifies the Form widget
  // and allows validation of the form.
  //
  // Note: This is a GlobalKey<FormState>,
  // not a GlobalKey<MyCustomFormState>.
  final _formKey = GlobalKey<FormState>();

  @override
  Widget build(BuildContext context) {
    // Build a Form widget using the _formKey created above.
    return Form(
      key: _formKey,
      child: Column(
        crossAxisAlignment: CrossAxisAlignment.start,
        children: [
          TextFormField(
            // The validator receives the text that the user has entered.
            validator: (value) {
              if (value == null || value.isEmpty) {
                return 'Please enter some text';
                // you could also set private members with
                // setState here
              }
              return null;
            },
          ),
          Padding(
            padding: const EdgeInsets.symmetric(vertical: 16),
            child: ElevatedButton(
              onPressed: () {
                // Validate returns true if the form is valid, or false otherwise.
                if (_formKey.currentState!.validate()) {
                  // If the form is valid, display a snackbar. In the real world,
                  // you'd often call a server or save the information in a database.
                  ScaffoldMessenger.of(context).showSnackBar(
                    const SnackBar(content: Text('Processing Data')),
                  );
                }
              },
              child: const Text('Submit'),
            ),
          ),
        ],
      ),
    );
  }
}

The TextEditingController offers precise management, allowing you to retrieve and modify the text, which is useful for pre-populating a text field. If you want to programmatically control the TextField and clear its value, for example, you'll need a TextEditingController.

// Define a custom Form widget.
class MyCustomForm extends StatefulWidget {
  const MyCustomForm({super.key});

  @override
  State<MyCustomForm> createState() => _MyCustomFormState();
}

class _MyCustomFormState extends State<MyCustomForm> {
  // Create a text controller and use it to retrieve the current value
  // of the TextField.
  final myController = TextEditingController();
  final _formKey = GlobalKey<FormState>();

  @override
  void dispose() {
    // Clean up the controller when the widget is disposed.
    myController.dispose();
    super.dispose();
  }

  @override
  Widget build(BuildContext context) {
    return Scaffold(
      appBar: AppBar(title: const Text('Retrieve Text Input')),
      body: Padding(
        padding: const EdgeInsets.all(16),
        child: Form(
          key: _formKey,
          child: Column(
            crossAxisAlignment: CrossAxisAlignment.start,
            children: [
              TextFormField(
                controller: myController,
                validator: (value) {
                  if (value == null || value.isEmpty) {
                    return 'Please enter some text';
                  }
                  return null;
                },
              ),
              Padding(
                padding: const EdgeInsets.symmetric(vertical: 16),
                child: ElevatedButton(
                  onPressed: () {
                    if (_formKey.currentState!.validate()) {
                      showDialog(
                        context: context,
                        builder: (context) {
                          return AlertDialog(
                            // Retrieve the text the that user has entered by using the
                            // TextEditingController.
                            content: Text(myController.text),
                          );
                        },
                      );
                    }
                  },
                  child: const Text('Submit'),
                ),
              ),
            ],
          ),
        ),
      ),
    );
  }
}

13.6.4 Adaptive

Adaptative Design Screen Density
Adaptative Design

In Flutter there is no easy way to create an adaptive design, i.e. a design that is good for large screens and small screens, for touch and mouse. Basically, you need to program for each device individually and use if-statements to apply one or the other. The following attributes are relevant for your UI

  • desktop vs. web vs. mobile
  • use adaptive widgets for iOS and material, e.g. Radio.adaptive, for more see Adaptive UI Widgets
  • width/height
  • screen density or device pixel ratio
  • text size, the user might increase the font size for better reading
  • left to right or right to left text due to different localization
import 'package:flutter/foundation.dart';

...

if(kIsWeb || Platform.isMacOS || Platform.isLinux || Platform.isWindows) {...}
final size = MediaQuery.sizeOf(context);
if (size.width > 1024) {...}
final devicePixelRatio = MediaQuery.devicePixelRatioOf(context);
if (devicePixelRatio >= 2) {...}
final textScaleFactor = MediaQuery.textScaleFactorOf(context);
final direction = Directionality.of(context);

13.6.5 Animation

Flutter makes it easy to enhance your user experience with animations. You can find many great resources and videos on the Introduction to Animations page.

13.6.6 Accessible

Accessibility isn't merely a matter of compliance, but a fundamental principle of inclusive design, ensuring that your applications are usable and enjoyable for everyone, regardless of their abilities. By embracing accessibility, you enhance the user experience for all and, crucially, empower individuals with disabilities to navigate and interact with the digital world more effectively.

13.6.6.1 Core Aspects of Accessible App Design and Implementation

  • Color and Contrast: Ensuring sufficient color contrast is crucial for visual accessibility. For individuals with visual impairments, including color blindness, poor contrast can make content unreadable. You must meticulously check the contrast ratios of your text against its background, as well as interactive elements. Tools and guidelines exist to help you meet the necessary contrast standards, ensuring readability for all users.
  • Font Size and Layout with Large Fonts: Users should be able to adjust font sizes. Your application's layout must adapt gracefully to larger font settings without breaking or becoming unreadable. Test your UIs rigorously with significantly increased font sizes (e.g., 200% text scale, as mentioned below) to ensure a seamless experience for users who require larger text.
  • Semantics for Meaningful Interactions: For users relying on screen readers or other assistive technologies, the semantic meaning of UI elements is paramount. Flutter's Semantics widget is your primary tool for providing this crucial context.
  • Target Area / Hit Size: Small interactive elements are a major barrier for users with motor skill impairments or those who have difficulty with precise touch. Ensure that all interactive elements, such as buttons and tappable areas, have a sufficiently large target area or "hit size (min 48)." While the visual representation of an icon might be small, its underlying tappable region should be generous to accommodate a wider range of motor abilities and improve usability for everyone.
  • Visual Hierarchy and Organization for Screen Readers: The overall visual hierarchy of your application needs to be reflected in its accessibility tree for screen readers. Organize your content logically, ensuring that the navigation order for screen readers makes sense and flows naturally. Group related elements and use appropriate headings so that a user who cannot see the screen can easily understand the structure and flow of your application.

13.6.6.2 Inspecting and Enhancing Accessibility

Thorough inspection and testing are crucial for identifying and rectifying accessibility issues. - Accessibility Scanner and Similar Tools: Leverage dedicated tools like the Accessibility Scanner - You can download its APK to audit your application on Android devices. - 200% Text Scale Testing: During your inspection, specifically test your app with a 200% text scale. - Development-Time Accessibility Checks: Integrate accessibility checks directly into your development workflow. The accessibility_tools` package.

13.6.6.3 Standards and Guidelines

Your efforts in accessible app development are guided by established international and regional standards - WCAG Criteria: Familiarize yourself with Die WCAG-Kriterien. These widely recognized guidelines provide a comprehensive framework for making web content, and by extension, mobile applications, accessible to people with disabilities. Understanding WCAG will equip you with a robust set of principles to guide your design and development decisions. - EU Accessibility Directive: Be aware of regional legal requirements, such as the [EU-Barrierefreiheitsrichtlinie] (EU Accessibility Directive) (https://onlinebarrierefrei.de/gesetzliche-anforderungen-eu-richtlinie/). This directive mandates accessibility for a broad range of products and services. Specifically, new products and services must meet the requirements of this directive from June 28, 2025. This applies to various items including computer operating systems, smartphones, tablets, and online shops. It's important to note that there are exceptions for micro-enterprises and in cases where implementation would pose a disproportionate burden.

The Wonderous app (https://github.com/gskinnerTeam/flutter-wonderous-app) is an open-source Flutter project that demonstrates best practices in various aspects of app development, including accessibility.

13.7 State Management

In the State section, we introduced the concept of state. For a single-page application with a simple structure, using setState can be sufficient. However, when we need to share data between multiple widgets or screens, a more sophisticated state management solution is required.

13.7.1 InheritedWidget

InheritedWidget offers a straightforward method for accessing data from a shared ancestor. It is particularly useful when you need to share data across multiple widgets in a subtree without having to pass it down manually at each level. This can simplify state management and make your code more maintainable. You can utilize InheritedWidget to create a state widget that wraps a common ancestor in the widget tree, as demonstrated in this example:

Inherited Widget

Example InheritedWidget
Example InheritedWidget

Question

  • Describe the term state in your own words.
  • Sketch an app with two screens and some widgets and its data flow and think, in which widget you might need which shared data.

Unlike many other frameworks where state management and overall architecture are integrated components, Flutter does not impose a specific architecture or state management solution. Consequently, there are multiple effective architectures and state management approaches available for Flutter.

There are several approaches to managing app state, as detailed in this guide on different state management options.

However, we now have the new architectural guidelines, which utilize the plain Provider package for dependency injection and ChangeNotifier (included in the Flutter SDK) to implement the observer pattern.

13.7.2 Suggested Approach

Based on Simple app state management, use this three-step approach:

app structure

13.7.2.1 Step 1: Create Your Model with ChangeNotifier (Observable/Publisher)

ChangeNotifier enables objects to notify listeners when the models state changes. Call notifyListeners() after state modifications.

class CartModel extends ChangeNotifier {
  /// Internal, private state of the cart.
  final List<Item> _items = [];

  /// An unmodifiable view of the items in the cart.
  UnmodifiableListView<Item> get items => UnmodifiableListView(_items);

  /// The current total price of all items (assuming all items cost $42).
  int get totalPrice => _items.length * 42;

  /// Adds [item] to cart. This and [removeAll] are the only ways to modify the
  /// cart from the outside.
  void add(Item item) {
    _items.add(item);
    // This call tells the widgets that are listening to this model to rebuild.
    notifyListeners(); // Triggers widget rebuilds of listeners
  }

  /// Removes all items from the cart.
  void removeAll() {
    _items.clear();
    // This call tells the widgets that are listening to this model to rebuild.
    notifyListeners();
  }
}

Counter Example

class CounterModel extends ChangeNotifier {
  int _count = 0;

  int get count => _count;

  void increment() {
    _count++;
    notifyListeners();
  }
}

13.7.2.2 Step 2: Register Models in main()

Use MultiProvider to make models available throughout your app:

void main() {
  runApp(
    MultiProvider(
      providers: [
        ChangeNotifierProvider(create: (context) => CartModel()),
        ChangeNotifierProvider(create: (context) => CounterModel()),
      ],
      child: const MyApp(),
    ),
  );
}

13.7.2.3 Step 3: Listen to Changes in Widgets

Option A: Consumer Widget Wraps only widgets that need to rebuild when the model changes:

return Consumer<CartModel>(
  builder: (context, cart, child) {
    return Text('Total price: ${cart.totalPrice}');
  },
);

Option B: context.watch() Listens to model changes directly:

// code source: https://github.com/flutter/samples/blob/main/provider_shopper/lib/screens/cart.dart
@override
Widget build(BuildContext context) {
  var cart = context.watch<CartModel>();

  return ListView.builder(
    itemCount: cart.items.length,
    itemBuilder:
        (context, index) => ListTile(
          leading: const Icon(Icons.done),
          trailing: IconButton(
            icon: const Icon(Icons.remove_circle_outline),
            onPressed: () {
              cart.remove(cart.items[index]);

To call model methods without listening to changes, use context.read<CounterModel>().increment().

Handling Loading States For asynchronous operations, add loading indicators:

Widget build(BuildContext context) {
    final cart = context.watch<CartModel>();
    return cart.isLoading
      ? Center(child: CircularProgressIndicator())
      : ListView.builder(
          itemCount: cart.items.length,
          itemBuilder: (context, index) {
            final item = cart.items[index];
...

No StatefulWidget Needed

With Provider, state updates happen in the model via notifyListeners(). No need for setState() or StatefulWidget.

Provider Organization

Create separate providers for unrelated data. Group related data in the same provider for efficiency.

Alternative: You can also use ListenableBuilder for listening to model changes.

13.7.3 Notifier and Provider

ChangeNotifier isn't your only option for notifier. Choose based on your needs:

  • ValueNotifier: Use for single values (int, bool, String) that change immediately without async operations. Perfect for simple UI controls like toggles or counters.
  • ChangeNotifier: Use for multiple related values or when you need async operations with loading/error states. Gives you full control over when the UI updates.

Both work with ChangeNotifierProvider.

  • FutureProvider: Use for data that loads once at app startup and doesn't change afterward. No loading states needed in your UI since the data is static once loaded.

See main_demo_different_providers.dart in Moodle for implementation examples.

In mobile applications, users typically move between different screens as they use the app. There are two main types of navigation to understand:

  • Root Navigation: These are the main screens users can access directly, often through a bottom navigation bar (like "Home", "Search", "Settings"). Think of these as the main sections of your app.
  • Stack Navigation: When users move from one screen to another within a section, Flutter uses a "stack" system - much like a stack of cards. When you open a new screen, it's placed on top of the stack (called "pushing"), and when you go back, the top screen is removed (called "popping"), see figure below.
push pop
Navigation: push vs pop

Basic Navigation in Flutter, see also Send data to a new screen and Return data from a screen

/ Push: Open a new screen
Navigator.push(
  context,
  MaterialPageRoute(builder: (context) => SecondPage()),
);

// Push with data: Open a new screen and pass information to it
Navigator.push(
    context,
    MaterialPageRoute(
      builder: (context) => DetailScreen(todo: todos[index]),
    ),
  );

// Pop: Return to previous screen
Navigator.pop(context);

// Pop with data: Return to previous screen with some result
Navigator.pop(context, "Some result data");

// Waiting for result when screen returns
final result = await Navigator.push(...);  // Will contain the data passed to pop

For more complex apps, especially those with bottom navigation bars, Flutter developers often use a package called go_router.

graph TD
  A[Products]
  A --> B[Details]
  B -->A
  C[Playground]
  D[Settings]

screenshot demo app

  1. Define your routes (like a map of your app's screens):
abstract class Routes {
  // Base routes
  static const String products = '/products';  // This is now our home route
  static const String playground = '/playground';
  static const String settings = '/settings';

  // Product details route is nested under products
  static const String productDetails = '/:id'; // works with /:id and with :id 

  // Helper method for product details path
  static String productDetailsPath(String id) => '/products/$id';
}
  1. Set up navigation keys (these help Flutter keep track of navigation state):

// based on https://github.com/flutter/packages/blob/main/packages/go_router/example/lib/stateful_shell_route.dart

// root navigator key and for each entry in the bottom navigation bar a navigator key
final _rootNavigatorKey = GlobalKey<NavigatorState>(debugLabel: 'home=products');
final _settingsNavigatorKey = GlobalKey<NavigatorState>(debugLabel: 'settings');
final _playgroundNavigatorKey = GlobalKey<NavigatorState>(
  debugLabel: 'playground',
);
3. Configure the router with your routes and navigation structure.

final router = GoRouter(
  initialLocation: Routes.products,
  navigatorKey: _rootNavigatorKey,
  debugLogDiagnostics: true,
  routes: [
    StatefulShellRoute.indexedStack(
      builder: (context, state, navigationShell) {
        return ScaffoldWithNavigationBar(navigationShell: navigationShell);
      },
      branches: _bottomNavBranches,
    ),
  ],
);

final _bottomNavBranches = [
  StatefulShellBranch(
    routes: [
      GoRoute(
        path: Routes.products,
        builder: (context, state) => const ProductsPage(),
        routes: [
          GoRoute(
            path: Routes.productDetails,
            pageBuilder: (context, state) {
              final id = state.pathParameters['id']!;
              // more transitions see https://github.com/flutter/packages/blob/main/packages/go_router/example/lib/transition_animations.dart
              return CustomTransitionPage(
                child: ProductDetailsPage(id: id),
                transitionsBuilder:
                    (_, animation, _, child) =>
                        ScaleTransition(scale: animation, child: child),
              );
            }
          ),
        ],
      ),
    ],
  ),
  StatefulShellBranch(
    navigatorKey: _playgroundNavigatorKey,
    routes: [
      GoRoute(
        path: Routes.playground,
        builder: (context, state) => const PlaygroundPage(),
      ),
    ],
  ),
  StatefulShellBranch(
    navigatorKey: _settingsNavigatorKey,
    routes: [
      GoRoute(
        path: Routes.settings,
        builder: (context, state) => const SettingsPage(),
      ),
    ],
  ),
];
4. Create the bottom navigation bar widget that lets users switch between main sections.
class ScaffoldWithNavigationBar extends StatelessWidget {
  const ScaffoldWithNavigationBar({
    required this.navigationShell,
    Key? key,
  }) : super(key: key ?? const ValueKey<String>('ScaffoldWithNavigationBar'));

  /// The navigation shell and container for the branch Navigators.
  final StatefulNavigationShell navigationShell;

  @override
  Widget build(BuildContext context) {
    return Scaffold(
      body: navigationShell,
      bottomNavigationBar: NavigationBar(
        destinations: <NavigationDestination>[
          NavigationDestination(icon: Icon(Icons.home), label: 'Products'),
          NavigationDestination(icon: Icon(Icons.play_arrow), label: 'Playground'),
          NavigationDestination(
              icon: Icon(Icons.settings), label: 'Settings'),
        ],
        selectedIndex: navigationShell.currentIndex,
        onDestinationSelected: (int index) => _onDestinationSelected(context, index),
      ),
    );
  }

  void _onDestinationSelected(BuildContext context, int index) {
    navigationShell.goBranch(
      index,
      initialLocation: index == navigationShell.currentIndex,
    );
  }
}

  1. Finally, connect everything in your main app by using MaterialApp.router
    class MainApp extends StatelessWidget {
      const MainApp({super.key});
    
      @override
      Widget build(BuildContext context) {
        return MaterialApp.router(
          ...
       routerConfig: router,
        );
      }
    }
    

13.9 Libs

There are many packages available at pub.dev. To select a package check for

  • last updated -- is it still maintained?
  • many contributors -- will it be available in the future, because a company is involved or many people?
  • is it popular -- many likes and hence many examples?

13.10 Persistence

After restarting an app you might need data to be persistent. We distinguish the data on your device (client) from the data shared between devices and or users (server).

13.10.0.1 Client

On the client there are two options to save data on all platforms

13.10.0.2 Server

The communication with the server is based on REST and hence typically on https-calls.

Cite

"The HTTP API is CRUD (Create, Retrieve, Update, and Delete):

  • GET = “give me some info” (Retrieve)
  • POST = “here’s some update info” (Update)
  • PUT = “here’s some new info” (Create)
  • DELETE = “delete some info” (Delete)
  • PATCH = The HTTP method PATCH can be used to update partial resources. For instance, when you only need to update one field of the resource, PUTting a complete resource representation might be cumbersome and utilizes more bandwidth."3

You may either use the dart package http or the package dio with a global configuration and some options to pass data and files more easily.

You need to configure your app to access the internet, see network access for Android or macOS.

Next implement the repository pattern described earlier.

13.10.0.2.1 Firebase

For real time application you might consider to use Firebase. - Codelab: Get to know Firebase for Flutter - firebase data model - Flutter & Firebase Auth on macOS: Resolving Common Issues

13.10.1 Asynchronous Programming

The following snippet shows the following principles for asynchronous programming and is a summary of Codelabs: Asynchronous programming: futures, async, await and Asynchronous programming: futures, async, await.

  • Future: A future represents the result of an asynchronous operation, and can have two states: uncompleted or completed.
  • async/await: The async and await keywords provide a declarative way to define asynchronous functions and use their results. An async function runs synchronously until the first await keyword. This means that within an async function body, all synchronous code before the first await keyword executes immediately.
Future<void> printOrderMessage() async {
  print('Awaiting user order...');
  var order = await fetchUserOrder();
  print('Your order is: $order');
}

Future<String> fetchUserOrder() {
  // Imagine that this function is more complex and slow.
  return Future.delayed(const Duration(seconds: 4), () => 'Large Latte');
}

build method async

The build method can be called up to 60-120 times per second, so it is important to avoid running long or expensive operations inside it.

WidgetsFlutterBinding

When you need to interact with platform-specific code or perform asynchronous operations before the app's main widget tree is built, you need to call WidgetsFlutterBinding.ensureInitialized()

void main() {
  WidgetsFlutterBinding.ensureInitialized();
  // now initialize firebase or do other async
  ...

For real time applications you will need streams. For further explanations and code snippets see Asynchronous programming: Streams.

13.10.2 Dio

Dio is an alternative lib to http. Offering more configuration options. With pretty_dio_logger you get nice logging information.

13.10.3 Freezed

Freezed is widely used for writing Data-Transfer-Objects (DTO) and or Domain Models. If you use it, you need to write only a view lines of code, to define data classes including serialization to JSON and back.

The following code shows an example of a person class.

part 'filename.freezed.dart';
part 'filename.g.dart';

@freezed
class Person with _$Person {
  const factory Person({
    required int id,
    @JsonKey(name: 'last_name') required String lastName,
    String? firstName,
    @Default([]) List<Person> friends,
  }) = _Person;

  factory Person.fromJson(Map<String, Object?> json) => _$PersonFromJson(json);
}

In your repository you may now write

final person = Person.fromJson(json.decode(response.data!) as Map<String, Object?>);
...
final response = await dio.put<String>(url, data: person.toJson());

The build runner needs to run in the background, and you have to follow the naming convention.

Depending on your backend, e.g. with Retool, you might need to remove the id field with a post query (e.g. create a new person), this can be done with the following extension method

extension JsonWithoutId on Person {
  String toJsonWithoutId() {
    final map = toJson();
    map.remove('id');
    return json.encode(map);
  }
}

...

final response = await dio.post<String>(url, data: person.toJsonWithoutId());

13.11 Testing

Testing is not covered in this course. Nevertheless, you need it in real apps.

13.12 Packages & VS Code Extensions

The following list was given by Verena Zaiser and complemented by the students during a talk in December 23 at the h_da.

You may also checkout mason and brickhub for a quick start of projects and/or features.

Further libs I find useful - cached_network_image: A flutter library to show images from the internet and keep them in the cache directory.

13.13 Architecture and Design Principles -- optional

13.13.1 Repository Pattern

The repository pattern is commonly used in mobile development, especially for apps that retrieve data from a server via an API. Additionally, data may be stored locally, particularly in offline-capable apps. In a layered architecture, it's crucial that the domain and view layers don't manage the logic of how or where data is fetched (whether from a local source or a server). This responsibility is delegated to the data layer. Below is an example of a typical three-layered architecture.

Layered Architecture
Layered Architecture
Data Layer
Data Layer

"Repository classes are responsible for the following tasks:

  • Exposing data to the rest of the app.
  • Centralizing changes to the data.
  • Resolving conflicts between multiple data sources.
  • Abstracting sources of data from the rest of the app.
  • Containing business logic." Data Layer

13.13.2 Riverpod

State management is a major issue in reactive programs and in flutter. Flutter suggests in its first codelab to use provider.

A provider is basically a piece of state, i.e. you do not want to hold every data relevant for your app in one class, but in several separated parts.

Provider

A provider holds a specific piece of your app's state. Hence, a provider "provides" a specific piece of state to any part of the app, which needs it.

For example, you have a state to handle favorites and a state for contacts and another state for login.

The package provider has some flaws, e.g. it is connected to the widget tree and hence testing is difficult. There exists a more sophisticated package called riverpod, more precisely flutter_riverpod.

"Since riverpod is more flexible than provider and does not rely on the Flutter widget tree to give objects, it can behave more naturally and allow for the representation of more complicated patterns." Unleashing Flutter Riverpod: State Management Mastery

Riverpod addresses the following problems

graph LR
 A[App] --> B[ProviderScope]
 A --> C[ConsumerWidget - Page 1]
 C --ref---> B
 A --> G[ConsumerWidget - Page 2]
 G --ref---> B
 B --- D[repositoryProvider]
 B --- E[state1Provider]
 E --ref---> D
 B --- F[state2Provider]

Sometimes you read, that all providers in Riverpod are global. That is not true, every provider, i.e. every piece of state or every provided object lives in the ProviderScope. ProviderScope stores the state of all the providers we create. We may access providers using the ref-object. To create the connection between the widget tree and a provider you need ConsumerWidget, which gives you a ref-object. Thus, derive your widget from ConsumerWidget and you have access to every provider and you may share objects and data. With the ref-object it is easy to watch for changes of a piece of state and rebuild/redraw the widget with the new value. This ref-object is accessible in each provider so that even outside the widget tree every provider may access every other provider. This ref-object is basically used to read and hence access a specific provider or to observe its state.

You may use Riverpod with its generator or implement it by yourself. I suggest to use the generator.

install riverpod

Follow the instructions to use riverpod.

To use the generator a listener must run to generate the code according to your typing

dart pub run build_runner watch

13.13.2.1 Provider

There are mainly four use cases for provider

  1. make an object available
  2. make a changeable single value accessible to different parts of your app: simple state
  3. implement a controller/viewmodel with state and methods to change the state
  4. make asynchronous operations

All cases are implemented in the sample application.

13.13.2.1.1 "global object": Provider

The most basic provider gives access to an object, e.g. a repository, a http-connection or a logger, that don't change.

// define the provider, with or without the generator
// final peopleRepositoryProvider = Provider<PeopleRepository>((ref) {
//   return PeopleRepository(dio: ref.read(dioProvider));// declared elsewhere
// });
@riverpod
PeopleRepository peopleRepository(PeopleRepositoryRef ref) =>
    PeopleRepository(dio: ref.read(dioProvider));
13.13.2.1.2 simple state that can change: StateProvider

The most simple piece of state is just an object with a getter and a setter, e.g. you want to change a selected person or a selected city in one widget, e.g. you select an element in a list, and other widgets like details information are related to it. To do so you may define a StateProvider.

// define the provider, with or without the generator
// final currentPersonProvider = StateProvider<Person?>((ref) { return null});
@riverpod
 Person? currentPerson(CurrentPersonRef ref) {
   return null;
}

// set the person
ref.read(currentPersonProvider.notifier).state = person

// observe changes
final state = ref.watch(currentPersonProvider)

The following code shows the counter example with riverpod

// 1. declare a [StateProvider]
final counterProvider = StateProvider<int>((ref) {
  return 0;
});

// 2. create a [ConsumerWidget] subclass
class CounterWidget extends ConsumerWidget {
  @override
  Widget build(BuildContext context, WidgetRef ref) {
    // 3. watch the provider and rebuild when the value changes
    final counter = ref.watch(counterProvider);
    return ElevatedButton(
      // 4. use the value
      child: Text('Value: $counter'),
      // 5. change the state inside a button callback
      onPressed: () => ref.read(counterProvider.notifier).state++,
    );
  }
}
13.13.2.1.3 Controller or ViewModel

To keep the UI and build methods simple, it is recommended to separate the interaction with the data layer and hence repositories. Use the following code to define a controller using the generator

@riverpod
class EditPersonController extends _$EditPersonController {
    @override
    FutureOr<Person?> build() {...}
    // methods calling the repository methods
}

// and in a widget
final state = ref.watch(editPersonControllerProvider);
final controller = ref.read(editPersonControllerProvider.notifier);

Or the counter example (complete code)

@riverpod
class Counter extends _$Counter {
  /// Classes annotated by `@riverpod` **must** define a [build] function.
  /// This function is expected to return the initial state of your shared state.
  /// It is totally acceptable for this function to return a [Future] or [Stream] if you need to.
  /// You can also freely define parameters on this method.
  @override
  int build() => 0;

  void increment() => state++;
}

class Home extends ConsumerWidget {
  @override
  Widget build(BuildContext context, WidgetRef ref) {
    return Scaffold(
      appBar: AppBar(title: const Text('Counter example')),
      body: Center(
        child: Text('${ref.watch(counterProvider)}'),
      ),
      floatingActionButton: FloatingActionButton(
        // The read method is a utility to read a provider without listening to it
        onPressed: () => ref.read(counterProvider.notifier).increment(),
        child: const Icon(Icons.add),
      ),
    );
  }
}

Or a more complex viewmodel with async

@riverpod
class EditPersonController extends _$EditPersonController {
    @override
    FutureOr<Person?> build() {
        state = const AsyncData(null);
        return state.value;
    }
    // async methods to change the state
}

naming convention -- needed by the generator

Function name EditPersonController needs to extend _$EditPersonController.

13.13.2.1.4 Asynchronous operations

Sometimes you do not need a full controller or access to the complete repository, but you just want to fetch a list of movies or a String asynchronously.

@riverpod
Future<String> boredSuggestion(BoredSuggestionRef ref) async {
  final response = await http.get(
    Uri.https('https://boredapi.com/api/activity'),
  );
  final json = jsonDecode(response.body);
  return json['activity']! as String;
}

// watching changes and show loading, error, changes
class Home extends ConsumerWidget {
  @override
  Widget build(BuildContext context, WidgetRef ref) {
    final boredSuggestion = ref.watch(boredSuggestionProvider);
    // Perform a switch-case on the result to handle loading/error states
    return boredSuggestion.when(
      loading: () => Text('loading'),
      error: (error, stackTrace) => Text('error: $error'),
      data: (data) => Text(data),
    );
  }
}

naming convention -- needed by the generator

Function name boredSuggestion needs the parameter BoredSuggestionRef ref.

The when/loading/error-part is needed often. Thus, it is recommended to put it into a custom widget, e.g. AsyncValueWidget.

Riverpod

Basically you need the annotation @riverpod and the naming convention. You do not need to know what provider is generated in the background.

  • class based notation class Counter extends _$Counter
  • function based notation Person? currentPerson(CurrentPersonRef ref)

13.13.2.2 Edge cases

Usually the provider lives as long as someone is interested in it, e.g. as long as there is a widget watching it. Sometimes you need to stay the provider alive, to do so use the annotation with a capital R.

@Riverpod(keepAlive:true)

Sometimes, you may want to force the destruction of a provider. This can be done by using ref.invalidate, which can be called from another provider or from a widget.

For everything else read the excellent documentation.

App Initialization

For real apps check out the article How to Build a Robust Flutter App Initialization Flow with Riverpod.

13.13.2.3 REST

If your data is not on one mobile device only, but you fetch weather data from a server, exchange data with other users or do CRUD (create-read-update-delete) operations on a database in the cloud or on a server, you will read and write data asynchronously. A proper architecture realizing such an application is shown below.

REST with Riverpod
How to Fetch Data and Perform Data Mutations with the Riverpod Architecture

13.13.3 MVVM

graph LR
    A[View] -->|UI event / command| B[ViewModel]
    B -->|change event| A
    A -->|get data| B
    B -->|read data| C[Model]
    C -->|notify/change event| B
    B -->|Updates| C

MVVM (Model-View-ViewModel) was first introduced by Microsoft as an adaptation of the MVC (Model-View-Controller) design pattern. While both patterns aim to separate concerns and promote maintainability, they achieve this in slightly different ways.

  • Core Similarities Between MVVM and MVC
    • Separation of Concerns: Both patterns aim to decouple the user interface from the underlying business logic, ensuring that the application's code is more modular and easier to maintain.
    • Model: In both patterns, the Model represents the data and business logic of the application. It is responsible for data access, processing, and ensuring that the correct information is provided to the rest of the application.
    • View: The View in both patterns represents the UI elements that the user interacts with. It is responsible for displaying information and receiving user input, though the way it interacts with the rest of the components differs between the patterns.
  • Key Differences Between MVVM and MVC
    • Controller vs. ViewModel
      • In MVC, the Controller serves as the intermediary between the View and the Model. It handles user input from the View, processes it (often modifying the Model), and updates the View accordingly. The logic in the Controller is typically imperative, meaning it explicitly defines how the View and Model interact.
      • In MVVM, the ViewModel acts as the intermediary between the View and the Model, but the interaction is typically more data-driven and declarative. Rather than the imperative approach seen in MVC, the ViewModel exposes data and commands that the View binds to (via mechanisms like data binding in frameworks like WPF or MAUI). The ViewModel does not directly manage user interaction; instead, it holds the presentation logic and state, providing the View with data in a form that's easier to display.
        • Data Binding: One of the key features of MVVM is data binding, where the View is automatically updated when the ViewModel changes. Similarly, user interactions with the View (such as button clicks or text input) are reflected in the ViewModel through bound properties or commands. This creates a more declarative UI, as the UI is automatically synchronized with the underlying data, reducing the need for manual updates.

The Flutter architecture guide recommends an MVVM-inspired approach, incorporating the Repository Pattern and the Command Pattern. The diagram below illustrate how these patterns integrate within the architecture.

MVVM
Guide to app architecture

Read and Understand

Read and understand the Guide to app architecture AND the Architecture case study.

In summary, the architecture follows a clear separation of concerns, see Communicating between layers:

  • View: Presents the UI, aware of exactly one ViewModel. When created, Flutter passes the view model to the view as an argument (dependency injection), exposing the view model's data and command callbacks to the view. Often a view is a screen, i.e. a collection of widgets. But it might be a single logout-button as well.
  • ViewModel: Acts as the intermediary between the View and one or more Repositories. The ViewModel contains logic and state specific to the View, and it implements the command pattern and triggers a rebuild of the view calling notifyListeners. A ViewModel belongs to exactly one view, which can see its data. A view model is aware of one or more repositories, which are passed into the view model's constructor (dependency injection).
  • Repository: Fetches and stores data, abstracting the data access layer. The repository is the single source of truth. A repository can be used by many view models, but it never needs to be aware of them.
  • Service: Performs specific operations (e.g., network requests) used by Repositories.

13.13.3.1 Reactive Programming

"In computing, reactive programming is a declarative programming paradigm concerned with data streams and the propagation of change." Wikipedia

The propagation of change and the redrawing of the UI is a major issue.

Streams may be associated with a stream of data. You might know streams from reading files from a file system. You could also think of real time applications like a chat app, where you get a stream of messages. "A stream is a sequence of ongoing events (state changes) ordered in time. Streams can emit three different things: a value (of some type), an error, or a "completed" signal. The events are captured asynchronously, by defining a function that will execute when a value is emitted, another function when an error is emitted, and another function when 'completed' is emitted. "Listening" to the stream is called subscribing. The functions we are defining are observers. The stream is the subject (or "observable") being observed." IBM

The widget and element trees are sometimes described as reactive, because new inputs provided in a widget’s constructor are immediately propagated as changes to lower-level widgets by the widget’s build method, and changes made in the lower widgets (for example, in response to user input) propagate back up the tree using event handlers. Aspects of both functional-reactive and imperative-reactive are present in the framework, depending on the needs of the widgets. Widgets with build methods that consist of just an expression describing how the widget reacts to changes in its configuration are functional reactive widgets (for example, the Material Divider class). Widgets whose build methods construct a list of children over several statements, describing how the widget reacts to changes in its configuration, are imperative reactive widgets (for example, the Chip class). What programming paradigm does Flutter’s framework use?

13.14 Security

Flutter, while providing a robust framework, requires developers to consciously implement security best practices.

Security is not an afterthought

Integrate security considerations from the very beginning of your app's design and development lifecycle, rather than trying to patch them in later.

13.14.1 Data Encryption

data encryption

Encrypt sensitive the data using crypto or pointycastle.

For secure local storage of sensitive data (even if encrypted), consider using platform-specific secure storage solutions like flutter_secure_storage or encrypted databases like sqflite_sqlcipher.

Consider Package Maintenance

While sqflite_sqlcipher and flutter_secure_storage are popular, note their primary single-developer maintenance. For core encryption, crypto or pointycastle are robust choices.

13.14.2 Authentication & Passwords

Never store raw user passwords. Instead, always store cryptographically hashed and salted passwords using robust algorithms. Hashing is a one-way process, ensuring the original password cannot be retrieved. Salting adds randomness, protecting against rainbow table attacks.

Password hashing is usually performed on the backend server. Your Flutter app should send the plain password securely via HTTPS to the backend, which then performs the hashing and stores the hash. If you need to store a password locally for remote server access, encrypt it - e.g. use crypto - don't hash it, so it can be securely transmitted. However, never store plain passwords anywhere.

secure authentication

Read and follow the article Authentication General Guidelines.

authentication with biometrics

Make authentication simple. Use local_auth to implement authentication with biometrics such as fingerprint or facial recognition.

13.14.2.1 OAuth

OAuth 2.0 is the industry-standard protocol for authorization. For authentication, it's often extended by OpenID Connect (OIDC), which provides an identity layer on top of OAuth 2.0, issuing an ID Token for user verification. What is OAuth 2.0 gives a good general introduction.

In your Flutter app, use the lib oauth2 or oauth2_client to facilitate user login via OAuth2/OIDC and utilize obtained access tokens for API calls. For mobile apps, PKCE (Proof Key for Code Exchange) is essential for security.

Note, your Flutter app must be registered with the OAuth2/OIDC provider (e.g., Google), and after successful authorization, a deep link is needed for your app to receive the authorization code, which is then securely exchanged with the Token Endpoint for the actual tokens (including the ID Token) to proceed (e.g., send to your REST API). See the sequence diagram below.

sequenceDiagram
    participant U as User 👤
    participant A as Flutter App 📱
    participant G_Auth as Google Auth Server 🔑
    participant G_Token as Token Endpoint of Google Auth Server 🏷️ 
    participant R_API as Your REST API 🔌
    participant DB as Your Cloud DB 🗄️

    U->>A: 1. Initiates "Login with Google"
    A->>G_Auth: 2. Redirect to Google Login (client_id, redirect_uri, data for PKCE)
    G_Auth-->>U: 3. Google Login Screen
    U->>G_Auth: 4. Authenticates & Grants Consent
    G_Auth-->>A: 5. Authorization Code (via deep link)
    A->>G_Token: 6. Request Tokens (code, code verifier for PKCE)
    G_Token-->>A: 7. Returns Google ID Token ♦️
    A->>R_API: 8. Initial Login Request (♦️)
    R_API->>G_Auth: 9. Fetch Google Public Keys (once)
    R_API->>R_API: 10. Validate ♦️ using public keys
    R_API->>DB: 11. Verify/Create User in Your DB (based on data of ♦️)
    DB-->>R_API: 12. User Info/Status
    R_API-->>A: 13. Return Your API's Session Token 🔹
    A->>R_API: 14. Subsequent API Calls (using 🔹)
    R_API->>DB: 15. Access Data in Cloud DB
    DB-->>R_API: 16. Return DB Data
    R_API-->>A: 17. Return JSON Data
    A-->>U: 18. Display to User

The following videos give a good explanation

13.14.3 Roles

role based access

Define roles and access rules at table or row levels to manage data permissions. Distinguish between read, edit, and delete access to ensure proper data security and authorization. Do the same for your navigation.

backend security check

Never trust the client app alone for access control—always enforce role checks on the backend.

13.14.4 API Keys

API keys grant access to backend services and external APIs. Exposing them in your client-side Flutter code can lead to unauthorized access and abuse.

no hard codes keys

Do not embed API keys directly in your source code. When your app is compiled, these keys become easily extractable through reverse engineering.

There is an excellent article discussing different options how to handle API keys in your flutter app How to Store API Keys in Flutter: --dart-define vs .env files.

13.14.5 HTTPS

Always use HTTPS/TLS: Ensure all communication between your Flutter app and backend servers, or any external APIs, is encrypted using HTTPS (TLS). This prevents eavesdropping and man-in-the-middle attacks.

Flutter's http package and Dio automatically handle HTTPS when you use https:// URLs.

13.14.5.1 Cross-Origin Resource Sharing (CORS)

CORS is a browser-level security mechanism that restricts web pages from making requests to a different domain than the one from which they originated.

When your Flutter application is deployed as a web app and attempts to fetch data from a backend server on a different domain, port, or protocol, the browser will typically block these requests unless the server explicitly allows them via CORS headers. This results in common errors like "No 'Access-Control-Allow-Origin' header is present on the requested resource.

The most secure and recommended approach is to correctly configure your backend server.

Specific Origins Over Wildcards

Avoid Access-Control-Allow-Origin: * in production environments due to security risks. Instead, explicitly list the specific origins of your frontend applications.

Within this module you might run into this problem and solve it using flutter_cors.

Never disable CORS checks in production

Disabling CORS checks is acceptable for this module, but you should never do it in a production environment.

Read the Mozilla Developer Docs to learn more on Cross-Origin Resource Sharing (CORS).

13.14.6 Code Quality

Beyond specific security implementations, adopting sound development practices significantly contributes to a safer application. This includes leveraging language features like null safety to prevent common programming errors that could lead to vulnerabilities. A well-defined architectural pattern (e.g., BLoC, Provider, Riverpod) promotes code organization, testability, and separation of concerns, making it easier to identify and fix security flaws. Comprehensive unit and integration tests are crucial for verifying that security mechanisms function as intended and that data is handled correctly under various scenarios, reducing the risk of regressions and undiscovered vulnerabilities.

Furthermore, regularly updating dependencies to their latest stable versions is critical -- see Flutter Docs Security. These updates often include security patches for newly discovered vulnerabilities. Minimize the number of libraries used and carefully evaluate their quality, maintenance status, and the number and identity of their contributors or publishers.

While staying current, always specify a reasonable minimum SDK version (minSdkVersion for Android, iOS Deployment Target for iOS) to ensure your app runs on devices that still receive security updates from their respective OS vendors, thereby minimizing exposure to platform-level vulnerabilities.

13.14.7 Evaluation

Create a comprehensive test guide for your app that addresses both user experience (UX) and security concerns. Conduct regular tests to ensure ongoing ux and safety.

Mobile App Test Guide

Check the instructions of the Mobile App Test Guide.

In addition add security issues to your UX evaluation

  • Measure user success objectives on UX security-related tasks.
  • Test how users perform essential security actions, like managing permissions or changing passwords without confusion or heavy cognitive load4.

13.14.8 Data Protection (Datenschutz)

Data protection, or "Datenschutz" in German, refers to the legal and ethical handling of personal data. Compliance with regulations like GDPR (General Data Protection Regulation) is crucial, especially if your app targets users in the EU. Read and understand the key principles of GDPR.

Practical Implications for Apps:

  • Privacy Policy: Clearly state what data your app collects, why, how it's used, and with whom it's shared. Make it easily accessible within the app.
  • User Consent: Obtain explicit consent before collecting or processing personal data, especially for non-essential data. Provide clear opt-in/opt-out mechanisms.
  • Data Subject Rights: Allow users to view, correct, delete and export their data.
  • Data Minimization: Only request permissions and collect data that is strictly necessary for your app's core functionality.

13.14.9 Security vs. Usability

  • Clear Communication: Explain security features and data practices in plain language. Avoid jargon.
  • Intuitive Controls: Make privacy settings and security options easy to find and understand. Test it with real users.
  • Feedback: Provide clear feedback when security actions are taken (e.g., "Password changed successfully," "Data deleted").
  • Error Handling: Implement user-friendly error messages for security failures, guiding users on how to resolve issues without revealing sensitive details. Don't show exceptions to your users, i.e. do not provide any information about your systems, databases etc.

13.14.10 Further Reading


  1. Flutter architectural overview. 2024. URL: https://docs.flutter.dev/resources/architectural-overview#reactive-user-interfaces (visited on 15.02.2024). 

  2. Common flutter errors. 2024. URL: https://docs.flutter.dev/testing/common-errors#a-renderflex-overflowed (visited on 12.03.2024). 

  3. Sanjay Patni. Fundamentals of RESTful APIs, pages 1–15. Apress, Berkeley, CA, 2023. URL: https://link.springer.com/chapter/10.1007/978-1-4842-9200-6_1#Sec11, doi:10.1007/978-1-4842-9200-6_1

  4. Sabine Roehl. Secure by design: a ux toolkit. 2025. URL: https://microsoft.design/articles/secure-by-design-a-ux-toolkit/ (visited on 11.07.2025).