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); final controller = RealtimeController(client); ref.onDispose(controller.dispose); return controller; }); /// 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 = {}; RealtimeController(this._client) { _init(); } void _init() { try { _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 (_recoveringChannels.add(channel)) { notifyListeners(); } } /// Mark a channel as recovered. Called when realtime reconnects /// successfully. void markChannelRecovered(String channel) { if (_recoveringChannels.remove(channel)) { notifyListeners(); } } /// Convenience callback suitable for [StreamRecoveryWrapper.onStatusChanged]. /// /// Routes [StreamConnectionStatus] to the appropriate mark method. void handleChannelStatus(String channel, StreamConnectionStatus status) { if (status == StreamConnectionStatus.connected) { 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; super.dispose(); } }