feat: Implement Firebase push notifications with support for flavor-specific configurations.

This commit is contained in:
pradeepkumar
2026-03-08 21:55:52 +05:30
parent 744b7b7ca8
commit e129be5a59
26 changed files with 974 additions and 124 deletions

View 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();
}
}

View File

@@ -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,
),
),
),

View File

@@ -515,142 +515,155 @@ class _AgentHomeContentState extends ConsumerState<_AgentHomeContent> {
// ── Name, verified badge, title, location, member since, bio ──
Widget _buildProfileInfo(AgentProfile agent) {
return Padding(
padding: const EdgeInsets.symmetric(horizontal: 38),
child: Stack(
padding: const EdgeInsets.symmetric(horizontal: 24),
child: Column(
crossAxisAlignment: CrossAxisAlignment.center,
children: [
Column(
// Name + Verified badge + (Verified Expert) + edit icon
Row(
mainAxisAlignment: MainAxisAlignment.center,
crossAxisAlignment: CrossAxisAlignment.center,
children: [
// Name + Verified badge + (Verified Expert) on same line
Row(
mainAxisAlignment: MainAxisAlignment.center,
crossAxisAlignment: CrossAxisAlignment.baseline,
textBaseline: TextBaseline.alphabetic,
children: [
Text(
agent.firstName,
style: const TextStyle(
fontFamily: 'Fractul',
fontSize: 18,
fontWeight: FontWeight.w700,
color: AppColors.primaryDark,
Flexible(
child: Row(
mainAxisSize: MainAxisSize.min,
crossAxisAlignment: CrossAxisAlignment.baseline,
textBaseline: TextBaseline.alphabetic,
children: [
Flexible(
child: Text(
'${agent.firstName} ',
style: const TextStyle(
fontFamily: 'Fractul',
fontSize: 18,
fontWeight: FontWeight.w700,
color: AppColors.primaryDark,
),
overflow: TextOverflow.ellipsis,
),
),
),
const SizedBox(width: 4),
if (agent.isVerified) ...[
const Padding(
padding: EdgeInsets.only(bottom: 1),
child: Icon(Icons.verified,
size: 16, color: Color(0xFF2196F3)),
),
const SizedBox(width: 4),
],
Text(
agent.lastName,
style: const TextStyle(
fontFamily: 'Fractul',
fontSize: 18,
fontWeight: FontWeight.w400,
color: AppColors.primaryDark,
),
),
if (agent.isVerified) ...[
const SizedBox(width: 6),
const Text(
'(Verified Expert)',
style: TextStyle(
fontFamily: 'SourceSerif4',
fontSize: 14,
fontWeight: FontWeight.w500,
color: Color(0xFF638559),
if (agent.isVerified)
const Padding(
padding: EdgeInsets.only(right: 4),
child: Icon(Icons.verified,
size: 16, color: Color(0xFF2196F3)),
),
Flexible(
child: Text(
agent.lastName,
style: const TextStyle(
fontFamily: 'Fractul',
fontSize: 18,
fontWeight: FontWeight.w400,
color: AppColors.primaryDark,
),
overflow: TextOverflow.ellipsis,
),
),
],
],
),
),
// Title
if (agent.agentType != null) ...[
const SizedBox(height: 6),
Text(
agent.headline ?? agent.agentType!.name,
style: const TextStyle(
fontFamily: 'Fractul',
if (agent.isVerified) ...[
const SizedBox(width: 6),
const Text(
'(Verified Expert)',
style: TextStyle(
fontFamily: 'SourceSerif4',
fontSize: 14,
color: AppColors.primaryDark,
fontWeight: FontWeight.w500,
color: Color(0xFF638559),
),
textAlign: TextAlign.center,
),
],
const SizedBox(height: 8),
// Location + Member Since
Row(
mainAxisAlignment: MainAxisAlignment.center,
children: [
if (agent.location.isNotEmpty) ...[
Icon(Icons.location_on,
size: 16,
color:
AppColors.primaryDark.withValues(alpha: 0.7)),
const SizedBox(width: 4),
Text(
agent.location,
style: const TextStyle(
fontFamily: 'SourceSerif4',
fontSize: 14,
fontWeight: FontWeight.w700,
color: AppColors.primaryDark,
),
),
const SizedBox(width: 20),
],
Icon(Icons.calendar_today,
size: 14,
color:
AppColors.primaryDark.withValues(alpha: 0.7)),
const SizedBox(width: 4),
Text(
agent.memberSinceText,
style: const TextStyle(
fontFamily: 'SourceSerif4',
fontSize: 13,
fontWeight: FontWeight.w500,
color: AppColors.primaryDark,
),
const SizedBox(width: 8),
GestureDetector(
behavior: HitTestBehavior.opaque,
onTap: () {},
child: Padding(
padding: const EdgeInsets.all(4),
child: SvgPicture.asset(
'assets/icons/edit_icon.svg',
width: 20,
height: 20,
),
],
),
),
// Bio
if (agent.description.isNotEmpty) ...[
const SizedBox(height: 16),
SizedBox(
width: 338,
child: Text(
agent.description,
style: const TextStyle(
fontFamily: 'SourceSerif4',
fontSize: 14,
color: AppColors.primaryDark,
height: 1.43,
),
textAlign: TextAlign.center,
),
),
],
],
),
// Edit icon positioned at top-right
Positioned(
right: 0,
top: 0,
child: GestureDetector(
onTap: () {},
child: SvgPicture.asset(
'assets/icons/edit_icon.svg',
width: 22,
height: 22,
// Title
if (agent.agentType != null) ...[
const SizedBox(height: 6),
Text(
agent.headline ?? agent.agentType!.name,
style: const TextStyle(
fontFamily: 'Fractul',
fontSize: 14,
color: AppColors.primaryDark,
),
textAlign: TextAlign.center,
),
],
const SizedBox(height: 8),
// Location + Member Since
Row(
mainAxisAlignment: MainAxisAlignment.center,
children: [
if (agent.location.isNotEmpty) ...[
SvgPicture.asset(
'assets/icons/location_filled_icon.svg',
width: 16,
height: 16,
),
const SizedBox(width: 4),
Text(
agent.location,
style: const TextStyle(
fontFamily: 'SourceSerif4',
fontSize: 14,
fontWeight: FontWeight.w700,
color: AppColors.primaryDark,
),
),
const SizedBox(width: 20),
],
SvgPicture.asset(
'assets/icons/calendar_icon.svg',
width: 16,
height: 16,
colorFilter: const ColorFilter.mode(
AppColors.primaryDark,
BlendMode.srcIn,
),
),
const SizedBox(width: 4),
Text(
agent.memberSinceText,
style: const TextStyle(
fontFamily: 'SourceSerif4',
fontSize: 13,
fontWeight: FontWeight.w500,
color: AppColors.primaryDark,
),
),
],
),
// Bio
if (agent.description.isNotEmpty) ...[
const SizedBox(height: 16),
SizedBox(
width: 338,
child: Text(
agent.description,
style: const TextStyle(
fontFamily: 'SourceSerif4',
fontSize: 14,
color: AppColors.primaryDark,
height: 1.43,
),
textAlign: TextAlign.center,
),
),
),
],
],
),
);

View File

@@ -1,6 +1,7 @@
import 'package:flutter_riverpod/flutter_riverpod.dart';
import 'package:real_estate_mobile/core/network/api_client.dart';
import 'package:real_estate_mobile/core/network/api_exceptions.dart';
import 'package:real_estate_mobile/core/services/push_notification_service.dart';
import 'package:real_estate_mobile/core/storage/secure_storage.dart';
import 'package:real_estate_mobile/features/auth/data/auth_repository.dart';
import 'package:real_estate_mobile/features/auth/data/models/register_request.dart';
@@ -76,6 +77,8 @@ class AuthNotifier extends StateNotifier<AuthState> {
status: AuthStatus.authenticated,
user: user,
);
// Register for push notifications after auth confirmed
_initPushNotifications();
} catch (_) {
// Token is invalid/expired — clear and show login
await SecureStorage.clearTokens();
@@ -85,6 +88,8 @@ class AuthNotifier extends StateNotifier<AuthState> {
Future<void> logout() async {
state = state.copyWith(status: AuthStatus.loading);
// Unregister FCM token before clearing auth
await PushNotificationService().unregister();
await _repository.logout();
state = const AuthState();
}
@@ -117,6 +122,7 @@ class AuthNotifier extends StateNotifier<AuthState> {
status: AuthStatus.authenticated,
user: result['user'] as UserModel,
);
_initPushNotifications();
} on ApiException catch (e) {
state = state.copyWith(
status: AuthStatus.error,
@@ -166,6 +172,7 @@ class AuthNotifier extends StateNotifier<AuthState> {
user: user,
registeredEmail: email,
);
_initPushNotifications();
} on ApiException catch (e) {
state = state.copyWith(
status: AuthStatus.error,
@@ -182,6 +189,11 @@ class AuthNotifier extends StateNotifier<AuthState> {
}
}
void _initPushNotifications() {
final push = PushNotificationService();
push.initialize().then((_) => push.registerAfterLogin());
}
void reset() {
state = const AuthState();
}

View File

@@ -73,6 +73,39 @@ class NotificationRepository {
);
}
}
/// Register FCM token with backend.
/// POST /notifications/fcm-token
Future<void> registerFcmToken(String token, String device) async {
try {
await _dio.post('/notifications/fcm-token', data: {
'token': token,
'device': device,
});
} on DioException catch (e) {
if (e.error is ApiException) throw e.error as ApiException;
throw ApiException(
message: e.message ?? 'Failed to register FCM token',
statusCode: e.response?.statusCode,
);
}
}
/// Remove FCM token from backend.
/// DELETE /notifications/fcm-token
Future<void> removeFcmToken(String token) async {
try {
await _dio.delete('/notifications/fcm-token', data: {
'token': token,
});
} on DioException catch (e) {
if (e.error is ApiException) throw e.error as ApiException;
throw ApiException(
message: e.message ?? 'Failed to remove FCM token',
statusCode: e.response?.statusCode,
);
}
}
}
class NotificationsResponse {

View File

@@ -1,3 +1,4 @@
import 'package:firebase_core/firebase_core.dart';
import 'package:flutter/material.dart';
import 'package:flutter_riverpod/flutter_riverpod.dart';
import 'package:real_estate_mobile/config/app_config.dart';
@@ -7,6 +8,7 @@ import 'package:real_estate_mobile/routing/app_router.dart';
Future<void> mainCommon(Flavor flavor) async {
WidgetsFlutterBinding.ensureInitialized();
await AppConfig.init(flavor);
await Firebase.initializeApp();
runApp(const ProviderScope(child: MyApp()));
}