docs
/
Example apps

Tutorial: the chat app

Build the chat example from scratch — a customer signs in, opens a support conversation over the realtime gateway, waits in the queue, and talks to the agent who picks it up, with every socket event shown beside the thread.

appmint_flutter_chat_demo

Get the code

Browse it on GitHub · Download the repository as a zip

git clone https://github.com/JacLight/appmint-examples.git
cd appmint-examples/appmint_flutter_chat_demo

The example depends on two packages from appmint-client by relative path (../../appmint-client/appmint_flutter_client and …/appmint_flutter_chat), so clone that repository beside this one — or change those two lines in pubspec.yaml to the git dependencies shown in Step 1.

This tutorial builds the chat example one file at a time. By the end you will have the customer's side of a support conversation: sign in, say something, sit in the queue, see the agent arrive, talk to them — and see every event that travelled over the socket while it happened, because everything else in these examples is a request you can watch, and this is the one thing that otherwise happens in silence.

Everything on this page was run against a live AppEngine while it was written, with a real agent on the other end.

Start with authentication. This app reuses the request log and the sign-in shape from the authentication tutorial. Those parts are shown here only as far as they differ.

Before you start

Flutter 3.27+, an organization with an app credential from Studio Manager, and somebody to answer. Chat is two-sided; messages to an empty queue sit there unanswered, which is realistic but confusing when you were not expecting it. The agent can be a person in the Appmint admin or Appmint Mobile, or the terminal agent in Step 8.

Step 1 — The project

flutter create --platforms=android,ios,web --org io.appmint \
  --project-name appmint_flutter_chat_demo \
  appmint_flutter_chat_demo
cd appmint_flutter_chat_demo

Two packages this time. The client for identity, the chat package for the socket:

dependencies:
  flutter:
    sdk: flutter

  appmint_flutter_client:
    git:
      url: https://github.com/JacLight/appmint-client.git
      path: appmint_flutter_client
  appmint_flutter_chat:
    git:
      url: https://github.com/JacLight/appmint-client.git
      path: appmint_flutter_chat
flutter pub get

Delete test/widget_test.dart — it references a MyApp class this project will not have.

Step 2 — Credentials, state, two logs

Credentials come in at build time, as in the events example. The state holds the client, the signed-in customer, the HTTP log — and a second log:

lib/main.dart:

class DemoState extends ChangeNotifier {
  DemoState() {
    appmint = Appmint(config);
    appmint.http.onCall = log.add;
    _sub = appmint.auth.changes.listen((u) {
      user = u;
      notifyListeners();
    });
    appmint.auth.restore();
  }

  late final Appmint appmint;
  final log = CallLog();          // HTTP, from the authentication example
  final socketLog = SocketLog();  // the socket — new
  AppmintUser? user;
  ...
}

lib/socket_log.dart is a list of lines with a direction — state for a connection change, in for an event from the gateway, out for one this app emitted — and a panel that draws them with ← / →:

class SocketLog extends ChangeNotifier {
  final List<SocketLine> _lines = [];
  void state(String s) => add('state', s);
  void incoming(String event, [String? detail]) => add('in', event, detail);
  void outgoing(String event, [String? detail]) => add('out', event, detail);
  ...
}

DemoScaffold puts the socket panel above the HTTP panel on the right. Routing is the usual three-way switch, with one detail:

} else {
  // A new SupportPage per sign-in: the socket belongs to the person,
  // and signing out must tear it down.
  body = SupportPage(key: ValueKey(state.user!.id), state: state);
}

Step 3 — Sign in as a customer

lib/screens/sign_in_page.dart is the authentication example's sign-in with the Staff option removed and an account-creation switch added, because a support chat is the customer's side and a real app makes accounts:

final customers = widget.state.appmint.customers;
final result = _creating
    ? await customers.signUp(email: email, password: password, firstName: name)
    : await customers.signIn(email, password);

switch (result) {
  case SignedIn():
    break;                              // auth.changes rebuilds onto SupportPage
  case NeedsVerification(:final challenge):
    ... // the code dialog, as before
  case SignInRejected(:final message):
    setState(() => _error = message);
}

Sign-up ends the same three ways as sign-in, so one handler covers both.

Step 4 — Wire the chat package to the client

lib/screens/support_page.dart is the file this example exists for. The chat package does the work — AppmintChatController owns the socket and AppmintChatView draws the thread — and this page is the wiring an app has to do:

_chat = AppmintChatController(AppmintChatConfig(
  endpoint: appmint.config.baseUrl,
  orgId: appmint.config.orgId,
  user: AppmintChatUser(email: user.email, name: user.displayName),
  token: () async => appmint.http.userToken,
  supportName: 'Support',
  welcome: 'Say hello. Somebody on the team will pick this up.',
));
_chat.start();

Every value comes from the client rather than being typed again. The line that matters is token. Everywhere else in these examples the person's token never leaves the client — every HTTP call attaches it for you. A WebSocket has no headers, so the gateway reads the token from the handshake auth instead, and http.userToken exists for exactly this. It is the customer's token, never the app's; the gateway derives the role from the signed payload, and an app token would be refused.

token is a callback, not a value: the package calls it on every reconnect, so a refreshed token is picked up without restarting the screen. Which brings up the second line that matters:

onTokenExpired: () async {
  await appmint.auth.refreshSession();
},

Access tokens last an hour. For HTTP you never notice — the client refreshes on a 401 and retries. A socket handshake has no retry: the gateway answers authenticate with token_expired and disconnects. So the package asks the app to refresh, then reconnects with whatever token returns next. Leave onTokenExpired out and a customer who reopens the app after lunch gets Token expired. Please refresh and reconnect. with nowhere to go — which is exactly what happened while writing this page.

Step 5 — Narrate the socket

Nothing in this step changes behaviour — the controller already reacts to all of it. It mirrors the socket into the panel on the right:

final s = _chat.service;
log.outgoing('connect', '${_chat.config.endpoint}/chat · token in handshake');
_subs.add(s.onConnection.listen((c) => log.state(c.name)));
_subs.add(s.onAuth.listen((a) => log.incoming('authenticate',
    a.success ? 'role=${a.role} email=${a.email}' : 'refused: ${a.error}')));
_subs.add(s.onMessage.listen((m) => log.incoming('message', '${m.from} → ${m.to}: ${m.content}')));
_subs.add(s.onQueue.listen((q) => log.incoming('queued', 'position ${q.position}, ${q.ahead} ahead')));
_subs.add(s.onAgent.listen((a) => log.incoming('agent-assigned', '${a.name} <${a.email}>')));
_subs.add(s.onStatus.listen((x) => log.incoming('status', '${x['from']}: ${x['status']}')));
_subs.add(s.onUpdate.listen((u) => log.incoming('update', '${u['uid']} → ${u['status']}')));
_subs.add(s.onEnded.listen((e) => log.incoming('chat-ended', '${e['reason']}')));

controller.service exposes every gateway event as a stream, so an app can react to any of them without reaching into the socket. Sends are logged from the controller's message list — the package emits chat-message for you.

Step 6 — The connection, where the person can see it

When the socket is down, sending does nothing and says nothing — the message simply does not go. An app that hides the connection state produces "the send button does nothing". So the header carries it:

final (text, colour) = switch (chat.connection) {
  AppmintChatConnection.authenticated => chat.peerTyping
      ? ('typing…', primary)
      : chat.agent != null
          ? ('${chat.agent!.email} is on this chat', primary)
          : chat.queue != null
              ? ('In the queue — position ${chat.queue!.position}', amber)
              : ('Connected', primary),
  AppmintChatConnection.connecting || AppmintChatConnection.connected =>
    ('Connecting…', outline),
  AppmintChatConnection.disconnected => ('Disconnected — reconnecting', error),
  AppmintChatConnection.failed => (chat.error ?? 'Not connected', error),
};

Note that connected is not enough — the socket is up but the gateway has not yet said who you are. Sending is possible only from authenticated, and chat.canSend says so.

The header title is chat.counterpartName: "Support" until an agent picks the conversation up, then their name.

Step 7 — The thread

AppmintChatView(
  controller: _chat,
  theme: AppmintChatTheme(
    accent: theme.colorScheme.primary,
    background: theme.colorScheme.surface,
  ),
)

That is the whole thread: history on open, bubbles, the composer, typing signals, read receipts, system lines for queue and agent changes. The theme takes your colours so it does not look bolted on.

Step 8 — An agent to talk to

You need somebody on the other end. In production that is a person in the Appmint admin or Appmint Mobile. For building, the example ships tool/agent.mjs — a support agent at a terminal:

npm install socket.io-client
APPMINT_URL=https://appengine.appmint.io APPMINT_ORG=your-org \
APPMINT_APP_ID=… APPMINT_APP_KEY=… APPMINT_APP_SECRET=… \
[email protected] AGENT_PASSWORD=… \
node tool/agent.mjs

It signs in as staff, connects to /chat with its token in the handshake, sets itself online, and then does what an agent does: on queue-notification it emits pick-next, greets the customer with sendMessage, and answers every message they send. Thirty lines, and it is the clearest statement of the agent side of the contract you will find.

Step 9 — Run the whole thing

flutter run -d chrome \
  --dart-define=APPMINT_URL=https://appengine.appmint.io \
  --dart-define=APPMINT_ORG=your-org \
  --dart-define=APPMINT_APP_ID=your-app-id \
  --dart-define=APPMINT_APP_KEY=your-app-key \
  --dart-define=APPMINT_APP_SECRET=your-app-secret

With the agent running, in order, you should see:

  1. Create an account. POST /profile/customer/signup in the HTTP log, and the support screen opens.
  2. In the socket panel: → connect, then connecting, connected, authenticated, then ← authenticate role=customer. The header says Connected.
  3. Type something and press Enter. → chat-message, then ← queued position 1, 0 ahead. The thread shows You are in the queue. An agent will be with you shortly. and the header turns amber: In the queue — position 1.
  4. The agent's terminal prints queue-notification and pick-next →. Within a second: ← message system → you: Door Staff has joined the chat., ← agent-assigned Door Staff, and the header becomes the agent's address.
  5. ← message from the agent — their greeting, in a bubble on the left.
  6. Reply. → chat-message, and the agent's terminal prints update … read — the read receipt the package sent on your behalf when their message arrived — then answers.

Sign out and back in: chat-history loads the same thread, because the id is customer::{email} and never changes.

What the socket is actually doing

Read from the gateway while writing this, because the vocabulary is easy to guess wrong:

  • Authentication is in the handshake. The client never emits authenticate; the server pushes the verdict on connect. A bad token gets a refusal and a disconnect a hundred milliseconds later.
  • Sending is chat-message, not sendMessage. The gateway resolves the thread from the session; you do not pass a to. sendMessage is the agent-side event, and it needs to and chatId.
  • The queue is per email, not per socket. Reconnecting does not lose your place, and a second message while queued is stored, not re-queued.
  • queue-update arrives every fifteen seconds while you wait, and stops when an agent picks the chat up.
  • agent-assigned and the system message are two events for one moment. One is for state (hide the queue banner, show the agent), the other is for the thread. The package handles both; an app that listened to only one would either miss the line or miss the name.
  • The read receipt is yours to send. The package emits updateMessageStatus for every incoming message that is not already read; the agent sees it as update.

When it does not work

What you seeWhat it means
Header stuck on Connecting…The socket connected but the gateway never answered authenticate. Usually the URL is a proxy that does not pass WebSockets
Invalid token in the socket logThe handshake carried the app token, or no token. It must be the customer's — appmint.http.userToken after a SignedIn
Token expired. Please refresh and reconnect.The customer's token aged out and onTokenExpired is not wired. Pass it (Step 4); the package reconnects with the new token
In the queue foreverNobody is online to answer. Start the terminal agent, or sign an agent into Appmint Mobile
Messages send but no reply arrivesThe agent is on a different organization, or answering a different thread. The thread id is customer::{email} — check the agent's terminal
Disconnected — reconnectingThe transport dropped. The package retries up to ten times with backoff; nothing sent in between goes anywhere