feat: polling and caching for posts and invitations

This commit is contained in:
sBubshait 2025-07-28 09:50:37 +03:00
parent e460cca64e
commit ff3ca9ab1f
14 changed files with 962 additions and 99 deletions

View File

@ -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<NavigatorState> navigatorKey = GlobalKey<NavigatorState>();
@ -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<SplashScreen> {
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<SignInPage> {
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(),

View File

@ -19,6 +19,15 @@ class InvitationTag {
iconName: json['iconName'],
);
}
Map<String, dynamic> toJson() {
return {
'id': id,
'name': name,
'colorHex': colorHex,
'iconName': iconName,
};
}
}
class InvitationCreator {
@ -39,6 +48,14 @@ class InvitationCreator {
avatar: json['avatar'],
);
}
Map<String, dynamic> toJson() {
return {
'id': id,
'displayName': displayName,
'avatar': avatar,
};
}
}
class InvitationAttendee {
@ -62,6 +79,15 @@ class InvitationAttendee {
joinedAt: DateTime.parse(json['joinedAt']),
);
}
Map<String, dynamic> 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<String, dynamic> 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<String, dynamic> 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<String, dynamic> 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';

View File

@ -35,7 +35,7 @@ class _FeedPageState extends State<FeedPage> {
),
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,

View File

@ -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<InvitationsPage>
Map<String, bool> _acceptingInvitations = {};
Map<String, bool> _acceptedInvitations = {};
Map<String, AnimationController> _animationControllers = {};
StreamSubscription<InvitationsData>? _invitationsStreamSubscription;
@override
void initState() {
super.initState();
_loadInvitations();
_setupInvitationsStream();
}
@override
@ -29,25 +32,55 @@ class _InvitationsPageState extends State<InvitationsPage>
for (final controller in _animationControllers.values) {
controller.dispose();
}
_invitationsStreamSubscription?.cancel();
super.dispose();
}
Future<void> _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<void> _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<InvitationsPage>
Future.delayed(Duration(milliseconds: 2000), () {
if (mounted) {
_loadInvitations();
_loadInvitations(forceRefresh: true);
}
});
} else {
@ -161,7 +194,7 @@ class _InvitationsPageState extends State<InvitationsPage>
);
if (result == true) {
_loadInvitations();
_loadInvitations(forceRefresh: true);
}
}
@ -510,7 +543,7 @@ class _InvitationsPageState extends State<InvitationsPage>
),
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<InvitationsPage>
: _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<InvitationsPage>
MaterialPageRoute(builder: (context) => CreateInvitationPage()),
);
if (result == true) {
_loadInvitations();
_loadInvitations(forceRefresh: true);
}
},
backgroundColor: Color(0xFF6A4C93),

View File

@ -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<ProfilePage> {
bool isLoadingUser = true;
int totalLikes = 0;
int totalPosts = 0;
StreamSubscription<Map<String, dynamic>>? _userStreamSubscription;
@override
void initState() {
@ -28,14 +31,33 @@ class _ProfilePageState extends State<ProfilePage> {
_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<void> _loadFCMToken() async {
setState(() {
isLoading = true;
@ -57,22 +79,31 @@ class _ProfilePageState extends State<ProfilePage> {
}
}
Future<void> _loadUserData() async {
setState(() {
isLoadingUser = true;
});
Future<void> _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<void> _loadUserStats() async {
@ -425,6 +456,8 @@ class _SettingsPageState extends State<SettingsPage> {
onPressed: () async {
Navigator.of(context).pop();
// Stop polling services before logout
AppLifecycleService.dispose();
await AuthService.logout();
Navigator.of(

View File

@ -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<void> refreshAllData() async {
await Future.wait([
PostService.getAllPosts(forceRefresh: true),
InvitationsService.getAllInvitations(forceRefresh: true),
UserService.getCurrentUser(forceRefresh: true),
]);
}
/// Get cache status for debugging
static Map<String, dynamic> getCacheStatus() {
return CacheService.getCacheInfo();
}
}

View File

@ -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<void> 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<void> handleAuthenticationError() async {
await logout();
final context = navigatorKey.currentContext;
if (context != null && context.mounted) {
Navigator.of(context).pushNamedAndRemoveUntil('/', (route) => false);

View File

@ -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<T> {
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<String, CachedData> _cache = {};
static final Map<String, Timer> _pollingTimers = {};
static final Map<String, StreamController> _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<T>(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<T>(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<T>(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<Post>)
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<T> getStream<T>(String key) {
if (!_streamControllers.containsKey(key)) {
_streamControllers[key] = StreamController<T>.broadcast();
}
return (_streamControllers[key] as StreamController<T>).stream;
}
/// Notify stream listeners of cache updates (safely) - Force UI refresh
static void notifyStreamListeners<T>(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<T>.broadcast();
}
final controller = _streamControllers[key] as StreamController<T>;
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<T>.broadcast();
(_streamControllers[key] as StreamController<T>).add(data);
}
} catch (e) {
print('Error notifying stream listeners for $key: $e');
// Recreate controller on error
try {
_streamControllers[key]?.close();
_streamControllers[key] = StreamController<T>.broadcast();
(_streamControllers[key] as StreamController<T>).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<Map<String, dynamic>> 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<Post>) {
print('📱 Notifying posts stream with ${data.length} posts');
notifyStreamListeners<List<Post>>(key, data);
} else if (key == invitationsAllKey && data is InvitationsData) {
print('📱 Notifying invitations stream');
notifyStreamListeners<InvitationsData>(key, data);
} else {
print('📱 Notifying generic stream for $key with ${data.runtimeType}');
notifyStreamListeners<dynamic>(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<String, dynamic> 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;
}

View File

@ -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<Map<String, dynamic>> getAllInvitations() async {
static bool _pollingStarted = false;
/// Get all invitations with caching - returns cached data if available and fresh
static Future<Map<String, dynamic>> getAllInvitations({bool forceRefresh = false}) async {
// Return cached data if available and not forcing refresh
if (!forceRefresh) {
final cached = CacheService.getCached<InvitationsData>(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<InvitationsData>(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<Map<String, dynamic>> _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<InvitationsData> getInvitationsStream() {
return CacheService.getStream<InvitationsData>(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<Map<String, dynamic>> 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'],

View File

@ -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<Map<String, dynamic>> getAllPosts() async {
static bool _pollingStarted = false;
/// Get all posts with caching - returns cached data if available and fresh
static Future<Map<String, dynamic>> getAllPosts({
bool forceRefresh = false,
}) async {
// Return cached data if available and not forcing refresh
if (!forceRefresh) {
final cached = CacheService.getCached<List<Post>>(
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<List<Post>>(
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<Map<String, dynamic>> _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<List<Post>> getPostsStream() {
return CacheService.getStream<List<Post>>(CacheService.postsAllKey);
}
/// Force notify stream with current cached data (useful for initial stream setup)
static void notifyStreamWithCurrentData() {
final cached = CacheService.getCachedStale<List<Post>>(
CacheService.postsAllKey,
);
if (cached != null) {
// Re-notify the stream with current data
CacheService.notifyStreamListeners<List<Post>>(
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<Map<String, dynamic>> 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<Map<String, dynamic>> 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<Map<String, dynamic>> 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<Map<String, dynamic>> 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'};
}
}
}

View File

@ -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<Map<String, dynamic>> getCurrentUser({
bool forceRefresh = false,
}) async {
static bool _pollingStarted = false;
/// Get current user with caching - returns cached data if available and fresh
static Future<Map<String, dynamic>> 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<Map<String, dynamic>>(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<Map<String, dynamic>>(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<Map<String, dynamic>> _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<Map<String, dynamic>> getUserStream() {
return CacheService.getStream<Map<String, dynamic>>(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<Map<String, dynamic>> updateFCMToken(String fcmToken) async {
try {
final response = await HttpService.post(ApiConstants.updateUserEndpoint, {

View File

@ -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<Map<String, dynamic>> Function() fetchPosts;
final Future<Map<String, dynamic>> Function({bool forceRefresh}) fetchPosts;
final String emptyStateTitle;
final String emptyStateSubtitle;
final bool showRefreshIndicator;
@ -29,39 +30,97 @@ class PostsListState extends State<PostsList> {
bool _isLoading = true;
List<Post> _posts = [];
String _errorMessage = '';
StreamSubscription<List<Post>>? _postsStreamSubscription;
@override
void initState() {
super.initState();
_loadPosts();
_setupPostsStream(); // This will also call _loadPosts()
}
Future<void> _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<void> _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<void> _refreshPosts() async {
await _loadPosts();
await _loadPosts(forceRefresh: true);
if (widget.showRefreshIndicator) {
ScaffoldMessenger.of(context).showSnackBar(
@ -154,7 +213,7 @@ class PostsListState extends State<PostsList> {
}
void refreshPosts() {
_loadPosts();
_loadPosts(forceRefresh: true);
}
}
@ -180,9 +239,14 @@ class _PostCardState extends State<PostCard> {
@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;
});
}
}

View File

@ -66,7 +66,7 @@ packages:
source: hosted
version: "0.3.4+2"
crypto:
dependency: transitive
dependency: "direct main"
description:
name: crypto
sha256: "1e445881f28f22d6140f181e07737b22f1e099a5e1ff94b0af2f9e4a463f4855"

View File

@ -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: