tasq/lib/providers/typing_provider.dart

153 lines
4.1 KiB
Dart

import 'dart:async';
import 'package:flutter_riverpod/flutter_riverpod.dart';
import 'package:supabase_flutter/supabase_flutter.dart';
import 'supabase_provider.dart';
class TypingIndicatorState {
const TypingIndicatorState({
required this.userIds,
required this.channelStatus,
required this.lastPayload,
});
final Set<String> userIds;
final String channelStatus;
final Map<String, dynamic> lastPayload;
TypingIndicatorState copyWith({
Set<String>? userIds,
String? channelStatus,
Map<String, dynamic>? lastPayload,
}) {
return TypingIndicatorState(
userIds: userIds ?? this.userIds,
channelStatus: channelStatus ?? this.channelStatus,
lastPayload: lastPayload ?? this.lastPayload,
);
}
}
final typingIndicatorProvider = StateNotifierProvider.autoDispose
.family<TypingIndicatorController, TypingIndicatorState, String>((
ref,
ticketId,
) {
final client = ref.watch(supabaseClientProvider);
final controller = TypingIndicatorController(client, ticketId);
ref.onDispose(controller.dispose);
return controller;
});
class TypingIndicatorController extends StateNotifier<TypingIndicatorState> {
TypingIndicatorController(this._client, this._ticketId)
: super(
const TypingIndicatorState(
userIds: {},
channelStatus: 'init',
lastPayload: {},
),
) {
_initChannel();
}
final SupabaseClient _client;
final String _ticketId;
RealtimeChannel? _channel;
Timer? _typingTimer;
final Map<String, Timer> _remoteTimeouts = {};
void _initChannel() {
final channel = _client.channel('typing:$_ticketId');
channel.onBroadcast(
event: 'typing',
callback: (payload) {
final Map<String, dynamic> data = _extractPayload(payload);
final userId = data['user_id'] as String?;
final rawType = data['type']?.toString();
final currentUserId = _client.auth.currentUser?.id;
state = state.copyWith(lastPayload: data);
if (userId == null || userId == currentUserId) {
return;
}
if (rawType == 'stop') {
_clearRemoteTyping(userId);
return;
}
_markRemoteTyping(userId);
},
);
channel.subscribe((status, error) {
state = state.copyWith(channelStatus: status.name);
});
_channel = channel;
}
Map<String, dynamic> _extractPayload(dynamic payload) {
if (payload is Map<String, dynamic>) {
final inner = payload['payload'];
if (inner is Map<String, dynamic>) {
return inner;
}
return payload;
}
final dynamic inner = payload.payload;
if (inner is Map<String, dynamic>) {
return inner;
}
return <String, dynamic>{};
}
void userTyping() {
if (_client.auth.currentUser?.id == null) return;
_sendTypingEvent('start');
_typingTimer?.cancel();
_typingTimer = Timer(const Duration(milliseconds: 150), () {
_sendTypingEvent('stop');
});
}
void stopTyping() {
_typingTimer?.cancel();
_sendTypingEvent('stop');
}
void _markRemoteTyping(String userId) {
final updated = {...state.userIds, userId};
state = state.copyWith(userIds: updated);
_remoteTimeouts[userId]?.cancel();
_remoteTimeouts[userId] = Timer(const Duration(milliseconds: 400), () {
_clearRemoteTyping(userId);
});
}
void _clearRemoteTyping(String userId) {
final updated = {...state.userIds}..remove(userId);
state = state.copyWith(userIds: updated);
_remoteTimeouts[userId]?.cancel();
_remoteTimeouts.remove(userId);
}
void _sendTypingEvent(String type) {
final userId = _client.auth.currentUser?.id;
if (userId == null || _channel == null) return;
_channel!.sendBroadcastMessage(
event: 'typing',
payload: {'user_id': userId, 'type': type},
);
}
@override
void dispose() {
stopTyping();
_typingTimer?.cancel();
for (final timer in _remoteTimeouts.values) {
timer.cancel();
}
_remoteTimeouts.clear();
_channel?.unsubscribe();
super.dispose();
}
}