Build a real-time Flutter app using WebSockets and Dart Frog
January 11, 2023
By
and
January 11, 2023
updated on
April 19, 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:
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:
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.
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.
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.
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.
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.
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:
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).