import 'dart:async'; import 'dart:convert'; import 'package:crypto/crypto.dart'; import '../models/post_models.dart'; import '../models/invitation_models.dart'; class CachedData { final T data; final DateTime timestamp; final String jsonHash; CachedData({ required this.data, required this.timestamp, required this.jsonHash, }); bool isExpired(Duration maxAge) { return DateTime.now().difference(timestamp) > maxAge; } } class CacheService { static final Map _cache = {}; static final Map _pollingTimers = {}; static final Map _streamControllers = {}; // Cache durations static const Duration _postsCacheDuration = Duration(seconds: 30); static const Duration _invitationsCacheDuration = Duration(seconds: 30); static const Duration _userCacheDuration = Duration(hours: 3); // Polling intervals static const Duration _postsPollingInterval = Duration(seconds: 5); static const Duration _invitationsPollingInterval = Duration(seconds: 5); static const Duration _userPollingInterval = Duration(hours: 3); /// Get cached data if available and not expired, otherwise return null static T? getCached(String key) { final cached = _cache[key]; if (cached != null) { return cached.data as T?; } return null; } /// Get cached data regardless of expiration (for showing stale data while loading) static T? getCachedStale(String key) { final cached = _cache[key]; if (cached != null) { return cached.data as T?; } return null; } /// Check if cache is expired static bool isCacheExpired(String key, Duration maxAge) { final cached = _cache[key]; if (cached == null) return true; return cached.isExpired(maxAge); } /// Set cached data with JSON hash for change detection static void setCached(String key, T data) { final jsonHash = _generateDataHash(data); _cache[key] = CachedData( data: data, timestamp: DateTime.now(), jsonHash: jsonHash, ); } /// Check if data has changed by comparing hashes static bool hasDataChanged(String key, dynamic newData) { final cached = _cache[key]; if (cached == null) return true; final newJsonHash = _generateDataHash(newData); final hasChanged = cached.jsonHash != newJsonHash; if (hasChanged) { print( 'Data changed for $key: old hash ${cached.jsonHash}, new hash $newJsonHash', ); } else { print('No data changes for $key: hash remains ${cached.jsonHash}'); } return hasChanged; } /// Generate a reliable hash for data comparison static String _generateDataHash(dynamic data) { try { String jsonString; if (data is List) { // Handle lists of objects (like List) final jsonList = data.map((item) { if (item is Map) { return item; } else if (item.runtimeType.toString().contains('Post')) { // Call toJson() on Post objects return (item as dynamic).toJson(); } else if (item.runtimeType.toString().contains('Invitation')) { // Call toJson() on Invitation objects return (item as dynamic).toJson(); } else { return item.toString(); } }).toList(); jsonString = jsonEncode(jsonList); } else if (data is Map) { jsonString = jsonEncode(data); } else if (data.runtimeType.toString().contains('Post') || data.runtimeType.toString().contains('Invitation')) { // Call toJson() on model objects jsonString = jsonEncode((data as dynamic).toJson()); } else { jsonString = data.toString(); } // Generate MD5 hash for comparison final bytes = utf8.encode(jsonString); final digest = md5.convert(bytes); final hashResult = digest.toString(); // Debug: Show first 100 chars of JSON for debugging final debugJson = jsonString.length > 100 ? jsonString.substring(0, 100) + '...' : jsonString; print('Generated hash $hashResult for JSON: $debugJson'); return hashResult; } catch (e) { print('Error generating data hash: $e'); // Fallback to simple string hash return data.toString().hashCode.toString(); } } /// Get stream for cache updates static Stream getStream(String key) { if (!_streamControllers.containsKey(key)) { _streamControllers[key] = StreamController.broadcast(); } return (_streamControllers[key] as StreamController).stream; } /// Notify stream listeners of cache updates (safely) - Force UI refresh static void notifyStreamListeners(String key, T data) { try { // Ensure we have a stream controller if (!_streamControllers.containsKey(key)) { print('Creating new stream controller for $key'); _streamControllers[key] = StreamController.broadcast(); } final controller = _streamControllers[key] as StreamController; if (!controller.isClosed) { print('Notifying stream listeners for $key with ${data.runtimeType}'); controller.add(data); } else { print('Stream controller for $key is closed, creating new one'); _streamControllers[key] = StreamController.broadcast(); (_streamControllers[key] as StreamController).add(data); } } catch (e) { print('Error notifying stream listeners for $key: $e'); // Recreate controller on error try { _streamControllers[key]?.close(); _streamControllers[key] = StreamController.broadcast(); (_streamControllers[key] as StreamController).add(data); } catch (recreateError) { print('Failed to recreate stream controller for $key: $recreateError'); } } } /// Start polling for a specific endpoint (silent - no UI notifications) static void startPolling( String key, Future> Function() fetchFunction, Duration interval, Duration cacheMaxAge, ) { // Cancel existing timer if any stopPolling(key); _pollingTimers[key] = Timer.periodic(interval, (timer) async { try { print('Polling $key...'); final result = await fetchFunction(); if (result['success'] == true) { // Use the parsed data from the result (e.g., result['posts'] for posts) final data = result['posts'] ?? result['invitations'] ?? result['data']; print('📊 Polling $key got data type: ${data.runtimeType}, length: ${data is List ? data.length : 'N/A'}'); // Check if data has changed BEFORE updating cache final dataChanged = hasDataChanged(key, data); if (dataChanged) { print( '🔄 Data changed for $key, updating cache and forcing UI refresh', ); setCached(key, data); // Force refresh by notifying stream listeners with appropriate types if (key == postsAllKey && data is List) { print('📱 Notifying posts stream with ${data.length} posts'); notifyStreamListeners>(key, data); } else if (key == invitationsAllKey && data is InvitationsData) { print('📱 Notifying invitations stream'); notifyStreamListeners(key, data); } else { print('📱 Notifying generic stream for $key with ${data.runtimeType}'); notifyStreamListeners(key, data); } } else { print('No data changes for $key - not notifying streams'); } } else { print('Polling failed for $key: ${result['message']}'); } } catch (e) { print('Polling error for $key: $e'); } }); } /// Stop polling for a specific endpoint static void stopPolling(String key) { _pollingTimers[key]?.cancel(); _pollingTimers.remove(key); } /// Stop all polling static void stopAllPolling() { for (final timer in _pollingTimers.values) { timer.cancel(); } _pollingTimers.clear(); } /// Clear all caches static void clearAll() { _cache.clear(); stopAllPolling(); _cleanupStreamControllers(); } /// Safely cleanup stream controllers static void _cleanupStreamControllers() { for (final controller in _streamControllers.values) { if (!controller.isClosed) { controller.close(); } } _streamControllers.clear(); } /// Clean up specific stream controller static void cleanupStream(String key) { if (_streamControllers.containsKey(key)) { if (!_streamControllers[key]!.isClosed) { _streamControllers[key]!.close(); } _streamControllers.remove(key); } } /// Clear specific cache static void clearCache(String key) { _cache.remove(key); } /// Get cache info for debugging static Map getCacheInfo() { return { 'cacheSize': _cache.length, 'activePollers': _pollingTimers.length, 'activeStreams': _streamControllers.length, 'cacheKeys': _cache.keys.toList(), }; } // Predefined cache keys static const String postsAllKey = 'posts_all'; static const String invitationsAllKey = 'invitations_all'; static const String userProfileKey = 'user_profile'; // Helper methods for specific cache durations static Duration get postsCacheDuration => _postsCacheDuration; static Duration get invitationsCacheDuration => _invitationsCacheDuration; static Duration get userCacheDuration => _userCacheDuration; static Duration get postsPollingInterval => _postsPollingInterval; static Duration get invitationsPollingInterval => _invitationsPollingInterval; static Duration get userPollingInterval => _userPollingInterval; }