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 userIds; final String channelStatus; final Map lastPayload; TypingIndicatorState copyWith({ Set? userIds, String? channelStatus, Map? lastPayload, }) { return TypingIndicatorState( userIds: userIds ?? this.userIds, channelStatus: channelStatus ?? this.channelStatus, lastPayload: lastPayload ?? this.lastPayload, ); } } final typingIndicatorProvider = StateNotifierProvider.autoDispose .family(( ref, ticketId, ) { final client = ref.watch(supabaseClientProvider); final controller = TypingIndicatorController(client, ticketId); ref.onDispose(controller.dispose); return controller; }); class TypingIndicatorController extends StateNotifier { 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 _remoteTimeouts = {}; void _initChannel() { final channel = _client.channel('typing:$_ticketId'); channel.onBroadcast( event: 'typing', callback: (payload) { final Map 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 _extractPayload(dynamic payload) { if (payload is Map) { final inner = payload['payload']; if (inner is Map) { return inner; } return payload; } final dynamic inner = payload.payload; if (inner is Map) { return inner; } return {}; } 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(); } }