Building a Real-time Chat App with Angel, Dart, and Flutter

Chat App

Github Repo

Dart has grown into an excellent language to use to build applications on every platform. Using one language across all endpoints of your application makes it possible to share code, and complete projects faster than ever before.

Angel for Dart is a high-powered server framework that is geared to make building it easier to build secure, scalable, full-stack applications. We've already covered how to build applications using Angel and Angular2 together, and now, we'll take a look at how simple it can be to build an application with Flutter as a front-end.


Contents


Project Set-Up

First things first - we need to set up our environment. We'll want to create one root directory that holds all of our code. Within this, we can create three distinct projects. If you use IntelliJ IDEA or one of its variants, these should all be set as content roots within your project structure:

  • common - Holds files used by both the server and client. In this case, models.
  • client - Holds our Flutter client app.
  • server - Holds our Angel server.

Common Files

Let's quickly create a package with our serializable models.

mkdir common  

We'll take advantage of package:angel_serialize in order to auto-generate (de)serialization code. Let's install some dependencies to help us accomplish this:

name: common  
dependencies:  
  angel_model: ^1.0.0
  angel_serialize: ^1.0.0-alpha
dev_dependencies:  
  angel_serialize_generator: ^1.0.0-alpha
  build_runner: ^0.3.0

After running pub get, create a file named tool/phases.dart that we will use to run the angel_serialize_generator:

import 'package:angel_serialize_generator/angel_serialize_generator.dart';  
import 'package:build_runner/build_runner.dart';  
import 'package:source_gen/source_gen.dart';

final PhaseGroup PHASES = new PhaseGroup.singleAction(  
    new GeneratorBuilder([new JsonModelGenerator()]),
    new InputSet('common', const ['lib/src/models/*.dart']));

Lastly, we just need an executable, tool/build.dart that we can call to build our serialization code on-demand:

import 'package:build_runner/build_runner.dart';  
import 'phases.dart';

main() => build(PHASES, deleteFilesByDefault: true);  

Thanks to package:angel_serialize, we can eliminate the need to write boilerplate serialization code for Angel projects, and speed up our development cycle a little bit more.

We just need to create our models now. In lib/src/models/models.dart:

library common.models;  
import 'package:angel_model/angel_model.dart';  
import 'package:angel_serialize/angel_serialize.dart';  
part 'models.g.dart';

@serializable
class _User extends Model {  
  String username, password, salt, avatar;
}

@serializable
class _Message extends Model {  
  String userId, text;
  _User user;
}

You might notice that our Model classes are private, because their names are pre-fixed with underscores (_). The public classes are found in models.g.dart after angel_serialize_generator builds them.

Note that camel-cased fields (such as userId) will be (de)serialized as snake case, ex. user_id. You can disable this by setting autoSnakeCaseNames to false in your call to the JsonModelGenerator constructor.

Let's export our models in lib/common.dart:

export 'src/models/models.dart';  

Now, we are ready to use these models and build the server-side component of our application.

The Server

If you have never used Angel before, this is where you should diverge, and read earlier tutorials first. This project makes good use of some of Angel's more advanced features.

Let's use the Angel CLI to scaffold a project:

# Install or update the CLI
pub global activate angel_cli

# Create project in subdirectory
angel init server  

I chose the Full Application boilerplate, but this tutorial will also work just fine with the Minimal boilerplate. Both include functionality that won't even be necessary for this project.

Ensure that you have the following dependencies installed:

description: "An easily-extensible web server framework in Dart."  
homepage: "https://github.com/angel-dart/angel"  
name: "server"  
publish_to: "none"  
dependencies:  
  angel_auth_google: ^1.0.0
  angel_common: "^1.0.0"
  angel_configuration: "^1.0.0"
  angel_hot: "^1.0.0-rc.1"
  angel_multiserver: "^1.0.0"
  angel_seeder: ^1.0.0
  angel_security: ^1.0.0
  angel_websocket: ^1.0.0
  common:
    path: ../common
dev_dependencies:  
  grinder: "^0.8.0"
  http: "^0.11.3"
  test: "^0.12.13"
environment:  
  sdk: ">=1.19.0"
transformers:  
  - "angel_configuration"

We will only need to make a couple of changes to our server to make it run to par.

First, let's change the address the server listens on from 127.0.0.1 to 0.0.0.0. Otherwise, our Flutter client won't be able to reach it. In config/default.yaml:

# Change
host: 127.0.0.1

# To this:
host: 0.0.0.0  

Authentication

Next, let's make some changes to the way we log users in. The Full Application boilerplate starts us off with an AuthController that uses package:angel_auth to authenticate users via username and password. If you chose a Minimal application, feel free to copy-paste, or write your own conforming implementation. All that matters is that user passwords are hashed, and that authenticated users receive JWT tokens.

The default implementation will authenticate a user if they provide the username of an existing user, and a password that when hashed, matches the hash in the database. Most applications would include a separate endpoint for registering users; however, in the interest of time, this endpoint will register a new user if you provide a username that is not seen in the database. Of course, if you provide an existing username and an incorrect password, you won't be authenticated.

Found in lib/src/routes/controllers/auth.dart:

LocalAuthVerifier localVerifier(Service userService) {  
  return (String username, String password) async {
    Iterable<User> users = (await userService.index({
      'query': {'username': username}
    }))
      .map(User.parse);

    if (users.isNotEmpty) {
      return users.firstWhere((user) {
        var hash = hashPassword(password, user.salt, app.jwt_secret);
        return user.username == username && user.password == hash;
      }, orElse: () => null);
    } else {
      // Let's just make a new user, because I am lazy.
      return await app.service('api/users').create({'username': username, 'password': password}).then(User.parse);
    }
  };
}

Note the calls to User.parse. With package:angel_auth, whatever value you return from your verifier function will be injected into the RequestContext as user. Any future requests with the same JWT will have an identical user injected (if the JWT is valid, of course). We need to parse the user because in this case, we are fetching it from a MapService, which is an in-memory service that only stores Maps.

Angel has a variety of different services, including database integration, persistent file storage, and even supports hooking services to add database-agnostic functionality in a composable manner. Most services, if not all, handle Maps primarily because they are an easy way to store and manipulate data without needing runtime reflection.

Services

Let's create a service that will hold our user data. This tutorial won't assume you have any particular database installed on your system, so we'll only use MapServices, which hold data in-memory.

import 'dart:math' as math;  
import 'package:angel_common/angel_common.dart';  
import 'package:angel_framework/hooks.dart' as hooks;  
import 'package:angel_websocket/hooks.dart' as ws;  
import 'package:crypto/crypto.dart' show sha256;  
import 'package:random_string/random_string.dart' as rs;  
// import '../validators/user.dart';

const List<String> avatars = const [  
  'dart.png',
  'favicon.png',
  'flutter.jpg',
  'google.png'
];

configureServer() {  
  return (Angel app) async {
    app.use('/api/users', new MapService());

    var service = app.service('api/users') as HookedService;

    service.before([
      HookedServiceEvent.CREATED,
      HookedServiceEvent.MODIFIED,
      HookedServiceEvent.UPDATED,
      HookedServiceEvent.REMOVED
    ], hooks.chainListeners([hooks.disable(), ws.doNotBroadcast()]));
}

A brief overview of the Hooks added above:

  • Clients cannot create, modify, update, or remove users. package:angel_websocket will also refrain from broadcasting these events to clients.

Now, let's add a hook to beforeCreated to ensure that user passwords are hashed on registration:

service.beforeCreated.listen((e) {  
    var salt = rs.randomAlphaNumeric(12);
    e.data
      ..['password'] = hashPassword(e.data['password'], salt, app.jwt_secret)
      ..['salt'] = salt;
  });

Finally, a hook to set an avatar for new users:

// Choose a random avatar when a new user is created.
var rnd = new math.Random();

service.beforeCreated.listen((HookedServiceEvent e) {  
  var avatar = avatars[rnd.nextInt(avatars.length)];
  e.data['avatar'] = avatar;
});

We could have used hooks.chainListeners to accomplish a similar effect as well.

Let's exclude hashed passwords and salts from serialized JSON. Nobody is ever going to need to see that except for the server. Use hooks.remove to prevent keys from showing up in the results presented to clients.

 service.afterAll(hooks.remove(['password', 'salt']));

Our service still has one key security flaw: it doesn't filter or validate user input. See how to do this here:

Next, we create another in-memory service, this time to hold the chat room's messages.

We start with similar hooks to the api/users service:

import 'package:angel_common/angel_common.dart';  
import 'package:angel_framework/hooks.dart' as hooks;  
import 'package:angel_relations/angel_relations.dart' as relations;  
import 'package:angel_security/hooks.dart' as auth;  
import 'package:angel_websocket/hooks.dart' as ws;

AngelConfigurer configureServer() {  
  return (Angel app) async {
    app.use('/api/messages', new MapService());

    var service = app.service('api/messages') as HookedService;

    service.before([
      HookedServiceEvent.MODIFIED,
      HookedServiceEvent.UPDATED,
      HookedServiceEvent.REMOVED
    ], hooks.chainListeners([hooks.disable(), ws.doNotBroadcast()]));

Naturally, we'll need some different functionality. We use auth.associateCurrentUser (from package:angel_security) to add a user_id field to incoming messages, to mark them as created by the currently authenticated user. This also has the effect of throwing 403 Forbidden if the user is not logged in. This makes it possible for us to trace messages back to whomever sent them.

service.beforeCreated.listen(auth.associateCurrentUser(ownerField: 'user_id'));  

You may recall our Message model having a User field. package:angel_relations can populate this for us after any service method by querying the api/users service for the user whose id matches the user_id field that we set above:

service.afterAll(relations.belongsTo('api/users', as: 'user', localKey: 'user_id'));  

The last thing we have to is hook the babies up to the server. In lib/src/services.dart:

library server.services;

import 'package:angel_common/angel_common.dart';  
import 'message.dart' as message;  
import 'user.dart' as user;

configureServer(Angel app) async {  
  await app.configure(message.configureServer());
  await app.configure(user.configureServer());
}

WebSocket Support

Thanks to package:angel_websocket, it is outstandingly easy to broadcast service events to connected WebSockets. This plug-in also supports authentication, runs service hooks, and handles data sent from the client to call service methods.

It doesn't really matter when you invoke the plug-in, but I placed it in server.dart, after all the other configuration is run:

/// Creates and configures the server instance.
Future<Angel> createServer() async {  
  /// Passing `startShared` to the constructor allows us to start multiple
  /// instances of our application concurrently, listening on a single port.
  ///
  /// This effectively lets us multi-thread the application.
  var app = new Angel.custom(startShared);

  /// Set up our application, using three plug-ins defined with this project.
  await app.configure(configuration.configureServer);
  await app.configure(services.configureServer);
  await app.configure(routes.configureServer);
  await app.configure(new AngelWebSocket());

  return app;
}

Bam! Your server is now ready to roll. Start it running and we can get to work on building the Flutter client.

dart --observe bin/server.dart  

Pro tip: Set the --observe VM flag to enable Angel's hot reloading support. You can have your application reload itself in real time on file changes. Hooray for faster edit/refresh cycles!

Flutter Client

Contrary to what you may have been expecting, this is not a Flutter tutorial in any way, shape or form. In fact, I wrote this article assuming that you already have some familiarity with the SDK. If not, there is a wealth of documentation on the official Flutter website.

What this is, however, is an example of how to use package:angel_client in a Flutter application to interact with an Angel server.

Initialize a new Flutter project:

flutter create client  

Ensure you have these dependencies in your pubspec.yaml before running flutter packages get:

dependencies:  
  angel_websocket: ^1.0.0
  common:
    path: ../common
  flutter:
    sdk: flutter

All of our widgets will be in separate files to prevent clutter. Rewrite your lib/main.dart to look like the following:

import 'package:flutter/material.dart';  
import 'src/widgets/chat_home.dart';

void main() {  
  runApp(new ChatApp());
}

class ChatApp extends StatelessWidget {  
  @override
  Widget build(BuildContext context) {
    return new MaterialApp(
      theme: new ThemeData(primarySwatch: Colors.teal),
      home: new ChatHome(),
    );
  }
}

This is just boilerplate to contain our ChatHome, which is stateful, and maintains a WebSocket connection to our server.

Let's get cracking. In lib/src/chat_home.dart:

import 'dart:async';  
import 'package:angel_client/flutter.dart';  
import 'package:angel_websocket/flutter.dart';  
import 'package:common/common.dart';  
import 'package:flutter/material.dart';  
import 'chat_login.dart';  
import 'chat_message_list.dart';

class ChatHome extends StatefulWidget {  
  @override
  State createState() => new _ChatHomeState();
}

class _ChatHomeState extends State<ChatHome> {  
  final Angel restApp = new Rest('http://<public-ip>:3000');
  String token;
  User user;
  WebSockets wsApp;
  Service service;
  bool connecting = true, error = false;
  List<Message> messages = [];

  @override
  Widget build(BuildContext context) {
    Widget body;

    // Render different content depending on the state of the application.
    if (connecting)
      body = new Text('Connecting to server...');
    else if (error)
      body = new Text('An error occurred while connecting to the server.');
    else {
      body = new ChatMessageList(restApp, service, messages, user);
    }

    return new Scaffold(
      appBar: new AppBar(
        title: new Text('Chat (${messages.length} messages)'),
      ),
      body: body,
    );
  }
}

Don't bother rendering your application yet; let's figure out what's going on here. The ChatHome can exist in three states:

  • connecting = true: We are connecting to the server (via WebSocket)
  • error = true: Connection to the server failed.
  • Otherwise, show a list of chat messages.

In addition to our models (user and messages), you'll find three Angel-specific properties:

  • restApp - This client queries our server using plain HTTP. We won't use this for chat functionality, but we will use it to authenticate against the server and get a JWT.
  • wsApp - This is the client that interacts with the server via a WebSocket.
  • service - A Service, hosted by wsApp, which queries the api/messages collection on the server.

Our WebSocket-related fields are null by default. We won't bother opening a WebSocket unless we have a JWT to authenticate it with. Otherwise, the server will refuse to create messages we sent it (we set angel_security hooks to enforce this).

Let's whip up a ChatLogin widget that POSTs to /auth/local to either log in or register a new user. Regardless, the return value of a successful authentication will contain a JWT, which we can forward to the server in the form of an AngelAuthResult instance that angel_client gives us.

import 'package:angel_client/flutter.dart';  
import 'package:flutter/material.dart';

typedef void HandleAuth(AngelAuthResult auth);

class ChatLogin extends StatefulWidget {  
  final Angel restApp;
  final HandleAuth handleAuth;

  ChatLogin(this.restApp, this.handleAuth);

  @override
  State createState() => new _ChatLoginState(restApp, handleAuth);
}

class _ChatLoginState extends State<ChatLogin> {  
  final Angel restApp;
  final HandleAuth handleAuth;
  String username, password;
  bool sending = false;

  _ChatLoginState(this.restApp, this.handleAuth);

  @override
  Widget build(BuildContext context) {
    return new Padding(
      padding: const EdgeInsets.all(16.0),
      child: new Form(
        child: new Column(
          children: <Widget>[
            new TextField(
              decoration: new InputDecoration(labelText: 'Username'),
              onChanged: (String str) => setState(() => username = str),
            ),
            new TextField(
              decoration: new InputDecoration(labelText: 'Password'),
              onChanged: (String str) => setState(() => password = str),
            ),
            sending
                ? new CircularProgressIndicator()
                : new RaisedButton(
                    onPressed: () {
                      setState(() => sending = true);
                      restApp.authenticate(type: 'local', credentials: {
                        'username': username,
                        'password': password
                      }).then((auth) {
                        handleAuth(auth);
                      }).catchError((e) {
                        showDialog(
                            context: context,
                            child: new SimpleDialog(
                              title: new Text('Login Error: $e'),
                            ));
                      }).whenComplete(() {
                        setState(() => sending = false);
                      });
                    },
                    color: Theme.of(context).primaryColor,
                    highlightColor: Theme.of(context).highlightColor,
                    child: new Text(
                      'SUBMIT',
                      style: new TextStyle(color: Colors.white),
                    ),
                  )
          ],
        ),
      ),
    );
  }
}

When the SUBMIT button is pressed, we authenticate against the server. angel_client and its variants expose an authenticate method that integrates with the angel_auth flow to perform stateless authentication, perfect for modern clients:

restApp.authenticate(type: 'local', credentials: {  
 'username': username,
 'password': password
}).then((auth) {
  handleAuth(auth);
});

Once we authenticate, we use the handleAuth to alert our ChatHome that we have a JWT.

Add the following code to the build method in _ChatHomeState, just after the first {:

if (user == null) {  
      return new Scaffold(
        appBar: new AppBar(
          title: new Text('Log In'),
        ),
        body: new ChatLogin(restApp, (AngelAuthResult auth) {
          setState(() {
            user = User.parse(auth.data);
            token = auth.token;
            wsApp = new WebSockets('ws://<public-ip>:3000/ws');
          });

          wsApp
              .connect()
              .then((_) {
            var c = new Completer();
            StreamSubscription onAuth, onError;

            onAuth = wsApp.onAuthenticated.listen((_) {
              service = wsApp.service('api/messages');

              service
                ..onIndexed.listen((WebSocketEvent e) {
                  setState(() {
                    messages
                      ..clear()
                      ..addAll(e.data.map(Message.parse));
                  });
                })
                ..onCreated.listen((WebSocketEvent e) {
                  setState(() {
                    messages.add(Message.parse(e.data));
                  });
                });

              service.index();
              onAuth.cancel();
              c.complete();
            });

            onError = wsApp.onError.listen((e) {
              onError.cancel();
              c.completeError(e);
            });

            wsApp.authenticateViaJwt(auth.token);
            return c.future;
          })
              .timeout(new Duration(minutes: 1))
              .catchError((e) {
            showDialog(
                context: context,
                child: new SimpleDialog(
                  title: new Text('Couldn\'t connect to chat server.'),
                )).then((_) {
              setState(() => error = true);
            });
          })
              .whenComplete(() {
            setState(() => connecting = false);
          });
        }),
      );
    }

This code is a little complicated to understand at first because of all the anonymous functions - I wrote it poorly. A few async calls here and there will take us out of "callback hell."

  1. Establish a Websocket connection to the server, timing out after one minute. Do the following if connection succeeds:
  2. Set a listener of wsApp.onAuthenticated, to run once our socket has been authenticated with the JWT. I used a StreamSubscription and a Completer together to run the callback only once, and dispose of the listener after it executes. Do the following once authenticated:
  3. Set the value of service.
  4. Attach some listeners. Once we index the service, update our messages list. When a new message is sent, add it. Call service.index(), or else no onIndexed events will ever be fired.
  5. Call wsApp.authenticateViaJwt, or else no onAuthenticated events will ever be fired.
  6. Of course, if we encounter a failure anywhere in this process, tell the user.

Once we've connected to the server, we just need to render the list of messages. We are already set up to update our state in real-time. In lib/src/widgets/chat_message_list.dart:

import 'package:angel_client/angel_client.dart';  
import 'package:common/common.dart';  
import 'package:flutter/material.dart';

/// Simply renders a list of chat messages.
class ChatMessageList extends StatelessWidget {  
  /// An [Angel] client pointing toward an HTTP server.
  ///
  /// We need it for its [basePath] property.
  final Angel restApp;

  /// A client-side [Service], which mirrors the server-side implementation.
  ///
  /// We use this to create messages on the server, in this case via WebSocket.
  final Service service;
  final List<Message> messages;
  final User user;

  ChatMessageList(this.restApp, this.service, this.messages, this.user);

  @override
  Widget build(BuildContext context) {
    return new Column(
      children: <Widget>[
        new Flexible(
          child: messages.isEmpty
              ? new Text('Nobody has said anything yet... Break the silence!')
              : new ListView.builder(
                  itemCount: messages.length,
                  itemBuilder: (_, int i) {
                    return new ListTile(
                      // Resolve the path of an image on the server, using the `basePath`
                      // of our `restApp`.
                      leading: new Image.network(
                          '${restApp.basePath}/images/${messages[i].user.avatar}'),
                      title: new Text(
                        messages[i].user.username,
                        style: new TextStyle(fontWeight: FontWeight.bold),
                      ),
                      subtitle: new Text(messages[i].text),
                    );
                  }),
        ),
        new Divider(height: 1.0),
        new Container(
          decoration: new BoxDecoration(color: Theme.of(context).cardColor),
          child: new Padding(
            padding: const EdgeInsets.only(left: 8.0, right: 8.0),
            child: new TextField(
              decoration: new InputDecoration(labelText: 'Send a message...'),
              onSubmitted: (String msg) {
                if (msg.isNotEmpty) {
                  service.create({'text': msg});
                }
              },
            ),
          ),
        )
      ],
    );
  }
}

We pass the restApp and service to the ChatMessageList, so that we can:

  • Resolve the URLs of user avatars using the basePath of restApp, instead of hard-coding the server URL everywhere.
  • create messages that propagate to the server, and in turn to the ChatHome component.

Conclusion

Congratulations! Now you know how to build a mobile app using the Flutter SDK, and Angel as a backend.

Check out the (commented) source code for this example here:
https://github.com/angel-example/flutter

Angel was born out of a need for a server-side Dart framework suitable for full-stack development, so it provides functionality out-of-the-box that lets you simultaneously build client-side and server-side applications. When combined with Flutter, Angel can be used to build end-to-end Dart apps in record time.

Try out the framework (maybe star the GitHub repo?)!

Feedback+issues are welcome. :)