From e73bc8887971280ded65e525f0f296961b48114c Mon Sep 17 00:00:00 2001 From: sBubshait Date: Mon, 28 Jul 2025 10:02:10 +0300 Subject: [PATCH] feat: cache and poll the user profile page --- frontend/lib/screens/pages/profile_page.dart | 41 ++++--- frontend/lib/services/cache_service.dart | 10 +- frontend/lib/services/post_service.dart | 113 +++++++++++++++++-- frontend/lib/services/user_service.dart | 14 +++ frontend/lib/widgets/posts_list.dart | 96 +++++++++++++--- 5 files changed, 236 insertions(+), 38 deletions(-) diff --git a/frontend/lib/screens/pages/profile_page.dart b/frontend/lib/screens/pages/profile_page.dart index c8d127d..adeb6a8 100644 --- a/frontend/lib/screens/pages/profile_page.dart +++ b/frontend/lib/screens/pages/profile_page.dart @@ -45,10 +45,14 @@ class _ProfilePageState extends State { print('Setting up user profile stream'); _userStreamSubscription = UserService.getUserStream().listen( (updatedUserData) { - print('Cache change detected - triggering refresh like refresh button'); + print('📱 User profile stream received data update'); if (mounted) { - // Force refresh like the refresh button - _loadUserData(forceRefresh: true); + // Update UI directly with stream data instead of making API call + setState(() { + userData = updatedUserData; + isLoadingUser = false; + }); + // Only reload stats which are from posts service _loadUserStats(); } }, @@ -56,6 +60,13 @@ class _ProfilePageState extends State { print('User profile stream error: $error'); }, ); + + // Trigger stream with any existing cached data + Future.delayed(Duration(milliseconds: 100), () { + if (mounted) { + UserService.notifyStreamWithCurrentData(); + } + }); } Future _loadFCMToken() async { @@ -109,16 +120,18 @@ class _ProfilePageState extends State { Future _loadUserStats() async { final result = await PostService.getUserPosts(); - setState(() { - if (result['success'] == true) { - final posts = result['posts'] as List; - totalPosts = posts.length; - totalLikes = posts.fold(0, (sum, post) => sum + post.likes); - } else { - totalPosts = 0; - totalLikes = 0; - } - }); + if (mounted) { + setState(() { + if (result['success'] == true) { + final posts = result['posts'] as List; + totalPosts = posts.length; + totalLikes = posts.fold(0, (sum, post) => sum + post.likes); + } else { + totalPosts = 0; + totalLikes = 0; + } + }); + } } void _showErrorAlert(String message) { @@ -294,7 +307,7 @@ class _ProfilePageState extends State { ), SizedBox(height: 16), ProfilePostsList( - fetchPosts: PostService.getUserPosts, + fetchPosts: () => PostService.getUserPosts(), onStatsUpdate: (posts, likes) { setState(() { totalPosts = posts; diff --git a/frontend/lib/services/cache_service.dart b/frontend/lib/services/cache_service.dart index 0ad9530..5c9b6fd 100644 --- a/frontend/lib/services/cache_service.dart +++ b/frontend/lib/services/cache_service.dart @@ -28,12 +28,14 @@ class CacheService { // Cache durations static const Duration _postsCacheDuration = Duration(seconds: 30); static const Duration _invitationsCacheDuration = Duration(seconds: 30); - static const Duration _userCacheDuration = Duration(hours: 3); + static const Duration _userCacheDuration = Duration(hours: 6); + static const Duration _userPostsCacheDuration = Duration(seconds: 30); // Polling intervals static const Duration _postsPollingInterval = Duration(seconds: 5); static const Duration _invitationsPollingInterval = Duration(seconds: 5); static const Duration _userPollingInterval = Duration(hours: 3); + static const Duration _userPostsPollingInterval = Duration(seconds: 5); /// Get cached data if available and not expired, otherwise return null static T? getCached(String key) { @@ -212,6 +214,9 @@ class CacheService { if (key == postsAllKey && data is List) { print('📱 Notifying posts stream with ${data.length} posts'); notifyStreamListeners>(key, data); + } else if (key == userPostsKey && data is List) { + print('📱 Notifying user posts stream with ${data.length} posts'); + notifyStreamListeners>(key, data); } else if (key == invitationsAllKey && data is InvitationsData) { print('📱 Notifying invitations stream'); notifyStreamListeners(key, data); @@ -291,13 +296,16 @@ class CacheService { static const String postsAllKey = 'posts_all'; static const String invitationsAllKey = 'invitations_all'; static const String userProfileKey = 'user_profile'; + static const String userPostsKey = 'user_posts'; // Helper methods for specific cache durations static Duration get postsCacheDuration => _postsCacheDuration; static Duration get invitationsCacheDuration => _invitationsCacheDuration; static Duration get userCacheDuration => _userCacheDuration; + static Duration get userPostsCacheDuration => _userPostsCacheDuration; static Duration get postsPollingInterval => _postsPollingInterval; static Duration get invitationsPollingInterval => _invitationsPollingInterval; static Duration get userPollingInterval => _userPollingInterval; + static Duration get userPostsPollingInterval => _userPostsPollingInterval; } \ No newline at end of file diff --git a/frontend/lib/services/post_service.dart b/frontend/lib/services/post_service.dart index b32dfbd..c6267e9 100644 --- a/frontend/lib/services/post_service.dart +++ b/frontend/lib/services/post_service.dart @@ -7,6 +7,7 @@ import 'cache_service.dart'; class PostService { static bool _pollingStarted = false; + static bool _userPostsPollingStarted = false; /// Get all posts with caching - returns cached data if available and fresh static Future> getAllPosts({ @@ -139,29 +140,126 @@ class PostService { } } + /// Start background polling for user posts + static void startUserPostsPolling() { + _userPostsPollingStarted = true; + CacheService.startPolling( + CacheService.userPostsKey, + () => _fetchAndCacheUserPosts(true), // Mark as from polling + CacheService.userPostsPollingInterval, + CacheService.userPostsCacheDuration, + ); + } + + /// Stop background polling for user posts + static void stopUserPostsPolling() { + CacheService.stopPolling(CacheService.userPostsKey); + _userPostsPollingStarted = false; + } + + /// Get user posts stream for real-time updates + static Stream> getUserPostsStream() { + return CacheService.getStream>(CacheService.userPostsKey); + } + + /// Force notify user posts stream with current cached data + static void notifyUserPostsStreamWithCurrentData() { + final cached = CacheService.getCachedStale>( + CacheService.userPostsKey, + ); + if (cached != null) { + // Re-notify the stream with current data + CacheService.notifyStreamListeners>( + CacheService.userPostsKey, + cached, + ); + } + } + /// Check if polling is active static bool get isPollingActive => _pollingStarted; + /// Check if user posts polling is active + static bool get isUserPostsPollingActive => _userPostsPollingStarted; + /// Invalidate posts cache (useful after creating/updating posts) static void invalidateCache() { CacheService.clearCache(CacheService.postsAllKey); } - static Future> getUserPosts() async { + /// Invalidate user posts cache (useful after creating posts) + static void invalidateUserPostsCache() { + CacheService.clearCache(CacheService.userPostsKey); + } + + /// Get user posts with caching - returns cached data if available and fresh + static Future> getUserPosts({ + bool forceRefresh = false, + }) async { + // Return cached data if available and not forcing refresh + if (!forceRefresh) { + final cached = CacheService.getCached>( + CacheService.userPostsKey, + ); + if (cached != null && + !CacheService.isCacheExpired( + CacheService.userPostsKey, + CacheService.userPostsCacheDuration, + )) { + return { + 'success': true, + 'message': 'Loaded from cache', + 'posts': cached, + }; + } + + // Return stale data while fetching fresh data in background + final stale = CacheService.getCachedStale>( + CacheService.userPostsKey, + ); + if (stale != null && !forceRefresh) { + // Trigger background refresh + _fetchAndCacheUserPosts(); + return { + 'success': true, + 'message': 'Loaded from cache (refreshing in background)', + 'posts': stale, + }; + } + } + + // Fetch fresh data + return await _fetchAndCacheUserPosts(); + } + + static Future> _fetchAndCacheUserPosts([bool fromPolling = false]) async { try { final response = await HttpService.get('/posts/user'); final responseData = jsonDecode(response.body); if (response.statusCode == 200) { + 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.userPostsKey, 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(); + return { + 'success': false, + 'message': 'Session expired. Please login again.', + 'posts': [], }; } else { return { @@ -191,8 +289,9 @@ class PostService { final responseData = jsonDecode(response.body); if (response.statusCode == 200 || response.statusCode == 201) { - // Invalidate posts cache since we added a new post + // Invalidate both posts caches since we added a new post invalidateCache(); + invalidateUserPostsCache(); return { 'success': responseData['status'] ?? false, diff --git a/frontend/lib/services/user_service.dart b/frontend/lib/services/user_service.dart index a78e595..b97927a 100644 --- a/frontend/lib/services/user_service.dart +++ b/frontend/lib/services/user_service.dart @@ -110,6 +110,20 @@ class UserService { return CacheService.getStream>(CacheService.userProfileKey); } + /// Force notify stream with current cached data (useful for initial stream setup) + static void notifyStreamWithCurrentData() { + final cached = CacheService.getCachedStale>( + CacheService.userProfileKey, + ); + if (cached != null) { + // Re-notify the stream with current data + CacheService.notifyStreamListeners>( + CacheService.userProfileKey, + cached, + ); + } + } + /// Check if polling is active static bool get isPollingActive => _pollingStarted; diff --git a/frontend/lib/widgets/posts_list.dart b/frontend/lib/widgets/posts_list.dart index 15a47cf..0b1ed69 100644 --- a/frontend/lib/widgets/posts_list.dart +++ b/frontend/lib/widgets/posts_list.dart @@ -511,40 +511,104 @@ class _ProfilePostsListState extends State { bool _isLoading = true; List _posts = []; String _errorMessage = ''; + StreamSubscription>? _userPostsStreamSubscription; @override void initState() { super.initState(); + _setupUserPostsStream(); _loadPosts(); + // Start user posts polling when profile page is active + PostService.startUserPostsPolling(); } - Future _loadPosts() async { - setState(() { - _isLoading = true; - _errorMessage = ''; - }); + @override + void dispose() { + _userPostsStreamSubscription?.cancel(); + // Stop user posts polling when profile page is disposed + PostService.stopUserPostsPolling(); + super.dispose(); + } - try { - final result = await widget.fetchPosts(); - setState(() { - if (result['success']) { - _posts = result['posts']; + void _setupUserPostsStream() { + print('Setting up user posts stream for profile'); + _userPostsStreamSubscription?.cancel(); // Cancel any existing subscription + + _userPostsStreamSubscription = PostService.getUserPostsStream().listen( + (updatedPosts) { + print('📱 User posts stream received data: ${updatedPosts.length} posts'); + if (mounted) { + // Update the posts directly instead of fetching again + setState(() { + _posts = updatedPosts; + _isLoading = false; + _errorMessage = ''; + }); // Update parent stats if (widget.onStatsUpdate != null) { final totalLikes = _posts.fold(0, (sum, post) => sum + post.likes); widget.onStatsUpdate!(_posts.length, totalLikes); } + print('🎯 User posts UI updated with ${updatedPosts.length} posts - setState called'); } else { - _errorMessage = result['message'] ?? 'Failed to load posts'; + print('⚠️ Widget not mounted, skipping UI update'); } - _isLoading = false; - }); - } catch (e) { + }, + onError: (error) { + print('User posts stream error: $error'); + if (mounted) { + setState(() { + _errorMessage = 'Stream error: $error'; + }); + } + }, + ); + + // Trigger stream with any existing cached data + Future.delayed(Duration(milliseconds: 100), () { + if (mounted) { + PostService.notifyUserPostsStreamWithCurrentData(); + } + }); + } + + Future _loadPosts() async { + // Don't show loading for cached data unless it's the initial load + final isInitialLoad = _posts.isEmpty; + + if (isInitialLoad) { setState(() { - _errorMessage = 'Network error: $e'; - _isLoading = false; + _isLoading = true; + _errorMessage = ''; }); } + + try { + final result = await widget.fetchPosts(); + if (mounted) { + setState(() { + if (result['success']) { + _posts = result['posts']; + // Update parent stats + if (widget.onStatsUpdate != null) { + final totalLikes = _posts.fold(0, (sum, post) => sum + post.likes); + widget.onStatsUpdate!(_posts.length, totalLikes); + } + _errorMessage = ''; + } else { + _errorMessage = result['message'] ?? 'Failed to load posts'; + } + _isLoading = false; + }); + } + } catch (e) { + if (mounted) { + setState(() { + _errorMessage = 'Network error: $e'; + _isLoading = false; + }); + } + } } @override