import 'dart:async'; import 'dart:convert'; import 'dart:io'; import 'package:flutter/foundation.dart'; import 'package:firebase_messaging/firebase_messaging.dart'; import 'package:flutter/material.dart'; import 'package:go_router/go_router.dart'; import 'package:permission_handler/permission_handler.dart'; import '../config/firebase_web_config.dart'; import '../theme/tokens.dart'; import '../routes/app_routes.dart'; import '../services/secure_chat_service.dart'; import '../theme/app_theme.dart'; import 'api_service.dart'; /// NotificationPreferences model class NotificationPreferences { final bool pushEnabled; final bool pushLikes; final bool pushComments; final bool pushReplies; final bool pushMentions; final bool pushFollows; final bool pushFollowRequests; final bool pushMessages; final bool pushSaves; final bool pushBeacons; final bool emailEnabled; final String emailDigestFrequency; final bool quietHoursEnabled; final String? quietHoursStart; final String? quietHoursEnd; final bool showBadgeCount; NotificationPreferences({ this.pushEnabled = true, this.pushLikes = true, this.pushComments = true, this.pushReplies = true, this.pushMentions = true, this.pushFollows = true, this.pushFollowRequests = true, this.pushMessages = true, this.pushSaves = true, this.pushBeacons = true, this.emailEnabled = false, this.emailDigestFrequency = 'never', this.quietHoursEnabled = false, this.quietHoursStart, this.quietHoursEnd, this.showBadgeCount = true, }); factory NotificationPreferences.fromJson(Map json) { return NotificationPreferences( pushEnabled: json['push_enabled'] ?? true, pushLikes: json['push_likes'] ?? true, pushComments: json['push_comments'] ?? true, pushReplies: json['push_replies'] ?? true, pushMentions: json['push_mentions'] ?? true, pushFollows: json['push_follows'] ?? true, pushFollowRequests: json['push_follow_requests'] ?? true, pushMessages: json['push_messages'] ?? true, pushSaves: json['push_saves'] ?? true, pushBeacons: json['push_beacons'] ?? true, emailEnabled: json['email_enabled'] ?? false, emailDigestFrequency: json['email_digest_frequency'] ?? 'never', quietHoursEnabled: json['quiet_hours_enabled'] ?? false, quietHoursStart: json['quiet_hours_start'], quietHoursEnd: json['quiet_hours_end'], showBadgeCount: json['show_badge_count'] ?? true, ); } Map toJson() => { 'push_enabled': pushEnabled, 'push_likes': pushLikes, 'push_comments': pushComments, 'push_replies': pushReplies, 'push_mentions': pushMentions, 'push_follows': pushFollows, 'push_follow_requests': pushFollowRequests, 'push_messages': pushMessages, 'push_saves': pushSaves, 'push_beacons': pushBeacons, 'email_enabled': emailEnabled, 'email_digest_frequency': emailDigestFrequency, 'quiet_hours_enabled': quietHoursEnabled, 'quiet_hours_start': quietHoursStart, 'quiet_hours_end': quietHoursEnd, 'show_badge_count': showBadgeCount, }; NotificationPreferences copyWith({ bool? pushEnabled, bool? pushLikes, bool? pushComments, bool? pushReplies, bool? pushMentions, bool? pushFollows, bool? pushFollowRequests, bool? pushMessages, bool? pushSaves, bool? pushBeacons, bool? emailEnabled, String? emailDigestFrequency, bool? quietHoursEnabled, String? quietHoursStart, String? quietHoursEnd, bool? showBadgeCount, }) { return NotificationPreferences( pushEnabled: pushEnabled ?? this.pushEnabled, pushLikes: pushLikes ?? this.pushLikes, pushComments: pushComments ?? this.pushComments, pushReplies: pushReplies ?? this.pushReplies, pushMentions: pushMentions ?? this.pushMentions, pushFollows: pushFollows ?? this.pushFollows, pushFollowRequests: pushFollowRequests ?? this.pushFollowRequests, pushMessages: pushMessages ?? this.pushMessages, pushSaves: pushSaves ?? this.pushSaves, pushBeacons: pushBeacons ?? this.pushBeacons, emailEnabled: emailEnabled ?? this.emailEnabled, emailDigestFrequency: emailDigestFrequency ?? this.emailDigestFrequency, quietHoursEnabled: quietHoursEnabled ?? this.quietHoursEnabled, quietHoursStart: quietHoursStart ?? this.quietHoursStart, quietHoursEnd: quietHoursEnd ?? this.quietHoursEnd, showBadgeCount: showBadgeCount ?? this.showBadgeCount, ); } } /// Badge count model class UnreadBadge { final int notificationCount; final int messageCount; final int totalCount; UnreadBadge({ this.notificationCount = 0, this.messageCount = 0, this.totalCount = 0, }); factory UnreadBadge.fromJson(Map json) { return UnreadBadge( notificationCount: json['notification_count'] ?? 0, messageCount: json['message_count'] ?? 0, totalCount: json['total_count'] ?? 0, ); } } class NotificationService { NotificationService._internal(); static final NotificationService instance = NotificationService._internal(); final FirebaseMessaging _messaging = FirebaseMessaging.instance; bool _initialized = false; String? _currentToken; String? _cachedVapidKey; // Badge count stream for UI updates final StreamController _badgeController = StreamController.broadcast(); Stream get badgeStream => _badgeController.stream; UnreadBadge _currentBadge = UnreadBadge(); UnreadBadge get currentBadge => _currentBadge; // Foreground notification stream for in-app banners final StreamController _foregroundMessageController = StreamController.broadcast(); Stream get foregroundMessages => _foregroundMessageController.stream; // Global overlay entry for in-app notification banner OverlayEntry? _currentBannerOverlay; // Active context tracking to suppress notifications for what the user is already seeing String? activeConversationId; String? activePostId; Future init() async { if (_initialized) return; _initialized = true; try { debugPrint('[FCM] Initializing for platform: ${_resolveDeviceType()}'); // Android 13+ requires explicit runtime permission request if (!kIsWeb && Platform.isAndroid) { final permissionStatus = await _requestAndroidNotificationPermission(); if (permissionStatus != PermissionStatus.granted) { debugPrint('[FCM] Android notification permission not granted: $permissionStatus'); return; } } final settings = await _messaging.requestPermission( alert: true, badge: true, sound: true, provisional: false, ); debugPrint('[FCM] Permission status: ${settings.authorizationStatus}'); if (settings.authorizationStatus == AuthorizationStatus.denied) { debugPrint('[FCM] Push notification permission denied'); return; } final vapidKey = kIsWeb ? await _resolveVapidKey() : null; if (kIsWeb && (vapidKey == null || vapidKey.isEmpty)) { debugPrint('[FCM] Web push is missing FIREBASE_WEB_VAPID_KEY'); } debugPrint('[FCM] Requesting token...'); final token = await _messaging.getToken( vapidKey: vapidKey, ); if (token != null) { _currentToken = token; debugPrint('[FCM] Token registered (${_resolveDeviceType()}): ${token.substring(0, 20)}...'); await _upsertToken(token); } else { debugPrint('[FCM] WARNING: Token is null after getToken()'); } _messaging.onTokenRefresh.listen((newToken) { debugPrint('[FCM] Token refreshed'); _currentToken = newToken; _upsertToken(newToken); }); // Handle messages when app is opened from notification FirebaseMessaging.onMessageOpenedApp.listen((msg) { debugPrint('[FCM] onMessageOpenedApp triggered: ${msg.notification?.title}'); _handleMessageOpen(msg); }); // Handle foreground messages - show in-app banner FirebaseMessaging.onMessage.listen((message) { debugPrint('[FCM] Foreground message received: ${message.notification?.title}'); _foregroundMessageController.add(message); _refreshBadgeCount(); }); // Check for initial message (app opened from terminated state) final initialMessage = await _messaging.getInitialMessage(); if (initialMessage != null) { debugPrint('[FCM] App opened from TERMINATED state via notification: ${initialMessage.notification?.title}'); // Delay to allow navigation setup (extended to 1000ms for safety) Future.delayed(const Duration(milliseconds: 1000), () { _handleMessageOpen(initialMessage); }); } // Initial badge count fetch await _refreshBadgeCount(); debugPrint('[FCM] Initialization complete'); } catch (e, stackTrace) { debugPrint('[FCM] Failed to initialize notifications: $e'); debugPrint('[FCM] Stack trace: $stackTrace'); } } /// Request POST_NOTIFICATIONS permission for Android 13+ (API 33+) Future _requestAndroidNotificationPermission() async { try { final status = await Permission.notification.status; debugPrint('[FCM] Current Android permission status: $status'); if (status.isDenied || status.isRestricted) { final result = await Permission.notification.request(); debugPrint('[FCM] Android permission request result: $result'); return result; } return status; } catch (e) { debugPrint('[FCM] Error requesting Android notification permission: $e'); return PermissionStatus.granted; } } /// Remove the current device's FCM token (call on logout) Future removeToken() async { if (_currentToken == null) { debugPrint('[FCM] No token to revoke'); return; } try { debugPrint('[FCM] Revoking token from backend...'); await ApiService.instance.callGoApi( '/notifications/device', method: 'DELETE', body: { 'token': _currentToken, }, ); debugPrint('[FCM] Token revoked successfully from backend'); await _messaging.deleteToken(); debugPrint('[FCM] Token deleted from Firebase'); } catch (e) { debugPrint('[FCM] Failed to revoke token: $e'); } finally { _currentToken = null; _initialized = false; _currentBadge = UnreadBadge(); _badgeController.add(_currentBadge); } } Future _upsertToken(String token) async { try { debugPrint('[FCM] Syncing token with backend...'); await ApiService.instance.callGoApi( '/notifications/device', method: 'POST', body: { 'fcm_token': token, 'platform': _resolveDeviceType() } ); debugPrint('[FCM] Token synced with Go Backend successfully'); } catch (e, stackTrace) { debugPrint('[FCM] Sync failed: $e'); debugPrint('[FCM] Stack trace: $stackTrace'); } } String _resolveDeviceType() { if (kIsWeb) return 'web'; if (Platform.isAndroid) return 'android'; if (Platform.isIOS) return 'ios'; return 'desktop'; } Future _resolveVapidKey() async { if (_cachedVapidKey != null && _cachedVapidKey!.isNotEmpty) { return _cachedVapidKey; } final envKey = FirebaseWebConfig.vapidKey; if (envKey != null && envKey.isNotEmpty) { _cachedVapidKey = envKey; return envKey; } return null; } // ============================================================================ // Badge Count Management // ============================================================================ Future _refreshBadgeCount() async { try { final response = await ApiService.instance.callGoApi( '/notifications/badge', method: 'GET', ); _currentBadge = UnreadBadge.fromJson(response); _badgeController.add(_currentBadge); } catch (e) { debugPrint('[FCM] Failed to refresh badge count: $e'); } } /// Call this after marking notifications as read Future refreshBadge() => _refreshBadgeCount(); // ============================================================================ // Preferences Management // ============================================================================ Future getPreferences() async { try { final response = await ApiService.instance.callGoApi( '/notifications/preferences', method: 'GET', ); return NotificationPreferences.fromJson(response); } catch (e) { debugPrint('[FCM] Failed to get preferences: $e'); return NotificationPreferences(); } } Future updatePreferences(NotificationPreferences prefs) async { try { await ApiService.instance.callGoApi( '/notifications/preferences', method: 'PUT', body: prefs.toJson(), ); return true; } catch (e) { debugPrint('[FCM] Failed to update preferences: $e'); return false; } } // ============================================================================ // Notification Actions // ============================================================================ Future markAsRead(String notificationId) async { try { await ApiService.instance.callGoApi( '/notifications/$notificationId/read', method: 'PUT', ); await _refreshBadgeCount(); } catch (e) { debugPrint('[FCM] Failed to mark as read: $e'); } } Future markAllAsRead() async { try { await ApiService.instance.callGoApi( '/notifications/read-all', method: 'PUT', ); _currentBadge = UnreadBadge(); _badgeController.add(_currentBadge); } catch (e) { debugPrint('[FCM] Failed to mark all as read: $e'); } } // ============================================================================ // In-App Notification Banner // ============================================================================ /// Show an in-app notification banner void showNotificationBanner(BuildContext context, RemoteMessage message) { final data = message.data; final type = data['type'] as String?; // Suppress if the user is already in this conversation if (activeConversationId != null && data['conversation_id']?.toString() == activeConversationId) { debugPrint('[FCM] Suppressing banner for active conversation: $activeConversationId'); return; } // Suppress if the user is already in this post/thread if (activePostId != null && (data['post_id']?.toString() == activePostId || data['beacon_id']?.toString() == activePostId)) { debugPrint('[FCM] Suppressing banner for active post: $activePostId'); return; } // Dismiss any existing banner _dismissCurrentBanner(); final OverlayState overlay; try { overlay = Overlay.of(context); } catch (e) { debugPrint('[FCM] Cannot show banner — no Overlay available'); return; } _currentBannerOverlay = OverlayEntry( builder: (context) => _NotificationBanner( message: message, onDismiss: _dismissCurrentBanner, onTap: () { _dismissCurrentBanner(); _handleMessageOpen(message); }, ), ); overlay.insert(_currentBannerOverlay!); // Auto-dismiss after 4 seconds Future.delayed(const Duration(seconds: 4), _dismissCurrentBanner); } void _dismissCurrentBanner() { _currentBannerOverlay?.remove(); _currentBannerOverlay = null; } // ============================================================================ // Navigation Handling // ============================================================================ Future _handleMessageOpen(RemoteMessage message) async { final data = message.data; // Try to get type from data, fallback to notification title parsing if needed final type = data['type'] as String?; debugPrint('[FCM] Handling message open - type: $type, data: $data'); // Use the router directly for reliability final router = AppRoutes.router; switch (type) { case 'chat': case 'new_message': case 'message': final conversationId = data['conversation_id']; if (conversationId != null) { _openConversation(conversationId.toString()); } else { router.go(AppRoutes.secureChat); } break; case 'like': case 'quip_reaction': case 'save': case 'comment': case 'reply': case 'mention': case 'share': final postId = data['post_id'] ?? data['beacon_id']; final target = data['target']; if (postId != null) { _navigateToPost(postId.toString(), target?.toString()); } break; case 'new_follower': case 'follow': case 'follow_request': case 'follow_accepted': final followerId = data['follower_id']; if (followerId != null) { router.push('${AppRoutes.userPrefix}/$followerId'); } else { router.go(AppRoutes.profile); } break; case 'beacon_vouch': case 'beacon_report': final beaconId = data['beacon_id'] ?? data['post_id']; if (beaconId != null) { _navigateToPost(beaconId.toString(), 'beacon_map'); } else { router.go(AppRoutes.beaconPrefix); } break; default: debugPrint('[FCM] Unknown notification type: $type'); // Retrieve generic target if available final target = data['target']; if (target != null) { _handleGenericTarget(target.toString()); } break; } } void _navigateToPost(String postId, String? target) { final router = AppRoutes.router; switch (target) { case 'beacon_map': router.go(AppRoutes.beaconPrefix); break; case 'quip_feed': router.go(AppRoutes.quips); break; case 'thread_view': case 'main_feed': default: router.push('${AppRoutes.postPrefix}/$postId'); break; } } void _handleGenericTarget(String target) { final router = AppRoutes.router; switch (target) { case 'secure_chat': router.go(AppRoutes.secureChat); break; case 'profile': router.go(AppRoutes.profile); break; case 'beacon_map': router.go(AppRoutes.beaconPrefix); break; case 'quip_feed': router.go(AppRoutes.quips); break; } } void _openConversation(String conversationId) { AppRoutes.router.push('${AppRoutes.secureChat}/$conversationId'); } void dispose() { _badgeController.close(); _foregroundMessageController.close(); } } // ============================================================================ // In-App Notification Banner Widget // ============================================================================ class _NotificationBanner extends StatefulWidget { final RemoteMessage message; final VoidCallback onDismiss; final VoidCallback onTap; const _NotificationBanner({ required this.message, required this.onDismiss, required this.onTap, }); @override State<_NotificationBanner> createState() => _NotificationBannerState(); } class _NotificationBannerState extends State<_NotificationBanner> with SingleTickerProviderStateMixin { late AnimationController _controller; late Animation _slideAnimation; late Animation _fadeAnimation; @override void initState() { super.initState(); _controller = AnimationController( duration: const Duration(milliseconds: 300), vsync: this, ); _slideAnimation = Tween( begin: const Offset(0, -1), end: Offset.zero, ).animate(CurvedAnimation(parent: _controller, curve: Curves.easeOut)); _fadeAnimation = Tween(begin: 0, end: 1).animate(_controller); _controller.forward(); } @override void dispose() { _controller.dispose(); super.dispose(); } String _getNotificationIcon(String? type) { switch (type) { case 'like': return '❤️'; case 'comment': case 'reply': return '💬'; case 'mention': return '@'; case 'follow': case 'new_follower': return '👤'; case 'follow_request': return '🔔'; case 'message': case 'chat': case 'new_message': return '✉️'; case 'save': return '🔖'; case 'beacon_vouch': return '✅'; case 'beacon_report': return '⚠️'; default: return '🔔'; } } @override Widget build(BuildContext context) { final notification = widget.message.notification; final type = widget.message.data['type'] as String?; final mediaQuery = MediaQuery.of(context); return Positioned( top: mediaQuery.padding.top + 8, left: 16, right: 16, child: SlideTransition( position: _slideAnimation, child: FadeTransition( opacity: _fadeAnimation, child: Material( elevation: 8, borderRadius: BorderRadius.circular(16), color: SojornColors.transparent, child: GestureDetector( onTap: widget.onTap, onHorizontalDragEnd: (details) { if (details.primaryVelocity != null && details.primaryVelocity!.abs() > 500) { widget.onDismiss(); } }, child: Container( padding: const EdgeInsets.all(16), decoration: BoxDecoration( color: AppTheme.cardSurface, borderRadius: BorderRadius.circular(20), border: Border.all( color: AppTheme.egyptianBlue.withValues(alpha: 0.1), width: 1.5, ), boxShadow: [ BoxShadow( color: AppTheme.brightNavy.withValues(alpha: 0.12), blurRadius: 20, offset: const Offset(0, 8), ), ], ), child: Row( children: [ // Icon Container( width: 44, height: 44, decoration: BoxDecoration( color: AppTheme.brightNavy.withValues(alpha: 0.1), borderRadius: BorderRadius.circular(12), ), child: Center( child: Text( _getNotificationIcon(type), style: const TextStyle(fontSize: 20), ), ), ), const SizedBox(width: 12), // Content Expanded( child: Column( crossAxisAlignment: CrossAxisAlignment.start, mainAxisSize: MainAxisSize.min, children: [ Text( notification?.title ?? 'Sojorn', style: TextStyle( color: AppTheme.textPrimary, fontWeight: FontWeight.w700, fontSize: 15, ), maxLines: 1, overflow: TextOverflow.ellipsis, ), if (notification?.body != null) ...[ const SizedBox(height: 4), Text( notification!.body!, style: TextStyle( color: AppTheme.textSecondary.withValues(alpha: 0.8), fontSize: 13, height: 1.3, ), maxLines: 2, overflow: TextOverflow.ellipsis, ), ], ], ), ), // Dismiss button GestureDetector( onTap: widget.onDismiss, child: Container( padding: const EdgeInsets.all(8), child: Icon( Icons.close, color: AppTheme.textSecondary.withValues(alpha: 0.3), size: 18, ), ), ), ], ), ), ), ), ), ), ); } }