feat: Implement Firebase push notifications with support for flavor-specific configurations.
This commit is contained in:
@@ -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,
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
],
|
||||
),
|
||||
);
|
||||
|
||||
@@ -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();
|
||||
}
|
||||
|
||||
@@ -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 {
|
||||
|
||||
Reference in New Issue
Block a user