Documentation

Chat with customer support

Connect to AppEngine's chat WebSocket from Flutter using socket_io_client.

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.