2026-03-08 21:48:56 +05:30
|
|
|
import 'dart:async';
|
|
|
|
|
|
|
|
|
|
import 'package:flutter_riverpod/flutter_riverpod.dart';
|
2026-03-19 03:32:35 +05:30
|
|
|
import 'package:real_estate_mobile/core/storage/secure_storage.dart';
|
2026-03-26 15:05:06 +05:30
|
|
|
import 'package:real_estate_mobile/features/messaging/data/socket_service.dart';
|
2026-03-08 21:48:56 +05:30
|
|
|
import 'package:real_estate_mobile/features/notifications/data/models/notification_model.dart';
|
|
|
|
|
import 'package:real_estate_mobile/features/notifications/data/notification_repository.dart';
|
|
|
|
|
|
|
|
|
|
/// Unread notification count — polled every 30 seconds.
|
|
|
|
|
final unreadCountProvider =
|
|
|
|
|
StateNotifierProvider<UnreadCountNotifier, int>((ref) {
|
|
|
|
|
return UnreadCountNotifier(NotificationRepository());
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
class UnreadCountNotifier extends StateNotifier<int> {
|
|
|
|
|
final NotificationRepository _repo;
|
|
|
|
|
Timer? _timer;
|
2026-03-26 15:05:06 +05:30
|
|
|
final List<StreamSubscription> _subs = [];
|
2026-03-08 21:48:56 +05:30
|
|
|
|
|
|
|
|
UnreadCountNotifier(this._repo) : super(0) {
|
|
|
|
|
refresh();
|
|
|
|
|
_timer = Timer.periodic(const Duration(seconds: 30), (_) => refresh());
|
2026-03-26 15:05:06 +05:30
|
|
|
// Refresh count immediately when socket events arrive
|
|
|
|
|
final socket = SocketService();
|
|
|
|
|
_subs.add(socket.onNewMessage.listen((_) => refresh()));
|
|
|
|
|
_subs.add(socket.onConnectionRequest.listen((_) => refresh()));
|
|
|
|
|
_subs.add(socket.onConnectionResponse.listen((_) => refresh()));
|
2026-03-08 21:48:56 +05:30
|
|
|
}
|
|
|
|
|
|
|
|
|
|
Future<void> refresh() async {
|
2026-03-19 03:32:35 +05:30
|
|
|
// Skip if no token (logged out) — prevents 401 errors
|
|
|
|
|
final token = await SecureStorage.getAccessToken();
|
|
|
|
|
if (token == null) {
|
|
|
|
|
if (mounted) state = 0;
|
|
|
|
|
return;
|
|
|
|
|
}
|
2026-03-08 21:48:56 +05:30
|
|
|
try {
|
|
|
|
|
final count = await _repo.getUnreadCount();
|
|
|
|
|
if (mounted) state = count;
|
|
|
|
|
} catch (_) {
|
|
|
|
|
// Silently fail — badge just won't update
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
2026-03-19 03:32:35 +05:30
|
|
|
/// Stop polling (call on logout).
|
|
|
|
|
void stopPolling() {
|
|
|
|
|
_timer?.cancel();
|
|
|
|
|
_timer = null;
|
|
|
|
|
if (mounted) state = 0;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
/// Restart polling (call on login).
|
|
|
|
|
void startPolling() {
|
|
|
|
|
_timer?.cancel();
|
|
|
|
|
refresh();
|
|
|
|
|
_timer = Timer.periodic(const Duration(seconds: 30), (_) => refresh());
|
|
|
|
|
}
|
|
|
|
|
|
2026-03-08 21:48:56 +05:30
|
|
|
@override
|
|
|
|
|
void dispose() {
|
|
|
|
|
_timer?.cancel();
|
2026-03-26 15:05:06 +05:30
|
|
|
for (final sub in _subs) {
|
|
|
|
|
sub.cancel();
|
|
|
|
|
}
|
2026-03-08 21:48:56 +05:30
|
|
|
super.dispose();
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
/// Notification list state.
|
|
|
|
|
class NotificationListState {
|
|
|
|
|
final List<AppNotification> notifications;
|
|
|
|
|
final bool isLoading;
|
|
|
|
|
final bool isLoadingMore;
|
|
|
|
|
final String? error;
|
|
|
|
|
final String filter; // 'all' | 'unread'
|
|
|
|
|
final int currentPage;
|
|
|
|
|
final int totalPages;
|
|
|
|
|
|
|
|
|
|
const NotificationListState({
|
|
|
|
|
this.notifications = const [],
|
|
|
|
|
this.isLoading = false,
|
|
|
|
|
this.isLoadingMore = false,
|
|
|
|
|
this.error,
|
|
|
|
|
this.filter = 'all',
|
|
|
|
|
this.currentPage = 1,
|
|
|
|
|
this.totalPages = 1,
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
NotificationListState copyWith({
|
|
|
|
|
List<AppNotification>? notifications,
|
|
|
|
|
bool? isLoading,
|
|
|
|
|
bool? isLoadingMore,
|
|
|
|
|
String? error,
|
|
|
|
|
String? filter,
|
|
|
|
|
int? currentPage,
|
|
|
|
|
int? totalPages,
|
|
|
|
|
}) {
|
|
|
|
|
return NotificationListState(
|
|
|
|
|
notifications: notifications ?? this.notifications,
|
|
|
|
|
isLoading: isLoading ?? this.isLoading,
|
|
|
|
|
isLoadingMore: isLoadingMore ?? this.isLoadingMore,
|
|
|
|
|
error: error,
|
|
|
|
|
filter: filter ?? this.filter,
|
|
|
|
|
currentPage: currentPage ?? this.currentPage,
|
|
|
|
|
totalPages: totalPages ?? this.totalPages,
|
|
|
|
|
);
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
final notificationListProvider =
|
|
|
|
|
StateNotifierProvider<NotificationListNotifier, NotificationListState>(
|
|
|
|
|
(ref) {
|
|
|
|
|
return NotificationListNotifier(NotificationRepository(), ref);
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
class NotificationListNotifier extends StateNotifier<NotificationListState> {
|
|
|
|
|
final NotificationRepository _repo;
|
|
|
|
|
final Ref _ref;
|
|
|
|
|
|
|
|
|
|
NotificationListNotifier(this._repo, this._ref)
|
|
|
|
|
: super(const NotificationListState());
|
|
|
|
|
|
|
|
|
|
Future<void> loadNotifications({String filter = 'all'}) async {
|
|
|
|
|
state = state.copyWith(isLoading: true, error: null, filter: filter);
|
|
|
|
|
try {
|
|
|
|
|
final response =
|
|
|
|
|
await _repo.getNotifications(filter: filter, page: 1, limit: 20);
|
|
|
|
|
state = state.copyWith(
|
|
|
|
|
notifications: response.notifications,
|
|
|
|
|
isLoading: false,
|
|
|
|
|
currentPage: response.currentPage,
|
|
|
|
|
totalPages: response.totalPages,
|
|
|
|
|
);
|
|
|
|
|
} catch (e) {
|
|
|
|
|
state = state.copyWith(isLoading: false, error: e.toString());
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
Future<void> loadMore() async {
|
|
|
|
|
if (state.isLoadingMore || state.currentPage >= state.totalPages) return;
|
|
|
|
|
|
|
|
|
|
state = state.copyWith(isLoadingMore: true);
|
|
|
|
|
try {
|
|
|
|
|
final nextPage = state.currentPage + 1;
|
|
|
|
|
final response = await _repo.getNotifications(
|
|
|
|
|
filter: state.filter, page: nextPage, limit: 20);
|
|
|
|
|
state = state.copyWith(
|
|
|
|
|
notifications: [...state.notifications, ...response.notifications],
|
|
|
|
|
isLoadingMore: false,
|
|
|
|
|
currentPage: response.currentPage,
|
|
|
|
|
totalPages: response.totalPages,
|
|
|
|
|
);
|
|
|
|
|
} catch (_) {
|
|
|
|
|
state = state.copyWith(isLoadingMore: false);
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
Future<void> markAsRead(String notificationId) async {
|
|
|
|
|
try {
|
|
|
|
|
await _repo.markAsRead(notificationId);
|
|
|
|
|
state = state.copyWith(
|
|
|
|
|
notifications: state.notifications.map((n) {
|
|
|
|
|
if (n.id == notificationId) return n.copyWith(read: true);
|
|
|
|
|
return n;
|
|
|
|
|
}).toList(),
|
|
|
|
|
);
|
|
|
|
|
_ref.read(unreadCountProvider.notifier).refresh();
|
|
|
|
|
} catch (_) {}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
Future<void> markAllAsRead() async {
|
|
|
|
|
try {
|
|
|
|
|
await _repo.markAllAsRead();
|
|
|
|
|
state = state.copyWith(
|
|
|
|
|
notifications:
|
|
|
|
|
state.notifications.map((n) => n.copyWith(read: true)).toList(),
|
|
|
|
|
);
|
|
|
|
|
_ref.read(unreadCountProvider.notifier).refresh();
|
|
|
|
|
} catch (_) {}
|
|
|
|
|
}
|
|
|
|
|
}
|