diff --git a/lib/providers/realtime_controller.dart b/lib/providers/realtime_controller.dart new file mode 100644 index 00000000..74ab8df2 --- /dev/null +++ b/lib/providers/realtime_controller.dart @@ -0,0 +1,107 @@ +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(( + 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 { + RealtimeController(this._client) { + _init(); + } + + final SupabaseClient _client; + + bool isConnecting = false; + bool _disposed = false; + + 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 (_) {} + } + + /// Try to reconnect the realtime client using a small exponential backoff. + Future recoverConnection() async { + if (_disposed) return; + if (isConnecting) return; + + isConnecting = true; + notifyListeners(); + + try { + int attempt = 0; + int maxAttempts = 4; + int delaySeconds = 1; + while (attempt < maxAttempts && !_disposed) { + attempt++; + 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)); + + // Exit early; we don't have a reliable sync API for connection + // state across all platforms, so treat this as a best-effort + // resurrection. + break; + } catch (_) { + await Future.delayed(Duration(seconds: delaySeconds)); + delaySeconds = delaySeconds * 2; + } + } + } finally { + if (!_disposed) { + isConnecting = false; + notifyListeners(); + } + } + } + + @override + void dispose() { + _disposed = true; + super.dispose(); + } +} diff --git a/lib/services/notification_bridge.dart b/lib/services/notification_bridge.dart index b33c0d12..2ebd9e85 100644 --- a/lib/services/notification_bridge.dart +++ b/lib/services/notification_bridge.dart @@ -4,6 +4,7 @@ import 'package:firebase_messaging/firebase_messaging.dart'; import '../models/notification_item.dart'; import '../providers/notifications_provider.dart'; +import '../providers/realtime_controller.dart'; /// Wraps the app and installs both a Supabase realtime listener and the /// FCM handlers described in the frontend design. @@ -24,21 +25,35 @@ class NotificationBridge extends ConsumerStatefulWidget { ConsumerState createState() => _NotificationBridgeState(); } -class _NotificationBridgeState extends ConsumerState { +class _NotificationBridgeState extends ConsumerState + with WidgetsBindingObserver { // store previous notifications to diff List _prevList = []; @override void initState() { super.initState(); + WidgetsBinding.instance.addObserver(this); _setupFcmHandlers(); } @override void dispose() { + WidgetsBinding.instance.removeObserver(this); super.dispose(); } + @override + void didChangeAppLifecycleState(AppLifecycleState state) { + super.didChangeAppLifecycleState(state); + if (state == AppLifecycleState.resumed) { + try { + // Trigger a best-effort realtime reconnection when the app resumes. + ref.read(realtimeControllerProvider).recoverConnection(); + } catch (_) {} + } + } + void _showBanner(String type, NotificationItem item) { final ctx = widget.navigatorKey.currentState?.overlay?.context; if (ctx == null) return;