Dart Frog full stack tutorial

Build a real-time Flutter app using WebSockets and Dart Frog

January 11, 2023
and 
January 11, 2023
updated on
November 15, 2024
By 
Guest Contributor

In this blog we're going to build, yup you guessed it, a Flutter counter — but we're not going to build just any Flutter counter. We're going to build a Flutter counter which connects to a Dart Frog server using WebSockets and increment the value of the counter in real time across all platforms! When we're finished, we should have something that looks like this:

We'll learn how to establish a WebSocket connection, react to changes in the connection state, and send and receive messages from the server. In this blog, we will focus on the Flutter application. Before we dive in, check out the Dart Frog WebSocket Counter tutorial in the Dart Frog docs to understand how to build the server used in this example.

Project structure

First, let's take a look at a how the app will work:

Diagram of project structure with steps 1. Send increment message 2. Compute new count 3. Broadcast new count. Diagram has three devices displaying a Flutter app. Above is a cloud with the Dart Frog logo to represent the Dart Frog server. An increment event arrow extends from one device to the Dart Frog server. Arrows point from the Dart Frog server to the three devices to represent the updated count on all devices.

The Flutter application we'll build in this blog will establish a WebSocket connection with a Dart Frog server and listen for changes in count. When a user taps the increment or decrement buttons, the client will send an increment or decrement message to the Dart Frog server. The Dart Frog server will handle computing the new count and broadcast that count to all connected clients.

When we're all done, our project structure should look something like this:

├── lib
│   ├── app
│   │   └── view
│   ├── counter
│   │   ├── bloc
│   │   └── view
│   └── l10n
├── packages
│   └── counter_repository
├── test

Note that other directories like ios, android, etc. are omitted for simplicity.

Creating the Flutter app

Let's use very_good_cli to generate a new Flutter application. If you haven't used very_good_cli already, you can install it from pub.dev:

dart pub global activate very_good_cli

Then, let's use the create command to generate a new Flutter app:

very_good create web_socket_counter_flutter --desc "A Flutter real-time counter which integrates with Dart Frog and WebSockets."

At this point we have a Flutter starter application with VGV best practices baked in. To learn more about Very Good CLI, check out the official documentation site.

Counter Repository

Before we make changes to the Flutter application, we'll start by creating a counter_repository package which will manage the counter domain. Creating the counter_repository allows us to establish a layer of abstraction between the application and the data giving us flexibility. For example, in the future if we decide to move away from WebSockets, we will not need to modify the Flutter application. To learn more about a layered architecture, check out our blog post.

We can use very_good_cli to create the counter_repository package:

very_good create counter_repository -t dart_pkg --desc "A Dart package which manages the counter domain."

Next, we'll need to add two dependencies to the counter_repository pubspec.yaml:

  • web_socket_client: A Dart WebSocket Client which will allow us to communicate with the Dart Frog server.
  • web_socket_counter: The Dart Frog WebSocket counter package which exports models which we can reuse.

The pubspec.yaml should look like:

name: counter_repository
description: A Dart package which manages the counter domain.
version: 0.1.0+1
publish_to: none

environment:
  sdk: ">=2.18.0 <3.0.0"

dependencies:
  web_socket_client: ^0.1.0-dev.1
  web_socket_counter:
    git:
      url: https://github.com/VeryGoodOpenSource/dart_frog
      path: examples/web_socket_counter

dev_dependencies:
  mocktail: ^0.3.0
  test: ^1.19.2
  very_good_analysis: ^3.1.0

Then, install the dependencies by running dart pub get.

The CounterRepository class will expose public methods that abstract the implementation details and provide domain models for the application to consume.

import 'package:web_socket_client/web_socket_client.dart';
import 'package:web_socket_counter/counter/counter.dart';

/// A Dart package which manages the counter domain.
class CounterRepository {
  CounterRepository({WebSocket? socket})
      : _ws = socket ?? WebSocket(Uri.parse('ws://localhost:8080/ws'));

  final WebSocket _ws;

  /// Send an increment message to the server.
  void increment() => _ws.send(Message.increment.value);

  /// Send a decrement message to the server.
  void decrement() => _ws.send(Message.decrement.value);

  /// Return a stream of real-time count updates from the server.
  Stream<int> get count => _ws.messages.cast<String>().map(int.parse);

  /// Return a stream of connection updates from the server.
  Stream<ConnectionState> get connection => _ws.connection;

  /// Close the connection.
  void close() => _ws.close();
}

We are exposing increment and decrement methods which send messages to the Dart Frog server. Note that the Message enum allows us to use strongly typed objects, and it is used and exported by the Dart Frog server. Being able to share the Message enum is an example of code sharing, which can help you be more efficient when building full stack Dart applications and ensure the frontend client code is compatible with the server.

The CounterRepository also exposes a stream of counts which the application can subscribe to in order to receive updates. A stream of ConnectionState is exposed in order to allow the application to react to changes in the WebSocket connection.

Lastly, the CounterRepository exposes a close method which closes the underlying connection and can be used to free up any resources.

That's it for the counter_repository implementation! Next, add the counter_repository to the pubspec.yaml of the Flutter application.

The updated pubspec.yaml should look like:

name: web_socket_counter_flutter
description: A Flutter real-time counter which integrates with Dart Frog and WebSockets.
version: 1.0.0+1
publish_to: none

environment:
  sdk: ">=2.18.0 <3.0.0"
  flutter: 3.3.10

dependencies:
  bloc: ^8.1.0
  counter_repository:
    path: packages/counter_repository
  equatable: ^2.0.5
  flutter:
    sdk: flutter
  flutter_bloc: ^8.1.1
  flutter_localizations:
    sdk: flutter
  intl: ^0.17.0

dev_dependencies:
  bloc_test: ^9.1.0
  flutter_test:
    sdk: flutter
  mocktail: ^0.3.0
  very_good_analysis: ^3.1.0

flutter:
  uses-material-design: true
  generate: true

Make sure to install all dependencies by running flutter packages get.

Now time to switch gears and focus on the Flutter app!

Counter Bloc

The application generated by Very Good CLI already comes with a counter feature which you can find in the lib directory. The first thing we'll do is remove the cubit directory and create a CounterBloc instead. Since we'll be working with streams, using a bloc provides some advantages over a cubit.

We can create a new bloc either by using the bloc VS Code extension or using the bloc brick.

CounterState

Once we have generated a new CounterBloc, let's work on modeling the state. In this example, we'll model the state as a single object which contains the value of the count as well as a status to indicate whether the client is connected to the server:

part of 'counter_bloc.dart';

enum CounterStatus { connected, disconnected }

class CounterState extends Equatable {
  const CounterState({
    this.count = 0,
    this.status = CounterStatus.disconnected,
  });

  final int count;
  final CounterStatus status;

  @override
  List<Object?> get props => [count, status];

  CounterState copyWith({int? count, CounterStatus? status}) {
    return CounterState(
      count: count ?? this.count,
      status: status ?? this.status,
    );
  }
}

CounterEvent

Next, let's define the events the bloc will be reacting to.

The CounterBloc in this example will react to five different events but only three of those events will be external.

  • CounterStarted: notifies the bloc that the counter feature has started and prompts the bloc to subscribe to changes from the backend
  • CounterIncrementPressed: notifies the bloc that the user has tapped on the increment button
  • CounterDecrementPressed: notifies the bloc that the user has tapped on the decrement button
  • _CounterCountChanged: notifies the bloc that the count has changed on the backend (internal)
  • _CounterConnectionStateChanged: notifies the bloc that the connection state has changed (internal)
part of 'counter_bloc.dart';

abstract class CounterEvent {
  const CounterEvent();
}

class CounterStarted extends CounterEvent {
  const CounterStarted();
}

class CounterIncrementPressed extends CounterEvent {
  const CounterIncrementPressed();
}

class CounterDecrementPressed extends CounterEvent {
  const CounterDecrementPressed();
}

class _CounterCountChanged extends CounterEvent {
  const _CounterCountChanged(this.count);

  final int count;
}

class _CounterConnectionStateChanged extends CounterEvent {
  const _CounterConnectionStateChanged(this.state);

  final ConnectionState state;
}

CounterBloc

Now that we have the state and events defined, we can implement the bloc.

class CounterBloc extends Bloc<CounterEvent, CounterState> {
  CounterBloc({required CounterRepository counterRepository})
      : _counterRepository = counterRepository,
        super(const CounterState()) {...}

  final CounterRepository _counterRepository;
}

First, the bloc will depend on the CounterRepository we created earlier, so we'll add it as a required parameter to the bloc constructor.

Next, we'll create an event handler for each event. Tip: you can use the onevent and _onevent snippets from the Bloc VS Code extension.

Let's start with CounterStarted. When the bloc receives a CounterStarted event, it will subscribe to changes in the count and connection and add internal events for whenever either changes. As a result, we need to define two StreamSubscription instances and override close to cancel those subscriptions when the bloc is closed.

class CounterBloc extends Bloc<CounterEvent, CounterState> {
  CounterBloc({required CounterRepository counterRepository})
      : _counterRepository = counterRepository,
        super(const CounterState()) {
    on<CounterStarted>(_onCounterStarted);
  }

  StreamSubscription<int>? _countSubscription;
  StreamSubscription<ConnectionState>? _connectionSubscription;

  void _onCounterStarted(
    CounterStarted event,
    Emitter<CounterState> emit,
  ) {
    _countSubscription = _counterRepository.count.listen(
      (count) => add(_CounterCountChanged(count)),
    );
    _connectionSubscription = _counterRepository.connection.listen((state) {
      add(_CounterConnectionStateChanged(state));
    });
  }

  @override
  Future<void> close() {
    _connectionSubscription?.cancel();
    _countSubscription?.cancel();
    return super.close();
  }
}

Next, let's implement the _CounterConnectionStateChanged handler. When the connection state changes, the bloc will emit a new state with an updated status based on whether we are connected or disconnected from the backend. We use a private extension on ConnectionState to convert the ConnectionState to a CounterStatus.

class CounterBloc extends Bloc<CounterEvent, CounterState> {
  CounterBloc({required CounterRepository counterRepository})
      : _counterRepository = counterRepository,
        super(const CounterState()) {
    ...
    on<_CounterConnectionStateChanged>(_onCounterConnectionStateChanged);
  }

  ...

  void _onCounterConnectionStateChanged(
    _CounterConnectionStateChanged event,
    Emitter<CounterState> emit,
  ) {
    emit(state.copyWith(status: event.state.toStatus()));
  }
}

extension on ConnectionState {
  CounterStatus toStatus() {
    return this is Connected || this is Reconnected
        ? CounterStatus.connected
        : CounterStatus.disconnected;
  }
}

The _CounterCountChanged event handler will be very similar so let's implement that next. When the bloc receives a _CounterCountChanged event it will emit a new state with the updated count and in this case always set the status to connected since the bloc can only ever receive count updates from the server if it is connected.

class CounterBloc extends Bloc<CounterEvent, CounterState> {
  CounterBloc({required CounterRepository counterRepository})
      : _counterRepository = counterRepository,
        super(const CounterState()) {
    on<CounterStarted>(_onCounterStarted);
    on<_CounterConnectionStateChanged>(_onCounterConnectionStateChanged);
    on<_CounterCountChanged>(_onCounterCountChanged);
  }
  ...

  void _onCounterCountChanged(
    _CounterCountChanged event,
    Emitter<CounterState> emit,
  ) {
    emit(state.copyWith(count: event.count, status: CounterStatus.connected));
  }
}

The CounterIncrementPressed and CounterDecrementPressed event handlers are all that's left and when a bloc receives either event it will invoke increment or decrement on the CounterRepository respectively.

The finished CounterBloc implementation should look like:

import 'dart:async';

import 'package:bloc/bloc.dart';
import 'package:counter_repository/counter_repository.dart';
import 'package:equatable/equatable.dart';

part 'counter_event.dart';
part 'counter_state.dart';

class CounterBloc extends Bloc<CounterEvent, CounterState> {
  CounterBloc({required CounterRepository counterRepository})
      : _counterRepository = counterRepository,
        super(const CounterState()) {
    on<CounterStarted>(_onCounterStarted);
    on<_CounterConnectionStateChanged>(_onCounterConnectionStateChanged);
    on<_CounterCountChanged>(_onCounterCountChanged);
    on<CounterIncrementPressed>(_onCounterIncrementPressed);
    on<CounterDecrementPressed>(_onCounterDecrementPressed);
  }

  final CounterRepository _counterRepository;
  StreamSubscription<int>? _countSubscription;
  StreamSubscription<ConnectionState>? _connectionSubscription;

  void _onCounterStarted(
    CounterStarted event,
    Emitter<CounterState> emit,
  ) {
    _countSubscription = _counterRepository.count.listen(
      (count) => add(_CounterCountChanged(count)),
    );
    _connectionSubscription = _counterRepository.connection.listen((state) {
      add(_CounterConnectionStateChanged(state));
    });
  }

  void _onCounterIncrementPressed(
    CounterIncrementPressed event,
    Emitter<CounterState> emit,
  ) {
    _counterRepository.increment();
  }

  void _onCounterDecrementPressed(
    CounterDecrementPressed event,
    Emitter<CounterState> emit,
  ) {
    _counterRepository.decrement();
  }

  void _onCounterConnectionStateChanged(
    _CounterConnectionStateChanged event,
    Emitter<CounterState> emit,
  ) {
    emit(state.copyWith(status: event.state.toStatus()));
  }

  void _onCounterCountChanged(
    _CounterCountChanged event,
    Emitter<CounterState> emit,
  ) {
    emit(state.copyWith(count: event.count, status: CounterStatus.connected));
  }

  @override
  Future<void> close() {
    _connectionSubscription?.cancel();
    _countSubscription?.cancel();
    return super.close();
  }
}

extension on ConnectionState {
  CounterStatus toStatus() {
    return this is Connected || this is Reconnected
        ? CounterStatus.connected
        : CounterStatus.disconnected;
  }
}

Counter Feature

Now that the CounterBloc is implemented, let's update the UI. First, let's provide an instance of the CounterRepository to the widget tree in app.dart

import 'package:counter_repository/counter_repository.dart';
import 'package:flutter/material.dart';
import 'package:flutter_bloc/flutter_bloc.dart';
import 'package:web_socket_counter_flutter/counter/counter.dart';
import 'package:web_socket_counter_flutter/l10n/l10n.dart';

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

  @override
  State<App> createState() => _AppState();
}

class _AppState extends State<App> {
  late final CounterRepository _counterRepository;

  @override
  void initState() {
    super.initState();
    _counterRepository = CounterRepository();
  }

  @override
  void dispose() {
    _counterRepository.close();
    super.dispose();
  }

  @override
  Widget build(BuildContext context) {
    return RepositoryProvider.value(
      value: _counterRepository,
      child: const AppView(),
    );
  }
}

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

  @override
  Widget build(BuildContext context) {
    return const MaterialApp(
      localizationsDelegates: AppLocalizations.localizationsDelegates,
      supportedLocales: AppLocalizations.supportedLocales,
      home: CounterPage(),
    );
  }
}

Note that we're using a StatefulWidget so that we can manage the CounterRepository instance and close it when the widget is disposed.

Next, let's update the CounterPage widget to provide an instance of the CounterBloc.

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

  @override
  Widget build(BuildContext context) {
    return BlocProvider(
      create: (context) => CounterBloc(
        counterRepository: context.read<CounterRepository>(),
      )..add(const CounterStarted()),
      child: const CounterView(),
    );
  }
}

We immediately add a CounterStarted event to the bloc in order to start listening for updates to the count as soon the widget is mounted.

Next, let's update the CounterView to display the counter text, connection text, and floating action buttons to increment and decrement the counter:

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

  @override
  Widget build(BuildContext context) {
    final l10n = context.l10n;
    return Scaffold(
      appBar: AppBar(title: Text(l10n.counterAppBarTitle)),
      body: Center(
        child: Column(
          mainAxisAlignment: MainAxisAlignment.center,
          children: const [
            CounterText(),
            ConnectionText(),
          ],
        ),
      ),
      floatingActionButton: Column(
        mainAxisAlignment: MainAxisAlignment.end,
        crossAxisAlignment: CrossAxisAlignment.end,
        children: const [
          IncrementButton(),
          SizedBox(height: 8),
          DecrementButton(),
        ],
      ),
    );
  }
}

The CounterText widget will use the context.select extension to rebuild whenever the count changes in the bloc state:

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

  @override
  Widget build(BuildContext context) {
    final theme = Theme.of(context);
    final count = context.select((CounterBloc bloc) => bloc.state.count);
    return Text('$count', style: theme.textTheme.headline1);
  }
}

Similarly, the ConnectionText widget uses context.select to rebuild whenever the status changes in the bloc state:

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

  @override
  Widget build(BuildContext context) {
    final theme = Theme.of(context);
    final l10n = context.l10n;
    final connection = context.select((CounterBloc bloc) => bloc.state.status);
    switch (connection) {
      case CounterStatus.connected:
        return Text(
          l10n.counterConnectedText,
          style: theme.textTheme.caption?.copyWith(
            color: theme.colorScheme.primary,
          ),
        );

      case CounterStatus.disconnected:
        return Text(
          l10n.counterDisconnectedText,
          style: theme.textTheme.caption?.copyWith(
            color: theme.colorScheme.error,
          ),
        );
    }
  }

Lastly, the increment and decrement buttons are only enabled if the status is connected. When tapped, they add the correct event to the bloc:

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

  @override
  Widget build(BuildContext context) {
    final isConnected = context.select(
      (CounterBloc bloc) => bloc.state.status == CounterStatus.connected,
    );
    return FloatingActionButton(
      backgroundColor: isConnected ? null : Colors.grey,
      onPressed: isConnected
          ? () => context.read<CounterBloc>().add(const CounterIncrementPressed());
          : null,
      child: const Icon(Icons.add),
    );
  }
}

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

  @override
  Widget build(BuildContext context) {
    final isConnected = context.select(
      (CounterBloc bloc) => bloc.state.status == CounterStatus.connected,
    );
    return FloatingActionButton(
      backgroundColor: isConnected ? null : Colors.grey,
      onPressed: isConnected
          ? () => context.read<CounterBloc>().add(const CounterDecrementPressed());
          : null,
      child: const Icon(Icons.remove),
    );
  }
}

Running the App

We're finally ready to run the app now! We recommend running the app in debug mode directly from either VS Code or Android Studio but you can also run it from the command line:

flutter run -t lib/main_development.dart --flavor development

Once the app is running you should see the client is disconnected and the floating action buttons are disabled.

Now all that's left to do is run the Dart Frog server. To see how we built the server, refer to the tutorial in the Dart Frog docs.

# Install Dart Frog CLI.
dart pub global activate dart_frog_cli

# Clone the GitHub Repository.
git clone https://github.com/VeryGoodOpenSource/dart_frog

# Change into the web_socket_counter example directory.
cd dart_frog/examples/web_socket_counter

# Install the dependencies.
dart pub get

# Run the development server.
dart_frog dev

Once the server is running you should see the client establish a connection. You can run the Flutter app on a different device to establish multiple connections to the server and watch the counter change in real-time.

Summary

That's it! You can now build a full stack Dart application with real-time bidirectional communication using WebSockets. The complete source code is available on GitHub (with 100% test coverage, of course).

Learn more about Dart Frog at dartfrog.vgv.dev →

More Stories