feat: Implement Firebase push notifications with support for flavor-specific configurations.
This commit is contained in:
195
lib/core/services/push_notification_service.dart
Normal file
195
lib/core/services/push_notification_service.dart
Normal file
@@ -0,0 +1,195 @@
|
||||
import 'dart:io';
|
||||
|
||||
import 'package:firebase_core/firebase_core.dart';
|
||||
import 'package:firebase_messaging/firebase_messaging.dart';
|
||||
import 'package:flutter/foundation.dart';
|
||||
import 'package:flutter_local_notifications/flutter_local_notifications.dart';
|
||||
import 'package:real_estate_mobile/features/notifications/data/notification_repository.dart';
|
||||
|
||||
/// Top-level handler for background messages (must be top-level function).
|
||||
@pragma('vm:entry-point')
|
||||
Future<void> _firebaseMessagingBackgroundHandler(RemoteMessage message) async {
|
||||
await Firebase.initializeApp();
|
||||
debugPrint('[FCM] Background message: ${message.messageId}');
|
||||
}
|
||||
|
||||
class PushNotificationService {
|
||||
static final PushNotificationService _instance =
|
||||
PushNotificationService._internal();
|
||||
factory PushNotificationService() => _instance;
|
||||
PushNotificationService._internal();
|
||||
|
||||
final FirebaseMessaging _messaging = FirebaseMessaging.instance;
|
||||
final FlutterLocalNotificationsPlugin _localNotifications =
|
||||
FlutterLocalNotificationsPlugin();
|
||||
final NotificationRepository _repo = NotificationRepository();
|
||||
|
||||
String? _currentToken;
|
||||
bool _initialized = false;
|
||||
|
||||
/// Callback when user taps a notification — set by the app to navigate.
|
||||
void Function(String? actionUrl)? onNotificationTap;
|
||||
|
||||
/// Initialize Firebase Messaging and local notifications.
|
||||
Future<void> initialize() async {
|
||||
if (_initialized) return;
|
||||
_initialized = true;
|
||||
|
||||
// Register background handler
|
||||
FirebaseMessaging.onBackgroundMessage(_firebaseMessagingBackgroundHandler);
|
||||
|
||||
// Request permission
|
||||
final settings = await _messaging.requestPermission(
|
||||
alert: true,
|
||||
badge: true,
|
||||
sound: true,
|
||||
provisional: false,
|
||||
);
|
||||
debugPrint('[FCM] Permission: ${settings.authorizationStatus}');
|
||||
|
||||
if (settings.authorizationStatus == AuthorizationStatus.denied) {
|
||||
return;
|
||||
}
|
||||
|
||||
// Setup local notifications for foreground display
|
||||
await _setupLocalNotifications();
|
||||
|
||||
// Get token and register with backend
|
||||
await _getAndRegisterToken();
|
||||
|
||||
// Listen for token refresh
|
||||
_messaging.onTokenRefresh.listen(_onTokenRefresh);
|
||||
|
||||
// Handle foreground messages
|
||||
FirebaseMessaging.onMessage.listen(_onForegroundMessage);
|
||||
|
||||
// Handle notification taps (app was in background)
|
||||
FirebaseMessaging.onMessageOpenedApp.listen(_onMessageOpenedApp);
|
||||
|
||||
// Check if app was opened from a terminated state notification
|
||||
final initialMessage = await _messaging.getInitialMessage();
|
||||
if (initialMessage != null) {
|
||||
_onMessageOpenedApp(initialMessage);
|
||||
}
|
||||
|
||||
// iOS: set foreground notification presentation options
|
||||
if (Platform.isIOS) {
|
||||
await _messaging.setForegroundNotificationPresentationOptions(
|
||||
alert: true,
|
||||
badge: true,
|
||||
sound: true,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
Future<void> _setupLocalNotifications() async {
|
||||
const androidChannel = AndroidNotificationChannel(
|
||||
'high_importance_channel',
|
||||
'High Importance Notifications',
|
||||
description: 'Used for important notifications',
|
||||
importance: Importance.high,
|
||||
);
|
||||
|
||||
await _localNotifications
|
||||
.resolvePlatformSpecificImplementation<
|
||||
AndroidFlutterLocalNotificationsPlugin>()
|
||||
?.createNotificationChannel(androidChannel);
|
||||
|
||||
const androidSettings =
|
||||
AndroidInitializationSettings('@mipmap/ic_launcher');
|
||||
const iosSettings = DarwinInitializationSettings(
|
||||
requestAlertPermission: false,
|
||||
requestBadgePermission: false,
|
||||
requestSoundPermission: false,
|
||||
);
|
||||
|
||||
await _localNotifications.initialize(
|
||||
const InitializationSettings(
|
||||
android: androidSettings,
|
||||
iOS: iosSettings,
|
||||
),
|
||||
onDidReceiveNotificationResponse: (response) {
|
||||
final payload = response.payload;
|
||||
onNotificationTap?.call(payload);
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
Future<void> _getAndRegisterToken() async {
|
||||
try {
|
||||
final token = await _messaging.getToken();
|
||||
if (token != null) {
|
||||
_currentToken = token;
|
||||
debugPrint('[FCM] Token: $token');
|
||||
await _registerTokenWithBackend(token);
|
||||
}
|
||||
} catch (e) {
|
||||
debugPrint('[FCM] Failed to get token: $e');
|
||||
}
|
||||
}
|
||||
|
||||
Future<void> _onTokenRefresh(String token) async {
|
||||
debugPrint('[FCM] Token refreshed: $token');
|
||||
_currentToken = token;
|
||||
await _registerTokenWithBackend(token);
|
||||
}
|
||||
|
||||
Future<void> _registerTokenWithBackend(String token) async {
|
||||
try {
|
||||
await _repo.registerFcmToken(token, Platform.isIOS ? 'ios' : 'android');
|
||||
debugPrint('[FCM] Token registered with backend');
|
||||
} catch (e) {
|
||||
debugPrint('[FCM] Failed to register token: $e');
|
||||
}
|
||||
}
|
||||
|
||||
void _onForegroundMessage(RemoteMessage message) {
|
||||
debugPrint('[FCM] Foreground message: ${message.notification?.title}');
|
||||
|
||||
final notification = message.notification;
|
||||
if (notification == null) return;
|
||||
|
||||
// Show local notification on Android (iOS handles it natively)
|
||||
if (Platform.isAndroid) {
|
||||
_localNotifications.show(
|
||||
notification.hashCode,
|
||||
notification.title,
|
||||
notification.body,
|
||||
const NotificationDetails(
|
||||
android: AndroidNotificationDetails(
|
||||
'high_importance_channel',
|
||||
'High Importance Notifications',
|
||||
channelDescription: 'Used for important notifications',
|
||||
importance: Importance.high,
|
||||
priority: Priority.high,
|
||||
icon: '@mipmap/ic_launcher',
|
||||
),
|
||||
),
|
||||
payload: message.data['actionUrl'],
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
void _onMessageOpenedApp(RemoteMessage message) {
|
||||
debugPrint('[FCM] Notification tap: ${message.data}');
|
||||
final actionUrl = message.data['actionUrl'] as String?;
|
||||
onNotificationTap?.call(actionUrl);
|
||||
}
|
||||
|
||||
/// Unregister FCM token from backend (call on logout).
|
||||
Future<void> unregister() async {
|
||||
if (_currentToken == null) return;
|
||||
try {
|
||||
await _repo.removeFcmToken(_currentToken!);
|
||||
debugPrint('[FCM] Token unregistered from backend');
|
||||
} catch (e) {
|
||||
debugPrint('[FCM] Failed to unregister token: $e');
|
||||
}
|
||||
_currentToken = null;
|
||||
}
|
||||
|
||||
/// Re-register token after login.
|
||||
Future<void> registerAfterLogin() async {
|
||||
await _getAndRegisterToken();
|
||||
}
|
||||
}
|
||||
@@ -1,14 +1,34 @@
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:go_router/go_router.dart';
|
||||
import 'package:real_estate_mobile/core/services/push_notification_service.dart';
|
||||
import 'package:real_estate_mobile/core/widgets/app_bottom_nav_bar.dart';
|
||||
import 'package:real_estate_mobile/features/home/presentation/widgets/home_header.dart';
|
||||
|
||||
/// Root shell that provides the persistent header and bottom nav bar.
|
||||
/// Only the content area (child) swaps when navigating between tabs.
|
||||
class AppShell extends StatelessWidget {
|
||||
class AppShell extends StatefulWidget {
|
||||
final Widget child;
|
||||
|
||||
const AppShell({super.key, required this.child});
|
||||
|
||||
@override
|
||||
State<AppShell> createState() => _AppShellState();
|
||||
}
|
||||
|
||||
class _AppShellState extends State<AppShell> {
|
||||
@override
|
||||
void initState() {
|
||||
super.initState();
|
||||
// Wire push notification taps to GoRouter navigation
|
||||
PushNotificationService().onNotificationTap = (actionUrl) {
|
||||
if (mounted && actionUrl != null && actionUrl.isNotEmpty) {
|
||||
context.go(actionUrl);
|
||||
} else if (mounted) {
|
||||
context.go('/notifications');
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return Scaffold(
|
||||
@@ -31,8 +51,8 @@ class AppShell extends StatelessWidget {
|
||||
);
|
||||
},
|
||||
child: KeyedSubtree(
|
||||
key: ValueKey(child.runtimeType),
|
||||
child: child,
|
||||
key: ValueKey(widget.child.runtimeType),
|
||||
child: widget.child,
|
||||
),
|
||||
),
|
||||
),
|
||||
|
||||
Reference in New Issue
Block a user