dart_phoenix_socket 0.1.0 copy "dart_phoenix_socket: ^0.1.0" to clipboard
dart_phoenix_socket: ^0.1.0 copied to clipboard

A lightweight Phoenix Channel V2 client for Dart and Flutter. Supports automatic reconnection, push buffering, heartbeat, and Phoenix Presence.

example/lib/main.dart

/// Flutter chat app example using dart_phoenix_socket.
///
/// Shows the recommended patterns for a real Flutter app:
///   - PhoenixSocketManager as a ChangeNotifier in the Provider tree
///   - Connect on login, disconnect on logout
///   - Join channels lazily and leave on dispose
///   - Fire-and-forget push vs. push-with-reply
///   - Reconnection state shown in the UI
library;

import 'dart:async';

import 'package:flutter/material.dart';
import 'package:dart_phoenix_socket/dart_phoenix_socket.dart';

// ---------------------------------------------------------------------------
// main
// ---------------------------------------------------------------------------

void main() {
  runApp(
    ChangeNotifierProvider(
      create: (_) => PhoenixSocketManager(),
      child: const MyApp(),
    ),
  );
}

class MyApp extends StatelessWidget {
  const MyApp({super.key});

  @override
  Widget build(BuildContext context) {
    return MaterialApp(
      title: 'dart_phoenix_socket demo',
      home: const LoginScreen(),
    );
  }
}

// ---------------------------------------------------------------------------
// PhoenixSocketManager — put this in lib/services/
// ---------------------------------------------------------------------------

/// Manages the Phoenix WebSocket connection for the whole app.
///
/// Add to your Provider tree once at the root. Call [connect] after the user
/// logs in and [disconnect] when they log out.
class PhoenixSocketManager extends ChangeNotifier {
  PhoenixSocket? _socket;
  StreamSubscription<PhoenixSocketState>? _stateSub;

  PhoenixSocketState _socketState = PhoenixSocketState.disconnected;
  PhoenixSocketState get socketState => _socketState;

  bool get isConnected => _socketState == PhoenixSocketState.connected;

  // ── connect / disconnect ──────────────────────────────────────────────────

  Future<void> connect({required String url, String? token}) async {
    if (_socket != null) return; // already connecting or connected

    _socket = PhoenixSocket(
      url,
      params: token != null ? {'token': token} : {},
    );

    _stateSub = _socket!.states.listen((state) {
      _socketState = state;
      notifyListeners();
    });

    await _socket!.connect();
  }

  Future<void> disconnect() async {
    await _stateSub?.cancel();
    _stateSub = null;
    await _socket?.disconnect();
    _socket = null;
    _socketState = PhoenixSocketState.disconnected;
    notifyListeners();
  }

  // ── channel helpers ───────────────────────────────────────────────────────

  /// Returns the channel for [topic], joining it if needed.
  ///
  /// The socket must be connected before calling this.
  PhoenixChannel channel(String topic) {
    assert(_socket != null, 'Call connect() before channel()');
    return _socket!.channel(topic);
  }
}

// ---------------------------------------------------------------------------
// Simple ChangeNotifierProvider shim (no extra package needed for the example)
// ---------------------------------------------------------------------------

class ChangeNotifierProvider<T extends ChangeNotifier> extends StatefulWidget {
  final T Function(BuildContext) create;
  final Widget child;

  const ChangeNotifierProvider({
    super.key,
    required this.create,
    required this.child,
  });

  static T of<T extends ChangeNotifier>(BuildContext context) {
    return context
        .dependOnInheritedWidgetOfExactType<_InheritedNotifier<T>>()!
        .notifier;
  }

  @override
  State<ChangeNotifierProvider<T>> createState() =>
      _ChangeNotifierProviderState<T>();
}

class _ChangeNotifierProviderState<T extends ChangeNotifier>
    extends State<ChangeNotifierProvider<T>> {
  late final T _notifier;

  @override
  void initState() {
    super.initState();
    _notifier = widget.create(context);
    _notifier.addListener(_rebuild);
  }

  void _rebuild() => setState(() {});

  @override
  void dispose() {
    _notifier.removeListener(_rebuild);
    super.dispose();
  }

  @override
  Widget build(BuildContext context) {
    return _InheritedNotifier(notifier: _notifier, child: widget.child);
  }
}

class _InheritedNotifier<T extends ChangeNotifier> extends InheritedWidget {
  final T notifier;

  const _InheritedNotifier({required this.notifier, required super.child});

  @override
  bool updateShouldNotify(_InheritedNotifier<T> old) => true;
}

// ---------------------------------------------------------------------------
// Login screen
// ---------------------------------------------------------------------------

class LoginScreen extends StatelessWidget {
  const LoginScreen({super.key});

  @override
  Widget build(BuildContext context) {
    final manager = ChangeNotifierProvider.of<PhoenixSocketManager>(context);

    return Scaffold(
      appBar: AppBar(title: const Text('Login')),
      body: Center(
        child: ElevatedButton(
          onPressed: manager.isConnected
              ? null
              : () async {
                  await manager.connect(
                    url: 'ws://localhost:4000/socket/websocket',
                    token: 'demo-token',
                  );
                  if (context.mounted) {
                    Navigator.of(context).push(
                      MaterialPageRoute(
                        builder: (_) => ChangeNotifierProvider(
                          create: (_) => ChatRoomNotifier(
                            manager: manager,
                            topic: 'room:lobby',
                          ),
                          child: const ChatScreen(),
                        ),
                      ),
                    );
                  }
                },
          child: const Text('Enter lobby'),
        ),
      ),
    );
  }
}

// ---------------------------------------------------------------------------
// ChatRoomNotifier — one per channel, put this in lib/features/chat/
// ---------------------------------------------------------------------------

/// Drives the chat UI for a single Phoenix channel.
///
/// Joins the channel on creation, leaves on dispose.
class ChatRoomNotifier extends ChangeNotifier {
  final PhoenixSocketManager manager;
  final String topic;

  late final PhoenixChannel _channel;
  StreamSubscription<PhoenixMessage>? _msgSub;

  final List<ChatMessage> messages = [];
  String? error;
  bool joined = false;

  ChatRoomNotifier({required this.manager, required this.topic}) {
    _join();
  }

  Future<void> _join() async {
    _channel = manager.channel(topic);

    // Listen for server-broadcast events BEFORE joining so we don't miss
    // messages that arrive in the same microtask as the join reply.
    _msgSub = _channel.messages.listen(_onMessage);

    try {
      await _channel.join();
      joined = true;
      error = null;
    } on PhoenixException catch (e) {
      // Server refused the join — e.g. authentication failed, room full.
      error = 'Join refused: ${e.message}';
      if (e.response != null) {
        error = '$error (${e.response})';
      }
    } on TimeoutException {
      // Server never replied. Channel is in `errored` state and will
      // auto-rejoin after the socket reconnects.
      error = 'Join timed out — will retry on reconnect';
    }
    notifyListeners();
  }

  void _onMessage(PhoenixMessage msg) {
    switch (msg.event) {
      case 'new_msg':
        final body = msg.payload['body'] as String? ?? '';
        final user = msg.payload['user'] as String? ?? 'unknown';
        messages.add(ChatMessage(user: user, body: body));
        notifyListeners();

      case 'user_joined':
        final user = msg.payload['user'] as String? ?? 'someone';
        messages.add(ChatMessage(user: 'system', body: '$user joined'));
        notifyListeners();

      default:
        // Ignore unknown events
        break;
    }
  }

  /// Sends a chat message.
  ///
  /// Returns true on success, false on error (with [error] set).
  Future<bool> sendMessage(String body) async {
    try {
      await _channel.push('new_msg', {'body': body});
      return true;
    } on PhoenixException catch (e) {
      // Server rejected the push — e.g. rate limited, message too long.
      error = 'Message rejected: ${e.message}';
      notifyListeners();
      return false;
    } on TimeoutException {
      error = 'Message timed out — server may not have received it';
      notifyListeners();
      return false;
    } on StateError catch (e) {
      // Channel was closed (e.g. socket.disconnect() was called).
      error = e.message;
      notifyListeners();
      return false;
    }
  }

  /// Fire-and-forget — tell the server the user is typing.
  void sendTyping() {
    // .ignore() suppresses the Future error so it doesn't become an
    // unhandled exception if the push fails (network drop, etc.)
    _channel.push('typing', {}).ignore();
  }

  @override
  void dispose() {
    _msgSub?.cancel();
    _channel.leave().ignore(); // best-effort; don't await in dispose
    super.dispose();
  }
}

class ChatMessage {
  final String user;
  final String body;
  ChatMessage({required this.user, required this.body});
}

// ---------------------------------------------------------------------------
// Chat screen
// ---------------------------------------------------------------------------

class ChatScreen extends StatefulWidget {
  const ChatScreen({super.key});

  @override
  State<ChatScreen> createState() => _ChatScreenState();
}

class _ChatScreenState extends State<ChatScreen> {
  final _controller = TextEditingController();

  @override
  void dispose() {
    _controller.dispose();
    super.dispose();
  }

  @override
  Widget build(BuildContext context) {
    final notifier = ChangeNotifierProvider.of<ChatRoomNotifier>(context);
    final socket = ChangeNotifierProvider.of<PhoenixSocketManager>(context);

    return Scaffold(
      appBar: AppBar(
        title: const Text('room:lobby'),
        actions: [
          // Show reconnection state in the app bar
          Padding(
            padding: const EdgeInsets.only(right: 12),
            child: _SocketStateBadge(state: socket.socketState),
          ),
        ],
      ),
      body: Column(
        children: [
          // Error banner
          if (notifier.error != null)
            MaterialBanner(
              content: Text(notifier.error!),
              actions: [
                TextButton(
                  onPressed: () {
                    notifier.error = null;
                    notifier.notifyListeners();
                  },
                  child: const Text('Dismiss'),
                ),
              ],
            ),

          // Message list
          Expanded(
            child: ListView.builder(
              padding: const EdgeInsets.all(12),
              itemCount: notifier.messages.length,
              itemBuilder: (_, i) {
                final msg = notifier.messages[i];
                return Padding(
                  padding: const EdgeInsets.symmetric(vertical: 2),
                  child: Text('[${msg.user}] ${msg.body}'),
                );
              },
            ),
          ),

          // Input row
          Padding(
            padding: const EdgeInsets.all(8),
            child: Row(
              children: [
                Expanded(
                  child: TextField(
                    controller: _controller,
                    onChanged: (_) => notifier.sendTyping(),
                    decoration:
                        const InputDecoration(hintText: 'Type a message…'),
                  ),
                ),
                IconButton(
                  icon: const Icon(Icons.send),
                  onPressed: notifier.joined
                      ? () async {
                          final text = _controller.text.trim();
                          if (text.isEmpty) return;
                          _controller.clear();
                          await notifier.sendMessage(text);
                        }
                      : null,
                ),
              ],
            ),
          ),
        ],
      ),
    );
  }
}

class _SocketStateBadge extends StatelessWidget {
  final PhoenixSocketState state;
  const _SocketStateBadge({required this.state});

  @override
  Widget build(BuildContext context) {
    final (label, color) = switch (state) {
      PhoenixSocketState.connected => ('connected', Colors.green),
      PhoenixSocketState.connecting => ('connecting…', Colors.orange),
      PhoenixSocketState.reconnecting => ('reconnecting…', Colors.orange),
      PhoenixSocketState.disconnected => ('offline', Colors.red),
    };
    return Chip(
      label: Text(label, style: const TextStyle(fontSize: 11)),
      backgroundColor: color.withOpacity(0.15),
      side: BorderSide(color: color),
    );
  }
}
1
likes
160
points
32
downloads

Documentation

API reference

Publisher

unverified uploader

Weekly Downloads

A lightweight Phoenix Channel V2 client for Dart and Flutter. Supports automatic reconnection, push buffering, heartbeat, and Phoenix Presence.

Repository (GitHub)
View/report issues

Topics

#phoenix #websocket #channels #realtime #flutter

License

MIT (license)

Dependencies

meta, web_socket_channel

More

Packages that depend on dart_phoenix_socket