From ff3ca9ab1f5e97fb1960a592306b11c85a6845ca Mon Sep 17 00:00:00 2001 From: sBubshait Date: Mon, 28 Jul 2025 09:50:37 +0300 Subject: [PATCH] feat: polling and caching for posts and invitations --- frontend/lib/main.dart | 9 + frontend/lib/models/invitation_models.dart | 65 ++++ frontend/lib/screens/pages/feed_page.dart | 2 +- .../lib/screens/pages/invitations_page.dart | 59 +++- frontend/lib/screens/pages/profile_page.dart | 61 +++- .../lib/services/app_lifecycle_service.dart | 101 ++++++ frontend/lib/services/auth_service.dart | 5 +- frontend/lib/services/cache_service.dart | 303 ++++++++++++++++++ .../lib/services/invitations_service.dart | 88 ++++- frontend/lib/services/post_service.dart | 163 ++++++++-- frontend/lib/services/user_service.dart | 90 +++++- frontend/lib/widgets/posts_list.dart | 112 +++++-- frontend/pubspec.lock | 2 +- frontend/pubspec.yaml | 1 + 14 files changed, 962 insertions(+), 99 deletions(-) create mode 100644 frontend/lib/services/app_lifecycle_service.dart create mode 100644 frontend/lib/services/cache_service.dart diff --git a/frontend/lib/main.dart b/frontend/lib/main.dart index b96bcc2..5e6c1e1 100644 --- a/frontend/lib/main.dart +++ b/frontend/lib/main.dart @@ -6,6 +6,7 @@ import 'screens/notification_permission_screen.dart'; import 'services/notification_service.dart'; import 'services/auth_service.dart'; import 'services/user_service.dart'; +import 'services/app_lifecycle_service.dart'; final GlobalKey navigatorKey = GlobalKey(); @@ -13,6 +14,9 @@ void main() async { WidgetsFlutterBinding.ensureInitialized(); await Firebase.initializeApp(options: DefaultFirebaseOptions.currentPlatform); + // Initialize app lifecycle service + AppLifecycleService.initialize(); + runApp(MyApp()); } @@ -49,6 +53,8 @@ class _SplashScreenState extends State { if (isLoggedIn) { final userResult = await UserService.getCurrentUser(); if (userResult['success'] == true) { + // Start polling services now that user is logged in and going to main screen + AppLifecycleService.startAllPolling(); Navigator.of(context).pushReplacement( MaterialPageRoute(builder: (context) => HomeScreen()), ); @@ -335,6 +341,9 @@ class _SignInPageState extends State { if (result['success'] == true) { final userResult = await UserService.getCurrentUser(forceRefresh: true); + // Start polling services now that user is logged in + AppLifecycleService.startAllPolling(); + Navigator.of(context).pushReplacement( MaterialPageRoute( builder: (context) => NotificationPermissionScreen(), diff --git a/frontend/lib/models/invitation_models.dart b/frontend/lib/models/invitation_models.dart index 1857afc..997731f 100644 --- a/frontend/lib/models/invitation_models.dart +++ b/frontend/lib/models/invitation_models.dart @@ -19,6 +19,15 @@ class InvitationTag { iconName: json['iconName'], ); } + + Map toJson() { + return { + 'id': id, + 'name': name, + 'colorHex': colorHex, + 'iconName': iconName, + }; + } } class InvitationCreator { @@ -39,6 +48,14 @@ class InvitationCreator { avatar: json['avatar'], ); } + + Map toJson() { + return { + 'id': id, + 'displayName': displayName, + 'avatar': avatar, + }; + } } class InvitationAttendee { @@ -62,6 +79,15 @@ class InvitationAttendee { joinedAt: DateTime.parse(json['joinedAt']), ); } + + Map toJson() { + return { + 'id': id, + 'displayName': displayName, + 'avatar': avatar, + 'joinedAt': joinedAt.toIso8601String(), + }; + } } class Invitation { @@ -103,6 +129,21 @@ class Invitation { createdAt: DateTime.parse(json['createdAt']), ); } + + Map toJson() { + return { + 'id': id, + 'title': title, + 'description': description, + 'dateTime': dateTime?.toIso8601String(), + 'location': location, + 'maxParticipants': maxParticipants, + 'currentAttendees': currentAttendees, + 'tag': tag.toJson(), + 'creator': creator.toJson(), + 'createdAt': createdAt.toIso8601String(), + }; + } } class InvitationsResponse { @@ -149,6 +190,14 @@ class InvitationsData { .toList(), ); } + + Map toJson() { + return { + 'created': created.map((item) => item.toJson()).toList(), + 'accepted': accepted.map((item) => item.toJson()).toList(), + 'available': available.map((item) => item.toJson()).toList(), + }; + } bool get isEmpty => created.isEmpty && accepted.isEmpty && available.isEmpty; } @@ -198,6 +247,22 @@ class InvitationDetails { ); } + Map toJson() { + return { + 'id': id, + 'title': title, + 'description': description, + 'dateTime': dateTime?.toIso8601String(), + 'location': location, + 'maxParticipants': maxParticipants, + 'currentAttendees': currentAttendees, + 'tag': tag.toJson(), + 'creator': creator.toJson(), + 'createdAt': createdAt.toIso8601String(), + 'attendees': attendees.map((item) => item.toJson()).toList(), + }; + } + String get status { if (currentAttendees >= maxParticipants) { return 'Full'; diff --git a/frontend/lib/screens/pages/feed_page.dart b/frontend/lib/screens/pages/feed_page.dart index 99b7a31..a2444d5 100644 --- a/frontend/lib/screens/pages/feed_page.dart +++ b/frontend/lib/screens/pages/feed_page.dart @@ -35,7 +35,7 @@ class _FeedPageState extends State { ), body: PostsList( key: _postsListKey, - fetchPosts: PostService.getAllPosts, + fetchPosts: ({bool forceRefresh = false}) => PostService.getAllPosts(forceRefresh: forceRefresh), emptyStateTitle: 'Nothing here..', emptyStateSubtitle: 'Create the first post!', showRefreshIndicator: true, diff --git a/frontend/lib/screens/pages/invitations_page.dart b/frontend/lib/screens/pages/invitations_page.dart index c5acd22..a23501f 100644 --- a/frontend/lib/screens/pages/invitations_page.dart +++ b/frontend/lib/screens/pages/invitations_page.dart @@ -1,4 +1,5 @@ import 'package:flutter/material.dart'; +import 'dart:async'; import '../../services/invitations_service.dart'; import '../../models/invitation_models.dart'; import '../../utils/invitation_utils.dart'; @@ -17,11 +18,13 @@ class _InvitationsPageState extends State Map _acceptingInvitations = {}; Map _acceptedInvitations = {}; Map _animationControllers = {}; + StreamSubscription? _invitationsStreamSubscription; @override void initState() { super.initState(); _loadInvitations(); + _setupInvitationsStream(); } @override @@ -29,25 +32,55 @@ class _InvitationsPageState extends State for (final controller in _animationControllers.values) { controller.dispose(); } + _invitationsStreamSubscription?.cancel(); super.dispose(); } - Future _loadInvitations() async { - setState(() { - _isLoading = true; - _errorMessage = null; - }); + void _setupInvitationsStream() { + print('Setting up invitations stream'); + _invitationsStreamSubscription = InvitationsService.getInvitationsStream() + .listen( + (updatedInvitationsData) { + print('Invitations stream received updated data'); + if (mounted) { + // Update the invitations directly instead of fetching again + setState(() { + _invitationsData = updatedInvitationsData; + _isLoading = false; + _errorMessage = null; + }); + print('Invitations UI updated with ${updatedInvitationsData.created.length + updatedInvitationsData.accepted.length + updatedInvitationsData.available.length} total invitations'); + } + }, + onError: (error) { + print('Invitations stream error: $error'); + }, + ); + } - final result = await InvitationsService.getAllInvitations(); + Future _loadInvitations({bool forceRefresh = false}) async { + // Don't show loading for cached data unless forcing refresh + final isInitialLoad = _invitationsData == null; + + if (isInitialLoad || forceRefresh) { + setState(() { + _isLoading = true; + _errorMessage = null; + }); + } + + final result = await InvitationsService.getAllInvitations(forceRefresh: forceRefresh); if (mounted) { setState(() { - _isLoading = false; if (result['success']) { - _invitationsData = result['data']; + final invitationsData = result['invitations'] as InvitationsData; + _invitationsData = invitationsData; + _errorMessage = null; } else { _errorMessage = result['message']; } + _isLoading = false; }); } } @@ -130,7 +163,7 @@ class _InvitationsPageState extends State Future.delayed(Duration(milliseconds: 2000), () { if (mounted) { - _loadInvitations(); + _loadInvitations(forceRefresh: true); } }); } else { @@ -161,7 +194,7 @@ class _InvitationsPageState extends State ); if (result == true) { - _loadInvitations(); + _loadInvitations(forceRefresh: true); } } @@ -510,7 +543,7 @@ class _InvitationsPageState extends State ), automaticallyImplyLeading: false, actions: [ - IconButton(icon: Icon(Icons.refresh), onPressed: _loadInvitations), + IconButton(icon: Icon(Icons.refresh), onPressed: () => _loadInvitations(forceRefresh: true)), ], ), body: _isLoading @@ -520,7 +553,7 @@ class _InvitationsPageState extends State : _invitationsData?.isEmpty ?? true ? _buildEmptyState() : RefreshIndicator( - onRefresh: _loadInvitations, + onRefresh: () => _loadInvitations(forceRefresh: true), color: Color(0xFF6A4C93), child: SingleChildScrollView( physics: AlwaysScrollableScrollPhysics(), @@ -554,7 +587,7 @@ class _InvitationsPageState extends State MaterialPageRoute(builder: (context) => CreateInvitationPage()), ); if (result == true) { - _loadInvitations(); + _loadInvitations(forceRefresh: true); } }, backgroundColor: Color(0xFF6A4C93), diff --git a/frontend/lib/screens/pages/profile_page.dart b/frontend/lib/screens/pages/profile_page.dart index 360e4b3..c8d127d 100644 --- a/frontend/lib/screens/pages/profile_page.dart +++ b/frontend/lib/screens/pages/profile_page.dart @@ -1,9 +1,11 @@ import 'package:flutter/material.dart'; import 'package:flutter/services.dart'; +import 'dart:async'; import '../../services/notification_service.dart'; import '../../services/user_service.dart'; import '../../services/auth_service.dart'; import '../../services/post_service.dart'; +import '../../services/app_lifecycle_service.dart'; import '../../models/post_models.dart'; import '../../widgets/posts_list.dart'; import '../../utils/password_generator.dart'; @@ -21,6 +23,7 @@ class _ProfilePageState extends State { bool isLoadingUser = true; int totalLikes = 0; int totalPosts = 0; + StreamSubscription>? _userStreamSubscription; @override void initState() { @@ -28,14 +31,33 @@ class _ProfilePageState extends State { _loadFCMToken(); _loadUserData(); _loadUserStats(); + _setupUserStream(); } @override void dispose() { _tokenController.dispose(); + _userStreamSubscription?.cancel(); super.dispose(); } + void _setupUserStream() { + print('Setting up user profile stream'); + _userStreamSubscription = UserService.getUserStream().listen( + (updatedUserData) { + print('Cache change detected - triggering refresh like refresh button'); + if (mounted) { + // Force refresh like the refresh button + _loadUserData(forceRefresh: true); + _loadUserStats(); + } + }, + onError: (error) { + print('User profile stream error: $error'); + }, + ); + } + Future _loadFCMToken() async { setState(() { isLoading = true; @@ -57,22 +79,31 @@ class _ProfilePageState extends State { } } - Future _loadUserData() async { - setState(() { - isLoadingUser = true; - }); + Future _loadUserData({bool forceRefresh = false}) async { + // Don't show loading for cached data unless forcing refresh + final isInitialLoad = userData == null; + + if (isInitialLoad || forceRefresh) { + setState(() { + isLoadingUser = true; + }); + } - final result = await UserService.getCurrentUser(); + final result = await UserService.getCurrentUser(forceRefresh: forceRefresh); - setState(() { - isLoadingUser = false; - if (result['success'] == true) { - userData = result['data']; - } else { - userData = null; - _showErrorAlert(result['message'] ?? 'Failed to load user data'); - } - }); + if (mounted) { + setState(() { + if (result['success'] == true) { + userData = result['data']; + } else { + userData = null; + if (isInitialLoad) { + _showErrorAlert(result['message'] ?? 'Failed to load user data'); + } + } + isLoadingUser = false; + }); + } } Future _loadUserStats() async { @@ -425,6 +456,8 @@ class _SettingsPageState extends State { onPressed: () async { Navigator.of(context).pop(); + // Stop polling services before logout + AppLifecycleService.dispose(); await AuthService.logout(); Navigator.of( diff --git a/frontend/lib/services/app_lifecycle_service.dart b/frontend/lib/services/app_lifecycle_service.dart new file mode 100644 index 0000000..71b144d --- /dev/null +++ b/frontend/lib/services/app_lifecycle_service.dart @@ -0,0 +1,101 @@ +import 'package:flutter/material.dart'; +import 'cache_service.dart'; +import 'post_service.dart'; +import 'invitations_service.dart'; +import 'user_service.dart'; + +class AppLifecycleService extends WidgetsBindingObserver { + static final AppLifecycleService _instance = AppLifecycleService._internal(); + factory AppLifecycleService() => _instance; + AppLifecycleService._internal(); + + bool _isInitialized = false; + + /// Initialize the app lifecycle service + static void initialize() { + if (!_instance._isInitialized) { + WidgetsBinding.instance.addObserver(_instance); + _instance._isInitialized = true; + + // DO NOT start polling automatically - only when user is logged in and on main screen + } + } + + /// Start all polling services (call this only when user is logged in and on main tabs) + static void startAllPolling() { + PostService.startPolling(); + InvitationsService.startPolling(); + UserService.startPolling(); + } + + /// Dispose the app lifecycle service + static void dispose() { + if (_instance._isInitialized) { + WidgetsBinding.instance.removeObserver(_instance); + _instance._isInitialized = false; + + // Stop all polling + CacheService.stopAllPolling(); + } + } + + @override + void didChangeAppLifecycleState(AppLifecycleState state) { + super.didChangeAppLifecycleState(state); + + switch (state) { + case AppLifecycleState.resumed: + _onAppResumed(); + break; + case AppLifecycleState.paused: + _onAppPaused(); + break; + case AppLifecycleState.inactive: + // App is transitioning, do nothing + break; + case AppLifecycleState.detached: + _onAppDetached(); + break; + case AppLifecycleState.hidden: + // App is hidden, treat similar to paused + break; + } + } + + void _onAppResumed() { + // Only restart polling if it was already running (user is logged in) + if (PostService.isPollingActive || InvitationsService.isPollingActive || UserService.isPollingActive) { + print('App resumed - restarting polling'); + PostService.startPolling(); + InvitationsService.startPolling(); + UserService.startPolling(); + } + } + + void _onAppPaused() { + print('App paused - stopping polling'); + // Stop polling to save battery when app goes to background + CacheService.stopAllPolling(); + } + + void _onAppDetached() { + print('App detached - cleaning up'); + // Clean up when app is completely terminated + CacheService.stopAllPolling(); + CacheService.clearAll(); + } + + /// Force refresh all cached data (useful for pull-to-refresh) + static Future refreshAllData() async { + await Future.wait([ + PostService.getAllPosts(forceRefresh: true), + InvitationsService.getAllInvitations(forceRefresh: true), + UserService.getCurrentUser(forceRefresh: true), + ]); + } + + /// Get cache status for debugging + static Map getCacheStatus() { + return CacheService.getCacheInfo(); + } +} \ No newline at end of file diff --git a/frontend/lib/services/auth_service.dart b/frontend/lib/services/auth_service.dart index 276b0df..d9e591d 100644 --- a/frontend/lib/services/auth_service.dart +++ b/frontend/lib/services/auth_service.dart @@ -4,6 +4,7 @@ import 'package:flutter_secure_storage/flutter_secure_storage.dart'; import 'package:http/http.dart' as http; import '../constants/api_constants.dart'; import '../main.dart'; +import 'app_lifecycle_service.dart'; class AuthService { static const FlutterSecureStorage _storage = FlutterSecureStorage(); @@ -62,6 +63,8 @@ class AuthService { } static Future logout() async { + // Stop all polling services when logging out + AppLifecycleService.dispose(); await _storage.delete(key: _tokenKey); await _storage.delete(key: _userDataKey); } @@ -92,7 +95,7 @@ class AuthService { static Future handleAuthenticationError() async { await logout(); - + final context = navigatorKey.currentContext; if (context != null && context.mounted) { Navigator.of(context).pushNamedAndRemoveUntil('/', (route) => false); diff --git a/frontend/lib/services/cache_service.dart b/frontend/lib/services/cache_service.dart new file mode 100644 index 0000000..0ad9530 --- /dev/null +++ b/frontend/lib/services/cache_service.dart @@ -0,0 +1,303 @@ +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; +} \ No newline at end of file diff --git a/frontend/lib/services/invitations_service.dart b/frontend/lib/services/invitations_service.dart index 3aadeab..8a0e0b7 100644 --- a/frontend/lib/services/invitations_service.dart +++ b/frontend/lib/services/invitations_service.dart @@ -3,9 +3,41 @@ import '../constants/api_constants.dart'; import '../models/invitation_models.dart'; import 'http_service.dart'; import 'auth_service.dart'; +import 'cache_service.dart'; class InvitationsService { - static Future> getAllInvitations() async { + static bool _pollingStarted = false; + /// Get all invitations with caching - returns cached data if available and fresh + static Future> getAllInvitations({bool forceRefresh = false}) async { + // Return cached data if available and not forcing refresh + if (!forceRefresh) { + final cached = CacheService.getCached(CacheService.invitationsAllKey); + if (cached != null && !CacheService.isCacheExpired(CacheService.invitationsAllKey, CacheService.invitationsCacheDuration)) { + return { + 'success': true, + 'message': 'Loaded from cache', + 'invitations': cached, + }; + } + + // Return stale data while fetching fresh data in background + final stale = CacheService.getCachedStale(CacheService.invitationsAllKey); + if (stale != null && !forceRefresh) { + // Trigger background refresh + _fetchAndCacheInvitations(); + return { + 'success': true, + 'message': 'Loaded from cache (refreshing in background)', + 'invitations': stale, + }; + } + } + + // Fetch fresh data + return await _fetchAndCacheInvitations(); + } + + static Future> _fetchAndCacheInvitations([bool fromPolling = false]) async { try { final response = await HttpService.get( ApiConstants.getAllInvitationsEndpoint, @@ -16,12 +48,23 @@ class InvitationsService { final invitationsResponse = InvitationsResponse.fromJson(responseData); if (invitationsResponse.status) { - return {'success': true, 'data': invitationsResponse.data}; + final invitationsData = invitationsResponse.data; + + // Only cache when not called from polling to prevent conflicts + if (!fromPolling) { + CacheService.setCached(CacheService.invitationsAllKey, invitationsData); + } + + return { + 'success': true, + 'invitations': invitationsData, + 'message': invitationsResponse.message ?? '', + }; } else { return { 'success': false, - 'message': - invitationsResponse.message ?? 'Failed to fetch invitations', + 'message': invitationsResponse.message ?? 'Failed to fetch invitations', + 'invitations': InvitationsData(created: [], accepted: [], available: []), }; } } else if (response.statusCode == 401 || response.statusCode == 403) { @@ -29,11 +72,13 @@ class InvitationsService { return { 'success': false, 'message': 'Session expired. Please login again.', + 'invitations': InvitationsData(created: [], accepted: [], available: []), }; } else { return { 'success': false, 'message': 'Server error (${response.statusCode})', + 'invitations': InvitationsData(created: [], accepted: [], available: []), }; } } catch (e) { @@ -41,10 +86,41 @@ class InvitationsService { return { 'success': false, 'message': 'Network error. Please check your connection.', + 'invitations': InvitationsData(created: [], accepted: [], available: []), }; } } + /// Start background polling for invitations + static void startPolling() { + _pollingStarted = true; + CacheService.startPolling( + CacheService.invitationsAllKey, + () => _fetchAndCacheInvitations(true), // Mark as from polling + CacheService.invitationsPollingInterval, + CacheService.invitationsCacheDuration, + ); + } + + /// Stop background polling for invitations + static void stopPolling() { + CacheService.stopPolling(CacheService.invitationsAllKey); + _pollingStarted = false; + } + + /// Get invitations stream for real-time updates + static Stream getInvitationsStream() { + return CacheService.getStream(CacheService.invitationsAllKey); + } + + /// Check if polling is active + static bool get isPollingActive => _pollingStarted; + + /// Invalidate invitations cache (useful after creating/updating invitations) + static void invalidateCache() { + CacheService.clearCache(CacheService.invitationsAllKey); + } + static Future> acceptInvitation(int invitationId) async { try { final response = await HttpService.post( @@ -54,6 +130,8 @@ class InvitationsService { if (response.statusCode == 200) { final responseData = jsonDecode(response.body); + // Invalidate invitations cache since invitation status changed + invalidateCache(); return { 'success': responseData['status'] ?? false, 'message': responseData['message'] ?? 'Request completed', @@ -93,6 +171,8 @@ class InvitationsService { if (response.statusCode == 200) { final responseData = jsonDecode(response.body); if (responseData['status'] == true) { + // Invalidate invitations cache since new invitation was created + invalidateCache(); return { 'success': true, 'data': responseData['data'], diff --git a/frontend/lib/services/post_service.dart b/frontend/lib/services/post_service.dart index f1eeced..b32dfbd 100644 --- a/frontend/lib/services/post_service.dart +++ b/frontend/lib/services/post_service.dart @@ -3,22 +3,72 @@ import '../models/post_models.dart'; import '../models/comment_models.dart'; import 'http_service.dart'; import 'auth_service.dart'; +import 'cache_service.dart'; class PostService { - static Future> getAllPosts() async { + static bool _pollingStarted = false; + + /// Get all posts with caching - returns cached data if available and fresh + static Future> getAllPosts({ + bool forceRefresh = false, + }) async { + // Return cached data if available and not forcing refresh + if (!forceRefresh) { + final cached = CacheService.getCached>( + CacheService.postsAllKey, + ); + if (cached != null && + !CacheService.isCacheExpired( + CacheService.postsAllKey, + CacheService.postsCacheDuration, + )) { + return { + 'success': true, + 'message': 'Loaded from cache', + 'posts': cached, + }; + } + + // Return stale data while fetching fresh data in background + final stale = CacheService.getCachedStale>( + CacheService.postsAllKey, + ); + if (stale != null && !forceRefresh) { + // Trigger background refresh + _fetchAndCachePosts(); + return { + 'success': true, + 'message': 'Loaded from cache (refreshing in background)', + 'posts': stale, + }; + } + } + + // Fetch fresh data + return await _fetchAndCachePosts(); + } + + static Future> _fetchAndCachePosts([bool fromPolling = false]) async { try { final response = await HttpService.get('/posts/all'); if (response.statusCode == 200) { final responseData = jsonDecode(response.body); + final posts = + (responseData['data'] as List?) + ?.map((post) => Post.fromJson(post)) + .toList() ?? + []; + + // Only cache when not called from polling to prevent conflicts + if (!fromPolling) { + CacheService.setCached(CacheService.postsAllKey, posts); + } + return { 'success': responseData['status'] ?? false, 'message': responseData['message'] ?? '', - 'posts': - (responseData['data'] as List?) - ?.map((post) => Post.fromJson(post)) - .toList() ?? - [], + 'posts': posts, }; } else if (response.statusCode == 401 || response.statusCode == 403) { await AuthService.handleAuthenticationError(); @@ -53,17 +103,65 @@ class PostService { } } + /// Start background polling for posts + static void startPolling() { + _pollingStarted = true; + CacheService.startPolling( + CacheService.postsAllKey, + () => _fetchAndCachePosts(true), // Mark as from polling + CacheService.postsPollingInterval, + CacheService.postsCacheDuration, + ); + } + + /// Stop background polling for posts + static void stopPolling() { + CacheService.stopPolling(CacheService.postsAllKey); + _pollingStarted = false; + } + + /// Get posts stream for real-time updates + static Stream> getPostsStream() { + return CacheService.getStream>(CacheService.postsAllKey); + } + + /// Force notify stream with current cached data (useful for initial stream setup) + static void notifyStreamWithCurrentData() { + final cached = CacheService.getCachedStale>( + CacheService.postsAllKey, + ); + if (cached != null) { + // Re-notify the stream with current data + CacheService.notifyStreamListeners>( + CacheService.postsAllKey, + cached, + ); + } + } + + /// Check if polling is active + static bool get isPollingActive => _pollingStarted; + + /// Invalidate posts cache (useful after creating/updating posts) + static void invalidateCache() { + CacheService.clearCache(CacheService.postsAllKey); + } + static Future> getUserPosts() async { try { final response = await HttpService.get('/posts/user'); - + final responseData = jsonDecode(response.body); - + if (response.statusCode == 200) { return { 'success': responseData['status'] ?? false, 'message': responseData['message'] ?? '', - 'posts': (responseData['data'] as List?)?.map((post) => Post.fromJson(post)).toList() ?? [], + 'posts': + (responseData['data'] as List?) + ?.map((post) => Post.fromJson(post)) + .toList() ?? + [], }; } else { return { @@ -93,6 +191,9 @@ class PostService { final responseData = jsonDecode(response.body); if (response.statusCode == 200 || response.statusCode == 201) { + // Invalidate posts cache since we added a new post + invalidateCache(); + return { 'success': responseData['status'] ?? false, 'message': responseData['message'] ?? '', @@ -115,10 +216,9 @@ class PostService { static Future> likePost(String postId) async { try { - final response = await HttpService.post( - '/posts/like', - {'postId': postId}, - ); + final response = await HttpService.post('/posts/like', { + 'postId': postId, + }); final responseData = jsonDecode(response.body); @@ -145,10 +245,9 @@ class PostService { static Future> unlikePost(String postId) async { try { - final response = await HttpService.post( - '/posts/unlike', - {'postId': postId}, - ); + final response = await HttpService.post('/posts/unlike', { + 'postId': postId, + }); final responseData = jsonDecode(response.body); @@ -202,17 +301,16 @@ class PostService { static Future> getComments(String postId) async { try { - final response = await HttpService.get('/posts/comments/list?postId=$postId'); + final response = await HttpService.get( + '/posts/comments/list?postId=$postId', + ); if (response.statusCode == 200) { final responseData = jsonDecode(response.body); final commentsResponse = CommentsResponse.fromJson(responseData); - + if (commentsResponse.status) { - return { - 'success': true, - 'comments': commentsResponse.data, - }; + return {'success': true, 'comments': commentsResponse.data}; } else { return { 'success': false, @@ -259,20 +357,20 @@ class PostService { String? replyComment, }) async { try { - final requestBody = { - 'postId': int.parse(postId), - 'body': body, - }; - + final requestBody = {'postId': int.parse(postId), 'body': body}; + if (replyComment != null) { requestBody['replyComment'] = int.parse(replyComment); } - final response = await HttpService.post('/posts/comments/create', requestBody); + final response = await HttpService.post( + '/posts/comments/create', + requestBody, + ); if (response.statusCode == 200 || response.statusCode == 201) { final responseData = jsonDecode(response.body); - + if (responseData['status'] == true) { return { 'success': true, @@ -306,10 +404,7 @@ class PostService { } } catch (e) { print('Error creating comment: $e'); - return { - 'success': false, - 'message': 'Network error: $e', - }; + return {'success': false, 'message': 'Network error: $e'}; } } } diff --git a/frontend/lib/services/user_service.dart b/frontend/lib/services/user_service.dart index d27c77a..a78e595 100644 --- a/frontend/lib/services/user_service.dart +++ b/frontend/lib/services/user_service.dart @@ -2,35 +2,80 @@ import 'dart:convert'; import '../constants/api_constants.dart'; import 'http_service.dart'; import 'auth_service.dart'; +import 'cache_service.dart'; class UserService { - static Future> getCurrentUser({ - bool forceRefresh = false, - }) async { + static bool _pollingStarted = false; + /// Get current user with caching - returns cached data if available and fresh + static Future> getCurrentUser({bool forceRefresh = false}) async { + // Return cached data if available and not forcing refresh if (!forceRefresh) { - final cachedData = await AuthService.getCachedUserData(); - if (cachedData != null) { - return {'success': true, 'data': cachedData}; + final cached = CacheService.getCached>(CacheService.userProfileKey); + if (cached != null && !CacheService.isCacheExpired(CacheService.userProfileKey, CacheService.userCacheDuration)) { + return { + 'success': true, + 'message': 'Loaded from cache', + 'data': cached, + }; + } + + // Return stale data while fetching fresh data in background + final stale = CacheService.getCachedStale>(CacheService.userProfileKey); + if (stale != null && !forceRefresh) { + // Trigger background refresh + _fetchAndCacheUser(); + return { + 'success': true, + 'message': 'Loaded from cache (refreshing in background)', + 'data': stale, + }; + } + + // Also check AuthService cache for backward compatibility + final authCachedData = await AuthService.getCachedUserData(); + if (authCachedData != null) { + // Update our cache with auth service data + CacheService.setCached(CacheService.userProfileKey, authCachedData); + return {'success': true, 'data': authCachedData}; } } + // Fetch fresh data + return await _fetchAndCacheUser(); + } + + static Future> _fetchAndCacheUser([bool fromPolling = false]) async { try { final response = await HttpService.get(ApiConstants.getUserEndpoint); if (response.statusCode == 200) { final data = jsonDecode(response.body); + + // Only cache when not called from polling to prevent conflicts + if (!fromPolling) { + CacheService.setCached(CacheService.userProfileKey, data); + } + + // Also save to AuthService for backward compatibility await AuthService.saveUserData(data); - return {'success': true, 'data': data}; + + return { + 'success': true, + 'data': data, + 'message': '', + }; } else if (response.statusCode == 401 || response.statusCode == 403) { await AuthService.handleAuthenticationError(); return { 'success': false, 'message': 'Session expired. Please login again.', + 'data': null, }; } else { return { 'success': false, 'message': 'Server error (${response.statusCode})', + 'data': null, }; } } catch (e) { @@ -38,10 +83,41 @@ class UserService { return { 'success': false, 'message': 'Network error. Please check your connection.', + 'data': null, }; } } + /// Start background polling for user profile + static void startPolling() { + _pollingStarted = true; + CacheService.startPolling( + CacheService.userProfileKey, + () => _fetchAndCacheUser(true), // Mark as from polling + CacheService.userPollingInterval, + CacheService.userCacheDuration, + ); + } + + /// Stop background polling for user profile + static void stopPolling() { + CacheService.stopPolling(CacheService.userProfileKey); + _pollingStarted = false; + } + + /// Get user profile stream for real-time updates + static Stream> getUserStream() { + return CacheService.getStream>(CacheService.userProfileKey); + } + + /// Check if polling is active + static bool get isPollingActive => _pollingStarted; + + /// Invalidate user cache (useful after updating profile) + static void invalidateCache() { + CacheService.clearCache(CacheService.userProfileKey); + } + static Future> updateFCMToken(String fcmToken) async { try { final response = await HttpService.post(ApiConstants.updateUserEndpoint, { diff --git a/frontend/lib/widgets/posts_list.dart b/frontend/lib/widgets/posts_list.dart index f38f129..15a47cf 100644 --- a/frontend/lib/widgets/posts_list.dart +++ b/frontend/lib/widgets/posts_list.dart @@ -1,12 +1,13 @@ import 'package:flutter/material.dart'; import 'package:share_plus/share_plus.dart'; +import 'dart:async'; import '../models/post_models.dart'; import '../services/post_service.dart'; import '../utils/invitation_utils.dart'; import '../screens/pages/post_view_page.dart'; class PostsList extends StatefulWidget { - final Future> Function() fetchPosts; + final Future> Function({bool forceRefresh}) fetchPosts; final String emptyStateTitle; final String emptyStateSubtitle; final bool showRefreshIndicator; @@ -29,39 +30,97 @@ class PostsListState extends State { bool _isLoading = true; List _posts = []; String _errorMessage = ''; + StreamSubscription>? _postsStreamSubscription; @override void initState() { super.initState(); - _loadPosts(); + _setupPostsStream(); // This will also call _loadPosts() } - Future _loadPosts() async { - setState(() { - _isLoading = true; - _errorMessage = ''; + @override + void dispose() { + _postsStreamSubscription?.cancel(); + super.dispose(); + } + + void _setupPostsStream() { + // Only set up stream for main feed posts, not user posts + print('Setting up posts stream for main feed'); + _postsStreamSubscription?.cancel(); // Cancel any existing subscription + + _postsStreamSubscription = PostService.getPostsStream().listen( + (updatedPosts) { + print('📱 Posts stream received data: ${updatedPosts.length} posts'); + if (mounted) { + // Update the posts directly instead of fetching again + setState(() { + _posts = updatedPosts; + _isLoading = false; + _errorMessage = ''; + }); + print('🎯 Posts UI updated with ${updatedPosts.length} posts - setState called'); + } else { + print('⚠️ Widget not mounted, skipping UI update'); + } + }, + onError: (error) { + print('Posts stream error: $error'); + if (mounted) { + setState(() { + _errorMessage = 'Stream error: $error'; + }); + } + }, + ); + + // Also trigger an initial load to populate the stream + _loadPosts(); + + // Trigger stream with any existing cached data + Future.delayed(Duration(milliseconds: 100), () { + if (mounted) { + PostService.notifyStreamWithCurrentData(); + } }); + } + + Future _loadPosts({bool forceRefresh = false}) async { + // Don't show loading for cached data unless forcing refresh + final isInitialLoad = _posts.isEmpty; + + if (isInitialLoad || forceRefresh) { + setState(() { + _isLoading = true; + _errorMessage = ''; + }); + } try { - final result = await widget.fetchPosts(); - setState(() { - if (result['success']) { - _posts = result['posts']; - } else { - _errorMessage = result['message'] ?? 'Failed to load posts'; - } - _isLoading = false; - }); + final result = await widget.fetchPosts(forceRefresh: forceRefresh); + if (mounted) { + setState(() { + if (result['success']) { + _posts = result['posts']; + _errorMessage = ''; + } else { + _errorMessage = result['message'] ?? 'Failed to load posts'; + } + _isLoading = false; + }); + } } catch (e) { - setState(() { - _errorMessage = 'Network error: $e'; - _isLoading = false; - }); + if (mounted) { + setState(() { + _errorMessage = 'Network error: $e'; + _isLoading = false; + }); + } } } Future _refreshPosts() async { - await _loadPosts(); + await _loadPosts(forceRefresh: true); if (widget.showRefreshIndicator) { ScaffoldMessenger.of(context).showSnackBar( @@ -154,7 +213,7 @@ class PostsListState extends State { } void refreshPosts() { - _loadPosts(); + _loadPosts(forceRefresh: true); } } @@ -180,9 +239,14 @@ class _PostCardState extends State { @override void didUpdateWidget(PostCard oldWidget) { super.didUpdateWidget(oldWidget); - // Update current post if the widget's post changed - if (oldWidget.post.id != widget.post.id) { - _currentPost = widget.post; + // Force update current post to reflect any changes + if (oldWidget.post.id != widget.post.id || + oldWidget.post.likes != widget.post.likes || + oldWidget.post.liked != widget.post.liked || + oldWidget.post.comments != widget.post.comments) { + setState(() { + _currentPost = widget.post; + }); } } diff --git a/frontend/pubspec.lock b/frontend/pubspec.lock index 0fe0702..c581007 100644 --- a/frontend/pubspec.lock +++ b/frontend/pubspec.lock @@ -66,7 +66,7 @@ packages: source: hosted version: "0.3.4+2" crypto: - dependency: transitive + dependency: "direct main" description: name: crypto sha256: "1e445881f28f22d6140f181e07737b22f1e099a5e1ff94b0af2f9e4a463f4855" diff --git a/frontend/pubspec.yaml b/frontend/pubspec.yaml index 5d9ebd1..b2c33a1 100644 --- a/frontend/pubspec.yaml +++ b/frontend/pubspec.yaml @@ -16,6 +16,7 @@ dependencies: googleapis_auth: ^1.6.0 flutter_secure_storage: ^9.2.2 share_plus: ^11.0.0 + crypto: ^3.0.3 dev_dependencies: flutter_test: