tasq/lib/providers/realtime_controller.dart

133 lines
4.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 'stream_recovery.dart';
import 'supabase_provider.dart';
final realtimeControllerProvider = ChangeNotifierProvider<RealtimeController>((
ref,
) {
final client = ref.watch(supabaseClientProvider);
// ChangeNotifierProvider automatically disposes the notifier; no need
// for ref.onDispose here — adding it causes a double-dispose assertion.
return RealtimeController(client);
});
/// Per-channel realtime controller for UI skeleton indicators.
///
/// Individual streams handle their own recovery via [StreamRecoveryWrapper].
/// This controller aggregates channel-level status so the UI can show
/// per-channel skeleton shimmers (e.g. only the tasks list shimmers when
/// the `tasks` channel is recovering, not the whole app).
///
/// Coordinates:
/// - Per-channel recovering state for pinpoint skeleton indicators
/// - Auth token refreshes for realtime connections
class RealtimeController extends ChangeNotifier {
final SupabaseClient _client;
bool _disposed = false;
/// Channels currently in a recovering/polling/stale state.
final Set<String> _recoveringChannels = {};
StreamSubscription<AuthState>? _authSub;
RealtimeController(this._client) {
_init();
}
void _init() {
try {
_authSub = _client.auth.onAuthStateChange.listen((data) {
final event = data.event;
if (event == AuthChangeEvent.tokenRefreshed) {
_ensureTokenFresh();
}
});
} catch (e) {
debugPrint('RealtimeController._init error: $e');
}
}
Future<void> _ensureTokenFresh() async {
if (_disposed) return;
try {
final authDynamic = _client.auth as dynamic;
if (authDynamic.refreshSession != null) {
await authDynamic.refreshSession?.call();
}
} catch (e) {
debugPrint('RealtimeController: token refresh failed: $e');
}
}
// ── Per-channel status ─────────────────────────────────────────────────
/// Whether a specific channel is currently recovering.
bool isChannelRecovering(String channel) =>
_recoveringChannels.contains(channel);
/// Global flag: true if **any** channel is recovering. Useful for global
/// indicators (e.g. dashboard) where per-channel granularity isn't needed.
bool get isAnyStreamRecovering => _recoveringChannels.isNotEmpty;
/// The set of channels currently recovering, for UI display.
Set<String> get recoveringChannels => Set.unmodifiable(_recoveringChannels);
/// Mark a channel as recovering. Called by [StreamRecoveryWrapper] via its
/// [ChannelStatusCallback].
void markChannelRecovering(String channel) {
if (_disposed) return;
if (_recoveringChannels.add(channel)) {
notifyListeners();
}
}
/// Mark a channel as recovered. Called when realtime reconnects
/// successfully.
void markChannelRecovered(String channel) {
if (_disposed) return;
if (_recoveringChannels.remove(channel)) {
notifyListeners();
}
}
/// Convenience callback suitable for [StreamRecoveryWrapper.onStatusChanged].
///
/// Routes [StreamConnectionStatus] to the appropriate mark method.
/// Both `connected` and `polling` are treated as "recovered" because
/// polling is a functional fallback that still delivers data — the user
/// doesn't need to see a reconnection indicator while data flows via REST.
void handleChannelStatus(String channel, StreamConnectionStatus status) {
if (status == StreamConnectionStatus.connected ||
status == StreamConnectionStatus.polling) {
markChannelRecovered(channel);
} else {
markChannelRecovering(channel);
}
}
// ── Legacy compat ─────────────────────────────────────────────────────
/// @deprecated Use [markChannelRecovering] instead.
void markStreamRecovering() {
// Kept for backward compatibility; maps to a synthetic channel.
markChannelRecovering('_global');
}
/// @deprecated Use [markChannelRecovered] instead.
void markStreamRecovered() {
markChannelRecovered('_global');
}
@override
void dispose() {
_disposed = true;
_authSub?.cancel();
super.dispose();
}
}