feat: polling and caching for posts and invitations
This commit is contained in:
parent
e460cca64e
commit
ff3ca9ab1f
@ -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(),
|
||||
|
||||
@ -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';
|
||||
|
||||
@ -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,
|
||||
|
||||
@ -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),
|
||||
|
||||
@ -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(
|
||||
|
||||
101
frontend/lib/services/app_lifecycle_service.dart
Normal file
101
frontend/lib/services/app_lifecycle_service.dart
Normal 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();
|
||||
}
|
||||
}
|
||||
@ -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);
|
||||
|
||||
303
frontend/lib/services/cache_service.dart
Normal file
303
frontend/lib/services/cache_service.dart
Normal 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;
|
||||
}
|
||||
@ -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'],
|
||||
|
||||
@ -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'};
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@ -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, {
|
||||
|
||||
@ -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;
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@ -66,7 +66,7 @@ packages:
|
||||
source: hosted
|
||||
version: "0.3.4+2"
|
||||
crypto:
|
||||
dependency: transitive
|
||||
dependency: "direct main"
|
||||
description:
|
||||
name: crypto
|
||||
sha256: "1e445881f28f22d6140f181e07737b22f1e099a5e1ff94b0af2f9e4a463f4855"
|
||||
|
||||
@ -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:
|
||||
|
||||
Loading…
Reference in New Issue
Block a user