feat: cache and poll the user profile page

This commit is contained in:
sBubshait 2025-07-28 10:02:10 +03:00
parent ff3ca9ab1f
commit e73bc88879
5 changed files with 236 additions and 38 deletions

View File

@ -45,10 +45,14 @@ class _ProfilePageState extends State<ProfilePage> {
print('Setting up user profile stream'); print('Setting up user profile stream');
_userStreamSubscription = UserService.getUserStream().listen( _userStreamSubscription = UserService.getUserStream().listen(
(updatedUserData) { (updatedUserData) {
print('Cache change detected - triggering refresh like refresh button'); print('📱 User profile stream received data update');
if (mounted) { if (mounted) {
// Force refresh like the refresh button // Update UI directly with stream data instead of making API call
_loadUserData(forceRefresh: true); setState(() {
userData = updatedUserData;
isLoadingUser = false;
});
// Only reload stats which are from posts service
_loadUserStats(); _loadUserStats();
} }
}, },
@ -56,6 +60,13 @@ class _ProfilePageState extends State<ProfilePage> {
print('User profile stream error: $error'); print('User profile stream error: $error');
}, },
); );
// Trigger stream with any existing cached data
Future.delayed(Duration(milliseconds: 100), () {
if (mounted) {
UserService.notifyStreamWithCurrentData();
}
});
} }
Future<void> _loadFCMToken() async { Future<void> _loadFCMToken() async {
@ -109,16 +120,18 @@ class _ProfilePageState extends State<ProfilePage> {
Future<void> _loadUserStats() async { Future<void> _loadUserStats() async {
final result = await PostService.getUserPosts(); final result = await PostService.getUserPosts();
setState(() { if (mounted) {
if (result['success'] == true) { setState(() {
final posts = result['posts'] as List<Post>; if (result['success'] == true) {
totalPosts = posts.length; final posts = result['posts'] as List<Post>;
totalLikes = posts.fold(0, (sum, post) => sum + post.likes); totalPosts = posts.length;
} else { totalLikes = posts.fold(0, (sum, post) => sum + post.likes);
totalPosts = 0; } else {
totalLikes = 0; totalPosts = 0;
} totalLikes = 0;
}); }
});
}
} }
void _showErrorAlert(String message) { void _showErrorAlert(String message) {
@ -294,7 +307,7 @@ class _ProfilePageState extends State<ProfilePage> {
), ),
SizedBox(height: 16), SizedBox(height: 16),
ProfilePostsList( ProfilePostsList(
fetchPosts: PostService.getUserPosts, fetchPosts: () => PostService.getUserPosts(),
onStatsUpdate: (posts, likes) { onStatsUpdate: (posts, likes) {
setState(() { setState(() {
totalPosts = posts; totalPosts = posts;

View File

@ -28,12 +28,14 @@ class CacheService {
// Cache durations // Cache durations
static const Duration _postsCacheDuration = Duration(seconds: 30); static const Duration _postsCacheDuration = Duration(seconds: 30);
static const Duration _invitationsCacheDuration = 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 // Polling intervals
static const Duration _postsPollingInterval = Duration(seconds: 5); static const Duration _postsPollingInterval = Duration(seconds: 5);
static const Duration _invitationsPollingInterval = Duration(seconds: 5); static const Duration _invitationsPollingInterval = Duration(seconds: 5);
static const Duration _userPollingInterval = Duration(hours: 3); 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 /// Get cached data if available and not expired, otherwise return null
static T? getCached<T>(String key) { static T? getCached<T>(String key) {
@ -212,6 +214,9 @@ class CacheService {
if (key == postsAllKey && data is List<Post>) { if (key == postsAllKey && data is List<Post>) {
print('📱 Notifying posts stream with ${data.length} posts'); print('📱 Notifying posts stream with ${data.length} posts');
notifyStreamListeners<List<Post>>(key, data); notifyStreamListeners<List<Post>>(key, data);
} else if (key == userPostsKey && data is List<Post>) {
print('📱 Notifying user posts stream with ${data.length} posts');
notifyStreamListeners<List<Post>>(key, data);
} else if (key == invitationsAllKey && data is InvitationsData) { } else if (key == invitationsAllKey && data is InvitationsData) {
print('📱 Notifying invitations stream'); print('📱 Notifying invitations stream');
notifyStreamListeners<InvitationsData>(key, data); notifyStreamListeners<InvitationsData>(key, data);
@ -291,13 +296,16 @@ class CacheService {
static const String postsAllKey = 'posts_all'; static const String postsAllKey = 'posts_all';
static const String invitationsAllKey = 'invitations_all'; static const String invitationsAllKey = 'invitations_all';
static const String userProfileKey = 'user_profile'; static const String userProfileKey = 'user_profile';
static const String userPostsKey = 'user_posts';
// Helper methods for specific cache durations // Helper methods for specific cache durations
static Duration get postsCacheDuration => _postsCacheDuration; static Duration get postsCacheDuration => _postsCacheDuration;
static Duration get invitationsCacheDuration => _invitationsCacheDuration; static Duration get invitationsCacheDuration => _invitationsCacheDuration;
static Duration get userCacheDuration => _userCacheDuration; static Duration get userCacheDuration => _userCacheDuration;
static Duration get userPostsCacheDuration => _userPostsCacheDuration;
static Duration get postsPollingInterval => _postsPollingInterval; static Duration get postsPollingInterval => _postsPollingInterval;
static Duration get invitationsPollingInterval => _invitationsPollingInterval; static Duration get invitationsPollingInterval => _invitationsPollingInterval;
static Duration get userPollingInterval => _userPollingInterval; static Duration get userPollingInterval => _userPollingInterval;
static Duration get userPostsPollingInterval => _userPostsPollingInterval;
} }

View File

@ -7,6 +7,7 @@ import 'cache_service.dart';
class PostService { class PostService {
static bool _pollingStarted = false; static bool _pollingStarted = false;
static bool _userPostsPollingStarted = false;
/// Get all posts with caching - returns cached data if available and fresh /// Get all posts with caching - returns cached data if available and fresh
static Future<Map<String, dynamic>> getAllPosts({ static Future<Map<String, dynamic>> 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<List<Post>> getUserPostsStream() {
return CacheService.getStream<List<Post>>(CacheService.userPostsKey);
}
/// Force notify user posts stream with current cached data
static void notifyUserPostsStreamWithCurrentData() {
final cached = CacheService.getCachedStale<List<Post>>(
CacheService.userPostsKey,
);
if (cached != null) {
// Re-notify the stream with current data
CacheService.notifyStreamListeners<List<Post>>(
CacheService.userPostsKey,
cached,
);
}
}
/// Check if polling is active /// Check if polling is active
static bool get isPollingActive => _pollingStarted; 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) /// Invalidate posts cache (useful after creating/updating posts)
static void invalidateCache() { static void invalidateCache() {
CacheService.clearCache(CacheService.postsAllKey); CacheService.clearCache(CacheService.postsAllKey);
} }
static Future<Map<String, dynamic>> 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<Map<String, dynamic>> getUserPosts({
bool forceRefresh = false,
}) async {
// Return cached data if available and not forcing refresh
if (!forceRefresh) {
final cached = CacheService.getCached<List<Post>>(
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<List<Post>>(
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<Map<String, dynamic>> _fetchAndCacheUserPosts([bool fromPolling = false]) async {
try { try {
final response = await HttpService.get('/posts/user'); final response = await HttpService.get('/posts/user');
final responseData = jsonDecode(response.body); final responseData = jsonDecode(response.body);
if (response.statusCode == 200) { 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 { return {
'success': responseData['status'] ?? false, 'success': responseData['status'] ?? false,
'message': responseData['message'] ?? '', 'message': responseData['message'] ?? '',
'posts': 'posts': posts,
(responseData['data'] as List?) };
?.map((post) => Post.fromJson(post)) } else if (response.statusCode == 401 || response.statusCode == 403) {
.toList() ?? await AuthService.handleAuthenticationError();
[], return {
'success': false,
'message': 'Session expired. Please login again.',
'posts': <Post>[],
}; };
} else { } else {
return { return {
@ -191,8 +289,9 @@ class PostService {
final responseData = jsonDecode(response.body); final responseData = jsonDecode(response.body);
if (response.statusCode == 200 || response.statusCode == 201) { 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(); invalidateCache();
invalidateUserPostsCache();
return { return {
'success': responseData['status'] ?? false, 'success': responseData['status'] ?? false,

View File

@ -110,6 +110,20 @@ class UserService {
return CacheService.getStream<Map<String, dynamic>>(CacheService.userProfileKey); return CacheService.getStream<Map<String, dynamic>>(CacheService.userProfileKey);
} }
/// Force notify stream with current cached data (useful for initial stream setup)
static void notifyStreamWithCurrentData() {
final cached = CacheService.getCachedStale<Map<String, dynamic>>(
CacheService.userProfileKey,
);
if (cached != null) {
// Re-notify the stream with current data
CacheService.notifyStreamListeners<Map<String, dynamic>>(
CacheService.userProfileKey,
cached,
);
}
}
/// Check if polling is active /// Check if polling is active
static bool get isPollingActive => _pollingStarted; static bool get isPollingActive => _pollingStarted;

View File

@ -511,40 +511,104 @@ class _ProfilePostsListState extends State<ProfilePostsList> {
bool _isLoading = true; bool _isLoading = true;
List<Post> _posts = []; List<Post> _posts = [];
String _errorMessage = ''; String _errorMessage = '';
StreamSubscription<List<Post>>? _userPostsStreamSubscription;
@override @override
void initState() { void initState() {
super.initState(); super.initState();
_setupUserPostsStream();
_loadPosts(); _loadPosts();
// Start user posts polling when profile page is active
PostService.startUserPostsPolling();
} }
Future<void> _loadPosts() async { @override
setState(() { void dispose() {
_isLoading = true; _userPostsStreamSubscription?.cancel();
_errorMessage = ''; // Stop user posts polling when profile page is disposed
}); PostService.stopUserPostsPolling();
super.dispose();
}
try { void _setupUserPostsStream() {
final result = await widget.fetchPosts(); print('Setting up user posts stream for profile');
setState(() { _userPostsStreamSubscription?.cancel(); // Cancel any existing subscription
if (result['success']) {
_posts = result['posts']; _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 // Update parent stats
if (widget.onStatsUpdate != null) { if (widget.onStatsUpdate != null) {
final totalLikes = _posts.fold(0, (sum, post) => sum + post.likes); final totalLikes = _posts.fold(0, (sum, post) => sum + post.likes);
widget.onStatsUpdate!(_posts.length, totalLikes); widget.onStatsUpdate!(_posts.length, totalLikes);
} }
print('🎯 User posts UI updated with ${updatedPosts.length} posts - setState called');
} else { } else {
_errorMessage = result['message'] ?? 'Failed to load posts'; print('⚠️ Widget not mounted, skipping UI update');
} }
_isLoading = false; },
}); onError: (error) {
} catch (e) { 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<void> _loadPosts() async {
// Don't show loading for cached data unless it's the initial load
final isInitialLoad = _posts.isEmpty;
if (isInitialLoad) {
setState(() { setState(() {
_errorMessage = 'Network error: $e'; _isLoading = true;
_isLoading = false; _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 @override