tech

How to build a complete Flutter app: step by step example

Learn how to create a Flutter app with the most common features: routing, navigation, data models, data storage, dependency injection, error handling, state management, and Flutter widgets.


Sandro Maglione

Sandro Maglione

Software development

Where do you start when implementing a Flutter app? 🤔

In this article we learn how to implement a complete Flutter app:

  • Navigation (go_router)
  • Data models (equatable)
  • Local Database (drift)
  • Dependency injection and error handling (fpdart)
  • State management (signals)
  • Screens and widgets

This article is an overview of all the basic features that you need to consider in most Flutter app and how to implement them 👇

Open Source Repository

Routing: go_router

I start from routing and navigation.

In this example I used go_router in combination with go_router_builder.

Using go_router_builder allows to generate type-safe routes (with required and optional parameters)

All the pages are defined inside a single router.dart file:

router.dart
part 'router.g.dart';

@TypedGoRoute<HomeRoute>(path: '/home')
class HomeRoute extends GoRouteData { /// 1️⃣
  const HomeRoute();

  @override
  Widget build(BuildContext context, GoRouterState state) => const HomeScreen();
}

@TypedGoRoute<TrackingRoute>(path: '/')
class TrackingRoute extends GoRouteData { /// 2️⃣
  const TrackingRoute();

  @override
  Widget build(BuildContext context, GoRouterState state) => const TrackingScreen();
}

This setup will generate 2 routes: HomeScreen and TrackingScreen. The pages are defined as normal Flutter widgets:

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

  @override
  Widget build(BuildContext context) { 
    return Scaffold( 
      body: SafeArea(
        child: Padding(
          padding: const EdgeInsets.only(left: 24, right: 24, top: 32),
          child: Column(
            children: [
              const InsertActivityForm(),
              const SizedBox(height: 24),
              Expanded(
                child: SingleChildScrollView(
                  child: Column(
                    children: [
                      StreamListener(
                        getIt.get<Database>().watchActivities,
                        builder: (context, data) => Column(
                          crossAxisAlignment: CrossAxisAlignment.stretch,
                          children: data
                              .map(
                                (activity) => ActivityCard(
                                  activity: activity,
                                ),
                              )
                              .toList(),
                        ),
                      ),
                    ],
                  ),
                ),
              ),
            ],
          ),
        ),
      ),
    );
  }
}

We can then navigate between routes using the go function generated by the builder:

tracking_screen.dart
class TrackingScreen extends StatelessWidget {
  const TrackingScreen({super.key});

  @override
  Widget build(BuildContext context) {
    return Scaffold(
      floatingActionButton: FloatingActionButton(
        onPressed: () => const HomeRoute().go(context),
        child: const Icon(Icons.add),
      ),
      /// ...

If you are interested in learning more about routing in Flutter you can read How to setup Routing in a Flutter app

Data models: equatable

Before displaying any widget or implement any business logic or state management we need to define the data models.

💡 Important: Make sure that all the information necessary to display and use the app are defined in your data models.

Dart offers many features to implement data models: enum, sealed, typedef, Records.

For example, I used enum to define an Emoji class that also overrides toString:

emoji.dart
enum Emoji {
  smile,
  rocket,
  dart;

  @override
  String toString() => switch (this) {
        Emoji.smile => "😁",
        Emoji.rocket => "🚀",
        Emoji.dart => "🎯",
      };
}

I can then use this inside a normal class. I also use equatable to add a custom equality check to data models:

activity_model.dart
class ActivityModel extends Equatable {
  final int id;
  final String name;
  final Emoji emoji;

  const ActivityModel({
    required this.id,
    required this.name,
    required this.emoji,
  });

  @override
  List<Object?> get props => [id, name, emoji];
}

Sometimes a class is not necessary: you can use a simple typedef.

This has the advantage of being less verbose and it also supports value equality without using Equatable:

typedef Day = ({int day, int month, int year});

You can use a typedef like any other type in other data models:

event_model.dart
class EventModel extends Equatable {
  final int id;
  final int activityId;
  final Day createdAt;

  const EventModel({
    required this.id,
    required this.activityId,
    required this.createdAt,
  });

  @override
  List<Object?> get props => [id, activityId, createdAt];
}

There is more 🤩

Timeless coding principles, practices, and tools that make a difference, regardless of your language or framework, delivered in your inbox every week.

Local database: drift and sqlite

Most apps need a way to store and retrieve data.

In this example I used drift:

Drift is a reactive persistence library for Flutter and Dart, built on top of SQLite (sqlite3)

Database tables

The first step is defining the table (SQL).

We map 1-1 the data models we defined previously.

By using @UseRowClass we can reference the models, drift will take care of converting between rows on the database and our models:

Every Event has a reference to an Activity: we implement this requirement using references (foreign key)

@UseRowClass(ActivityModel)
class Activity extends Table {
  IntColumn get id => integer().autoIncrement()();
  TextColumn get name => text()();
  TextColumn get emoji => text().map(EmojiConverter())();
}

@UseRowClass(EventModel)
class Event extends Table {
  IntColumn get id => integer().autoIncrement()();
  IntColumn get activityId => integer().references(Activity, #id)();
  TextColumn get createdAt => text().map(DayConverter())();
}

Converters

A drift table support only primitive data types. We therefore need to create custom converters for Emoji (enum) and Day (typedef).

drift provides a TypeConverter class. This requires to override fromSql and toSql, used to convert our custom type to a primitive type stored in the database:

class EmojiConverter extends TypeConverter<Emoji, String> {
  @override
  Emoji fromSql(String fromDb) => Emoji.values.firstWhere(
        (emoji) => emoji.toString() == fromDb,
        orElse: () => Emoji.dart,
      );

  @override
  String toSql(Emoji value) => value.toString();
}

For Day I defined a reusable extension for toSql and I implemented fromSql using Option from fpdart (functional programming):

extension OnDay on Day {
  String get toSql => "$day/$month/$year";
}

class DayConverter extends TypeConverter<Day, String> {
  @override
  Day fromSql(String fromDb) => Option<Day>.Do((_) {
        final split = fromDb.split("/");
        final day = _(
          Option.fromNullable(split.elementAtOrNull(0)).flatMap(
            (str) => str.toIntOption,
          ),
        );
        final month = _(
          Option.fromNullable(split.elementAtOrNull(1)).flatMap(
            (str) => str.toIntOption,
          ),
        );
        final year = _(
          Option.fromNullable(split.elementAtOrNull(2)).flatMap(
            (str) => str.toIntOption,
          ),
        );

        return (day: day, month: month, year: year);
      }).getOrElse(
        () => (day: 0, month: 0, year: 0),
      );

  @override
  String toSql(Day value) => value.toSql;
}

Database connection

The last step is creating a Database class that defines all the tables and opens the connection when created:

  • Define tables using @DriftDatabase
  • Call super to run the logic to create a LazyDatabase (_openConnection())
database.dart
@DriftDatabase(tables: [Activity, Event])
class Database extends _$Database {
  Database() : super(_openConnection());

  @override
  int get schemaVersion => 1;
}

_openConnection() defines the path where to store the sqlite database and returns a LazyDatabase:

LazyDatabase _openConnection() => LazyDatabase(
      () async {
        final dbFolder = await getApplicationDocumentsDirectory();
        final file = File(path.join(dbFolder.path, 'db.sqlite'));

        if (Platform.isAndroid) {
          await applyWorkaroundToOpenSqlite3OnOldAndroidVersions();
        }

        final cachebase = (await getTemporaryDirectory()).path;
        sqlite3.tempDirectory = cachebase;

        return NativeDatabase.createInBackground(file);
      },
    );

Database queries: Stream

With this we can now define the queries to select, insert, update, and delete the data.

drift provides dart methods for all the operations. For example, I used select on the generated activity table and watch() to return a Stream (in sync with added and removed values in the table):

@DriftDatabase(tables: [Activity, Event])
class Database extends _$Database {
  Database() : super(_openConnection());

  @override
  int get schemaVersion => 1;

  Stream<List<ActivityModel>> get watchActivities => select(activity).watch();
}

Functional API: fpdart

We now need a way to define API requests:

  • Provide data to the API (from a user input for example)
  • Validate the data and execute the logic, while also handling possible errors

We can do this using fpdart and functional programming.

In this example I added a query helper function inside Database. query allows to execute a query and catch errors using TaskEither.tryCatch:

If you are interested in learning more about fpdart you can read Getting started with fpdart and Functional programming in dart

@DriftDatabase(tables: [Activity, Event])
class Database extends _$Database {
  Database() : super(_openConnection());

  @override
  int get schemaVersion => 1;

  TaskEither<ApiError, Res> query<Res>(Future<Res> Function() execute) =>
      TaskEither.tryCatch(execute, QueryError.new);
}

Dependency injection and error handling: ReaderTaskEither

fpdart supports handling both dependencies and errors using the ReaderTaskEither type.

ReaderTaskEither requires 3 generic parameters:

  1. Dependency (Database)
  2. Error type (ApiError)
  3. Success return value (int)

I defined an addActivity function that executes an insert query in the activity table:

ReaderTaskEither<Database, ApiError, int> addActivity({
  required String name,
  required Emoji emoji,
}) =>
    ReaderTaskEither.Do(
      (_) async {
        final db = await _(ReaderTaskEither.ask());

        return _(
          ReaderTaskEither.fromTaskEither(
            db.query(
              () => db.into(db.activity).insert(
                    ActivityCompanion.insert(
                      name: name,
                      emoji: emoji,
                    ),
                  ),
            ),
          ),
        );
      },
    );

All errors are defined as a sealed class:

By using ReaderTaskEither (TaskEither and Either) all errors are tracked internally by fpdart

api_error.dart
sealed class ApiError {
  const ApiError();

  @override
  String toString() {
    return "Unexpected error!";
  }
}

class QueryError extends ApiError {
  final Object error;
  final StackTrace stackTrace;
  const QueryError(this.error, this.stackTrace);

  @override
  String toString() {
    return "Query error: $error";
  }
}

We can then define all the methods of the API in a similar way.

For example, we can add also a deleteEvent method that executes a delete query:

ReaderTaskEither<Database, ApiError, int> deleteEvent({
  required int eventId,
}) =>
    ReaderTaskEither.Do(
      (_) async {
        final db = await _(ReaderTaskEither.ask());

        return _(
          ReaderTaskEither.fromTaskEither(
            db.query(
              (db.delete(db.event)
                    ..where(
                      (table) => table.id.equals(eventId),
                    ))
                  .go,
            ),
          ),
        );
      },
    );

There is more 🤩

Timeless coding principles, practices, and tools that make a difference, regardless of your language or framework, delivered in your inbox every week.

State management: signals

The app allows the user to select a day and view all the events registered in that day:

event_model.dart
class EventModel extends Equatable { 
  final int id;
  final int activityId;
  final Day createdAt; 

  const EventModel({
    required this.id,
    required this.activityId,
    required this.createdAt, 
  });

  @override
  List<Object?> get props => [id, activityId, createdAt];
}

For state management I used signals:

  • Define a DaySignal class that contains the app state using signal() (day selected)
  • Add a derived state using computed()
  • Provide methods to update the state
class DaySignal {
  final _currentDayTime = signal(DateTime.now());
  late final _currentDay = computed<Day>(() {
    final dateTime = _currentDayTime();
    return (day: dateTime.day, month: dateTime.month, year: dateTime.year);
  });

  void previousDay() => _currentDayTime.value = _currentDayTime.value.subtract(
        const Duration(days: 1),
      );

  void nextDay() => _currentDayTime.value = _currentDayTime.value.add(
        const Duration(days: 1),
      );

  Day get day => _currentDay.value;
}

Service Locator: get_it

We then need to connect all the dependencies together.

I used get_it to manage the creation of concrete instances of Database and DaySignal (as lazy singletons):

final getIt = GetIt.instance;

void setup() {
  getIt.registerLazySingleton<Database>(() => Database());
  getIt.registerLazySingleton<DayController>(() => DaySignal());
}

Screens and widgets

With this we now have all the pieces in place to start working on the UI:

The UI is responsible to:

  • Display the current state (retrieved from Database as a Stream of data)
  • Display and update the selected day (state management using signals)
  • Execute events on user actions (using the API methods defined with fpdart)
  • Display the result of events (text messages, loading indicators, navigation)

The code below shows an example of how we can put everything that we implemented together in the final widget tree:

  • StreamListener: Custom widget used that uses StreamBuilder to react to changes from the database
  • watchActivities: Returns Stream with List<ActivityModel> from the database (Database)
  • Watch: Allows to react to changes of signal
  • DayController: Reads data using signal
StreamListener(
  getIt.get<Database>().watchActivities,
  builder: (context, data) => Expanded(
    child: GridView.count(
      crossAxisCount: 3,
      children: data
          .map(
            (activity) => Watch(
              (_) => CheckActivity(
                activityModel: activity,
                day: getIt.get<DayController>().day,
              ),
            ),
          )
          .toList(),
    ),
  ),
)
stream_listener.dart
class StreamListener<DataT> extends StatelessWidget {
  final Stream<DataT> _stream;
  final Widget Function(BuildContext context, DataT data) builder;
  const StreamListener(this._stream, {required this.builder, super.key});

  @override
  Widget build(BuildContext context) {
    return StreamBuilder(
      stream: _stream,
      builder: (context, snapshot) =>
          switch ((snapshot.connectionState, snapshot.data)) {
        (ConnectionState.none, _) => const CircularProgressIndicator(),
        (ConnectionState.waiting, _) => const CircularProgressIndicator(),
        (ConnectionState.done, _) => const Text("Completed"),
        (ConnectionState.active, null) => const Text("Error: Missing data"),
        (ConnectionState.active, final state?) => builder(context, state),
      },
    );
  }
}

We can then define functions to execute methods from the API and return a success or failure message to the user:

Future<void> onAdd(BuildContext context) =>
    addEvent(activityId: activityModel.id, day: day).match<void>(
      (apiError) {
        ScaffoldMessenger.of(context).showSnackBar(
          SnackBar(
            content: Text(
              "Error while adding new event for ${activityModel.name}: $apiError",
            ),
          ),
        );
      },
      (id) {
        print(id); /// Success ✅
      },
    ).run(getIt.get<Database>());

With these we now have a complete app:

  • Routing and navigation
  • Data storage
  • API methods
  • State management

These are all features that you will found in most Flutter apps. We learned how to define them and connect them together to build a complete Flutter app 🚀

If you are interested to learn more, every week I publish a new open source project and share notes and lessons learned in my newsletter. You can subscribe here below 👇

Thanks for reading.

👋・Interested in learning more, every week?

Timeless coding principles, practices, and tools that make a difference, regardless of your language or framework, delivered in your inbox every week.