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/notification_service.dart';
import 'services/auth_service.dart'; import 'services/auth_service.dart';
import 'services/user_service.dart'; import 'services/user_service.dart';
import 'services/app_lifecycle_service.dart';
final GlobalKey<NavigatorState> navigatorKey = GlobalKey<NavigatorState>(); final GlobalKey<NavigatorState> navigatorKey = GlobalKey<NavigatorState>();
@ -13,6 +14,9 @@ void main() async {
WidgetsFlutterBinding.ensureInitialized(); WidgetsFlutterBinding.ensureInitialized();
await Firebase.initializeApp(options: DefaultFirebaseOptions.currentPlatform); await Firebase.initializeApp(options: DefaultFirebaseOptions.currentPlatform);
// Initialize app lifecycle service
AppLifecycleService.initialize();
runApp(MyApp()); runApp(MyApp());
} }
@ -49,6 +53,8 @@ class _SplashScreenState extends State<SplashScreen> {
if (isLoggedIn) { if (isLoggedIn) {
final userResult = await UserService.getCurrentUser(); final userResult = await UserService.getCurrentUser();
if (userResult['success'] == true) { if (userResult['success'] == true) {
// Start polling services now that user is logged in and going to main screen
AppLifecycleService.startAllPolling();
Navigator.of(context).pushReplacement( Navigator.of(context).pushReplacement(
MaterialPageRoute(builder: (context) => HomeScreen()), MaterialPageRoute(builder: (context) => HomeScreen()),
); );
@ -335,6 +341,9 @@ class _SignInPageState extends State<SignInPage> {
if (result['success'] == true) { if (result['success'] == true) {
final userResult = await UserService.getCurrentUser(forceRefresh: true); final userResult = await UserService.getCurrentUser(forceRefresh: true);
// Start polling services now that user is logged in
AppLifecycleService.startAllPolling();
Navigator.of(context).pushReplacement( Navigator.of(context).pushReplacement(
MaterialPageRoute( MaterialPageRoute(
builder: (context) => NotificationPermissionScreen(), builder: (context) => NotificationPermissionScreen(),

View File

@ -19,6 +19,15 @@ class InvitationTag {
iconName: json['iconName'], iconName: json['iconName'],
); );
} }
Map<String, dynamic> toJson() {
return {
'id': id,
'name': name,
'colorHex': colorHex,
'iconName': iconName,
};
}
} }
class InvitationCreator { class InvitationCreator {
@ -39,6 +48,14 @@ class InvitationCreator {
avatar: json['avatar'], avatar: json['avatar'],
); );
} }
Map<String, dynamic> toJson() {
return {
'id': id,
'displayName': displayName,
'avatar': avatar,
};
}
} }
class InvitationAttendee { class InvitationAttendee {
@ -62,6 +79,15 @@ class InvitationAttendee {
joinedAt: DateTime.parse(json['joinedAt']), joinedAt: DateTime.parse(json['joinedAt']),
); );
} }
Map<String, dynamic> toJson() {
return {
'id': id,
'displayName': displayName,
'avatar': avatar,
'joinedAt': joinedAt.toIso8601String(),
};
}
} }
class Invitation { class Invitation {
@ -103,6 +129,21 @@ class Invitation {
createdAt: DateTime.parse(json['createdAt']), 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 { class InvitationsResponse {
@ -150,6 +191,14 @@ class InvitationsData {
); );
} }
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; 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 { String get status {
if (currentAttendees >= maxParticipants) { if (currentAttendees >= maxParticipants) {
return 'Full'; return 'Full';

View File

@ -35,7 +35,7 @@ class _FeedPageState extends State<FeedPage> {
), ),
body: PostsList( body: PostsList(
key: _postsListKey, key: _postsListKey,
fetchPosts: PostService.getAllPosts, fetchPosts: ({bool forceRefresh = false}) => PostService.getAllPosts(forceRefresh: forceRefresh),
emptyStateTitle: 'Nothing here..', emptyStateTitle: 'Nothing here..',
emptyStateSubtitle: 'Create the first post!', emptyStateSubtitle: 'Create the first post!',
showRefreshIndicator: true, showRefreshIndicator: true,

View File

@ -1,4 +1,5 @@
import 'package:flutter/material.dart'; import 'package:flutter/material.dart';
import 'dart:async';
import '../../services/invitations_service.dart'; import '../../services/invitations_service.dart';
import '../../models/invitation_models.dart'; import '../../models/invitation_models.dart';
import '../../utils/invitation_utils.dart'; import '../../utils/invitation_utils.dart';
@ -17,11 +18,13 @@ class _InvitationsPageState extends State<InvitationsPage>
Map<String, bool> _acceptingInvitations = {}; Map<String, bool> _acceptingInvitations = {};
Map<String, bool> _acceptedInvitations = {}; Map<String, bool> _acceptedInvitations = {};
Map<String, AnimationController> _animationControllers = {}; Map<String, AnimationController> _animationControllers = {};
StreamSubscription<InvitationsData>? _invitationsStreamSubscription;
@override @override
void initState() { void initState() {
super.initState(); super.initState();
_loadInvitations(); _loadInvitations();
_setupInvitationsStream();
} }
@override @override
@ -29,25 +32,55 @@ class _InvitationsPageState extends State<InvitationsPage>
for (final controller in _animationControllers.values) { for (final controller in _animationControllers.values) {
controller.dispose(); controller.dispose();
} }
_invitationsStreamSubscription?.cancel();
super.dispose(); super.dispose();
} }
Future<void> _loadInvitations() async { 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');
},
);
}
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(() { setState(() {
_isLoading = true; _isLoading = true;
_errorMessage = null; _errorMessage = null;
}); });
}
final result = await InvitationsService.getAllInvitations(); final result = await InvitationsService.getAllInvitations(forceRefresh: forceRefresh);
if (mounted) { if (mounted) {
setState(() { setState(() {
_isLoading = false;
if (result['success']) { if (result['success']) {
_invitationsData = result['data']; final invitationsData = result['invitations'] as InvitationsData;
_invitationsData = invitationsData;
_errorMessage = null;
} else { } else {
_errorMessage = result['message']; _errorMessage = result['message'];
} }
_isLoading = false;
}); });
} }
} }
@ -130,7 +163,7 @@ class _InvitationsPageState extends State<InvitationsPage>
Future.delayed(Duration(milliseconds: 2000), () { Future.delayed(Duration(milliseconds: 2000), () {
if (mounted) { if (mounted) {
_loadInvitations(); _loadInvitations(forceRefresh: true);
} }
}); });
} else { } else {
@ -161,7 +194,7 @@ class _InvitationsPageState extends State<InvitationsPage>
); );
if (result == true) { if (result == true) {
_loadInvitations(); _loadInvitations(forceRefresh: true);
} }
} }
@ -510,7 +543,7 @@ class _InvitationsPageState extends State<InvitationsPage>
), ),
automaticallyImplyLeading: false, automaticallyImplyLeading: false,
actions: [ actions: [
IconButton(icon: Icon(Icons.refresh), onPressed: _loadInvitations), IconButton(icon: Icon(Icons.refresh), onPressed: () => _loadInvitations(forceRefresh: true)),
], ],
), ),
body: _isLoading body: _isLoading
@ -520,7 +553,7 @@ class _InvitationsPageState extends State<InvitationsPage>
: _invitationsData?.isEmpty ?? true : _invitationsData?.isEmpty ?? true
? _buildEmptyState() ? _buildEmptyState()
: RefreshIndicator( : RefreshIndicator(
onRefresh: _loadInvitations, onRefresh: () => _loadInvitations(forceRefresh: true),
color: Color(0xFF6A4C93), color: Color(0xFF6A4C93),
child: SingleChildScrollView( child: SingleChildScrollView(
physics: AlwaysScrollableScrollPhysics(), physics: AlwaysScrollableScrollPhysics(),
@ -554,7 +587,7 @@ class _InvitationsPageState extends State<InvitationsPage>
MaterialPageRoute(builder: (context) => CreateInvitationPage()), MaterialPageRoute(builder: (context) => CreateInvitationPage()),
); );
if (result == true) { if (result == true) {
_loadInvitations(); _loadInvitations(forceRefresh: true);
} }
}, },
backgroundColor: Color(0xFF6A4C93), backgroundColor: Color(0xFF6A4C93),

View File

@ -1,9 +1,11 @@
import 'package:flutter/material.dart'; import 'package:flutter/material.dart';
import 'package:flutter/services.dart'; import 'package:flutter/services.dart';
import 'dart:async';
import '../../services/notification_service.dart'; import '../../services/notification_service.dart';
import '../../services/user_service.dart'; import '../../services/user_service.dart';
import '../../services/auth_service.dart'; import '../../services/auth_service.dart';
import '../../services/post_service.dart'; import '../../services/post_service.dart';
import '../../services/app_lifecycle_service.dart';
import '../../models/post_models.dart'; import '../../models/post_models.dart';
import '../../widgets/posts_list.dart'; import '../../widgets/posts_list.dart';
import '../../utils/password_generator.dart'; import '../../utils/password_generator.dart';
@ -21,6 +23,7 @@ class _ProfilePageState extends State<ProfilePage> {
bool isLoadingUser = true; bool isLoadingUser = true;
int totalLikes = 0; int totalLikes = 0;
int totalPosts = 0; int totalPosts = 0;
StreamSubscription<Map<String, dynamic>>? _userStreamSubscription;
@override @override
void initState() { void initState() {
@ -28,14 +31,33 @@ class _ProfilePageState extends State<ProfilePage> {
_loadFCMToken(); _loadFCMToken();
_loadUserData(); _loadUserData();
_loadUserStats(); _loadUserStats();
_setupUserStream();
} }
@override @override
void dispose() { void dispose() {
_tokenController.dispose(); _tokenController.dispose();
_userStreamSubscription?.cancel();
super.dispose(); 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 { Future<void> _loadFCMToken() async {
setState(() { setState(() {
isLoading = true; isLoading = true;
@ -57,23 +79,32 @@ class _ProfilePageState extends State<ProfilePage> {
} }
} }
Future<void> _loadUserData() async { 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(() { setState(() {
isLoadingUser = true; isLoadingUser = true;
}); });
}
final result = await UserService.getCurrentUser(); final result = await UserService.getCurrentUser(forceRefresh: forceRefresh);
if (mounted) {
setState(() { setState(() {
isLoadingUser = false;
if (result['success'] == true) { if (result['success'] == true) {
userData = result['data']; userData = result['data'];
} else { } else {
userData = null; userData = null;
if (isInitialLoad) {
_showErrorAlert(result['message'] ?? 'Failed to load user data'); _showErrorAlert(result['message'] ?? 'Failed to load user data');
} }
}
isLoadingUser = false;
}); });
} }
}
Future<void> _loadUserStats() async { Future<void> _loadUserStats() async {
final result = await PostService.getUserPosts(); final result = await PostService.getUserPosts();
@ -425,6 +456,8 @@ class _SettingsPageState extends State<SettingsPage> {
onPressed: () async { onPressed: () async {
Navigator.of(context).pop(); Navigator.of(context).pop();
// Stop polling services before logout
AppLifecycleService.dispose();
await AuthService.logout(); await AuthService.logout();
Navigator.of( 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 'package:http/http.dart' as http;
import '../constants/api_constants.dart'; import '../constants/api_constants.dart';
import '../main.dart'; import '../main.dart';
import 'app_lifecycle_service.dart';
class AuthService { class AuthService {
static const FlutterSecureStorage _storage = FlutterSecureStorage(); static const FlutterSecureStorage _storage = FlutterSecureStorage();
@ -62,6 +63,8 @@ class AuthService {
} }
static Future<void> logout() async { static Future<void> logout() async {
// Stop all polling services when logging out
AppLifecycleService.dispose();
await _storage.delete(key: _tokenKey); await _storage.delete(key: _tokenKey);
await _storage.delete(key: _userDataKey); await _storage.delete(key: _userDataKey);
} }

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 '../models/invitation_models.dart';
import 'http_service.dart'; import 'http_service.dart';
import 'auth_service.dart'; import 'auth_service.dart';
import 'cache_service.dart';
class InvitationsService { 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 { try {
final response = await HttpService.get( final response = await HttpService.get(
ApiConstants.getAllInvitationsEndpoint, ApiConstants.getAllInvitationsEndpoint,
@ -16,12 +48,23 @@ class InvitationsService {
final invitationsResponse = InvitationsResponse.fromJson(responseData); final invitationsResponse = InvitationsResponse.fromJson(responseData);
if (invitationsResponse.status) { 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 { } else {
return { return {
'success': false, 'success': false,
'message': 'message': invitationsResponse.message ?? 'Failed to fetch invitations',
invitationsResponse.message ?? 'Failed to fetch invitations', 'invitations': InvitationsData(created: [], accepted: [], available: []),
}; };
} }
} else if (response.statusCode == 401 || response.statusCode == 403) { } else if (response.statusCode == 401 || response.statusCode == 403) {
@ -29,11 +72,13 @@ class InvitationsService {
return { return {
'success': false, 'success': false,
'message': 'Session expired. Please login again.', 'message': 'Session expired. Please login again.',
'invitations': InvitationsData(created: [], accepted: [], available: []),
}; };
} else { } else {
return { return {
'success': false, 'success': false,
'message': 'Server error (${response.statusCode})', 'message': 'Server error (${response.statusCode})',
'invitations': InvitationsData(created: [], accepted: [], available: []),
}; };
} }
} catch (e) { } catch (e) {
@ -41,10 +86,41 @@ class InvitationsService {
return { return {
'success': false, 'success': false,
'message': 'Network error. Please check your connection.', '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 { static Future<Map<String, dynamic>> acceptInvitation(int invitationId) async {
try { try {
final response = await HttpService.post( final response = await HttpService.post(
@ -54,6 +130,8 @@ class InvitationsService {
if (response.statusCode == 200) { if (response.statusCode == 200) {
final responseData = jsonDecode(response.body); final responseData = jsonDecode(response.body);
// Invalidate invitations cache since invitation status changed
invalidateCache();
return { return {
'success': responseData['status'] ?? false, 'success': responseData['status'] ?? false,
'message': responseData['message'] ?? 'Request completed', 'message': responseData['message'] ?? 'Request completed',
@ -93,6 +171,8 @@ class InvitationsService {
if (response.statusCode == 200) { if (response.statusCode == 200) {
final responseData = jsonDecode(response.body); final responseData = jsonDecode(response.body);
if (responseData['status'] == true) { if (responseData['status'] == true) {
// Invalidate invitations cache since new invitation was created
invalidateCache();
return { return {
'success': true, 'success': true,
'data': responseData['data'], 'data': responseData['data'],

View File

@ -3,22 +3,72 @@ import '../models/post_models.dart';
import '../models/comment_models.dart'; import '../models/comment_models.dart';
import 'http_service.dart'; import 'http_service.dart';
import 'auth_service.dart'; import 'auth_service.dart';
import 'cache_service.dart';
class PostService { 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 { try {
final response = await HttpService.get('/posts/all'); final response = await HttpService.get('/posts/all');
if (response.statusCode == 200) { if (response.statusCode == 200) {
final responseData = jsonDecode(response.body); final responseData = jsonDecode(response.body);
return { final posts =
'success': responseData['status'] ?? false,
'message': responseData['message'] ?? '',
'posts':
(responseData['data'] as List?) (responseData['data'] as List?)
?.map((post) => Post.fromJson(post)) ?.map((post) => Post.fromJson(post))
.toList() ?? .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': posts,
}; };
} else if (response.statusCode == 401 || response.statusCode == 403) { } else if (response.statusCode == 401 || response.statusCode == 403) {
await AuthService.handleAuthenticationError(); await AuthService.handleAuthenticationError();
@ -53,6 +103,50 @@ 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 { static Future<Map<String, dynamic>> getUserPosts() async {
try { try {
final response = await HttpService.get('/posts/user'); final response = await HttpService.get('/posts/user');
@ -63,7 +157,11 @@ class PostService {
return { return {
'success': responseData['status'] ?? false, 'success': responseData['status'] ?? false,
'message': responseData['message'] ?? '', '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 { } else {
return { return {
@ -93,6 +191,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
invalidateCache();
return { return {
'success': responseData['status'] ?? false, 'success': responseData['status'] ?? false,
'message': responseData['message'] ?? '', 'message': responseData['message'] ?? '',
@ -115,10 +216,9 @@ class PostService {
static Future<Map<String, dynamic>> likePost(String postId) async { static Future<Map<String, dynamic>> likePost(String postId) async {
try { try {
final response = await HttpService.post( final response = await HttpService.post('/posts/like', {
'/posts/like', 'postId': postId,
{'postId': postId}, });
);
final responseData = jsonDecode(response.body); final responseData = jsonDecode(response.body);
@ -145,10 +245,9 @@ class PostService {
static Future<Map<String, dynamic>> unlikePost(String postId) async { static Future<Map<String, dynamic>> unlikePost(String postId) async {
try { try {
final response = await HttpService.post( final response = await HttpService.post('/posts/unlike', {
'/posts/unlike', 'postId': postId,
{'postId': postId}, });
);
final responseData = jsonDecode(response.body); final responseData = jsonDecode(response.body);
@ -202,17 +301,16 @@ class PostService {
static Future<Map<String, dynamic>> getComments(String postId) async { static Future<Map<String, dynamic>> getComments(String postId) async {
try { 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) { if (response.statusCode == 200) {
final responseData = jsonDecode(response.body); final responseData = jsonDecode(response.body);
final commentsResponse = CommentsResponse.fromJson(responseData); final commentsResponse = CommentsResponse.fromJson(responseData);
if (commentsResponse.status) { if (commentsResponse.status) {
return { return {'success': true, 'comments': commentsResponse.data};
'success': true,
'comments': commentsResponse.data,
};
} else { } else {
return { return {
'success': false, 'success': false,
@ -259,16 +357,16 @@ class PostService {
String? replyComment, String? replyComment,
}) async { }) async {
try { try {
final requestBody = { final requestBody = {'postId': int.parse(postId), 'body': body};
'postId': int.parse(postId),
'body': body,
};
if (replyComment != null) { if (replyComment != null) {
requestBody['replyComment'] = int.parse(replyComment); 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) { if (response.statusCode == 200 || response.statusCode == 201) {
final responseData = jsonDecode(response.body); final responseData = jsonDecode(response.body);
@ -306,10 +404,7 @@ class PostService {
} }
} catch (e) { } catch (e) {
print('Error creating comment: $e'); print('Error creating comment: $e');
return { return {'success': false, 'message': 'Network error: $e'};
'success': false,
'message': 'Network error: $e',
};
} }
} }
} }

View File

@ -2,35 +2,80 @@ import 'dart:convert';
import '../constants/api_constants.dart'; import '../constants/api_constants.dart';
import 'http_service.dart'; import 'http_service.dart';
import 'auth_service.dart'; import 'auth_service.dart';
import 'cache_service.dart';
class UserService { class UserService {
static Future<Map<String, dynamic>> getCurrentUser({ static bool _pollingStarted = false;
bool forceRefresh = false, /// Get current user with caching - returns cached data if available and fresh
}) async { static Future<Map<String, dynamic>> getCurrentUser({bool forceRefresh = false}) async {
// Return cached data if available and not forcing refresh
if (!forceRefresh) { if (!forceRefresh) {
final cachedData = await AuthService.getCachedUserData(); final cached = CacheService.getCached<Map<String, dynamic>>(CacheService.userProfileKey);
if (cachedData != null) { if (cached != null && !CacheService.isCacheExpired(CacheService.userProfileKey, CacheService.userCacheDuration)) {
return {'success': true, 'data': cachedData}; 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 { try {
final response = await HttpService.get(ApiConstants.getUserEndpoint); final response = await HttpService.get(ApiConstants.getUserEndpoint);
if (response.statusCode == 200) { if (response.statusCode == 200) {
final data = jsonDecode(response.body); 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); await AuthService.saveUserData(data);
return {'success': true, 'data': data};
return {
'success': true,
'data': data,
'message': '',
};
} else if (response.statusCode == 401 || response.statusCode == 403) { } else if (response.statusCode == 401 || response.statusCode == 403) {
await AuthService.handleAuthenticationError(); await AuthService.handleAuthenticationError();
return { return {
'success': false, 'success': false,
'message': 'Session expired. Please login again.', 'message': 'Session expired. Please login again.',
'data': null,
}; };
} else { } else {
return { return {
'success': false, 'success': false,
'message': 'Server error (${response.statusCode})', 'message': 'Server error (${response.statusCode})',
'data': null,
}; };
} }
} catch (e) { } catch (e) {
@ -38,10 +83,41 @@ class UserService {
return { return {
'success': false, 'success': false,
'message': 'Network error. Please check your connection.', '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 { static Future<Map<String, dynamic>> updateFCMToken(String fcmToken) async {
try { try {
final response = await HttpService.post(ApiConstants.updateUserEndpoint, { final response = await HttpService.post(ApiConstants.updateUserEndpoint, {

View File

@ -1,12 +1,13 @@
import 'package:flutter/material.dart'; import 'package:flutter/material.dart';
import 'package:share_plus/share_plus.dart'; import 'package:share_plus/share_plus.dart';
import 'dart:async';
import '../models/post_models.dart'; import '../models/post_models.dart';
import '../services/post_service.dart'; import '../services/post_service.dart';
import '../utils/invitation_utils.dart'; import '../utils/invitation_utils.dart';
import '../screens/pages/post_view_page.dart'; import '../screens/pages/post_view_page.dart';
class PostsList extends StatefulWidget { 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 emptyStateTitle;
final String emptyStateSubtitle; final String emptyStateSubtitle;
final bool showRefreshIndicator; final bool showRefreshIndicator;
@ -29,39 +30,97 @@ class PostsListState extends State<PostsList> {
bool _isLoading = true; bool _isLoading = true;
List<Post> _posts = []; List<Post> _posts = [];
String _errorMessage = ''; String _errorMessage = '';
StreamSubscription<List<Post>>? _postsStreamSubscription;
@override @override
void initState() { void initState() {
super.initState(); super.initState();
_loadPosts(); _setupPostsStream(); // This will also call _loadPosts()
} }
Future<void> _loadPosts() async { @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(() { setState(() {
_isLoading = true; _isLoading = true;
_errorMessage = ''; _errorMessage = '';
}); });
}
try { try {
final result = await widget.fetchPosts(); final result = await widget.fetchPosts(forceRefresh: forceRefresh);
if (mounted) {
setState(() { setState(() {
if (result['success']) { if (result['success']) {
_posts = result['posts']; _posts = result['posts'];
_errorMessage = '';
} else { } else {
_errorMessage = result['message'] ?? 'Failed to load posts'; _errorMessage = result['message'] ?? 'Failed to load posts';
} }
_isLoading = false; _isLoading = false;
}); });
}
} catch (e) { } catch (e) {
if (mounted) {
setState(() { setState(() {
_errorMessage = 'Network error: $e'; _errorMessage = 'Network error: $e';
_isLoading = false; _isLoading = false;
}); });
} }
} }
}
Future<void> _refreshPosts() async { Future<void> _refreshPosts() async {
await _loadPosts(); await _loadPosts(forceRefresh: true);
if (widget.showRefreshIndicator) { if (widget.showRefreshIndicator) {
ScaffoldMessenger.of(context).showSnackBar( ScaffoldMessenger.of(context).showSnackBar(
@ -154,7 +213,7 @@ class PostsListState extends State<PostsList> {
} }
void refreshPosts() { void refreshPosts() {
_loadPosts(); _loadPosts(forceRefresh: true);
} }
} }
@ -180,9 +239,14 @@ class _PostCardState extends State<PostCard> {
@override @override
void didUpdateWidget(PostCard oldWidget) { void didUpdateWidget(PostCard oldWidget) {
super.didUpdateWidget(oldWidget); super.didUpdateWidget(oldWidget);
// Update current post if the widget's post changed // Force update current post to reflect any changes
if (oldWidget.post.id != widget.post.id) { 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; _currentPost = widget.post;
});
} }
} }

View File

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

View File

@ -16,6 +16,7 @@ dependencies:
googleapis_auth: ^1.6.0 googleapis_auth: ^1.6.0
flutter_secure_storage: ^9.2.2 flutter_secure_storage: ^9.2.2
share_plus: ^11.0.0 share_plus: ^11.0.0
crypto: ^3.0.3
dev_dependencies: dev_dependencies:
flutter_test: flutter_test: