tasq/lib/providers/typing_provider.dart

271 lines
8.4 KiB
Dart

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<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.family<
TypingIndicatorController,
TypingIndicatorState,
String
>((ref, ticketId) {
final client = ref.watch(supabaseClientProvider);
final controller = TypingIndicatorController(client, ticketId);
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 = {};
// 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) {
try {
if (_disposed || !mounted) return;
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;
if (_disposed || !mounted) return;
state = state.copyWith(lastPayload: data);
if (userId == null || userId == currentUserId) {
return;
}
if (rawType == 'stop') {
_clearRemoteTyping(userId);
return;
}
_markRemoteTyping(userId);
} catch (e, st) {
debugPrint(
'TypingIndicatorController: broadcast callback error: $e\n$st',
);
}
},
);
channel.subscribe((status, error) {
try {
if (_disposed || !mounted) return;
state = state.copyWith(channelStatus: status.name);
if (error != null) {
debugPrint('TypingIndicatorController: subscribe error: $error');
}
} catch (e, st) {
debugPrint(
'TypingIndicatorController: subscribe callback error: $e\n$st',
);
}
});
_channel = channel;
}
Map<String, dynamic> _extractPayload(dynamic payload) {
// The realtime client can wrap the actual broadcast payload inside
// several nested fields (e.g. {payload: {payload: {...}}}). Walk the
// object until we find a map containing `user_id` or `type` keys which
// represent the actual typing payload.
try {
dynamic current = payload;
for (var i = 0; i < 6; i++) {
if (current is Map<String, dynamic>) {
// Only return when we actually find the `user_id`, otherwise try
// to unwrap nested envelopes. Some wrappers include `type: broadcast`
// at the top-level which should not be treated as the message.
if (current.containsKey('user_id')) {
return Map<String, dynamic>.from(current);
}
if (current.containsKey('payload') &&
current['payload'] is Map<String, dynamic>) {
current = current['payload'];
continue;
}
// Some realtime envelope stores the payload at `data`.
if (current.containsKey('data') &&
current['data'] is Map<String, dynamic>) {
current = current['data'];
continue;
}
break;
}
// Try common field on wrapper objects (e.g. RealtimeMessage.payload)
try {
final dyn = (current as dynamic).payload;
if (dyn is Map<String, dynamic>) {
current = dyn;
continue;
}
} catch (_) {
break;
}
}
} catch (_) {}
// As a last-resort, do a shallow recursive search for a map containing
// `user_id` in case the realtime client used a Map<dynamic,dynamic>
// shape that wasn't caught above.
try {
Map<String, dynamic>? found;
void search(dynamic node, int depth) {
if (found != null || depth > 4) return;
if (node is Map) {
try {
final m = Map<String, dynamic>.from(node);
if (m.containsKey('user_id')) {
found = m;
return;
}
for (final v in m.values) {
search(v, depth + 1);
if (found != null) return;
}
} catch (_) {
// ignore conversion errors
}
} else if (node is Iterable) {
for (final v in node) {
search(v, depth + 1);
if (found != null) return;
}
}
}
search(payload, 0);
if (found != null) return found!;
} catch (_) {}
return <String, dynamic>{};
}
void userTyping() {
if (_disposed || !mounted) return;
if (_client.auth.currentUser?.id == null) return;
_sendTypingEvent('start');
_typingTimer?.cancel();
// Debounce sending the stop event slightly so quick pauses don't spam
// the network. 150ms was short and caused frequent start/stop bursts;
// increase to 600ms to stabilize UX.
_typingTimer = Timer(const Duration(milliseconds: 600), () {
if (_disposed || !mounted) return;
_sendTypingEvent('stop');
});
}
void stopTyping() {
if (_disposed || !mounted) return;
_typingTimer?.cancel();
_sendTypingEvent('stop');
}
void _markRemoteTyping(String userId) {
if (_disposed || !mounted) return;
final updated = {...state.userIds, userId};
if (_disposed || !mounted) return;
state = state.copyWith(userIds: updated);
_remoteTimeouts[userId]?.cancel();
// Extend timeout to 2500ms to accommodate brief realtime interruptions
// (auth refresh, channel reconnect, message processing, etc.) without
// clearing the presence. This gives a smoother typing experience.
_remoteTimeouts[userId] = Timer(const Duration(milliseconds: 3500), () {
if (_disposed || !mounted) return;
_clearRemoteTyping(userId);
});
}
void _clearRemoteTyping(String userId) {
if (_disposed || !mounted) 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();
}
}