tasq/lib/providers/realtime_controller.dart

130 lines
3.7 KiB
Dart

import 'dart:async';
import 'package:flutter/foundation.dart';
import 'package:flutter/material.dart';
import 'package:flutter_riverpod/flutter_riverpod.dart';
import 'package:supabase_flutter/supabase_flutter.dart';
import 'supabase_provider.dart';
final realtimeControllerProvider = ChangeNotifierProvider<RealtimeController>((
ref,
) {
final client = ref.watch(supabaseClientProvider);
final controller = RealtimeController(client);
ref.onDispose(controller.dispose);
return controller;
});
/// A lightweight controller that attempts to recover the Supabase Realtime
/// connection when the app returns to the foreground or when auth tokens
/// are refreshed.
class RealtimeController extends ChangeNotifier {
final SupabaseClient _client;
bool isConnecting = false;
bool isFailed = false;
String? lastError;
int attempts = 0;
final int maxAttempts;
bool _disposed = false;
RealtimeController(this._client, {this.maxAttempts = 4}) {
_init();
}
void _init() {
try {
// Listen for auth changes and try to recover the realtime connection
_client.auth.onAuthStateChange.listen((data) {
final event = data.event;
if (event == AuthChangeEvent.tokenRefreshed ||
event == AuthChangeEvent.signedIn) {
recoverConnection();
}
});
} catch (e) {
debugPrint('RealtimeController._init error: $e');
}
}
/// Try to reconnect the realtime client using a small exponential backoff.
Future<void> recoverConnection() async {
if (_disposed) return;
if (isConnecting) return;
isFailed = false;
lastError = null;
isConnecting = true;
notifyListeners();
try {
int delaySeconds = 1;
while (attempts < maxAttempts && !_disposed) {
attempts++;
try {
// Best-effort disconnect then connect so the realtime client picks
// up any refreshed tokens.
try {
// Try to refresh session/token if the SDK supports it. Use dynamic
// to avoid depending on a specific SDK version symbol.
try {
await (_client.auth as dynamic).refreshSession?.call();
} catch (_) {}
// Best-effort disconnect then connect so the realtime client picks
// up any refreshed tokens. The realtime connect/disconnect are
// marked internal by the SDK; suppress the lint here since this
// is a deliberate best-effort recovery.
// ignore: invalid_use_of_internal_member
_client.realtime.disconnect();
} catch (_) {}
await Future.delayed(const Duration(milliseconds: 300));
try {
// ignore: invalid_use_of_internal_member
_client.realtime.connect();
} catch (_) {}
// Give the socket a moment to stabilise.
await Future.delayed(const Duration(seconds: 1));
// Success (best-effort). Reset attempt counter and clear failure.
attempts = 0;
isFailed = false;
lastError = null;
break;
} catch (e) {
lastError = e.toString();
if (attempts >= maxAttempts) {
isFailed = true;
break;
}
await Future.delayed(Duration(seconds: delaySeconds));
delaySeconds = delaySeconds * 2;
}
}
} finally {
if (!_disposed) {
isConnecting = false;
notifyListeners();
}
}
}
/// Retry a failed recovery attempt.
Future<void> retry() async {
if (_disposed) return;
attempts = 0;
isFailed = false;
lastError = null;
notifyListeners();
await recoverConnection();
}
@override
void dispose() {
_disposed = true;
super.dispose();
}
}