# Flutter Chat Presence Integration

This contract prevents duplicate notification noise while a recipient is
already reading the exact support conversation.

## Expected behavior

| Recipient state | Save message | Database notification | Socket.IO | FCM |
|---|---:|---:|---:|---:|
| Viewing this conversation | Yes | No | Yes | No |
| Outside this conversation | Yes | Yes | If connected | Yes, when enabled |

The REST API remains responsible for saving messages. Socket.IO delivers new
messages and tracks presence; Flutter must not send chat messages through the
generic Socket.IO `message` event.

## Socket connection

Use the same JWT returned by the Laravel login endpoint. The backend reads the
user ID from the verified JWT, so Flutter must never send a `user_id` in a
presence event.

```yaml
dependencies:
  socket_io_client: ^3.1.2
```

```dart
import 'package:socket_io_client/socket_io_client.dart' as io;

io.Socket createSocket({
  required String socketUrl,
  required String jwt,
}) {
  return io.io(
    socketUrl,
    io.OptionBuilder()
        .setTransports(['websocket'])
        .disableAutoConnect()
        .setAuth({'token': jwt})
        .enableReconnection()
        .build(),
  );
}
```

Create one shared socket per signed-in user. Do not create and disconnect a
new socket for every chat page.

## Presence controller

Call `start()` after the conversation screen is visible and `dispose()` when
the screen is removed. The controller re-enters after reconnect, leaves while
the app is backgrounded, and sends a heartbeat every 20 seconds. The server
expires presence after 60 seconds if a leave event is lost.

```dart
import 'dart:async';
import 'package:flutter/widgets.dart';
import 'package:socket_io_client/socket_io_client.dart' as io;

class ChatPresenceController with WidgetsBindingObserver {
  ChatPresenceController({
    required this.socket,
    required this.conversationId,
  });

  final io.Socket socket;
  final int conversationId;

  Timer? _heartbeat;
  bool _started = false;
  bool _foreground = true;
  bool _routeVisible = true;

  bool get _shouldBePresent =>
      _started && _foreground && _routeVisible;

  void start() {
    if (_started) return;

    _started = true;
    WidgetsBinding.instance.addObserver(this);
    socket.onConnect(_handleConnect);

    _syncPresence();
  }

  void _handleConnect(dynamic _) {
    if (_shouldBePresent) {
      _enter();
    }
  }

  void _enter() {
    if (!socket.connected || !_shouldBePresent) return;

    socket.emitWithAck(
      'conversation:enter',
      {'conversation_id': conversationId},
      ack: _handleAck,
    );

    _heartbeat?.cancel();
    _heartbeat = Timer.periodic(const Duration(seconds: 20), (_) {
      if (socket.connected && _shouldBePresent) {
        socket.emit('conversation:heartbeat', {
          'conversation_id': conversationId,
        });
      }
    });
  }

  void _leave() {
    _heartbeat?.cancel();
    _heartbeat = null;

    if (socket.connected) {
      socket.emit('conversation:leave', {
        'conversation_id': conversationId,
      });
    }
  }

  void _syncPresence() {
    _shouldBePresent ? _enter() : _leave();
  }

  void setRouteVisible(bool visible) {
    if (_routeVisible == visible) return;

    _routeVisible = visible;
    _syncPresence();
  }

  void _handleAck(dynamic response) {
    if (response is Map && response['ok'] != true) {
      // Send this to the app logger/monitoring service.
      final error = response['error'];
      debugPrint('Chat presence rejected: $error');
    }
  }

  @override
  void didChangeAppLifecycleState(AppLifecycleState state) {
    final isForeground = state == AppLifecycleState.resumed;
    if (_foreground == isForeground) return;

    _foreground = isForeground;
    _syncPresence();
  }

  void dispose() {
    if (!_started) return;

    _started = false;
    _leave();
    socket.off('connect', _handleConnect);
    WidgetsBinding.instance.removeObserver(this);
  }
}
```

## Route visibility

Flutter normally keeps the previous route mounted when another page is pushed
over it. Therefore, `dispose()` alone is not enough. Register a shared
`RouteObserver` in `MaterialApp`, then pause presence from `didPushNext()` and
restore it from `didPopNext()`.

```dart
final RouteObserver<ModalRoute<void>> routeObserver =
    RouteObserver<ModalRoute<void>>();

MaterialApp(
  navigatorObservers: [routeObserver],
  // ...
);
```

Example conversation screen lifecycle:

```dart
class _ConversationScreenState extends State<ConversationScreen>
    with RouteAware {
  late final ChatPresenceController presence;
  ModalRoute<void>? _route;

  @override
  void initState() {
    super.initState();
    presence = ChatPresenceController(
      socket: socketService.socket,
      conversationId: widget.conversationId,
    )..start();
  }

  @override
  void didChangeDependencies() {
    super.didChangeDependencies();
    final route = ModalRoute.of<void>(context);
    if (route == null || route == _route) return;

    if (_route != null) routeObserver.unsubscribe(this);
    _route = route;
    routeObserver.subscribe(this, route);
  }

  @override
  void didPush() => presence.setRouteVisible(true);

  @override
  void didPopNext() => presence.setRouteVisible(true);

  @override
  void didPushNext() => presence.setRouteVisible(false);

  @override
  void dispose() {
    routeObserver.unsubscribe(this);
    presence.dispose();
    super.dispose();
  }
}
```

## Receiving messages

The event name contains the notification type, conversation ID, and the
authenticated recipient ID:

```text
VN-support_reply:{conversationId}:{userId}
VN-support_message:{conversationId}:{userId}
```

Customers normally listen for `support_reply`; admins listen for
`support_message`. Subscribe when the screen opens and always remove the exact
handler in `dispose()`.

```dart
late final String eventName;

void subscribeToMessages(int currentUserId, int conversationId) {
  eventName = 'VN-support_reply:$conversationId:$currentUserId';
  socket.on(eventName, _onMessage);
}

void _onMessage(dynamic payload) {
  // Parse payload, deduplicate by message id, then append to visible messages.
}

void unsubscribeFromMessages() {
  socket.off(eventName, _onMessage);
}
```

After any reconnect, refetch conversation history through REST. Socket delivery
is realtime and fire-and-forget, so the history endpoint remains the source of
truth. Deduplicate using the message `id` because the REST refresh and socket
event can contain the same message.

## Required environment agreement

Laravel and Socket.IO must use the same Redis instance/database and the same:

```dotenv
CHAT_PRESENCE_KEY_PREFIX=chat:presence:
```

Socket.IO also uses:

```dotenv
CHAT_PRESENCE_TTL_SECONDS=60
```

Do not emit `join_room` for conversations. The server automatically joins only
the authenticated `user:{id}` room and rejects attempts to join other users'
rooms.
