import 'dart:async'; import 'package:flutter/foundation.dart'; 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); 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 = {}; // Marked when dispose() starts to prevent late async callbacks mutating state. bool _disposed = false; void _initChannel() { final channel = _client.channel('typing:$_ticketId'); channel.onBroadcast( event: 'typing', callback: (payload) { // Prevent any work if we're already disposing. Log stack for diagnostics. if (_disposed || !mounted) { if (kDebugMode) { debugPrint( 'TypingIndicatorController: onBroadcast skipped (disposed|unmounted)', ); debugPrint(StackTrace.current.toString()); } return; } final Map data = _extractPayload(payload); final userId = data['user_id'] as String?; final rawType = data['type']?.toString(); final currentUserId = _client.auth.currentUser?.id; if (_disposed || !mounted) { if (kDebugMode) { debugPrint( 'TypingIndicatorController: payload received but controller disposed/unmounted', ); debugPrint(StackTrace.current.toString()); } return; } state = state.copyWith(lastPayload: data); if (userId == null || userId == currentUserId) { return; } if (rawType == 'stop') { _clearRemoteTyping(userId); return; } _markRemoteTyping(userId); }, ); channel.subscribe((status, error) { if (_disposed || !mounted) { if (kDebugMode) { debugPrint( 'TypingIndicatorController: subscribe callback skipped (disposed|unmounted)', ); debugPrint(StackTrace.current.toString()); } return; } 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 (_disposed || !mounted) { if (kDebugMode) { debugPrint( 'TypingIndicatorController.userTyping() ignored after dispose', ); } return; } if (_client.auth.currentUser?.id == null) return; _sendTypingEvent('start'); _typingTimer?.cancel(); _typingTimer = Timer(const Duration(milliseconds: 150), () { if (_disposed || !mounted) { if (kDebugMode) { debugPrint( 'TypingIndicatorController._typingTimer callback ignored after dispose', ); } return; } _sendTypingEvent('stop'); }); } void stopTyping() { if (_disposed || !mounted) { if (kDebugMode) { debugPrint( 'TypingIndicatorController.stopTyping() ignored after dispose', ); } return; } _typingTimer?.cancel(); _sendTypingEvent('stop'); } void _markRemoteTyping(String userId) { if (_disposed || !mounted) { if (kDebugMode) { debugPrint( 'TypingIndicatorController._markRemoteTyping ignored after dispose for user: $userId', ); debugPrint(StackTrace.current.toString()); } return; } final updated = {...state.userIds, userId}; if (_disposed || !mounted) return; state = state.copyWith(userIds: updated); _remoteTimeouts[userId]?.cancel(); _remoteTimeouts[userId] = Timer(const Duration(milliseconds: 400), () { if (_disposed || !mounted) { if (kDebugMode) { debugPrint( 'TypingIndicatorController.remote timeout callback ignored after dispose for user: $userId', ); } return; } _clearRemoteTyping(userId); }); } void _clearRemoteTyping(String userId) { if (_disposed || !mounted) { if (kDebugMode) { debugPrint( 'TypingIndicatorController._clearRemoteTyping ignored after dispose for user: $userId', ); } return; } final updated = {...state.userIds}..remove(userId); if (_disposed || !mounted) return; state = state.copyWith(userIds: updated); _remoteTimeouts[userId]?.cancel(); _remoteTimeouts.remove(userId); } void _sendTypingEvent(String type) { if (_disposed || !mounted) return; final userId = _client.auth.currentUser?.id; if (userId == null || _channel == null) return; _channel!.sendBroadcastMessage( event: 'typing', payload: {'user_id': userId, 'type': type}, ); } // Exposed for tests only: simulate a remote typing broadcast. @visibleForTesting void debugSimulateRemoteTyping(String userId, {bool stop = false}) { if (_disposed || !mounted) return; final data = {'user_id': userId, 'type': stop ? 'stop' : 'start'}; state = state.copyWith(lastPayload: data); if (stop) { _clearRemoteTyping(userId); } else { _markRemoteTyping(userId); } } @override void dispose() { // Mark disposed first so any late async callbacks will no-op. _disposed = true; // Cancel local timers and remote timeouts; do NOT send network events during // dispose (prevents broadcasts from re-entering callbacks after disposal). _typingTimer?.cancel(); for (final timer in _remoteTimeouts.values) { timer.cancel(); } _remoteTimeouts.clear(); // Unsubscribe from realtime channel. _channel?.unsubscribe(); _channel = null; super.dispose(); } }