feat: First initialization of laravel_echo_client.dart

This commit is contained in:
Diyor Khanazarov
2025-10-07 16:55:06 +05:00
commit 46a64b67ce
79 changed files with 2297 additions and 0 deletions
+7
View File
@@ -0,0 +1,7 @@
library laravel_echo_client;
export 'src/api/socket_channel.dart';
export 'src/api/socket_client.dart';
export 'src/config/socket_config.dart';
export 'src/socket_event.dart';
export 'src/client/laravel_echo_socket_client.dart';
+23
View File
@@ -0,0 +1,23 @@
import 'package:flutter_laravel_echo_client/src/core/types.dart';
abstract class SocketChannel {
String get name;
SocketChannel listen(String event, SocketEventHandler handler);
SocketChannel stopListening(String event, {SocketEventHandler? handler});
Future<void> unsubscribe();
}
abstract class SocketPrivateChannel extends SocketChannel {
void whisper(String event, Map<String, dynamic> data);
}
abstract class SocketPresenceChannel extends SocketChannel {
SocketPresenceChannel here(void Function(List<Map<String, dynamic>>) handler);
SocketPresenceChannel joining(void Function(Map<String, dynamic>) handler);
SocketPresenceChannel leaving(void Function(Map<String, dynamic>) handler);
}
+25
View File
@@ -0,0 +1,25 @@
import 'package:flutter_laravel_echo_client/laravel_echo_client.dart';
abstract class SocketClient {
Future<void> connect();
Future<void> disconnect({bool force = false});
bool get isConnected;
void updateAuthToken(String token);
SocketChannel channel(String name);
SocketPrivateChannel private(String name);
SocketPresenceChannel presence(String name);
Stream<void> get onConnected;
Stream<void> get onDisconnected;
Stream<Object> get onError;
Stream<int> get onReconnectAttempt;
}
+106
View File
@@ -0,0 +1,106 @@
import 'package:flutter_laravel_echo_client/src/api/socket_channel.dart';
import 'package:flutter_laravel_echo_client/src/core/auth.dart';
import 'package:flutter_laravel_echo_client/src/core/payloads.dart';
import 'package:flutter_laravel_echo_client/src/core/types.dart';
import 'package:socket_io_client/socket_io_client.dart' as io;
abstract class BaseChannel implements SocketChannel {
BaseChannel(this.name, this.socket, this._authProvider);
@override
final String name;
/// Публичный сокет (раньше было _socket — private для другого файла).
io.Socket? socket;
/// Провайдер заголовков для auth в subscribe/unsubscribe.
final AuthHeadersProvider? _authProvider;
final Map<String, List<SocketEventHandler>> _handlers = {};
final Map<String, void Function(dynamic)> _aggregators = {};
final Map<String, String> _boundEventName = {};
void bind(io.Socket newSocket) {
socket = newSocket;
// перевесить агрегаторы после реконнекта
for (final entry in _aggregators.entries) {
final user = entry.key;
final bound = _boundEventName[user] ?? normalizeEvent(user);
socket?.on(bound, entry.value);
}
}
void subscribe() => socket?.emit('subscribe', subscribePayload(name, _authProvider));
void resubscribe() => subscribe();
@override
SocketChannel listen(String event, SocketEventHandler handler) {
final userEvent = event;
final socketEvent = normalizeEvent(userEvent);
_handlers.putIfAbsent(userEvent, () => []).add(handler);
_aggregators.putIfAbsent(userEvent, () {
void aggregator(dynamic payload) {
Map<String, dynamic>? data;
if (payload is Map) {
data = Map<String, dynamic>.from(payload);
} else if (payload is List && payload.isNotEmpty) {
final last = payload.last;
if (last is Map) data = Map<String, dynamic>.from(last);
}
if (data != null) {
final hs = _handlers[userEvent];
if (hs != null) {
for (final h in List.of(hs)) {
h(userEvent, data);
}
}
}
}
socket?.on(socketEvent, aggregator);
_boundEventName[userEvent] = socketEvent;
return aggregator;
});
return this;
}
@override
SocketChannel stopListening(String event, {SocketEventHandler? handler}) {
final list = _handlers[event];
if (list == null) return this;
if (handler == null) {
list.clear();
} else {
list.remove(handler);
}
if (list.isEmpty) {
final aggregator = _aggregators.remove(event);
final bound = _boundEventName.remove(event) ?? normalizeEvent(event);
if (aggregator != null) socket?.off(bound, aggregator);
_handlers.remove(event);
}
return this;
}
@override
Future<void> unsubscribe() async {
socket?.emit('unsubscribe', unsubscribePayload(name, _authProvider));
for (final entry in _aggregators.entries) {
final user = entry.key;
final bound = _boundEventName[user] ?? normalizeEvent(user);
socket?.off(bound, entry.value);
}
_aggregators.clear();
_handlers.clear();
_boundEventName.clear();
}
}
+27
View File
@@ -0,0 +1,27 @@
import 'package:flutter_laravel_echo_client/src/api/socket_channel.dart';
import 'package:flutter_laravel_echo_client/src/channels/private_channel.dart';
class PresenceChannel extends PrivateChannel implements SocketPresenceChannel {
PresenceChannel(super.name, super.socket, super.p);
@override
SocketPresenceChannel here(void Function(List<Map<String, dynamic>>) cb) {
listen('presence:subscribed', (_, data) {
final users = (data['users'] as List?)?.cast<Map>() ?? const [];
cb(users.map((e) => Map<String, dynamic>.from(e)).toList());
});
return this;
}
@override
SocketPresenceChannel joining(void Function(Map<String, dynamic>) cb) {
listen('presence:joining', (_, data) => cb(Map<String, dynamic>.from(data)));
return this;
}
@override
SocketPresenceChannel leaving(void Function(Map<String, dynamic>) cb) {
listen('presence:leaving', (_, data) => cb(Map<String, dynamic>.from(data)));
return this;
}
}
+11
View File
@@ -0,0 +1,11 @@
import 'package:flutter_laravel_echo_client/src/api/socket_channel.dart';
import 'package:flutter_laravel_echo_client/src/channels/base_channel.dart';
class PrivateChannel extends BaseChannel implements SocketPrivateChannel {
PrivateChannel(super.name, super.socket, super.p);
@override
void whisper(String event, Map<String, dynamic> data) {
socket?.emit('client event', {'channel': name, 'event': 'client-$event', 'data': data});
}
}
+5
View File
@@ -0,0 +1,5 @@
import 'package:flutter_laravel_echo_client/src/channels/base_channel.dart';
class PublicChannel extends BaseChannel {
PublicChannel(super.name, super.socket, super.p);
}
@@ -0,0 +1,141 @@
import 'dart:async';
import 'package:flutter_laravel_echo_client/src/api/socket_channel.dart';
import 'package:flutter_laravel_echo_client/src/channels/base_channel.dart';
import 'package:flutter_laravel_echo_client/src/channels/presence_channel.dart';
import 'package:flutter_laravel_echo_client/src/channels/private_channel.dart';
import 'package:flutter_laravel_echo_client/src/channels/public_channel.dart';
import 'package:flutter_laravel_echo_client/src/config/socket_config.dart';
import 'package:flutter_laravel_echo_client/src/core/auth.dart';
import 'package:flutter_laravel_echo_client/src/core/channel_registry.dart';
import 'package:socket_io_client/socket_io_client.dart' as io;
enum _ChannelType { public, private, presence }
class LaravelEchoSocketClient {
LaravelEchoSocketClient(SocketConfig config) : _config = config.copyWith() {
_auth = _config.authHeadersProvider ?? bearerProvider(_config.token); // дефолтный Bearer
}
SocketConfig _config;
late AuthHeadersProvider _auth;
io.Socket? _socket;
final _onConnected = StreamController<void>.broadcast();
final _onDisconnected = StreamController<void>.broadcast();
final _onError = StreamController<Object>.broadcast();
final _onReconnectAttempt = StreamController<int>.broadcast();
final _channels = ChannelRegistry<BaseChannel>();
bool get isConnected => _socket?.connected ?? false;
Stream<void> get onConnected => _onConnected.stream;
Stream<void> get onDisconnected => _onDisconnected.stream;
Stream<Object> get onError => _onError.stream;
Stream<int> get onReconnectAttempt => _onReconnectAttempt.stream;
Future<void> connect() async {
if (_socket?.connected ?? false) return;
final opts =
io.OptionBuilder()
.setTransports(_config.transports)
.setPath(_config.path)
.setQuery(_config.query ?? {})
.setReconnectionDelay(_config.reconnectionDelay.inMilliseconds)
.setReconnectionDelayMax(_config.reconnectionDelayMax.inMilliseconds)
.setTimeout(_config.ackTimeout.inMilliseconds)
.build();
_socket = io.io(_config.host.toString(), opts);
_socket!
..onConnect((_) {
_onConnected.add(null);
for (final ch in _channels.values) {
ch
..bind(_socket!)
..resubscribe();
}
})
..onDisconnect((_) => _onDisconnected.add(null))
..onConnectError((e) => _onError.add(e))
..onError((e) => _onError.add(e))
..onReconnectAttempt((n) => _onReconnectAttempt.add(n is int ? n : 0));
}
Future<void> disconnect({bool force = false}) async {
for (final ch in _channels.values) {
await ch.unsubscribe();
}
_channels.clear();
final s = _socket;
_socket = null;
if (s != null) {
s
..off('connect')
..off('disconnect')
..off('connect_error')
..off('error')
..off('reconnect_attempt')
..disconnect();
if (force) s.dispose();
}
}
/// Мягкое обновление токена: меняем провайдер и переподключаемся.
void updateAuthToken(String token) {
_config = _config.copyWith(token: token);
_auth = _config.authHeadersProvider ?? bearerProvider(token);
final s = _socket;
if (s != null) {
s.io
..disconnect()
..connect();
}
}
SocketChannel channel(String name) => _getOrCreate(name, _ChannelType.public);
SocketPrivateChannel private(String name) => _getOrCreate(name, _ChannelType.private) as SocketPrivateChannel;
SocketPresenceChannel presence(String name) => _getOrCreate(name, _ChannelType.presence) as SocketPresenceChannel;
void leave(String name) {
for (final t in _ChannelType.values) {
leaveChannel(_fullName(name, t));
}
}
void leaveChannel(String fullName) {
final ch = _channels.remove(fullName);
ch?.unsubscribe();
}
// ---- helpers ----
BaseChannel _getOrCreate(String name, _ChannelType type) {
final full = _fullName(name, type);
return _channels.putIfAbsent(full, () {
final ch = switch (type) {
_ChannelType.public => PublicChannel(full, _socket, _auth),
_ChannelType.private => PrivateChannel(full, _socket, _auth),
_ChannelType.presence => PresenceChannel(full, _socket, _auth),
}..subscribe();
return ch;
});
}
String _fullName(String name, _ChannelType type) => switch (type) {
_ChannelType.public => name,
_ChannelType.private => 'private-$name',
_ChannelType.presence => 'presence-$name',
};
}
+41
View File
@@ -0,0 +1,41 @@
import 'package:flutter_laravel_echo_client/src/core/auth.dart';
class SocketConfig {
const SocketConfig({
required this.host,
this.token,
this.authHeadersProvider,
this.query,
this.transports = const ['websocket'],
this.ackTimeout = const Duration(seconds: 20),
this.reconnectionDelay = const Duration(seconds: 1),
this.reconnectionDelayMax = const Duration(seconds: 5),
this.reconnectionAttempts = 0,
this.path = '/socket.io',
});
final Uri host;
final String? token;
final AuthHeadersProvider? authHeadersProvider;
final Map<String, dynamic>? query;
final List<String> transports;
final Duration ackTimeout;
final Duration reconnectionDelay;
final Duration reconnectionDelayMax;
final int reconnectionAttempts;
final String path;
SocketConfig copyWith({String? token}) => SocketConfig(
host: host,
token: token ?? this.token,
authHeadersProvider: authHeadersProvider ?? bearerProvider(token ?? this.token),
query: query,
transports: transports,
ackTimeout: ackTimeout,
reconnectionDelay: reconnectionDelay,
reconnectionDelayMax: reconnectionDelayMax,
reconnectionAttempts: reconnectionAttempts,
path: path,
);
}
+8
View File
@@ -0,0 +1,8 @@
typedef AuthHeadersProvider = Map<String, String> Function();
AuthHeadersProvider bearerProvider(String? token) {
return () {
if (token == null || token.isEmpty) return <String, String>{};
return {'Authorization': 'Bearer $token'};
};
}
+19
View File
@@ -0,0 +1,19 @@
import 'package:flutter_laravel_echo_client/laravel_echo_client.dart';
class ChannelRegistry<C extends SocketChannel> {
final _map = <String, C>{};
C? get(String fullName) => _map[fullName];
C putIfAbsent(String fullName, C Function() builder) {
return _map.putIfAbsent(fullName, builder);
}
C? remove(String fullName) => _map.remove(fullName);
Iterable<C> get values => _map.values;
void clear() => _map.clear();
bool contains(String fullName) => _map.containsKey(fullName);
}
+14
View File
@@ -0,0 +1,14 @@
import 'package:flutter_laravel_echo_client/src/core/auth.dart';
Map<String, dynamic> subscribePayload(String channel, AuthHeadersProvider? provider) {
final payload = <String, dynamic>{'channel': channel};
final headers = provider?.call();
if (headers != null && headers.isNotEmpty) {
payload['auth'] = {'headers': headers};
}
return payload;
}
Map<String, dynamic> unsubscribePayload(String channel, AuthHeadersProvider? provider) {
return subscribePayload(channel, provider);
}
+7
View File
@@ -0,0 +1,7 @@
typedef SocketEventHandler = void Function(String event, Map<String, dynamic> data);
/// ".chat.foo" -> "chat.foo"; "chat.foo" -> "chat.foo"
String normalizeEvent(String userEvent) => userEvent.startsWith('.') ? userEvent.substring(1) : userEvent;
/// "chat.foo" -> ".chat.foo"
String userEventName(String socketEvent) => '.$socketEvent';
+9
View File
@@ -0,0 +1,9 @@
class SocketEvent {
SocketEvent({required this.channel, required this.event, required this.data, DateTime? receivedAt})
: receivedAt = receivedAt ?? DateTime.now();
final String channel;
final String event;
final Map<String, dynamic> data;
final DateTime receivedAt;
}