Building a Real-time Counter App with Phoenix Channels, Phoenix.PubSub, and Flutter
In this tutorial, we'll build a simple real-time counter application using Phoenix as the backend and Flutter as the frontend. The counter will be stored on the server-side and transmitted to the Flutter app via WebSockets (Phoenix Channels).
- Name
- Franz Bettag
- @fbettag
14 days ago
In this tutorial, we’ll build a simple real-time counter application using Phoenix as the backend and Flutter as the frontend. The counter will be stored on the server-side and transmitted to the Flutter app via WebSockets (Phoenix Channels). The Flutter app will display the counter value and include a button to increment the counter. We will also add Message Broadcasting in Petal Pro part of this tutorial. Additionally we will be handling environment variables and show you how to bundle different WebSocket Endpoints for different environments.
Prerequisites
- Basic understanding of Elixir, Phoenix, and Flutter.
- Phoenix and Flutter installed on your machine.
- Use Petal Pro as base project (optional)
1. Setting Up the Phoenix Project
1.1 Creating a New Phoenix Project or use the Petal Pro Base
Start by creating a new Phoenix project:
mix phx.new counter_backend
cd counter_backend
mix deps.get
mix ecto.reset
Feel free to alternatively use the Petal Pro base project, we have a custom User Dashboard specifically for Petal Pro at the end of Section 1.
1.2 Implementing the GenServer with PubSub
We’ll implement a GenServer that holds the counter’s value and uses Phoenix.PubSub
to broadcast the counter value whenever it changes.
Create a new file lib/counter_backend/counter_server.ex
with the following content:
defmodule CounterBackend.CounterServer do
use GenServer
alias Phoenix.PubSub
@topic "counter_updates"
# Public API
def start_link(_) do
GenServer.start_link(__MODULE__, 0, name: __MODULE__)
end
def increment do
GenServer.call(__MODULE__, :increment)
end
def get_value do
GenServer.call(__MODULE__, :get_value)
end
# GenServer Callbacks
def init(initial_value) do
{:ok, initial_value}
end
def handle_call(:increment, _from, state) do
new_state = state + 1
PubSub.broadcast(CounterBackend.PubSub, @topic, {:new_count, new_state})
{:reply, new_state, new_state}
end
def handle_call(:get_value, _from, state) do
{:reply, state, state}
end
end
1.3 Adding Phoenix.PubSub to the Supervision Tree
Next, we’ll add Phoenix.PubSub
to the supervision tree in lib/counter_backend/application.ex
:
defmodule CounterBackend.Application do
use Application
def start(_type, _args) do
children = [
...
{Phoenix.PubSub, name: CounterBackend.PubSub},
...
CounterBackend.CounterServer,
]
opts = [strategy: :one_for_one, name: CounterBackend.Supervisor]
Supervisor.start_link(children, opts)
end
end
This ensures that the PubSub system is started and ready to broadcast messages.
1.4 Creating the Phoenix Channel
Now, we will create a Phoenix channel to handle communication between the Flutter app and the server.
Create a new file lib/counter_backend_web/channels/counter_channel.ex
:
defmodule CounterBackendWeb.CounterChannel do
use Phoenix.Channel
require Logger
alias Phoenix.PubSub
@topic "counter_updates"
def join("counter:lobby", _message, socket) do
# Subscribe to the counter updates PubSub topic
PubSub.subscribe(CounterBackend.PubSub, @topic)
Logger.info "Flutter client connected and subscribed to counter updates"
Process.send_after(self(), :after_join, 100)
{:ok, socket}
end
# Handle incoming "increment" events from the client
def handle_in("increment", _payload, socket) do
Logger.warning "Flutter client sent an increment event"
# Increment the counter through the GenServer
CounterBackend.CounterServer.increment()
{:noreply, socket}
end
# Handle messages broadcasted by PubSub
def handle_info({:new_count, count}, socket) do
Logger.warning "Flutter client receiving an increment event"
push(socket, "new_count", %{count: count})
{:noreply, socket}
end
def handle_info(:after_join, socket) do
Logger.info "Flutter client joined the channel"
push(socket, "new_count", %{count: CounterBackend.CounterServer.get_value()})
{:noreply, socket}
end
def handle_info({:message, message}, socket) do
Logger.info "Broadcasting message: #{inspect(message)}"
push(socket, "broadcast", %{text: message})
{:noreply, socket}
end
end
1.5 Setting Up the Channel Route
Finally, we need to route requests to this channel by modifying lib/counter_backend_web/channels/user_socket.ex
:
defmodule CounterBackendWeb.UserSocket do
use Phoenix.Socket
## Channels
channel "counter:lobby", CounterBackendWeb.CounterChannel
def connect(_params, socket, _connect_info) do
{:ok, socket}
end
def id(_socket), do: nil
end
1.6 Changing the Petal User Dashboard (Petal Pro subscribers only)
If you’re a Petal Pro subscriber, you can use the following dashboard_live.ex
to replace the default:
defmodule CounterBackendWeb.DashboardLive do
@moduledoc false
use CounterBackendWeb, :live_view
@topic "counter_updates"
@impl true
def mount(_params, _session, socket) do
if connected?(socket), do: Phoenix.PubSub.subscribe(CounterBackend.PubSub, @topic)
{:ok, assign(socket, page_title: gettext("Dashboard"), counter: CounterBackend.CounterServer.get_value())}
end
@impl true
def handle_info({:new_count, new_count}, socket) do
{:noreply, assign(socket, counter: new_count)}
end
@impl true
def handle_info({:message, _text}, socket) do
{:noreply, socket}
end
@impl true
def handle_event("increment", _params, socket) do
CounterBackend.CounterServer.increment()
{:noreply, socket}
end
@impl true
def handle_event("send", %{"text" => text}, socket) do
Phoenix.PubSub.broadcast(CounterBackend.PubSub, @topic, {:message, text})
{:noreply, socket}
end
end
You will also need to update the template dashboard_live.html.heex
with the following:
<.layout current_page={:dashboard} current_user={@current_user} type="sidebar">
<.container class="py-16">
<.h2><%= gettext("Welcome, %{name}", name: user_name(@current_user)) %> 👋</.h2>
<div class="px-4 py-8 sm:px-0">
<div class="flex flex-col items-center justify-center border-4 border-gray-300 border-dashed rounded-lg dark:border-gray-800 h-auto p-8 space-y-6">
<!-- Existing Content -->
<div class="text-xl text-center">
<div class="mb-4">Build your masterpiece 🚀</div>
<!-- Counter Display -->
<div id="counter" class="text-4xl font-bold mb-4 text-gray-800 dark:text-white">
<%= @counter %>
</div>
<!-- Increment Button -->
<.button phx-click="increment" color="success">
Increment Counter
</.button>
</div>
<!-- Broadcast Message Input Field -->
<.form for={%{}} as={:message} class="w-full max-w-md" phx-submit="send">
<.field type="textarea" name={:text} value="" label="Broadcast Message" rows="4"
wrapper_class="shadow-sm focus:ring-blue-500 focus:border-blue-500 block w-full sm:text-sm border-gray-300 dark:border-gray-700 dark:bg-gray-800 rounded-md"
placeholder="Type your message here..." />
<.button type="submit" color="info">
Send Broadcast
</.button>
</.form>
</div>
</div>
</.container>
</.layout>
1.6 Running the Phoenix Server
Now, you can start the Phoenix server:
mix phx.server
Your Phoenix backend is now set up and running, ready to handle real-time counter updates via channels and PubSub.
2. Setting Up the Flutter Project
With the Phoenix backend ready, we’ll now set up a Flutter project to connect to this backend and interact with the counter.
2.1 Creating the Flutter Project
Create a new Flutter project:
flutter create counter_app
cd counter_app
2.2 Installing Dependencies
Use the following command to add the necessary dependencies:
flutter pub add phoenix_socket flutter_dotenv
2.3 Creating the Flutter UI
Replace the content of lib/main.dart
with the following code:
import 'dart:async';
import 'package:flutter/foundation.dart';
import 'package:flutter/material.dart';
import 'package:flutter_dotenv/flutter_dotenv.dart';
import 'package:phoenix_socket/phoenix_socket.dart';
void main() async {
await dotenv.load(fileName: kReleaseMode ? '.env.production' : '.env.debug');
runApp(const MyApp());
}
class MyApp extends StatelessWidget {
const MyApp({super.key});
@override
Widget build(BuildContext context) {
return const MaterialApp(
home: CounterPage(),
);
}
}
class CounterPage extends StatefulWidget {
const CounterPage({super.key});
@override
CounterPageState createState() => CounterPageState();
}
class CounterPageState extends State<CounterPage> {
late PhoenixSocket socket;
PhoenixChannel? channel;
int counter = 0;
@override
void initState() {
super.initState();
_connectSocket();
}
Future<void> _connectSocket() async {
final wsUrl = dotenv.env['PHX_WS'];
const topic = 'counter:lobby';
print('Connecting to socket $wsUrl...');
socket = PhoenixSocket(wsUrl!);
await socket.connect();
print('Connected to socket!');
channel = socket.addChannel(topic: topic);
print('Joining channel $topic...');
await channel!.join().future;
print('Joined channel $topic!');
await for (var message in channel!.messages) {
if (message.event == const PhoenixChannelEvent.custom('new_count')) {
setState(() {
counter = message.payload!['count'] as int;
});
} else if (message.event == const PhoenixChannelEvent.custom('broadcast')) {
final text = message.payload!['text'] as String;
_showNotification(text);
}
}
}
void _showNotification(String text) {
ScaffoldMessenger.of(context).showSnackBar(
SnackBar(
content: Text(text),
duration: const Duration(seconds: 3),
),
);
}
void _incrementCounter() {
print("Incrementing counter: $channel");
channel!.push("increment", {});
}
@override
Widget build(BuildContext context) {
return Scaffold(
appBar: AppBar(title: const Text('Counter App')),
body: Center(
child: Text(
'$counter',
style: const TextStyle(fontSize: 48),
),
),
floatingActionButton: FloatingActionButton(
onPressed: _incrementCounter,
child: const Icon(Icons.add),
),
);
}
@override
void dispose() {
socket.dispose();
super.dispose();
}
}
2.4 Configuring Environment Variables
Create two environment files in the root of your Flutter project:
-
.env.debug
for development:PHX_WS=ws://localhost:4000/socket/websocket
-
.env.production
for production:PHX_WS=wss://your-production-url.com/socket/websocket
2.5 Add Environment files to Flutter assets
Add the .env
file to your assets bundle in pubspec.yaml
. Ensure that the path corresponds to the location of the .env file!
assets:
- .env
Remember to add the .env
file as an entry in your .gitignore
if it isn’t already unless you want it included in your version control.
*.env
2.6 Adding network client capabilities (macOS)
In order to get client networking to work on macOS or iOS, you need to edit your DebugProfile.entitlements
and Release.entitlements
files.
For the purpose of this tutorial, edit macos/Runner/DebugProfile.entitlements
to contain:
<key>com.apple.security.network.client</key>
<true/>
With a clean Flutter project, this file should look something like this now:
xml
<?xml version="1.0" encoding="UTF-8"?>
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
<plist version="1.0">
<dict>
<key>com.apple.security.app-sandbox</key>
<true/>
<key>com.apple.security.cs.allow-jit</key>
<true/>
<key>com.apple.security.network.server</key>
<true/>
<key>com.apple.security.network.client</key>
<true/>
</dict>
</plist>
2.6 Running the Flutter Application
To run the Flutter app in debug mode:
flutter run
Or to build and run it in production mode:
flutter run --release
Conclusion
This tutorial demonstrates how to build a real-time counter application using Phoenix and Flutter, showcasing the power and flexibility of these technologies when integrated. By leveraging Phoenix Channels with PubSub, we’ve created a robust system where server-side events can seamlessly update client-side applications in real-time. This setup is not only efficient but also highly scalable, making it ideal for modern applications that require instant updates across multiple clients.
For developers and teams working within the PETAL stack (comprising of Phoenix, Elixir, Tailwind CSS, AlpineJS, and LiveView), this approach offers even greater potential. Petal Pro, in particular, enhances the Phoenix framework by providing a unified and powerful toolkit that accelerates the development of both the front-end and back-end. With Petal Pro, you can rapidly develop sophisticated web applications with responsive, dynamic user interfaces, all while benefiting from the robustness and efficiency of Phoenix on the backend.
When you bring Flutter into the mix, the advantages multiply. By incorporating Flutter, your single Phoenix backend can now serve native applications across all major platforms (macOS, Windows, Linux, iOS, and Android). This cross-platform capability ensures that you can deliver a consistent and high-quality user experience regardless of the device or platform. This is a significant advantage for any development team, as it allows you to maintain one cohesive backend while expanding your reach to multiple front-end platforms with minimal additional effort.
The PETAL stack’s design further simplifies the development process. Tailwind CSS makes it easy to create beautiful, responsive designs without needing extensive custom CSS. AlpineJS and LiveView enable interactive and reactive user experiences with minimal JavaScript, reducing complexity and improving maintainability. And with Phoenix Channels, real-time communication becomes straightforward, allowing you to implement features like live updates and notifications with ease.
One of the standout benefits of using Elixir and Phoenix, is the scalability offered by Elixir’s underlying Erlang VM. Thanks to Erlang’s battle-tested capabilities in building distributed systems, you can effortlessly scale your backend as your application grows. Erlang’s built-in support for clustering allows you to distribute your application across multiple nodes, ensuring that your system can handle increased load and traffic without sacrificing performance or reliability. This makes it possible to scale your backend dynamically, responding to user demand and ensuring a smooth experience for all users.
tl;dr
In summary, by integrating Petal Pro with Flutter, you’re not just building an application, you’re creating a scalable, future-proof solution that can grow with your business needs. This approach allows you to develop and maintain a high-quality backend that serves both web and native applications across various platforms, all while benefiting from the robustness and scalability of Elixir and Erlang. Whether you’re a small team or a large enterprise, this stack positions you to efficiently develop, deploy, and scale your applications, providing a strong foundation for long-term success.