AppEngine's chat gateway runs on the /chat Socket.IO namespace and serves both the customer-side widget and the agent desk. From Flutter, you connect with socket_io_client, authenticate via JWT, and exchange messages with whatever agent (or AI assistant) the routing rules pick. This recipe covers the customer side — sending and receiving — and points to the operator side for the queue/transfer flow.
What the gateway is
A unified WebSocket namespace. Customers and agents both connect to the same /chat; the server identifies you from the JWT and routes accordingly. Events you send: sendMessage, join-chat, set-status. Events you receive: chat-message, messages, presence-change, errors. The full set lives in /Users/imzee/projects/appengine/src/chat/chat.gateway.ts — start there if you need a server-handler not covered below.
Setup
dependencies:
socket_io_client: ^3.0.0
The Flutter package speaks Socket.IO 3.x and 4.x. AppEngine runs Socket.IO 4 — make sure you're not on the older 2.x branch.
ChatService
Wrap the socket in a singleton with stream-based callbacks. The reference shape is appmint_mobile/lib/services/chat_service.dart (admin/operator) and event_app/lib/services/community_chat_service.dart (consumer). The customer-facing version below is the simpler subset.
// lib/services/chat_service.dart
import 'dart:async';
import 'package:flutter/foundation.dart';
import 'package:socket_io_client/socket_io_client.dart' as io;
import '../config/environment.dart';
class ChatService {
static final ChatService _instance = ChatService._internal();
factory ChatService() => _instance;
ChatService._internal();
io.Socket? _socket;
bool _connected = false;
final _messages = StreamController<ChatMessage>.broadcast();
final _status = StreamController<ChatStatus>.broadcast();
final _errors = StreamController<String>.broadcast();
Stream<ChatMessage> get onMessage => _messages.stream;
Stream<ChatStatus> get onStatus => _status.stream;
Stream<String> get onError => _errors.stream;
bool get isConnected => _connected;
void connect({
required String token,
required String orgId,
required String userEmail,
String? chatId,
}) {
if (_connected) return;
final endpoint = EnvironmentConfig.appengineEndpoint;
_socket = io.io(
'$endpoint/chat',
io.OptionBuilder()
.setTransports(['websocket'])
.setAuth({
'token': token,
'orgId': orgId,
'userEmail': userEmail,
if (chatId != null) 'chatId': chatId,
})
.disableAutoConnect()
.enableReconnection()
.setReconnectionDelay(2000)
.setReconnectionAttempts(10)
.build(),
);
_socket!.onConnect((_) {
_connected = true;
if (kDebugMode) debugPrint('chat connected');
});
_socket!.onDisconnect((_) {
_connected = false;
if (kDebugMode) debugPrint('chat disconnected');
});
_socket!.onConnectError((err) {
_errors.add('Connect error: $err');
});
_socket!.on('chat-message', (data) {
if (data is Map) {
_messages.add(ChatMessage.fromJson(Map<String, dynamic>.from(data)));
}
});
_socket!.on('messages', (data) {
// Initial backlog when joining a chat
if (data is List) {
for (final m in data) {
if (m is Map) {
_messages.add(ChatMessage.fromJson(Map<String, dynamic>.from(m)));
}
}
}
});
_socket!.on('presence-change', (data) {
if (data is Map) {
_status.add(ChatStatus.fromJson(Map<String, dynamic>.from(data)));
}
});
_socket!.on('error', (data) {
_errors.add(data.toString());
});
_socket!.connect();
}
/// Join an existing chat or create one keyed by the customer.
void joinChat(String chatId) {
_socket?.emit('join-chat', {'chatId': chatId});
}
/// Send a message into the active chat.
void sendMessage(String chatId, String text) {
_socket?.emit('sendMessage', {
'chatId': chatId,
'text': text,
'sentAt': DateTime.now().toIso8601String(),
});
}
/// Set typing/online presence.
void setStatus(String status) {
_socket?.emit('set-status', {'status': status});
}
void disconnect() {
_socket?.disconnect();
_socket?.dispose();
_socket = null;
_connected = false;
}
}
class ChatMessage {
final String id;
final String chatId;
final String text;
final String fromEmail;
final String? fromName;
final DateTime sentAt;
final bool fromAgent;
ChatMessage({
required this.id,
required this.chatId,
required this.text,
required this.fromEmail,
this.fromName,
required this.sentAt,
required this.fromAgent,
});
factory ChatMessage.fromJson(Map<String, dynamic> json) {
final data = (json['data'] ?? json) as Map<String, dynamic>;
return ChatMessage(
id: (json['pk'] ?? data['id'] ?? '').toString(),
chatId: (data['chatId'] ?? '').toString(),
text: (data['text'] ?? '').toString(),
fromEmail: (data['from'] ?? data['fromEmail'] ?? '').toString(),
fromName: data['fromName'] as String?,
sentAt: DateTime.tryParse(data['sentAt']?.toString() ?? '') ??
DateTime.now(),
fromAgent: data['fromAgent'] == true,
);
}
}
class ChatStatus {
final String chatId;
final String? status;
ChatStatus({required this.chatId, this.status});
factory ChatStatus.fromJson(Map<String, dynamic> json) => ChatStatus(
chatId: (json['chatId'] ?? '').toString(),
status: json['status'] as String?,
);
}
Chat screen
A scrolling message list with a composer at the bottom.
// lib/screens/chat/chat_screen.dart
import 'dart:async';
import 'package:flutter/material.dart';
import 'package:provider/provider.dart';
import '../../providers/auth_provider.dart';
import '../../services/chat_service.dart';
class ChatScreen extends StatefulWidget {
final String chatId;
const ChatScreen({super.key, required this.chatId});
@override
State<ChatScreen> createState() => _ChatScreenState();
}
class _ChatScreenState extends State<ChatScreen> {
final _input = TextEditingController();
final _scroll = ScrollController();
final _messages = <ChatMessage>[];
late final StreamSubscription _sub;
@override
void initState() {
super.initState();
final auth = context.read<AuthProvider>();
final token = auth.accessToken;
final orgId = auth.orgId;
final email = auth.user?['email'] as String? ?? '';
final chat = ChatService();
if (!chat.isConnected) {
chat.connect(
token: token!,
orgId: orgId!,
userEmail: email,
chatId: widget.chatId,
);
}
chat.joinChat(widget.chatId);
_sub = chat.onMessage.listen((m) {
if (m.chatId != widget.chatId) return;
setState(() => _messages.add(m));
WidgetsBinding.instance.addPostFrameCallback((_) {
if (_scroll.hasClients) {
_scroll.animateTo(
_scroll.position.maxScrollExtent + 100,
duration: const Duration(milliseconds: 200),
curve: Curves.easeOut,
);
}
});
});
}
void _send() {
final text = _input.text.trim();
if (text.isEmpty) return;
ChatService().sendMessage(widget.chatId, text);
_input.clear();
}
@override
void dispose() {
_sub.cancel();
_input.dispose();
_scroll.dispose();
super.dispose();
}
@override
Widget build(BuildContext context) {
return Scaffold(
appBar: AppBar(title: const Text('Support')),
body: Column(
children: [
Expanded(
child: ListView.builder(
controller: _scroll,
padding: const EdgeInsets.all(8),
itemCount: _messages.length,
itemBuilder: (_, i) => _Bubble(message: _messages[i]),
),
),
SafeArea(
top: false,
child: Padding(
padding: const EdgeInsets.all(8),
child: Row(
children: [
Expanded(
child: TextField(
controller: _input,
onSubmitted: (_) => _send(),
decoration: const InputDecoration(
hintText: 'Type a message',
border: OutlineInputBorder(),
),
),
),
IconButton(
icon: const Icon(Icons.send),
onPressed: _send,
),
],
),
),
),
],
),
);
}
}
class _Bubble extends StatelessWidget {
final ChatMessage message;
const _Bubble({required this.message});
@override
Widget build(BuildContext context) {
final mine = !message.fromAgent;
return Align(
alignment: mine ? Alignment.centerRight : Alignment.centerLeft,
child: Container(
margin: const EdgeInsets.symmetric(vertical: 4),
padding: const EdgeInsets.symmetric(horizontal: 12, vertical: 8),
decoration: BoxDecoration(
color: mine ? Colors.blue.shade100 : Colors.grey.shade200,
borderRadius: BorderRadius.circular(12),
),
child: Text(message.text),
),
);
}
}
Reconnection
socket_io_client reconnects automatically when configured with enableReconnection(). The default backoff is 1s, doubling up to a 5s cap; we override to a 2s base with 10 attempts. If the user resumes the app after a long sleep, the socket reconnects within a few seconds — the server replays buffered messages on messages.
If you want a manual reconnect button (because the user clearly knows their wifi is back), call _socket?.connect() again. It's a no-op if already connected.
AI assist and human handoff
The same gateway services AI agents and human operators. Customer-side, you don't care which one is replying — both arrive on the chat-message event with a data.fromAgent flag and a data.fromName you can show in the bubble. The transfer from AI to human happens server-side; the UI updates by itself when the next message comes in from a different sender.
If you want to surface "an agent is joining" or "you've been transferred", listen on:
_socket!.on('chat-assigned', (data) {
// { chatId, agentName, agentEmail }
// Show a system message in the chat
});
Authentication
The socket auth is the same JWT you use for REST. Pass it in the auth payload — Socket.IO sends it during the handshake and the gateway validates it on connection. If the JWT is expired, the connection fails with an error event. Refresh and reconnect — there is no auto-refresh on the socket itself.
Disconnecting cleanly
Always call ChatService().disconnect() when the user signs out. A connected socket survives across screens but should not survive across users.
Future<void> signOut() async {
ChatService().disconnect();
await authProvider.signOut();
}
The next recipe — Community feed — uses the parallel /community-chat namespace plus REST endpoints to render a social feed.