Offline Support

This commit is contained in:
2026-04-27 06:20:39 +08:00
parent 7d8851a94a
commit 48cd3bcd60
121 changed files with 14798 additions and 2214 deletions
+61 -10
View File
@@ -4,6 +4,7 @@ import 'package:flutter/foundation.dart';
import 'package:flutter_riverpod/flutter_riverpod.dart';
import 'package:supabase_flutter/supabase_flutter.dart';
import 'connectivity_provider.dart';
import 'supabase_provider.dart';
class TypingIndicatorState {
@@ -37,24 +38,47 @@ final typingIndicatorProvider =
String
>((ref, ticketId) {
final client = ref.watch(supabaseClientProvider);
final controller = TypingIndicatorController(client, ticketId);
final controller = TypingIndicatorController(
client,
ticketId,
isOnline: () => ref.read(isOnlineProvider),
);
// Pause the realtime channel when the device goes offline so Supabase's
// internal reconnection loop stops firing WebSocket errors.
// Resume (re-subscribe) when connectivity returns.
ref.listen<bool>(isOnlineProvider, (previous, next) {
if (next == false) {
controller.pauseForOffline();
} else if (previous != true && next == true) {
// previous is null on first call after a provider rebuild — treat
// null and false both as "was offline" to ensure the channel resumes.
controller.resumeFromOffline();
}
});
return controller;
});
class TypingIndicatorController extends StateNotifier<TypingIndicatorState> {
TypingIndicatorController(this._client, this._ticketId)
: super(
const TypingIndicatorState(
userIds: {},
channelStatus: 'init',
lastPayload: {},
),
) {
TypingIndicatorController(
this._client,
this._ticketId, {
bool Function()? isOnline,
}) : _isOnline = isOnline ?? (() => true),
super(
const TypingIndicatorState(
userIds: {},
channelStatus: 'init',
lastPayload: {},
),
) {
_initChannel();
}
final SupabaseClient _client;
final String _ticketId;
final bool Function() _isOnline;
RealtimeChannel? _channel;
Timer? _typingTimer;
final Map<String, Timer> _remoteTimeouts = {};
@@ -94,7 +118,9 @@ class TypingIndicatorController extends StateNotifier<TypingIndicatorState> {
try {
if (_disposed || !mounted) return;
state = state.copyWith(channelStatus: status.name);
if (error != null) {
if (error != null && _isOnline()) {
// Only log when online — offline WebSocket errors are expected and
// handled by pauseForOffline() which unsubscribes the channel.
debugPrint('TypingIndicatorController: subscribe error: $error');
}
} catch (e, st) {
@@ -235,6 +261,31 @@ class TypingIndicatorController extends StateNotifier<TypingIndicatorState> {
);
}
/// Called when the device goes offline.
///
/// Unsubscribes the Supabase channel so its internal WebSocket reconnection
/// loop stops firing errors. The channel object is retained so
/// [resumeFromOffline] can cleanly re-subscribe on the same ticket.
void pauseForOffline() {
if (_disposed) return;
debugPrint('TypingIndicatorController[$_ticketId]: offline — unsubscribing');
_channel?.unsubscribe();
}
/// Called when the device comes back online.
///
/// Tears down the stale channel and opens a fresh subscription so broadcast
/// events are received again without any missed-message gaps.
void resumeFromOffline() {
if (_disposed) return;
debugPrint(
'TypingIndicatorController[$_ticketId]: back online — resubscribing',
);
_channel?.unsubscribe();
_channel = null;
_initChannel();
}
// Exposed for tests only: simulate a remote typing broadcast.
@visibleForTesting
void debugSimulateRemoteTyping(String userId, {bool stop = false}) {