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(( 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 _recoveringChannels = {}; StreamSubscription? _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 _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 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(); } }