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_demoThe 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_demoTwo 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_chatflutter pub getDelete 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.mjsIt 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-secretWith the agent running, in order, you should see:
- Create an account.
POST /profile/customer/signupin the HTTP log, and the support screen opens. - In the socket panel:
→ connect, thenconnecting,connected,authenticated, then← authenticate role=customer. The header says Connected. - 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. - The agent's terminal prints
queue-notificationandpick-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. ← messagefrom the agent — their greeting, in a bubble on the left.- Reply.
→ chat-message, and the agent's terminal printsupdate … 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, notsendMessage. The gateway resolves the thread from the session; you do not pass ato.sendMessageis the agent-side event, and it needstoandchatId. - 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-updatearrives every fifteen seconds while you wait, and stops when an agent picks the chat up.agent-assignedand 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
updateMessageStatusfor every incoming message that is not alreadyread; the agent sees it asupdate.
When it does not work
| What you see | What 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 log | The 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 forever | Nobody is online to answer. Start the terminal agent, or sign an agent into Appmint Mobile |
| Messages send but no reply arrives | The agent is on a different organization, or answering a different thread. The thread id is customer::{email} — check the agent's terminal |
| Disconnected — reconnecting | The transport dropped. The package retries up to ten times with backoff; nothing sent in between goes anywhere |