Blog Blog

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).

//res.cloudinary.com/wickedsites/image/upload/c_fill,g_face,h_64,w_64/petal_marketing/prod/avatars/21974
Name
Franz Bettag
Twitter
@

yesterday

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.heexwith 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.yamlEnsure 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.

The end

More posts