inverge_websocket

Flutter client SDK for the Inverge WebSocket service. Room-based realtime messaging over Socket.IO with an app-wide provider, context.socket access from any page, automatic reconnect and room re-join, ack futures, and a connection-status stream.

Features

  • 🔌 One-line app-wide setup with WebSocketInitializer.
  • 📍 context.socket anywhere — plus InvergeWebSocket.instance for blocs/services.
  • 🏠 Room helpers: joinRoom / leaveRoom (and bulk), with automatic re-join after a reconnect (a fresh socket has no room membership — this fixes the classic "events stop after reconnect" bug).
  • ✅ Ack futures: await socket.joinRoom(...), await socket.emitWithAck(...).
  • 📶 ConnectionStatus stream for reactive UI.
  • 🔁 Reconnect on app resume; optional disconnect on background.
  • 🧭 Room helper to build recipient-scoped names (order:8821).

Install

Run this command:

$ dart pub add inverge_websocket

With Flutter:

$ flutter pub add inverge_websocket

This will add a line like this to your package's pubspec.yaml (and run an implicit dart pub get / flutter pub get):

dependencies:
  inverge_websocket: ^1.1.0

Quick start

Wrap your app once near the root:

import 'package:inverge_websocket/inverge_websocket.dart';

WebSocketInitializer(
  config: const WebSocketConfig(
    baseUrl: 'https://websocket.inverge.net',
    apiKey: 'your_api_key',
  ),
  child: MaterialApp.router(routerConfig: appRouter),
);

Then use it from any page via context.socket:

@override
void initState() {
  super.initState();
  context.socket.joinRoom(Room.scoped('order', order.id));
  context.socket.on('order.updated', _onUpdate);
}

@override
void dispose() {
  context.socket.off('order.updated', _onUpdate);
  context.socket.leaveRoom(Room.scoped('order', order.id));
  super.dispose();
}

From a bloc/service (no BuildContext), use the singleton:

InvergeWebSocket.instance.emit('typing', {'orderId': 8821});

Rooms

final socket = context.socket;

await socket.joinRoom('order:8821');           // returns the server ack
await socket.joinRooms(['order:8821', 'ops']);
await socket.leaveRoom('order:8821');

socket.joinedRooms;                            // currently tracked rooms

Joined rooms are remembered and re-joined automatically after a reconnect (disable with rejoinRoomsOnReconnect: false). Calling joinRoom while disconnected is fine — it joins on the next connect.

Scope rooms by recipient, not by topic. A room is a delivery list — join a room only if you want every message sent there. Use Room.scoped('order', id) rather than one big room filtered by event name.

Events & acks

socket.on('order.updated', (data) { /* ... */ });
socket.once('welcome', (data) { /* ... */ });
socket.off('order.updated');                   // remove all handlers for event

socket.emit('typing', {'orderId': 8821});      // fire-and-forget
final ack = await socket.emitWithAck('ping', {}); // await the server ack

Connection status

StreamBuilder<ConnectionStatus>(
  stream: context.socket.statusStream,
  initialData: context.socket.status,
  builder: (context, snap) => Text(snap.data!.name),
);

context.socket.isConnected; // bool
context.socket.id;          // current socket id (or null)

Configuration

Option Default Description
baseUrl — Service origin, e.g. https://websocket.inverge.net.
apiKey — Tenant key, sent in the handshake auth payload.
transports ['websocket','polling'] Socket.IO transports.
autoConnect true Connect on initialize.
reconnection true Enable Socket.IO reconnection.
reconnectionAttempts 5 Max attempts.
reconnectionDelay 1000ms Delay between attempts.
connectTimeout 20s Handshake timeout.
ackTimeout 10s Default timeout for ack emits.
rejoinRoomsOnReconnect true Re-join tracked rooms after reconnect.
enableLogging false Log lifecycle/room ops via debugPrint.
extraAuth {} Extra fields merged into the handshake auth.
sendPlatformInfo true Report the client OS type + version at connection time so the service can identify / filter each session by platform.
query {} Extra connection query params.

Lifecycle & ownership

WebSocketInitializer(config: ...) creates and owns the client — it connects on start, reconnects on resume, and disposes on teardown. To manage the client yourself, build it and pass it in:

final client = InvergeWebSocket.initialize(config);
WebSocketInitializer.client(client: client, child: app);

Native emit (Android & iOS background)

The socket client above lives in the Dart/UI layer. For backend-style emits from native code — an Android foreground LocationService, an iOS background task — where a live socket (and even the Flutter engine) may be gone, use the native HTTP emitter. It posts to the same /partner/rooms/... API the server SDKs use, is dependency-free (Android HttpURLConnection, iOS URLSession), and its credentials can be persisted so a background service restores them.

Configure once from Dart (persists to native storage):

await WebSocketNative.configure(
  baseUrl: 'https://websocket.inverge.net',
  apiKey: 'your_api_key',
); // persist: true by default -> Android SharedPreferences("native_env"), iOS UserDefaults

// Emit from Dart via the native HTTP path (one or more events):
await WebSocketNative.emit('order:8821', 'location', payload: {'lat': 1.0, 'lng': 2.0});
await WebSocketNative.emit('order:8821', ['location', 'audit'], payload: {'lat': 1.0});

// Emit to SEVERAL rooms in one request with broadcast(). Each message accepts
// `room`/`name`, `event` (String) or `events` (List<String>), and `payload`:
final result = await WebSocketNative.broadcast([
  {'room': 'order:8821', 'event': 'location', 'payload': {'lat': 1.0, 'lng': 2.0}},
  {'room': 'ops',        'events': ['location'], 'payload': {'orderId': 8821}},
]);
debugPrint('broadcast -> ${result.statusCode} ok=${result.success}'); // NativeEmitResult

Emit from native Android (Kotlin) — e.g. inside a foreground service, off the main thread:

import net.inverge.websocket.InvergeWebSocket

// Restore the credentials Dart persisted (safe to call repeatedly):
InvergeWebSocket.loadFrom(applicationContext)

InvergeWebSocket.emit(
    room = "order:$orderId",
    event = "location",
    payload = mapOf(
        "lat" to location.latitude,
        "lng" to location.longitude,
        "speed" to location.speed,
        "accuracy" to location.accuracy,
        "timestamp" to location.time,
    ),
) { result ->
    if (!result.success) Log.w("LocationService", "emit failed: ${result.error}")
}

// MULTIPLE EVENTS to one room — pass a list:
InvergeWebSocket.emit(
    room = "order:$orderId",
    events = listOf("location", "audit"),
    payload = mapOf("lat" to lat, "lng" to lng),
)

// MULTIPLE ROOMS in one request — broadcast(). Each message map takes
// "room"/"name", "event" (String) or "events" (List<String>), and "payload":
InvergeWebSocket.broadcast(
    listOf(
        mapOf("room" to "order:$orderId", "event" to "location", "payload" to mapOf("lat" to lat, "lng" to lng)),
        mapOf("room" to "ops", "events" to listOf("location"), "payload" to mapOf("orderId" to orderId)),
    ),
) { result ->
    Log.d("LocationService", "broadcast -> ${result.statusCode} ok=${result.success}")
}

// Or, inside an existing coroutine / worker thread, the blocking form
// (single room, one or more events):
val result = InvergeWebSocket.emitBlocking(
    room = "order:$orderId",
    events = listOf("location", "audit"),
    payload = JSONObject().put("lat", lat).put("lng", lng),
)

Emit from native iOS (Swift):

InvergeWebSocket.shared.loadFromDefaults()

// Single event:
InvergeWebSocket.shared.emit(room: "order:\(orderId)", event: "location",
                            payload: ["lat": lat, "lng": lng])

// MULTIPLE EVENTS to one room:
InvergeWebSocket.shared.emit(room: "order:\(orderId)", events: ["location", "audit"],
                            payload: ["lat": lat, "lng": lng])

// MULTIPLE ROOMS in one request — broadcast(). Each message takes
// "room"/"name", "event" (String) or "events" ([String]), and "payload":
InvergeWebSocket.shared.broadcast([
  ["room": "order:\(orderId)", "event": "location", "payload": ["lat": lat, "lng": lng]],
  ["room": "ops", "events": ["location"], "payload": ["orderId": orderId]],
]) { result in
  NSLog("broadcast -> \(result.statusCode) ok=\(result.success)")
}

This replaces hand-rolled HttpURLConnection/URLSession emit code in native services with one shared, correct implementation.

Native emit is Android/iOS only; the Dart WebSocketNative calls throw MissingPluginException on other platforms.

Full method reference

Every public method, with a usage example.

WebSocketConfig

// Construct (only baseUrl + apiKey are required):
const config = WebSocketConfig(
  baseUrl: 'https://websocket.inverge.net',
  apiKey: 'your_api_key',
  transports: ['websocket', 'polling'],
  autoConnect: true,
  reconnection: true,
  reconnectionAttempts: 5,
  reconnectionDelay: Duration(milliseconds: 1000),
  connectTimeout: Duration(seconds: 20),
  ackTimeout: Duration(seconds: 10),
  rejoinRoomsOnReconnect: true,
  enableLogging: false,
  extraAuth: {'deviceId': 'abc'},   // merged into the handshake auth
  query: {'v': '1'},                // extra connection query params
);

// Copy with overrides:
final verbose = config.copyWith(enableLogging: true);

WebSocketInitializer (widget)

// Owns the client (connects, reconnects on resume, disposes on teardown):
WebSocketInitializer(
  config: config,
  reconnectOnResume: true,        // reconnect when app returns to foreground
  disconnectOnBackground: false,  // keep the socket while backgrounded
  child: myApp,
);

// Or provide a client you built yourself:
WebSocketInitializer.client(client: myClient, child: myApp);

InvergeWebSocket (client)

// Create the app-wide instance (used by WebSocketInitializer under the hood):
final ws = InvergeWebSocket.initialize(config);

// App-wide access from non-widget code (blocs/services):
InvergeWebSocket.instance;        // the client (throws if not initialized)
InvergeWebSocket.hasInstance;     // bool — safe to check first

// Connection lifecycle:
ws.connect();                     // open (no-op if already connected)
ws.disconnect();                  // close but keep reusable (rooms remembered)
ws.dispose();                     // permanent teardown (closes the status stream)

// State getters:
ws.isConnected;                   // bool
ws.status;                        // ConnectionStatus
ws.id;                            // String? current socket id
ws.statusStream;                  // Stream<ConnectionStatus>
ws.joinedRooms;                   // Set<String> tracked rooms
ws.socket;                        // the raw socket_io_client Socket (advanced)
ws.config;                        // the WebSocketConfig in use

// Rooms (all return the server ack future):
await ws.joinRoom('order:8821');
await ws.leaveRoom('order:8821');
await ws.joinRooms(['order:8821', 'ops']);
await ws.leaveRooms(['order:8821', 'ops']);

// Events:
ws.on('order.updated', (data) { /* ... */ });   // subscribe
ws.once('welcome', (data) { /* ... */ });        // subscribe once
ws.off('order.updated');                          // remove all handlers for event
ws.off('order.updated', myHandler);               // remove one handler

// Emitting:
ws.emit('typing', {'orderId': 8821});             // fire-and-forget
final ack = await ws.emitWithAck('ping', {});     // await server ack
final ack2 = await ws.emitWithAck('ping', {}, Duration(seconds: 3)); // custom timeout

ConnectionStatus (enum)

ConnectionStatus.disconnected;
ConnectionStatus.connecting;
ConnectionStatus.connected;
ConnectionStatus.reconnecting;

ConnectionStatus.connected.isConnected;   // true; false for the others

Room (scoped names)

Room.scoped('order', 8821);          // 'order:8821'
Room.of('pilot', 17);                // 'pilot:17'  (alias of scoped)
Room.path(['order', 8821, 'chat']);  // 'order:8821:chat'

WebSocketNative (Android/iOS HTTP emit)

// Configure once (persist:true stores creds in native storage):
await WebSocketNative.configure(
  baseUrl: 'https://websocket.inverge.net',
  apiKey: 'your_api_key',
  persist: true,
);

// Emit one event, or a list of events:
final r1 = await WebSocketNative.emit('order:8821', 'location', payload: {'lat': 1.0});
final r2 = await WebSocketNative.emit('order:8821', ['location', 'audit']);

// Emit to several rooms in one request:
final r3 = await WebSocketNative.broadcast([
  {'room': 'order:8821', 'event': 'location', 'payload': {'lat': 1.0}},
  {'room': 'ops', 'events': ['location'], 'payload': {'orderId': 8821}},
]);

// NativeEmitResult:
r3.success;     // bool
r3.statusCode;  // int (HTTP status)
r3.body;        // String? response body
r3.error;       // String? error code/message when !success

Migrating from a hand-rolled manager

Replace your singleton WebSocketManager + context.socket with this package:

  • WebSocketManager.instance → InvergeWebSocket.instance (or context.socket).
  • Hardcoded Env.socketBaseUrl / Env.socketApiKey → WebSocketConfig.
  • Room joins now survive reconnects automatically, and join/leave return futures.

License

MIT.

Libraries

inverge_websocket
Flutter client SDK for the Inverge WebSocket service.