Skip to content

Instantly share code, notes, and snippets.

@tshego3
Last active February 9, 2025 14:15
Show Gist options
  • Save tshego3/9e2e833b7b151900d4c27567410fe159 to your computer and use it in GitHub Desktop.
Save tshego3/9e2e833b7b151900d4c27567410fe159 to your computer and use it in GitHub Desktop.
Flutter Cheatsheet
Common Errors
  • Resolve Error: The term 'flutter' is not recognized as the name of a cmdlet.

    flutter : The term 'flutter' is not recognized as the name of a cmdlet, function, script file, or operable program. Check the spelling of the name, or if a path was included, verify that the path is correct and try again.

    For CMD:

    flutter doctor
    flutter doctor -v
    flutter --disable-analytics
    dart --disable-analytics
    

    Check If You added C:\Users\%USERPROFILE%\flutter\bin to environment variables 'Path':

    The Dart SDK lives inside the C:\Users\%USERPROFILE%\flutter\bin\cache\dart-sdk\bin.

  • Resolve Error: Android sdkmanager not found.

    flutter : Android sdkmanager not found. Update to the latest Android SDK and ensure that the cmdline-tools are installed to resolve this.

    For CMD:

    flutter doctor --android-licenses
    

    Open Android Studio, Go to File -> Settings... -> Languages & Frameworks -> Android SDK, Update/Install Android Studio Command-line Tools. Click apply and restart android studio:

  • Press "shift + right-click" in the folder containing vs_enterprise.exe, select "open PowerShell window here" and in the PowerShell window; type "start cmd", press the enter button and run the command below.

    VisualStudioSetup.exe --layout c:\localVSlayout --add Microsoft.VisualStudio.Workload.NativeDesktop --add Microsoft.VisualStudio.ComponentGroup.VC.Tools.142.x86.x64 --includeOptional --lang en-US

  • Resolve Error: Please upgrade your AGP version to at least 8.x.x.

    • We need to download latest JDK version, compatible with the relative Gradle version, and install the x64 Installer.
      • Find the relative Gradle version: C:\Users<UserName>\source\repos<FlutterApp>\android\gradle\wrapper\gradle-wrapper.properties distributionUrl=https\://services.gradle.org/distributions/gradle-x-all.zip
    • Add the path of JDK, C:\Program Files\Android\Android Studio\jbr\bin OR C:\Program Files\Java\jdk-x\bin, into environment variables 'Path', and add path, C:\Program Files\Android\Android Studio\jbr OR C:\Program Files\Java\jdk-x, on the environment variable 'JAVA_HOME'.
    • Check if everything is working properly. Open command prompt, java -version.
    • We need to download latest Gradle (complete) version.
    • Then extract the contents of Gradle zip file in location C:\Users\%USERPROFILE%\gradle-8.x.x.
    • Add the path of Gradle, C:\Users\%USERPROFILE%\gradle-8.x.x\bin, into environment variables 'Path'.
    • Check if everything is working properly. Open command prompt, gradle -v.
Flutter CLI

The flutter command-line tool is how developers (or IDEs on behalf of developers) interact with Flutter. For Dart related commands, you can use the dart command-line tool.

Here's how you might use the flutter tool to create, analyze, test, and run an app:

$ flutter create my_app
$ cd my_app
$ flutter analyze
$ flutter test
$ flutter run lib/main.dart

To run pub commands using the flutter tool:

$ flutter pub get
$ flutter pub outdated
$ flutter pub upgrade

To view all commands that flutter supports:

$ flutter --help --verbose

To get the current version of the Flutter SDK, including its framework, engine, and tools:

$ flutter --version

flutter commands

The following table shows which commands you can use with the flutter tool:

Command Example of use More information
analyze flutter analyze -d <DEVICE_ID> Analyzes the project's Dart source code.
Use instead of dart analyze.
assemble flutter assemble -o <DIRECTORY> Assemble and build flutter resources.
attach flutter attach -d <DEVICE_ID> Attach to a running application.
bash-completion flutter bash-completion Output command line shell completion setup scripts.
build flutter build <DIRECTORY> Flutter build commands.
channel flutter channel <CHANNEL_NAME> List or switch flutter channels.
clean flutter clean Delete the build/ and .dart_tool/ directories.
config flutter config --build-dir=<DIRECTORY> Configure Flutter settings. To remove a setting, configure it to an empty string.
create flutter create <DIRECTORY> Creates a new project.
custom-devices flutter custom-devices list Add, delete, list, and reset custom devices.
devices flutter devices -d <DEVICE_ID> List all connected devices.
doctor flutter doctor Show information about the installed tooling.
downgrade flutter downgrade Downgrade Flutter to the last active version for the current channel.
drive flutter drive Runs Flutter Driver tests for the current project.
emulators flutter emulators List, launch and create emulators.
gen-l10n flutter gen-l10n <DIRECTORY> Generate localizations for the Flutter project.
install flutter install -d <DEVICE_ID> Install a Flutter app on an attached device.
logs flutter logs Show log output for running Flutter apps.
precache flutter precache <ARGUMENTS> Populates the Flutter tool's cache of binary artifacts.
pub flutter pub <PUB_COMMAND> Works with packages.
Use instead of dart pub.
run flutter run <DART_FILE> Runs a Flutter program.
screenshot flutter screenshot Take a screenshot of a Flutter app from a connected device.
symbolize flutter symbolize --input=<STACK_TRACK_FILE> Symbolize a stack trace from the AOT compiled flutter application.
test flutter test [<DIRECTORYDART_FILE>] Runs tests in this package.
Use instead of dart test.
upgrade flutter upgrade Upgrade your copy of Flutter.

{:.table .table-striped .nowrap}

For additional help on any of the commands, enter flutter help <command> or follow the links in the More information column. You can also get details on pub commands — for example, flutter help pub outdated.

Flutter Common Cheatsheet

Sample program

import 'package:flutter/material.dart';

void main() {
    runApp(MyApp());
}

class MyApp extends StatelessWidget {
  @override
  Widget build(BuildContext context) {
    return MaterialApp(
      title: 'Hello world!',
      home: Scaffold(
        body: Center(
          child: Text('Hello world'),
        ),
      ),
    );
  }
}
  • import function: imports the assets of the specified library from the pubspec.yaml file.
  • void main(): main function that runs first when the app is compiled every time.
  • class MyApp extends StatelessWidget {}: Contains a class (similar to java class) and creates a stateless widget.
  • @override: @override marks an instance member as overriding a superclass member with the same name.
  • build(BuildContext context): BuildContext is a locator that is used to track each widget in a tree and locate them and their position in the tree. The BuildContext of each widget is passed to their build method.
  • Scaffold(): Scaffold is a class in flutter which provides many widgets or we can say APIs like Drawer, SnackBar, BottomNavigationBar etc.
  • body:: represents the body of the app , wrapped inside a scaffold , where several widgets can be used to layout a UI.
  • Center(): is a property to wrap around any widget and push the widget layout to be at the center of the screen/widget.
  • child:: It is a property of the parent widget that allows widgets to be put inside widgets , forming a widget tree.
  • Text(): It's a widget to pass in text to display it on the current screen.-

The State is information that can read synchronously when the widget is build and might change during the lifetime of the widget. In simpler words, the state of the widget is the information of the objects that its properties (parameters) are holding at the time of its creation (when the widget is painted on the screen). The state can also change when it is used for example the color of RaisedButton widget might change when pressed.

Stateless Widget

Stateless widgets are the widgets that don’t change i.e. they are immutable. Its appearance and properties remain unchanged throughout the lifetime of the widget. In simple words, Stateless widgets cannot change their state during the runtime of the app, which means the widgets cannot be redrawn while the app is in action. Examples: Icon, IconButton, and Text are examples of stateless widgets.

class MyCounter extends StatelessWidget {
  final int count;
  const MyCounter({super.key, required this.count});

  @override
  Widget build(BuildContext context) {
    return Text('$count');
  }
}

Stateful Widget

Stateful Widgets are the ones that change its properties during run-time. They are dynamic i.e., they are mutable and can be drawn multiple times within its lifetime. It can change its appearance in response to events triggered by user interactions or when it receives data. Examples : Checkbox, Radio Button, Slider, InkWell, Form, and TextField are examples of Stateful widgets

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

  @override
  State<MyCounter> createState() => _MyCounterState();
}

class _MyCounterState extends State<MyCounter> {
  int count = 0;

  @override
  Widget build(BuildContext context) {
    return Column(
      children: [
        Text('Count: $count'),
        TextButton(
          onPressed: () {
            setState(() {
              count++;
            });
          },
          child: Text('Increment'),
        )
      ],
    );
  }
}

Inherited Widget

Manually passing data down the widget tree can be verbose and cause unwanted boilerplate code, so Flutter provides InheritedWidget, which provides a way to efficiently host data in a parent widget so that child widgets can get access them without storing them as a field.

To use InheritedWidget, extend the InheritedWidget class and implement the static method of() using dependOnInheritedWidgetOfExactType. A widget calling of() in a build method creates a dependency that is managed by the Flutter framework, so that any widgets that depend on this InheritedWidget rebuild when this widget re-builds with new data and updateShouldNotify returns true.

class MyState extends InheritedWidget {
  const MyState({
    super.key,
    required this.data,
    required super.child,
  });

  final String data;

  static MyState of(BuildContext context) {
    // This method looks for the nearest `MyState` widget ancestor.
    final result = context.dependOnInheritedWidgetOfExactType<MyState>();

    assert(result != null, 'No MyState found in context');

    return result!;
  }

  @override
  // This method should return true if the old widget's data is different
  // from this widget's data. If true, any widgets that depend on this widget
  // by calling `of()` will be re-built.
  bool updateShouldNotify(MyState oldWidget) => data != oldWidget.data;
}
	
// Next, call the of() method from the build()method of the widget that needs access to the shared state:

class HomeScreen extends StatelessWidget {
  const HomeScreen({super.key});

  @override
  Widget build(BuildContext context) {
    var data = MyState.of(context).data;
    return Scaffold(
      body: Center(
        child: Text(data),
      ),
    );
  }
}

Required and default props

import 'package:flutter/material.dart';

class SomeComponent extends StatelessWidget {
  SomeComponent({
    @required this.foo,
    this.bar = 'some string',
  });

  final String foo;
  final String bar;

  @override
  Widget build(BuildContext context) {
    return Container(
      child: Text('$foo $bar'),
    );
  }
}

Android Ink effect

InkWell(
  child: Text('Button'),
  onTap: _onTap,
  onLongPress: _onLongPress,
  onDoubleTap: _onDoubleTap,
  onTapCancel: _onTapCancel,
);

Detecting Gestures

GestureDetector(
  onTap: _onTap,
  onLongPress: _onLongPress,
  child: Text('Button'),
);

Loading indicator

class SomeWidget extends StatefulWidget {
  @override
  _SomeWidgetState createState() => _SomeWidgetState();
}

class _SomeWidgetState extends State<SomeWidget> {
  Future future;

  @override
  void initState() {
    future = Future.delayed(Duration(seconds: 1));
    super.initState();
  }

  @override
  Widget build(BuildContext context) {
    return FutureBuilder(
      future: future,
      builder: (context, snapshot) {
        return snapshot.connectionState == ConnectionState.done
            ? Text('Loaded')
            : CircularProgressIndicator();
      },
    );
  }
}

Platform specific code

import 'dart:io' show Platform;

if (Platform.isIOS) {
  doSmthIOSSpecific();
}

if (Platform.isAndroid) {
  doSmthAndroidSpecific();
}

Hide status bar

import 'package:flutter/services.dart';

void main() {
    SystemChrome.setEnabledSystemUIOverlays([]);
}

Lock orientation

import 'package:flutter/services.dart';

void main() async {
  await SystemChrome.setPreferredOrientations([
    DeviceOrientation.portraitUp,
  ]);

  runApp(App());
}

Make http request

dependencies:
  http: ^0.12.0
import 'dart:convert' show json;
import 'package:http/http.dart' as http;

http.get(API_URL).then((http.Response res) {
    final data = json.decode(res.body);
    print(data);
});

Async Await

Future<int> doSmthAsync() async {
  final result = await Future.value(42);
  return result;
}

class SomeClass {
  method() async {
    final result = await Future.value(42);
    return result;
  }
}

JSON

import 'dart:convert' show json;

json.decode(someString);
json.encode(encodableObject);

Check if dev

bool isDev = false;
assert(isDev == true);

if (isDev) {
    doSmth();
}

Navigation

import 'package:flutter/material.dart';

class FirstScreen extends StatelessWidget {
  @override
  Widget build(BuildContext context) {
    return Center(
      child: RaisedButton(
        child: Text('Go to SecondScreen'),
        onPressed: () => Navigator.pushNamed(context, '/second'),
      ),
    );
  }
}

class SecondScreen extends StatelessWidget {
  void _pushSecondScreen(context) {
    Navigator.push(context, MaterialPageRoute(builder: (context) => SecondScreen()));
  }

  @override
  Widget build(BuildContext context) {
    return Column(
      children: <Widget>[
        RaisedButton(
          child: Text('Go back!'),
          onPressed: () => Navigator.pop(context),
        ),
        RaisedButton(
          child: Text('Go to SecondScreen... again!'),
          onPressed: () => _pushSecondScreen(context),
        ),
      ],
    );
  }
}

void main() {
  runApp(MaterialApp(
    initialRoute: '/',
    routes: {
      '/': (context) => FirstScreen(),
      '/second': (context) => SecondScreen(),
    },
  ));
}

Show alert

showDialog<void>(
  context: context,
  barrierDismissible: false,
  builder: (BuildContext context) {
    return AlertDialog(
      title: Text('Alert Title'),
      content: Text('My Alert Msg'),
      actions: <Widget>[
        FlatButton(
          child: Text('Ask me later'),
          onPressed: () {
            print('Ask me later pressed');
            Navigator.of(context).pop();
          },
        ),
        FlatButton(
          child: Text('Cancel'),
          onPressed: () {
            print('Cancel pressed');
            Navigator.of(context).pop();
          },
        ),
        FlatButton(
          child: Text('OK'),
          onPressed: () {
            print('OK pressed');
            Navigator.of(context).pop();
          },
        ),
      ],
    );
  },
);

Arrays

final length = items.length;

final newItems = items..addAll(otherItems);

final allEven = items.every((item) => item % 2 == 0);

final filled = List<int>.filled(3, 42);

final even = items.where((n) => n % 2 == 0).toList();

final found = items.firstWhere((item) => item.id == 42);

final index = items.indexWhere((item) => item.id == 42);

final flat = items.expand((_) => _).toList();

final mapped = items.expand((item) => [item + 1]).toList();

items.forEach((item) => print(item));

items.asMap().forEach((index, item) => print('$item, $index'));

final includes = items.contains(42);

final indexOf = items.indexOf(42);

final joined = items.join(',');

final newItems = items.map((item) => item + 1).toList();

final item = items.removeLast();

items.add(42);

final reduced = items.fold({}, (acc, item) {
  acc[item.id] = item;
  return acc;
});

final reversed = items.reversed;

items.removeAt(0);

final slice = items.sublist(15, 42);

final hasOdd = items.any((item) => item % 2 == 0);

items.sort((a, b) => a - b);

items.replaceRange(15, 42, [1, 2, 3]);

items.insert(0, 42);

Changing the app icon in a Flutter app is a great way to customize your app's appearance. Here’s a simple guide to help you out:

  1. Prepare Your Icons: You need to create your app icon in different sizes for various devices. You can use an online tool like App Icon Generator to generate these.

  2. Add Icons to Your Project:

    • iOS:
      • In your Flutter project, navigate to the ios folder and then to Runner.
      • Replace the icons in Assets.xcassets/AppIcon.appiconset with your generated icons.
    • Android:
      • Navigate to the android folder, then to app/src/main/res.
      • Replace the icons in the mipmap folders (like mipmap-hdpi, mipmap-mdpi, etc.) with your generated icons.
  3. Update the pubspec.yaml: Ensure that your pubspec.yaml file includes the updated assets if necessary.

  4. Run the App: Finally, run your Flutter app to see the new app icon in action!

To update the icon of a Flutter Windows desktop application before packaging use the following instructions:

In the Flutter project, navigate to windows\runner\resources. Replace the app_icon.ico with the desired icon. If the name of the icon is other than app_icon.ico, proceed to change the IDI_APP_ICON value in the windows\runner\Runner.rc file to point to the new path.

for loop, forEach, map, cascade & spread operator in Dart

In Dart, you have several ways to iterate over a list or apply functions to elements, such as using a for loop, forEach, and map. Let me show you how each one works:

1. Using a for loop

The traditional for loop is useful when you need to iterate by index or when you want to modify the list in some way.

void main() {
  List<String> fruits = ['Apple', 'Banana', 'Orange', 'Mango'];

  // Using for loop
  for (int i = 0; i < fruits.length; i++) {
    print(fruits[i]);
  }
}

2. Using forEach

forEach is a method that allows you to apply a function to each element in the list. It’s convenient when you don’t need the index, just the item.

void main() {
  List<String> fruits = ['Apple', 'Banana', 'Orange', 'Mango'];

  // Using forEach
  fruits.forEach((fruit) {
    print(fruit);
  });
}

This is functionally similar to the for-in loop but with a cleaner syntax for passing the operation as a function.

3. Using map

map transforms the elements of the list into a new list. It’s useful when you want to apply a function to each element and return a new list of transformed items.

void main() {
  List<String> fruits = ['Apple', 'Banana', 'Orange', 'Mango'];

  // Using map
  List<String> uppercasedFruits = fruits.map((fruit) => fruit.toUpperCase()).toList();
  
  print(uppercasedFruits);  // ['APPLE', 'BANANA', 'ORANGE', 'MANGO']
}

Here, the map function takes a function as an argument and returns a new list where each element is the result of applying the function. We used toUpperCase() as an example, but you can perform any transformation.

Summary:

  • for loop: Iterates through a list using an index.
  • forEach: Iterates through each element in a list and performs an action.
  • map: Transforms each element and returns a new list.

Sure thing! Let's start with examples in Dart using the cascade operator (..) and the spread operator (...).

Dart: Cascade Operator (..)

Here's a simple example of using the cascade operator to chain multiple operations on an object:

class Car {
  String color;
  int year;
  
  void setColor(String color) {
    this.color = color;
  }

  void setYear(int year) {
    this.year = year;
  }
}

void main() {
  var myCar = Car()
    ..setColor('Red')
    ..setYear(2021);

  print('Car color: ${myCar.color}, Year: ${myCar.year}');
}

Dart: Spread Operator (...)

Now, an example using the spread operator to combine lists:

void main() {
  var list1 = [1, 2, 3];
  var list2 = [4, 5, 6];

  var combinedList = [...list1, ...list2];

  print(combinedList);  // Output: [1, 2, 3, 4, 5, 6]
}

Comparison with C#

C# doesn't have a direct equivalent for the cascade operator, but you can achieve similar results using method chaining:

public class Car
{
    public string Color { get; set; }
    public int Year { get; set; }

    public Car SetColor(string color)
    {
        Color = color;
        return this;
    }

    public Car SetYear(int year)
    {
        Year = year;
        return this;
    }
}

public class Program
{
    public static void Main()
    {
        var myCar = new Car()
            .SetColor("Red")
            .SetYear(2021);

        Console.WriteLine($"Car color: {myCar.Color}, Year: {myCar.Year}");
    }
}

For the spread operator, C# also doesn't have a direct equivalent. However, you can combine lists using LINQ:

using System;
using System.Collections.Generic;
using System.Linq;

public class Program
{
    public static void Main()
    {
        var list1 = new List<int> { 1, 2, 3 };
        var list2 = new List<int> { 4, 5, 6 };

        var combinedList = list1.Concat(list2).ToList();

        Console.WriteLine(string.Join(", ", combinedList));  // Output: 1, 2, 3, 4, 5, 6
    }
}
How to fetch authorized data from a web service.

To fetch data from most web services, you need to provide authorization. There are many ways to do this, but perhaps the most common uses the Authorization HTTP header.

Add authorization headers

The http package provides a convenient way to add headers to your requests. Alternatively, use the HttpHeaders class from the dart:io library.

final response = await http.get(
  Uri.parse('https://jsonplaceholder.typicode.com/albums/1'),
  // Send authorization headers to the backend.
  headers: {
    HttpHeaders.authorizationHeader: 'Basic your_api_token_here',
  },
);

Complete example

This example builds upon the Fetching data from the internet recipe.

import 'dart:async';
import 'dart:convert';
import 'dart:io';

import 'package:http/http.dart' as http;

Future<Album> fetchAlbum() async {
  final response = await http.get(
    Uri.parse('https://jsonplaceholder.typicode.com/albums/1'),
    // Send authorization headers to the backend.
    headers: {
      HttpHeaders.authorizationHeader: 'Basic your_api_token_here',
    },
  );
  final responseJson = jsonDecode(response.body) as Map<String, dynamic>;

  return Album.fromJson(responseJson);
}

class Album {
  final int userId;
  final int id;
  final String title;

  const Album({
    required this.userId,
    required this.id,
    required this.title,
  });

  factory Album.fromJson(Map<String, dynamic> json) {
    return switch (json) {
      {
        'userId': int userId,
        'id': int id,
        'title': String title,
      } =>
        Album(
          userId: userId,
          id: id,
          title: title,
        ),
      _ => throw const FormatException('Failed to load album.'),
    };
  }
}
How to fetch data over the internet using the http package.

Fetching data from the internet is necessary for most apps. Luckily, Dart and Flutter provide tools, such as the http package, for this type of work.

:::note You should avoid directly using dart:io or dart:html to make HTTP requests. Those libraries are platform-dependent and tied to a single implementation. :::

This recipe uses the following steps:

  1. Add the http package.
  2. Make a network request using the http package.
  3. Convert the response into a custom Dart object.
  4. Fetch and display the data with Flutter.

1. Add the http package

The http package provides the simplest way to fetch data from the internet.

To add the http package as a dependency, run flutter pub add:

$ flutter pub add http

Import the http package.

import 'package:http/http.dart' as http;

{% render docs/cookbook/networking/internet-permission.md %}

2. Make a network request

This recipe covers how to fetch a sample album from the JSONPlaceholder using the http.get() method.

Future<http.Response> fetchAlbum() {
  return http.get(Uri.parse('https://jsonplaceholder.typicode.com/albums/1'));
}

The http.get() method returns a Future that contains a Response.

  • Future is a core Dart class for working with async operations. A Future object represents a potential value or error that will be available at some time in the future.
  • The http.Response class contains the data received from a successful http call.

3. Convert the response into a custom Dart object

While it's easy to make a network request, working with a raw Future<http.Response> isn't very convenient. To make your life easier, convert the http.Response into a Dart object.

Create an Album class

First, create an Album class that contains the data from the network request. It includes a factory constructor that creates an Album from JSON.

Converting JSON using pattern matching is only one option. For more information, see the full article on JSON and serialization.

class Album {
  final int userId;
  final int id;
  final String title;

  const Album({
    required this.userId,
    required this.id,
    required this.title,
  });

  factory Album.fromJson(Map<String, dynamic> json) {
    return switch (json) {
      {
        'userId': int userId,
        'id': int id,
        'title': String title,
      } =>
        Album(
          userId: userId,
          id: id,
          title: title,
        ),
      _ => throw const FormatException('Failed to load album.'),
    };
  }
}

Convert the http.Response to an Album

Now, use the following steps to update the fetchAlbum() function to return a Future<Album>:

  1. Convert the response body into a JSON Map with the dart:convert package.
  2. If the server does return an OK response with a status code of 200, then convert the JSON Map into an Album using the fromJson() factory method.
  3. If the server does not return an OK response with a status code of 200, then throw an exception. (Even in the case of a "404 Not Found" server response, throw an exception. Do not return null. This is important when examining the data in snapshot, as shown below.)
Future<Album> fetchAlbum() async {
  final response = await http
      .get(Uri.parse('https://jsonplaceholder.typicode.com/albums/1'));

  if (response.statusCode == 200) {
    // If the server did return a 200 OK response,
    // then parse the JSON.
    return Album.fromJson(jsonDecode(response.body) as Map<String, dynamic>);
  } else {
    // If the server did not return a 200 OK response,
    // then throw an exception.
    throw Exception('Failed to load album');
  }
}

Hooray! Now you've got a function that fetches an album from the internet.

4. Fetch the data

Call the fetchAlbum() method in either the initState() or didChangeDependencies() methods.

The initState() method is called exactly once and then never again. If you want to have the option of reloading the API in response to an InheritedWidget changing, put the call into the didChangeDependencies() method. See State for more details.

class _MyAppState extends State<MyApp> {
  late Future<Album> futureAlbum;

  @override
  void initState() {
    super.initState();
    futureAlbum = fetchAlbum();
  }
  // ···
}

This Future is used in the next step.

5. Display the data

To display the data on screen, use the FutureBuilder widget. The FutureBuilder widget comes with Flutter and makes it easy to work with asynchronous data sources.

You must provide two parameters:

  1. The Future you want to work with. In this case, the future returned from the fetchAlbum() function.
  2. A builder function that tells Flutter what to render, depending on the state of the Future: loading, success, or error.

Note that snapshot.hasData only returns true when the snapshot contains a non-null data value.

Because fetchAlbum can only return non-null values, the function should throw an exception even in the case of a "404 Not Found" server response. Throwing an exception sets the snapshot.hasError to true which can be used to display an error message.

Otherwise, the spinner will be displayed.

FutureBuilder<Album>(
  future: futureAlbum,
  builder: (context, snapshot) {
    if (snapshot.hasData) {
      return Text(snapshot.data!.title);
    } else if (snapshot.hasError) {
      return Text('${snapshot.error}');
    }

    // By default, show a loading spinner.
    return const CircularProgressIndicator();
  },
)

Why is fetchAlbum() called in initState()?

Although it's convenient, it's not recommended to put an API call in a build() method.

Flutter calls the build() method every time it needs to change anything in the view, and this happens surprisingly often. The fetchAlbum() method, if placed inside build(), is repeatedly called on each rebuild causing the app to slow down.

Storing the fetchAlbum() result in a state variable ensures that the Future is executed only once and then cached for subsequent rebuilds.

Testing

For information on how to test this functionality, see the following recipes:

Complete example

import 'dart:async';
import 'dart:convert';

import 'package:flutter/material.dart';
import 'package:http/http.dart' as http;

Future<Album> fetchAlbum() async {
  final response = await http
      .get(Uri.parse('https://jsonplaceholder.typicode.com/albums/1'));

  if (response.statusCode == 200) {
    // If the server did return a 200 OK response,
    // then parse the JSON.
    return Album.fromJson(jsonDecode(response.body) as Map<String, dynamic>);
  } else {
    // If the server did not return a 200 OK response,
    // then throw an exception.
    throw Exception('Failed to load album');
  }
}

class Album {
  final int userId;
  final int id;
  final String title;

  const Album({
    required this.userId,
    required this.id,
    required this.title,
  });

  factory Album.fromJson(Map<String, dynamic> json) {
    return switch (json) {
      {
        'userId': int userId,
        'id': int id,
        'title': String title,
      } =>
        Album(
          userId: userId,
          id: id,
          title: title,
        ),
      _ => throw const FormatException('Failed to load album.'),
    };
  }
}

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

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

  @override
  State<MyApp> createState() => _MyAppState();
}

class _MyAppState extends State<MyApp> {
  late Future<Album> futureAlbum;

  @override
  void initState() {
    super.initState();
    futureAlbum = fetchAlbum();
  }

  @override
  Widget build(BuildContext context) {
    return MaterialApp(
      title: 'Fetch Data Example',
      theme: ThemeData(
        colorScheme: ColorScheme.fromSeed(seedColor: Colors.deepPurple),
      ),
      home: Scaffold(
        appBar: AppBar(
          title: const Text('Fetch Data Example'),
        ),
        body: Center(
          child: FutureBuilder<Album>(
            future: futureAlbum,
            builder: (context, snapshot) {
              if (snapshot.hasData) {
                return Text(snapshot.data!.title);
              } else if (snapshot.hasError) {
                return Text('${snapshot.error}');
              }

              // By default, show a loading spinner.
              return const CircularProgressIndicator();
            },
          ),
        ),
      ),
    );
  }
}
How to use the http package to send data over the internet.

Sending data to the internet is necessary for most apps. The http package has got that covered, too.

This recipe uses the following steps:

  1. Add the http package.
  2. Send data to a server using the http package.
  3. Convert the response into a custom Dart object.
  4. Get a title from user input.
  5. Display the response on screen.

1. Add the http package

To add the http package as a dependency, run flutter pub add:

$ flutter pub add http

Import the http package.

import 'package:http/http.dart' as http;

{% render docs/cookbook/networking/internet-permission.md %}

2. Sending data to server

This recipe covers how to create an Album by sending an album title to the JSONPlaceholder using the http.post() method.

Import dart:convert for access to jsonEncode to encode the data:

import 'dart:convert';

Use the http.post() method to send the encoded data:

Future<http.Response> createAlbum(String title) {
  return http.post(
    Uri.parse('https://jsonplaceholder.typicode.com/albums'),
    headers: <String, String>{
      'Content-Type': 'application/json; charset=UTF-8',
    },
    body: jsonEncode(<String, String>{
      'title': title,
    }),
  );
}

The http.post() method returns a Future that contains a Response.

  • Future is a core Dart class for working with asynchronous operations. A Future object represents a potential value or error that will be available at some time in the future.
  • The http.Response class contains the data received from a successful http call.
  • The createAlbum() method takes an argument title that is sent to the server to create an Album.

3. Convert the http.Response to a custom Dart object

While it's easy to make a network request, working with a raw Future<http.Response> isn't very convenient. To make your life easier, convert the http.Response into a Dart object.

Create an Album class

First, create an Album class that contains the data from the network request. It includes a factory constructor that creates an Album from JSON.

Converting JSON with pattern matching is only one option. For more information, see the full article on JSON and serialization.

class Album {
  final int id;
  final String title;

  const Album({required this.id, required this.title});

  factory Album.fromJson(Map<String, dynamic> json) {
    return switch (json) {
      {
        'id': int id,
        'title': String title,
      } =>
        Album(
          id: id,
          title: title,
        ),
      _ => throw const FormatException('Failed to load album.'),
    };
  }
}

Convert the http.Response to an Album

Use the following steps to update the createAlbum() function to return a Future<Album>:

  1. Convert the response body into a JSON Map with the dart:convert package.
  2. If the server returns a CREATED response with a status code of 201, then convert the JSON Map into an Album using the fromJson() factory method.
  3. If the server doesn't return a CREATED response with a status code of 201, then throw an exception. (Even in the case of a "404 Not Found" server response, throw an exception. Do not return null. This is important when examining the data in snapshot, as shown below.)
Future<Album> createAlbum(String title) async {
  final response = await http.post(
    Uri.parse('https://jsonplaceholder.typicode.com/albums'),
    headers: <String, String>{
      'Content-Type': 'application/json; charset=UTF-8',
    },
    body: jsonEncode(<String, String>{
      'title': title,
    }),
  );

  if (response.statusCode == 201) {
    // If the server did return a 201 CREATED response,
    // then parse the JSON.
    return Album.fromJson(jsonDecode(response.body) as Map<String, dynamic>);
  } else {
    // If the server did not return a 201 CREATED response,
    // then throw an exception.
    throw Exception('Failed to create album.');
  }
}

Hooray! Now you've got a function that sends the title to a server to create an album.

4. Get a title from user input

Next, create a TextField to enter a title and a ElevatedButton to send data to server. Also define a TextEditingController to read the user input from a TextField.

When the ElevatedButton is pressed, the _futureAlbum is set to the value returned by createAlbum() method.

Column(
  mainAxisAlignment: MainAxisAlignment.center,
  children: <Widget>[
    TextField(
      controller: _controller,
      decoration: const InputDecoration(hintText: 'Enter Title'),
    ),
    ElevatedButton(
      onPressed: () {
        setState(() {
          _futureAlbum = createAlbum(_controller.text);
        });
      },
      child: const Text('Create Data'),
    ),
  ],
)

On pressing the Create Data button, make the network request, which sends the data in the TextField to the server as a POST request. The Future, _futureAlbum, is used in the next step.

5. Display the response on screen

To display the data on screen, use the FutureBuilder widget. The FutureBuilder widget comes with Flutter and makes it easy to work with asynchronous data sources. You must provide two parameters:

  1. The Future you want to work with. In this case, the future returned from the createAlbum() function.
  2. A builder function that tells Flutter what to render, depending on the state of the Future: loading, success, or error.

Note that snapshot.hasData only returns true when the snapshot contains a non-null data value. This is why the createAlbum() function should throw an exception even in the case of a "404 Not Found" server response. If createAlbum() returns null, then CircularProgressIndicator displays indefinitely.

FutureBuilder<Album>(
  future: _futureAlbum,
  builder: (context, snapshot) {
    if (snapshot.hasData) {
      return Text(snapshot.data!.title);
    } else if (snapshot.hasError) {
      return Text('${snapshot.error}');
    }

    return const CircularProgressIndicator();
  },
)

Complete example

import 'dart:async';
import 'dart:convert';

import 'package:flutter/material.dart';
import 'package:http/http.dart' as http;

Future<Album> createAlbum(String title) async {
  final response = await http.post(
    Uri.parse('https://jsonplaceholder.typicode.com/albums'),
    headers: <String, String>{
      'Content-Type': 'application/json; charset=UTF-8',
    },
    body: jsonEncode(<String, String>{
      'title': title,
    }),
  );

  if (response.statusCode == 201) {
    // If the server did return a 201 CREATED response,
    // then parse the JSON.
    return Album.fromJson(jsonDecode(response.body) as Map<String, dynamic>);
  } else {
    // If the server did not return a 201 CREATED response,
    // then throw an exception.
    throw Exception('Failed to create album.');
  }
}

class Album {
  final int id;
  final String title;

  const Album({required this.id, required this.title});

  factory Album.fromJson(Map<String, dynamic> json) {
    return switch (json) {
      {
        'id': int id,
        'title': String title,
      } =>
        Album(
          id: id,
          title: title,
        ),
      _ => throw const FormatException('Failed to load album.'),
    };
  }
}

void main() {
  runApp(const MyApp());
}

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

  @override
  State<MyApp> createState() {
    return _MyAppState();
  }
}

class _MyAppState extends State<MyApp> {
  final TextEditingController _controller = TextEditingController();
  Future<Album>? _futureAlbum;

  @override
  Widget build(BuildContext context) {
    return MaterialApp(
      title: 'Create Data Example',
      theme: ThemeData(
        colorScheme: ColorScheme.fromSeed(seedColor: Colors.deepPurple),
      ),
      home: Scaffold(
        appBar: AppBar(
          title: const Text('Create Data Example'),
        ),
        body: Container(
          alignment: Alignment.center,
          padding: const EdgeInsets.all(8),
          child: (_futureAlbum == null) ? buildColumn() : buildFutureBuilder(),
        ),
      ),
    );
  }

  Column buildColumn() {
    return Column(
      mainAxisAlignment: MainAxisAlignment.center,
      children: <Widget>[
        TextField(
          controller: _controller,
          decoration: const InputDecoration(hintText: 'Enter Title'),
        ),
        ElevatedButton(
          onPressed: () {
            setState(() {
              _futureAlbum = createAlbum(_controller.text);
            });
          },
          child: const Text('Create Data'),
        ),
      ],
    );
  }

  FutureBuilder<Album> buildFutureBuilder() {
    return FutureBuilder<Album>(
      future: _futureAlbum,
      builder: (context, snapshot) {
        if (snapshot.hasData) {
          return Text(snapshot.data!.title);
        } else if (snapshot.hasError) {
          return Text('${snapshot.error}');
        }

        return const CircularProgressIndicator();
      },
    );
  }
}
How to perform a task in the background.

By default, Dart apps do all of their work on a single thread. In many cases, this model simplifies coding and is fast enough that it does not result in poor app performance or stuttering animations, often called "jank."

However, you might need to perform an expensive computation, such as parsing a very large JSON document. If this work takes more than 16 milliseconds, your users experience jank.

To avoid jank, you need to perform expensive computations like this in the background. On Android, this means scheduling work on a different thread. In Flutter, you can use a separate Isolate. This recipe uses the following steps:

  1. Add the http package.
  2. Make a network request using the http package.
  3. Convert the response into a list of photos.
  4. Move this work to a separate isolate.

1. Add the http package

First, add the http package to your project. The http package makes it easier to perform network requests, such as fetching data from a JSON endpoint.

To add the http package as a dependency, run flutter pub add:

$ flutter pub add http

2. Make a network request

This example covers how to fetch a large JSON document that contains a list of 5000 photo objects from the JSONPlaceholder REST API, using the http.get() method.

Future<http.Response> fetchPhotos(http.Client client) async {
  return client.get(Uri.parse('https://jsonplaceholder.typicode.com/photos'));
}

:::note You're providing an http.Client to the function in this example. This makes the function easier to test and use in different environments. :::

3. Parse and convert the JSON into a list of photos

Next, following the guidance from the Fetch data from the internet recipe, convert the http.Response into a list of Dart objects. This makes the data easier to work with.

Create a Photo class

First, create a Photo class that contains data about a photo. Include a fromJson() factory method to make it easy to create a Photo starting with a JSON object.

class Photo {
  final int albumId;
  final int id;
  final String title;
  final String url;
  final String thumbnailUrl;

  const Photo({
    required this.albumId,
    required this.id,
    required this.title,
    required this.url,
    required this.thumbnailUrl,
  });

  factory Photo.fromJson(Map<String, dynamic> json) {
    return Photo(
      albumId: json['albumId'] as int,
      id: json['id'] as int,
      title: json['title'] as String,
      url: json['url'] as String,
      thumbnailUrl: json['thumbnailUrl'] as String,
    );
  }
}

Convert the response into a list of photos

Now, use the following instructions to update the fetchPhotos() function so that it returns a Future<List<Photo>>:

  1. Create a parsePhotos() function that converts the response body into a List<Photo>.
  2. Use the parsePhotos() function in the fetchPhotos() function.
// A function that converts a response body into a List<Photo>.
List<Photo> parsePhotos(String responseBody) {
  final parsed =
      (jsonDecode(responseBody) as List).cast<Map<String, dynamic>>();

  return parsed.map<Photo>((json) => Photo.fromJson(json)).toList();
}

Future<List<Photo>> fetchPhotos(http.Client client) async {
  final response = await client
      .get(Uri.parse('https://jsonplaceholder.typicode.com/photos'));

  // Synchronously run parsePhotos in the main isolate.
  return parsePhotos(response.body);
}

4. Move this work to a separate isolate

If you run the fetchPhotos() function on a slower device, you might notice the app freezes for a brief moment as it parses and converts the JSON. This is jank, and you want to get rid of it.

You can remove the jank by moving the parsing and conversion to a background isolate using the compute() function provided by Flutter. The compute() function runs expensive functions in a background isolate and returns the result. In this case, run the parsePhotos() function in the background.

Future<List<Photo>> fetchPhotos(http.Client client) async {
  final response = await client
      .get(Uri.parse('https://jsonplaceholder.typicode.com/photos'));

  // Use the compute function to run parsePhotos in a separate isolate.
  return compute(parsePhotos, response.body);
}

Notes on working with isolates

Isolates communicate by passing messages back and forth. These messages can be primitive values, such as null, num, bool, double, or String, or simple objects such as the List<Photo> in this example.

You might experience errors if you try to pass more complex objects, such as a Future or http.Response between isolates.

As an alternate solution, check out the worker_manager or workmanager packages for background processing.

Complete example

import 'dart:async';
import 'dart:convert';

import 'package:flutter/foundation.dart';
import 'package:flutter/material.dart';
import 'package:http/http.dart' as http;

Future<List<Photo>> fetchPhotos(http.Client client) async {
  final response = await client
      .get(Uri.parse('https://jsonplaceholder.typicode.com/photos'));

  // Use the compute function to run parsePhotos in a separate isolate.
  return compute(parsePhotos, response.body);
}

// A function that converts a response body into a List<Photo>.
List<Photo> parsePhotos(String responseBody) {
  final parsed =
      (jsonDecode(responseBody) as List).cast<Map<String, dynamic>>();

  return parsed.map<Photo>((json) => Photo.fromJson(json)).toList();
}

class Photo {
  final int albumId;
  final int id;
  final String title;
  final String url;
  final String thumbnailUrl;

  const Photo({
    required this.albumId,
    required this.id,
    required this.title,
    required this.url,
    required this.thumbnailUrl,
  });

  factory Photo.fromJson(Map<String, dynamic> json) {
    return Photo(
      albumId: json['albumId'] as int,
      id: json['id'] as int,
      title: json['title'] as String,
      url: json['url'] as String,
      thumbnailUrl: json['thumbnailUrl'] as String,
    );
  }
}

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

class MyApp extends StatelessWidget {
  const MyApp({super.key});

  @override
  Widget build(BuildContext context) {
    const appTitle = 'Isolate Demo';

    return const MaterialApp(
      title: appTitle,
      home: MyHomePage(title: appTitle),
    );
  }
}

class MyHomePage extends StatefulWidget {
  const MyHomePage({super.key, required this.title});

  final String title;

  @override
  State<MyHomePage> createState() => _MyHomePageState();
}

class _MyHomePageState extends State<MyHomePage> {
  late Future<List<Photo>> futurePhotos;

  @override
  void initState() {
    super.initState();
    futurePhotos = fetchPhotos(http.Client());
  }

  @override
  Widget build(BuildContext context) {
    return Scaffold(
      appBar: AppBar(
        title: Text(widget.title),
      ),
      body: FutureBuilder<List<Photo>>(
        future: futurePhotos,
        builder: (context, snapshot) {
          if (snapshot.hasError) {
            return const Center(
              child: Text('An error has occurred!'),
            );
          } else if (snapshot.hasData) {
            return PhotosList(photos: snapshot.data!);
          } else {
            return const Center(
              child: CircularProgressIndicator(),
            );
          }
        },
      ),
    );
  }
}

class PhotosList extends StatelessWidget {
  const PhotosList({super.key, required this.photos});

  final List<Photo> photos;

  @override
  Widget build(BuildContext context) {
    return GridView.builder(
      gridDelegate: const SliverGridDelegateWithFixedCrossAxisCount(
        crossAxisCount: 2,
      ),
      itemCount: photos.length,
      itemBuilder: (context, index) {
        return Image.network(photos[index].thumbnailUrl);
      },
    );
  }
}

Isolate demo{:.site-mobile-screenshot}

How to connect to a web socket.

In addition to normal HTTP requests, you can connect to servers using WebSockets. WebSockets allow for two-way communication with a server without polling.

In this example, connect to a test WebSocket server sponsored by Lob.com. The server sends back the same message you send to it. This recipe uses the following steps:

  1. Connect to a WebSocket server.
  2. Listen for messages from the server.
  3. Send data to the server.
  4. Close the WebSocket connection.

1. Connect to a WebSocket server

The web_socket_channel package provides the tools you need to connect to a WebSocket server.

The package provides a WebSocketChannel that allows you to both listen for messages from the server and push messages to the server.

In Flutter, use the following line to create a WebSocketChannel that connects to a server:

final channel = WebSocketChannel.connect(
  Uri.parse('wss://echo.websocket.events'),
);

2. Listen for messages from the server

Now that you've established a connection, listen to messages from the server.

After sending a message to the test server, it sends the same message back.

In this example, use a StreamBuilder widget to listen for new messages, and a Text widget to display them.

StreamBuilder(
  stream: channel.stream,
  builder: (context, snapshot) {
    return Text(snapshot.hasData ? '${snapshot.data}' : '');
  },
)

How this works

The WebSocketChannel provides a Stream of messages from the server.

The Stream class is a fundamental part of the dart:async package. It provides a way to listen to async events from a data source. Unlike Future, which returns a single async response, the Stream class can deliver many events over time.

The StreamBuilder widget connects to a Stream and asks Flutter to rebuild every time it receives an event using the given builder() function.

3. Send data to the server

To send data to the server, add() messages to the sink provided by the WebSocketChannel.

channel.sink.add('Hello!');

How this works

The WebSocketChannel provides a StreamSink to push messages to the server.

The StreamSink class provides a general way to add sync or async events to a data source.

4. Close the WebSocket connection

After you're done using the WebSocket, close the connection:

channel.sink.close();

Complete example

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

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

class MyApp extends StatelessWidget {
  const MyApp({super.key});

  @override
  Widget build(BuildContext context) {
    const title = 'WebSocket Demo';
    return const MaterialApp(
      title: title,
      home: MyHomePage(
        title: title,
      ),
    );
  }
}

class MyHomePage extends StatefulWidget {
  const MyHomePage({
    super.key,
    required this.title,
  });

  final String title;

  @override
  State<MyHomePage> createState() => _MyHomePageState();
}

class _MyHomePageState extends State<MyHomePage> {
  final TextEditingController _controller = TextEditingController();
  final _channel = WebSocketChannel.connect(
    Uri.parse('wss://echo.websocket.events'),
  );

  @override
  Widget build(BuildContext context) {
    return Scaffold(
      appBar: AppBar(
        title: Text(widget.title),
      ),
      body: Padding(
        padding: const EdgeInsets.all(20),
        child: Column(
          crossAxisAlignment: CrossAxisAlignment.start,
          children: [
            Form(
              child: TextFormField(
                controller: _controller,
                decoration: const InputDecoration(labelText: 'Send a message'),
              ),
            ),
            const SizedBox(height: 24),
            StreamBuilder(
              stream: _channel.stream,
              builder: (context, snapshot) {
                return Text(snapshot.hasData ? '${snapshot.data}' : '');
              },
            )
          ],
        ),
      ),
      floatingActionButton: FloatingActionButton(
        onPressed: _sendMessage,
        tooltip: 'Send message',
        child: const Icon(Icons.send),
      ), // This trailing comma makes auto-formatting nicer for build methods.
    );
  }

  void _sendMessage() {
    if (_controller.text.isNotEmpty) {
      _channel.sink.add(_controller.text);
    }
  }

  @override
  void dispose() {
    _channel.sink.close();
    _controller.dispose();
    super.dispose();
  }
}

Web sockets demo{:.site-mobile-screenshot}

Implementing MVVM Architecture in Flutter

Generated by Medium to Markdown

A Guide to Flutter Architecture Patterns: Folder Structures for MVC, MVVM, BLoC, and More

Master the art of building scalable, testable, and maintainable Flutter apps using the MVVM architecture.

captionless image

Flutter, with its powerful and efficient development environment, has become a top choice for mobile app developers. As apps become more complex, maintaining clean, testable, and scalable code is crucial. One of the best ways to ensure this is by adopting the MVVM (Model-View-ViewModel) architecture, which provides a structured and organized approach to building apps.

In this article, we’ll walk through how to implement the MVVM architecture in Flutter, step by step, from a beginner-friendly perspective to advanced considerations. By the end, you’ll have a clear understanding of how to create clean, maintainable Flutter apps using MVVM, with real-world examples and practical tips.

What is MVVM Architecture?

MVVM (Model-View-ViewModel) is a software design pattern that separates the app’s business logic from the UI, making the codebase more modular and testable. Here’s a quick breakdown of each component:

  • Model: Represents the app’s data layer, such as network requests, databases, and APIs.
  • View: The UI component, which listens for changes from the ViewModel and updates the UI accordingly.
  • ViewModel: Serves as the intermediary between the View and the Model, holding the app’s logic and managing the data flow between the two.

By separating concerns, MVVM helps developers write cleaner, more maintainable code that is easier to test, debug, and scale.

Why Choose MVVM for Flutter?

Flutter’s reactive nature makes it an ideal candidate for implementing MVVM. In Flutter, widgets are responsible for the UI, and by pairing them with a ViewModel, developers can efficiently separate the UI from business logic. This approach ensures that the code is both scalable and easy to test.

Benefits of MVVM in Flutter:

  • Separation of concerns: Each part of the architecture has a distinct responsibility, making it easier to manage larger apps.
  • Testability: The ViewModel contains all the app’s logic, which can be tested in isolation from the UI.
  • Scalability: As the app grows, MVVM ensures that the architecture remains clean and modular.

How to Implement MVVM in Flutter

Let’s walk through a step-by-step guide to implementing MVVM in a Flutter project with practical code snippets.

Step 1: Set Up Your Flutter Project

Create a new Flutter project using the following command:

flutter create mvvm_example

Step 2: Folder Structure

To keep the architecture clean, structure your Flutter project into models, views, viewmodels, and services. This separation ensures that each component has a well-defined role.

Here’s an example folder structure for a Flutter MVVM project:

lib/
│
├── models/
├── services/
├── viewmodels/
├── views/

Step 3: Create the Model

Let’s assume we are building a simple app that fetches a list of books. The model will represent the book data.

// models/book_model.dart
class Book {
  final String title;
  final String author;
  Book({required this.title, required this.author});
}

Step 4: Create the Service (Data Layer)

The service will handle the network requests and provide data to the ViewModel. In this example, we’ll use a simple service to simulate fetching data.

// services/book_service.dart
import 'package:mvvm_example/models/book_model.dart';
class BookService {
  Future<List<Book>> fetchBooks() async {
    // Simulating network request
    await Future.delayed(Duration(seconds: 2));
    return [      Book(title: 'Clean Code', author: 'Robert C. Martin'),
      Book(title: 'Effective Dart', author: 'Google'),
    ];
  }
}

Step 5: Create the ViewModel

The ViewModel will manage the state and communicate with the service to fetch the data. The ViewModel will then expose the necessary data to the View.

// viewmodels/book_viewmodel.dart
import 'package:flutter/foundation.dart';
import 'package:mvvm_example/models/book_model.dart';
import 'package:mvvm_example/services/book_service.dart';
class BookViewModel extends ChangeNotifier {
  final BookService _bookService = BookService();
  List<Book> _books = [];
  bool _loading = false;
  List<Book> get books => _books;
  bool get loading => _loading;
  Future<void> fetchBooks() async {
    _loading = true;
    notifyListeners();
    
    _books = await _bookService.fetchBooks();
    
    _loading = false;
    notifyListeners();
  }
}

Step 6: Create the View (UI Layer)

In Flutter, the View is simply a widget that listens to the ViewModel and updates accordingly. We’ll use the Provider package to manage state in this example.

// views/book_view.dart
import 'package:flutter/material.dart';
import 'package:mvvm_example/viewmodels/book_viewmodel.dart';
import 'package:provider/provider.dart';
class BookView extends StatelessWidget {
  @override
  Widget build(BuildContext context) {
    final bookViewModel = Provider.of<BookViewModel>(context);
    return Scaffold(
      appBar: AppBar(
        title: Text('Books'),
      ),
      body: bookViewModel.loading
          ? Center(child: CircularProgressIndicator())
          : ListView.builder(
              itemCount: bookViewModel.books.length,
              itemBuilder: (context, index) {
                final book = bookViewModel.books[index];
                return ListTile(
                  title: Text(book.title),
                  subtitle: Text(book.author),
                );
              },
            ),
      floatingActionButton: FloatingActionButton(
        onPressed: () {
          bookViewModel.fetchBooks();
        },
        child: Icon(Icons.refresh),
      ),
    );
  }
}

Step 7: Connect Everything Together

In the main file, set up the Provider to manage the state of the BookViewModel.

// main.dart
import 'package:flutter/material.dart';
import 'package:mvvm_example/viewmodels/book_viewmodel.dart';
import 'package:mvvm_example/views/book_view.dart';
import 'package:provider/provider.dart';
void main() {
  runApp(MyApp());
}
class MyApp extends StatelessWidget {
  @override
  Widget build(BuildContext context) {
    return MultiProvider(
      providers: [        ChangeNotifierProvider(create: (_) => BookViewModel()),
      ],
      child: MaterialApp(
        home: BookView(),
      ),
    );
  }
}

Advanced Considerations and Best Practices

  1. Use Dependency Injection: =============================

To make the ViewModel more testable and modular, consider injecting services using packages like get_it or riverpod.

  1. Testing ViewModels: =======================

With the MVVM pattern, testing becomes straightforward. You can test the ViewModel without involving the UI by simply mocking the data layer.

  1. Error Handling: ===================

In real-world applications, make sure to handle errors gracefully in the ViewModel. You can provide additional states for errors and update the View accordingly.

  1. Code Organization: ======================

As your app scales, consider organizing ViewModels and Services into feature-specific folders to keep the project manageable.

Conclusion

By following the MVVM architecture in Flutter, you ensure that your codebase remains clean, modular, and easy to maintain. Whether you’re a beginner or an experienced developer, adopting MVVM will help you build scalable applications while keeping the business logic separate from the UI.

Implementing this architecture may take some initial setup time, but the benefits in terms of code organization, testability, and scalability are well worth it. Happy coding!

Flutter MVVM (Model-View-ViewModel) todo application with REST API CRUD operations

A comprehensive guide to creating a Flutter MVVM (Model-View-ViewModel) todo application with REST API CRUD operations. This app will include a login screen, a list of todo items, and detailed views for each item.

Project Setup

  1. Create a new Flutter project:

    flutter create todo_app
    cd todo_app
  2. Add dependencies in pubspec.yaml:

    dependencies:
      flutter:
        sdk: flutter
      provider: ^6.0.0
      http: ^0.14.0
      shared_preferences: ^2.0.6
    flutter pub get

Folder Structure

lib/
├── models/
│   └── todo.dart
├── viewmodels/
│   ├── login_viewmodel.dart
│   └── todo_viewmodel.dart
├── views/
│   ├── login_view.dart
│   ├── todo_list_view.dart
│   └── todo_detail_view.dart
├── services/
│   ├── api_service.dart
│   └── auth_service.dart
└── main.dart

Models

models/todo.dart:

class Todo {
  final int id;
  final String title;
  final int userId;
  final bool completed;

  Todo({
    required this.id,
    required this.title,
    required this.userId,
    required this.completed,
  });

  factory Todo.fromJson(Map<String, dynamic> json) {
    return Todo(
      id: json['id'],
      title: json['title'],
      userId: json['userId'],
      completed: json['completed'],
    );
  }

  Map<String, dynamic> toJson() {
    return {
      'id': id,
      'title': title,
      'userId': userId,
      'completed': completed,
    };
  }
}

Services

services/api_service.dart:

import 'dart:convert';
import 'package:http/http.dart' as http;
import '../models/todo.dart';

class ApiService {
  final String baseUrl = 'https://jsonplaceholder.typicode.com/todos';

  Future<List<Todo>> fetchTodos() async {
    final response = await http.get(Uri.parse(baseUrl));
    if (response.statusCode == 200) {
      List jsonResponse = json.decode(response.body);
      return jsonResponse.map((todo) => Todo.fromJson(todo)).toList();
    } else {
      throw Exception('Failed to load todos');
    }
  }

  Future<Todo> fetchTodoById(int id) async {
    final response = await http.get(Uri.parse('$baseUrl/$id'));
    if (response.statusCode == 200) {
      return Todo.fromJson(json.decode(response.body));
    } else {
      throw Exception('Failed to load todo');
    }
  }

  Future<void> createTodo(Todo todo) async {
    final response = await http.post(
      Uri.parse(baseUrl),
      headers: {'Content-Type': 'application/json'},
      body: json.encode(todo.toJson()),
    );
    if (response.statusCode != 201) {
      throw Exception('Failed to create todo');
    }
  }

  Future<void> updateTodo(Todo todo) async {
    final response = await http.put(
      Uri.parse('$baseUrl/${todo.id}'),
      headers: {'Content-Type': 'application/json'},
      body: json.encode(todo.toJson()),
    );
    if (response.statusCode != 200) {
      throw Exception('Failed to update todo');
    }
  }

  Future<void> deleteTodo(int id) async {
    final response = await http.delete(Uri.parse('$baseUrl/$id'));
    if (response.statusCode != 200) {
      throw Exception('Failed to delete todo');
    }
  }
}

services/auth_service.dart:

import 'package:shared_preferences/shared_preferences.dart';

class AuthService {
  Future<void> login(String username, String password) async {
    // Simulate a login API call
    if (username == 'user' && password == 'password') {
      final prefs = await SharedPreferences.getInstance();
      await prefs.setBool('isLoggedIn', true);
    } else {
      throw Exception('Invalid credentials');
    }
  }

  Future<void> logout() async {
    final prefs = await SharedPreferences.getInstance();
    await prefs.remove('isLoggedIn');
  }

  Future<bool> isLoggedIn() async {
    final prefs = await SharedPreferences.getInstance();
    return prefs.getBool('isLoggedIn') ?? false;
  }
}

ViewModels

viewmodels/login_viewmodel.dart:

import 'package:flutter/material.dart';
import '../services/auth_service.dart';

class LoginViewModel extends ChangeNotifier {
  final AuthService _authService = AuthService();
  bool _isLoading = false;

  bool get isLoading => _isLoading;

  Future<void> login(String username, String password) async {
    _isLoading = true;
    notifyListeners();
    try {
      await _authService.login(username, password);
    } catch (e) {
      rethrow;
    } finally {
      _isLoading = false;
      notifyListeners();
    }
  }
}

viewmodels/todo_viewmodel.dart:

import 'package:flutter/material.dart';
import '../models/todo.dart';
import '../services/api_service.dart';

class TodoViewModel extends ChangeNotifier {
  final ApiService _apiService = ApiService();
  List<Todo> _todos = [];
  bool _isLoading = false;

  List<Todo> get todos => _todos;
  bool get isLoading => _isLoading;

  TodoViewModel() {
    fetchTodos();
  }

  Future<void> fetchTodos() async {
    _isLoading = true;
    notifyListeners();
    try {
      _todos = await _apiService.fetchTodos();
    } catch (e) {
      print(e);
    } finally {
      _isLoading = false;
      notifyListeners();
    }
  }

  Future<void> addTodo(Todo todo) async {
    await _apiService.createTodo(todo);
    _todos.add(todo);
    notifyListeners();
  }

  Future<void> updateTodo(Todo todo) async {
    await _apiService.updateTodo(todo);
    final index = _todos.indexWhere((t) => t.id == todo.id);
    if (index != -1) {
      _todos[index] = todo;
      notifyListeners();
    }
  }

  Future<void> deleteTodoById(int id) async {
    await _apiService.deleteTodo(id);
    _todos.removeWhere((todo) => todo.id == id);
    notifyListeners();
  }
}

Views

views/login_view.dart:

import 'package:flutter/material.dart';
import 'package:provider/provider.dart';
import '../viewmodels/login_viewmodel.dart';

class LoginView extends StatelessWidget {
  final TextEditingController _usernameController = TextEditingController();
  final TextEditingController _passwordController = TextEditingController();

  @override
  Widget build(BuildContext context) {
    final viewModel = Provider.of<LoginViewModel>(context);

    return Scaffold(
      appBar: AppBar(title: Text('Login')),
      body: Padding(
        padding: const EdgeInsets.all(16.0),
        child: Column(
          children: [
            TextField(
              controller: _usernameController,
              decoration: InputDecoration(labelText: 'Username'),
            ),
            TextField(
              controller: _passwordController,
              decoration: InputDecoration(labelText: 'Password'),
              obscureText: true,
            ),
            SizedBox(height: 20),
            viewModel.isLoading
                ? CircularProgressIndicator()
                : ElevatedButton(
                    onPressed: () async {
                      try {
                        await viewModel.login(
                          _usernameController.text,
                          _passwordController.text,
                        );
                        Navigator.pushReplacementNamed(context, '/todos');
                      } catch (e) {
                        ScaffoldMessenger.of(context).showSnackBar(
                          SnackBar(content: Text('Login failed')),
                        );
                      }
                    },
                    child: Text('Login'),
                  ),
          ],
        ),
      ),
    );
  }
}

views/todo_list_view.dart:

import 'package:flutter/material.dart';
import 'package:provider/provider.dart';
import '../viewmodels/todo_viewmodel.dart';
import '../models/todo.dart';

class TodoListView extends StatelessWidget {
  const TodoListView({super.key});

  @override
  Widget build(BuildContext context) {
    final viewModel = Provider.of<TodoViewModel>(context);

    return Scaffold(
      appBar: AppBar(title: Text('Todo List')),
      body: viewModel.isLoading
          ? Center(child: CircularProgressIndicator())
          : ListView.builder(
              itemCount: viewModel.todos.length,
              itemBuilder: (context, index) {
                final todo = viewModel.todos[index];
                return ListTile(
                  title: Text(todo.title),
                  subtitle: Text(todo.userId.toString()),
                  trailing: Checkbox(
                    value: todo.completed,
                    onChanged: (bool? value) {
                      final updatedTodo = Todo(
                        id: todo.id,
                        title: todo.title,
                        userId: todo.userId,
                        completed: value ?? false,
                      );
                      viewModel.updateTodo(updatedTodo);
                    },
                  ),
                  onTap: () {
                    Navigator.pushNamed(
                      context,
                      '/todoDetail',
                      arguments: todo,
                    );
                  },
                );
              },
            ),
      floatingActionButton: FloatingActionButton(
        onPressed: () {
          Navigator.pushNamed(context, '/addTodo');
        },
        child: Icon(Icons.add),
      ),
    );
  }
}

views/todo_detail_view.dart:

import 'package:flutter/material.dart';
import '../models/todo.dart';

class TodoDetailView extends StatelessWidget {
  const TodoDetailView({super.key});

  @override
  Widget build(BuildContext context) {
    final Todo todo = ModalRoute.of(context)!.settings.arguments as Todo;

    return Scaffold(
      appBar: AppBar(title: Text(todo.title)),
      body: Padding(
        padding: const EdgeInsets.all(16.0),
        child: Column(
          crossAxisAlignment: CrossAxisAlignment.start,
          children: [
            Text(
              todo.title,
              style: TextStyle(fontSize: 24, fontWeight: FontWeight.bold),
            ),
            SizedBox(height: 10),
            Text(
              todo.userId.toString(),
              style: TextStyle(fontSize: 18),
            ),
            SizedBox(height: 20),
            Row(
              children: [
                Text('Completed: '),
                Checkbox(
                  value: todo.completed,
                  onChanged: (bool? value) {
                    // Handle checkbox change	
                  },
                ),
              ],
            ),
          ],
        ),
      ),
    );
  }
}

Main Application

main.dart:

import 'package:flutter/material.dart';
import 'package:provider/provider.dart';
import 'viewmodels/login_viewmodel.dart';
import 'viewmodels/todo_viewmodel.dart';
import 'views/login_view.dart';
import 'views/todo_list_view.dart';
import 'views/todo_detail_view.dart';

void main() {
  runApp(MyApp());
}

class MyApp extends StatelessWidget {
  @override
  Widget build(BuildContext context) {
    return MultiProvider(
      providers: [
        ChangeNotifierProvider(create: (_) => LoginViewModel()),
        ChangeNotifierProvider(create: (_) => TodoViewModel()),
      ],
      child: MaterialApp(
        title: 'Flutter MVVM Todo App',
        theme: ThemeData(
          primarySwatch: Colors.blue,
        ),
        initialRoute: '/',
        routes: {
          '/': (context) => LoginView(),
          '/todos': (context) => TodoListView(),
          '/todoDetail': (context) => TodoDetailView(),
        },
      ),
    );
  }
}
flutter run

Summary

This Flutter MVVM todo application includes:

  • A login screen where users can log in.
  • A list of todo items fetched from a REST API.
  • Detailed views for each todo item.
  • CRUD operations (Create, Read, Update, Delete) for managing todo items.

You can further enhance this application by adding more features like user registration, better error handling, and more sophisticated UI elements. If you have any questions or need further assistance, feel free to ask!

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment