feat: Implement notifications feature, enhance profile settings with new sections, and integrate Firebase configurations for multiple environments.

This commit is contained in:
pradeepkumar
2026-03-08 21:48:56 +05:30
parent 0362d7cfe0
commit 744b7b7ca8
24 changed files with 3413 additions and 557 deletions

View File

@@ -9,7 +9,10 @@ import 'package:real_estate_mobile/features/auth/presentation/providers/auth_pro
int _currentTabIndex(BuildContext context) {
final location = GoRouterState.of(context).matchedLocation;
if (location.startsWith('/messages')) return 2;
if (location.startsWith('/agents/search')) return 1;
if (location.startsWith('/agents/search') ||
location.startsWith('/agent/network')) {
return 1;
}
if (location.startsWith('/profile')) return 3;
return 0; // home and everything else
}
@@ -45,7 +48,16 @@ class AppBottomNavBar extends ConsumerWidget {
svgPath: 'assets/icons/nav_list_icon.svg',
fallbackIcon: Icons.list,
isSelected: selectedIndex == 1,
onTap: () => context.go('/agents/search'),
onTap: () {
final authState = ref.read(authProvider);
// Agents see their network, users see agent search
if (authState.status == AuthStatus.authenticated &&
authState.user?.role == 'AGENT') {
context.go('/agent/network');
} else {
context.go('/agents/search');
}
},
),
_NavItem(
svgPath: 'assets/icons/nav_messages_icon.svg',

View File

@@ -1,6 +1,9 @@
import 'dart:io';
import 'package:flutter/material.dart';
import 'package:flutter_riverpod/flutter_riverpod.dart';
import 'package:flutter_svg/flutter_svg.dart';
import 'package:image_picker/image_picker.dart';
import 'package:real_estate_mobile/core/constants/app_colors.dart';
import 'package:real_estate_mobile/core/widgets/s3_image.dart';
import 'package:real_estate_mobile/features/agents/data/models/agent_profile.dart';
@@ -81,6 +84,118 @@ class _AgentHomeContent extends ConsumerStatefulWidget {
}
class _AgentHomeContentState extends ConsumerState<_AgentHomeContent> {
bool _isUploadingAvatar = false;
bool _isTogglingAvailability = false;
File? _localAvatarFile;
Future<void> _pickAndUploadAvatar() async {
final picker = ImagePicker();
final picked = await picker.pickImage(
source: ImageSource.gallery,
maxWidth: 1200,
maxHeight: 1200,
imageQuality: 85,
);
if (picked == null) return;
final file = File(picked.path);
final fileName = picked.name;
final ext = fileName.split('.').last.toLowerCase();
// Validate file type
const allowedTypes = ['jpg', 'jpeg', 'png', 'gif'];
if (!allowedTypes.contains(ext)) {
if (mounted) {
ScaffoldMessenger.of(context).showSnackBar(
const SnackBar(content: Text('Only JPG, PNG, and GIF images are allowed')),
);
}
return;
}
// Validate file size (2MB max)
final fileSize = await file.length();
if (fileSize > 2 * 1024 * 1024) {
if (mounted) {
ScaffoldMessenger.of(context).showSnackBar(
const SnackBar(content: Text('Image must be smaller than 2MB')),
);
}
return;
}
final contentType = ext == 'png'
? 'image/png'
: ext == 'gif'
? 'image/gif'
: 'image/jpeg';
setState(() {
_localAvatarFile = file;
_isUploadingAvatar = true;
});
try {
final repo = ProfileRepository();
// 1. Get presigned upload URL
final uploadData = await repo.getAvatarUploadUrl('AGENT', fileName, contentType);
final uploadUrl = uploadData['uploadUrl']!;
final key = uploadData['key']!;
// 2. Upload to S3
final fileBytes = await file.readAsBytes();
await repo.uploadToS3(uploadUrl, fileBytes, contentType);
// 3. Update profile with new avatar key
await repo.updateProfile('AGENT', {'avatar': key});
// 4. Refresh the agent detail provider
ref.invalidate(agentDetailProvider(widget.agentProfileId));
if (mounted) {
ScaffoldMessenger.of(context).showSnackBar(
const SnackBar(content: Text('Photo updated successfully')),
);
}
} catch (e) {
if (mounted) {
ScaffoldMessenger.of(context).showSnackBar(
SnackBar(content: Text('Failed to upload photo: ${e.toString()}')),
);
}
} finally {
if (mounted) {
setState(() {
_isUploadingAvatar = false;
_localAvatarFile = null;
});
}
}
}
Future<void> _toggleAvailability(bool newValue) async {
if (_isTogglingAvailability) return;
setState(() => _isTogglingAvailability = true);
try {
final repo = ProfileRepository();
await repo.updateProfile('AGENT', {'isAvailable': newValue});
// Refresh agent detail to reflect new status
ref.invalidate(agentDetailProvider(widget.agentProfileId));
} catch (e) {
if (mounted) {
ScaffoldMessenger.of(context).showSnackBar(
SnackBar(content: Text('Failed to update availability: $e')),
);
}
} finally {
if (mounted) {
setState(() => _isTogglingAvailability = false);
}
}
}
@override
Widget build(BuildContext context) {
final state = ref.watch(agentDetailProvider(widget.agentProfileId));
@@ -161,37 +276,67 @@ class _AgentHomeContentState extends ConsumerState<_AgentHomeContent> {
child: SizedBox(
width: coverWidth,
height: 346,
child: agent.avatarUrl != null && agent.avatarUrl!.isNotEmpty
? S3Image(
imageUrl: agent.avatarUrl!,
child: _localAvatarFile != null
? Image.file(
_localAvatarFile!,
width: coverWidth,
height: 346,
fit: BoxFit.cover,
)
: Container(
color: const Color(0xFFC4D9D4),
alignment: Alignment.center,
child: Icon(Icons.person,
size: 80, color: AppColors.primaryDark),
),
: agent.avatarUrl != null && agent.avatarUrl!.isNotEmpty
? S3Image(
imageUrl: agent.avatarUrl!,
width: coverWidth,
height: 346,
fit: BoxFit.cover,
)
: Container(
color: const Color(0xFFC4D9D4),
alignment: Alignment.center,
child: Icon(Icons.person,
size: 80, color: AppColors.primaryDark),
),
),
),
// Loading overlay during upload
if (_isUploadingAvatar)
Positioned.fill(
child: ClipRRect(
borderRadius: BorderRadius.circular(20),
child: Container(
color: Colors.black.withValues(alpha: 0.4),
child: const Center(
child: CircularProgressIndicator(
color: Colors.white,
strokeWidth: 3,
),
),
),
),
),
// Orange edit icon top-right (tabler:edit style)
Positioned(
top: 12,
right: 10,
child: Container(
width: 28,
height: 26,
decoration: BoxDecoration(
color: Colors.white,
borderRadius: BorderRadius.circular(7),
),
alignment: Alignment.center,
child: SvgPicture.asset(
'assets/icons/edit_icon.svg',
width: 20,
height: 20,
top: 8,
right: 6,
child: GestureDetector(
behavior: HitTestBehavior.opaque,
onTap: _isUploadingAvatar ? null : _pickAndUploadAvatar,
child: Padding(
padding: const EdgeInsets.all(8),
child: Container(
width: 28,
height: 26,
decoration: BoxDecoration(
color: Colors.white,
borderRadius: BorderRadius.circular(7),
),
alignment: Alignment.center,
child: SvgPicture.asset(
'assets/icons/edit_icon.svg',
width: 20,
height: 20,
),
),
),
),
),
@@ -200,89 +345,104 @@ class _AgentHomeContentState extends ConsumerState<_AgentHomeContent> {
);
}
// ── Available / Unavailable status ──
// ── Available / Unavailable status toggle (matches web design) ──
Widget _buildStatusSection(AgentProfile agent) {
final isAvailable = agent.isAvailable;
return Padding(
padding: const EdgeInsets.symmetric(horizontal: 24),
child: Column(
children: [
_buildStatusRow(
isAvailable: true,
_buildStatusOption(
label: 'Available.',
buttonLabel: 'Connect',
buttonColor: AppColors.primaryDark,
buttonTextColor: Colors.white,
isActive: isAvailable,
dotColor: const Color(0xFF4CAF50),
activeColor: const Color(0xFFE8F5E9),
activeBorderColor: const Color(0xFF4CAF50),
onTap: isAvailable ? null : () => _toggleAvailability(true),
),
const SizedBox(height: 12),
_buildStatusRow(
isAvailable: false,
_buildStatusOption(
label: 'Unavailable.',
buttonLabel: 'Connect',
buttonColor: AppColors.accentOrange,
buttonTextColor: AppColors.primaryDark,
isActive: !isAvailable,
dotColor: const Color(0xFF00293D),
activeColor: const Color(0xFFE8EDF2),
activeBorderColor: const Color(0xFF00293D),
onTap: !isAvailable ? null : () => _toggleAvailability(false),
),
],
),
);
}
Widget _buildStatusRow({
required bool isAvailable,
Widget _buildStatusOption({
required String label,
required String buttonLabel,
required Color buttonColor,
required Color buttonTextColor,
required bool isActive,
required Color dotColor,
required Color activeColor,
required Color activeBorderColor,
VoidCallback? onTap,
}) {
return Container(
height: 55,
decoration: BoxDecoration(
color: Colors.white,
borderRadius: BorderRadius.circular(15),
border: Border.all(color: const Color(0xFFE0E0E0), width: 1),
),
child: Row(
children: [
const SizedBox(width: 20),
Container(
width: 12,
height: 12,
decoration: BoxDecoration(
shape: BoxShape.circle,
color: isAvailable
? const Color(0xFF4CAF50)
: const Color(0xFFD0D0D0),
),
return GestureDetector(
onTap: _isTogglingAvailability ? null : onTap,
child: AnimatedContainer(
duration: const Duration(milliseconds: 200),
height: 50,
padding: const EdgeInsets.symmetric(horizontal: 20),
decoration: BoxDecoration(
color: isActive ? activeColor : Colors.white,
borderRadius: BorderRadius.circular(15),
border: Border.all(
color: isActive ? activeBorderColor : const Color(0xFFE0E0E0),
width: isActive ? 1.5 : 1,
),
const SizedBox(width: 10),
Text(
label,
style: const TextStyle(
fontFamily: 'Fractul',
fontSize: 12,
fontWeight: FontWeight.w700,
color: AppColors.primaryDark,
),
child: Row(
children: [
Container(
width: 12,
height: 12,
decoration: BoxDecoration(
shape: BoxShape.circle,
color: isActive ? dotColor : Colors.white,
border: Border.all(
color: isActive ? dotColor : const Color(0xFFD0D0D0),
width: 1.5,
),
),
),
),
const Spacer(),
Container(
height: 55,
width: 164,
decoration: BoxDecoration(
color: buttonColor,
borderRadius: BorderRadius.circular(15),
),
alignment: Alignment.center,
child: Text(
buttonLabel,
const SizedBox(width: 10),
Text(
label,
style: TextStyle(
fontFamily: 'Fractul',
fontSize: 14,
fontWeight: FontWeight.w400,
color: buttonTextColor,
fontWeight: isActive ? FontWeight.w700 : FontWeight.w500,
color: AppColors.primaryDark,
),
),
),
],
const Spacer(),
if (_isTogglingAvailability && !isActive)
const SizedBox(
width: 16,
height: 16,
child: CircularProgressIndicator(
strokeWidth: 2,
color: AppColors.accentOrange,
),
)
else if (isActive)
Text(
'Active',
style: TextStyle(
fontFamily: 'Fractul',
fontSize: 13,
fontWeight: FontWeight.w500,
color: activeBorderColor,
),
),
],
),
),
);
}

View File

@@ -1,4 +1,5 @@
import 'package:flutter/material.dart';
import 'package:flutter_svg/flutter_svg.dart';
import 'package:go_router/go_router.dart';
import 'package:real_estate_mobile/core/constants/app_colors.dart';
@@ -97,7 +98,7 @@ class _ContactScreenState extends State<ContactScreen> {
controller: _emailController,
hintText: '123@gmail.com',
keyboardType: TextInputType.emailAddress,
prefixIcon: Icons.email,
svgIcon: 'assets/icons/email_orange_icon.svg',
),
const SizedBox(height: 24),
_buildLabel('Phone Number'),
@@ -106,7 +107,7 @@ class _ContactScreenState extends State<ContactScreen> {
controller: _phoneController,
hintText: '12345678990',
keyboardType: TextInputType.phone,
prefixIcon: Icons.call,
svgIcon: 'assets/icons/phone_orange_icon.svg',
),
const SizedBox(height: 24),
_buildLabel('Message'),
@@ -135,7 +136,7 @@ class _ContactScreenState extends State<ContactScreen> {
required TextEditingController controller,
required String hintText,
TextInputType? keyboardType,
IconData? prefixIcon,
String? svgIcon,
}) {
final borderSide = BorderSide(
color: AppColors.primaryDark.withValues(alpha: 0.1),
@@ -173,10 +174,17 @@ class _ContactScreenState extends State<ContactScreen> {
enabledBorder: border,
focusedBorder: border,
disabledBorder: border,
prefixIcon: prefixIcon != null
? Icon(prefixIcon, size: 22, color: AppColors.primaryDark)
prefixIcon: svgIcon != null
? Padding(
padding: const EdgeInsets.all(14),
child: SvgPicture.asset(
svgIcon,
width: 22,
height: 22,
),
)
: null,
prefixIconConstraints: prefixIcon != null
prefixIconConstraints: svgIcon != null
? const BoxConstraints(minWidth: 48)
: null,
),
@@ -321,14 +329,14 @@ class _ContactScreenState extends State<ContactScreen> {
_buildDetailDivider(),
// Email Support
_buildContactDetailRow(
icon: Icons.email_outlined,
svgIcon: 'assets/icons/email_orange_icon.svg',
label: 'Email Support',
value: '123support@gmail.com',
),
_buildDetailDivider(),
// Phone
_buildContactDetailRow(
icon: Icons.call_outlined,
svgIcon: 'assets/icons/phone_orange_icon.svg',
label: 'Phone',
value: '1234567890',
subtitle: 'Mon-Fri 9am-6pm',
@@ -336,29 +344,21 @@ class _ContactScreenState extends State<ContactScreen> {
_buildDetailDivider(),
// Office
_buildContactDetailRow(
icon: Icons.location_on_outlined,
svgIcon: 'assets/icons/location_orange_icon.svg',
label: 'Office',
value: '123 Market Street\nNew York CA 234737',
),
const SizedBox(height: 16),
// Map placeholder
// Map image
Padding(
padding: const EdgeInsets.symmetric(horizontal: 36),
child: Container(
height: 166,
decoration: BoxDecoration(
borderRadius: BorderRadius.circular(20),
border: Border.all(
color: AppColors.primaryDark.withValues(alpha: 0.1),
width: 1,
),
color: const Color(0xFFF5F5F5),
),
alignment: Alignment.center,
child: Icon(
Icons.map_outlined,
size: 48,
color: AppColors.primaryDark.withValues(alpha: 0.3),
child: ClipRRect(
borderRadius: BorderRadius.circular(20),
child: Image.asset(
'assets/images/contact_map.jpg',
height: 166,
width: double.infinity,
fit: BoxFit.cover,
),
),
),
@@ -367,7 +367,7 @@ class _ContactScreenState extends State<ContactScreen> {
GestureDetector(
onTap: () {},
child: Container(
width: 120,
width: 139,
height: 35,
decoration: BoxDecoration(
borderRadius: BorderRadius.circular(15),
@@ -406,7 +406,7 @@ class _ContactScreenState extends State<ContactScreen> {
}
Widget _buildContactDetailRow({
required IconData icon,
required String svgIcon,
required String label,
required String value,
String? subtitle,
@@ -416,10 +416,10 @@ class _ContactScreenState extends State<ContactScreen> {
child: Row(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Icon(
icon,
size: 22,
color: AppColors.primaryDark,
SvgPicture.asset(
svgIcon,
width: 20,
height: 20,
),
const SizedBox(width: 12),
Expanded(
@@ -493,19 +493,13 @@ class _ContactScreenState extends State<ContactScreen> {
),
),
const SizedBox(height: 24),
// Illustration placeholder
Container(
width: 254,
height: 254,
decoration: BoxDecoration(
shape: BoxShape.circle,
color: const Color(0xFFFFF5EB),
),
alignment: Alignment.center,
child: Icon(
Icons.real_estate_agent,
size: 80,
color: AppColors.accentOrange.withValues(alpha: 0.6),
// Agent illustration
ClipOval(
child: Image.asset(
'assets/images/agent_illustration.png',
width: 254,
height: 254,
fit: BoxFit.cover,
),
),
const SizedBox(height: 24),
@@ -514,7 +508,7 @@ class _ContactScreenState extends State<ContactScreen> {
onTap: () => context.push('/agents/search'),
child: Container(
height: 46,
width: 200,
width: 220,
decoration: BoxDecoration(
color: AppColors.accentOrange,
borderRadius: BorderRadius.circular(15),
@@ -533,8 +527,8 @@ class _ContactScreenState extends State<ContactScreen> {
),
const SizedBox(width: 8),
const Icon(
Icons.arrow_forward,
size: 18,
Icons.arrow_forward_rounded,
size: 16,
color: AppColors.primaryDark,
),
],

View File

@@ -3,12 +3,17 @@ import 'package:flutter_riverpod/flutter_riverpod.dart';
import 'package:go_router/go_router.dart';
import 'package:real_estate_mobile/core/constants/app_colors.dart';
import 'package:real_estate_mobile/features/auth/presentation/providers/auth_provider.dart';
import 'package:real_estate_mobile/features/notifications/presentation/providers/notification_provider.dart';
class HomeHeader extends StatelessWidget {
class HomeHeader extends ConsumerWidget {
const HomeHeader({super.key});
@override
Widget build(BuildContext context) {
Widget build(BuildContext context, WidgetRef ref) {
final authState = ref.watch(authProvider);
final isAuthenticated = authState.status == AuthStatus.authenticated;
final unreadCount = isAuthenticated ? ref.watch(unreadCountProvider) : 0;
return Container(
padding: const EdgeInsets.symmetric(horizontal: 23, vertical: 12),
decoration: BoxDecoration(
@@ -27,13 +32,54 @@ class HomeHeader extends StatelessWidget {
// Right side: notification + hamburger menu
Row(
children: [
// Notification bell
// Notification bell with badge
GestureDetector(
onTap: () {},
child: const Icon(
Icons.notifications_outlined,
color: AppColors.primaryDark,
size: 26,
onTap: () {
if (!isAuthenticated) {
context.push('/login');
return;
}
context.go('/notifications');
},
child: SizedBox(
width: 30,
height: 30,
child: Stack(
clipBehavior: Clip.none,
children: [
const Center(
child: Icon(
Icons.notifications_outlined,
color: AppColors.primaryDark,
size: 26,
),
),
if (unreadCount > 0)
Positioned(
top: -2,
right: -4,
child: Container(
padding: const EdgeInsets.symmetric(
horizontal: 4, vertical: 1),
constraints: const BoxConstraints(minWidth: 18),
decoration: BoxDecoration(
color: Colors.red,
borderRadius: BorderRadius.circular(9),
),
child: Text(
unreadCount > 99 ? '99+' : '$unreadCount',
textAlign: TextAlign.center,
style: const TextStyle(
fontSize: 10,
fontWeight: FontWeight.w700,
color: Colors.white,
height: 1.4,
),
),
),
),
],
),
),
),
const SizedBox(width: 16),

View File

@@ -0,0 +1,53 @@
class AppNotification {
final String id;
final String userId;
final String type; // 'connection' | 'message' | 'system' | 'update' | 'request'
final String title;
final String description;
final bool read;
final String? actionUrl;
final Map<String, String>? data;
final DateTime createdAt;
const AppNotification({
required this.id,
required this.userId,
required this.type,
required this.title,
required this.description,
required this.read,
this.actionUrl,
this.data,
required this.createdAt,
});
factory AppNotification.fromJson(Map<String, dynamic> json) {
return AppNotification(
id: json['id'] as String,
userId: json['userId'] as String? ?? '',
type: json['type'] as String? ?? 'system',
title: json['title'] as String? ?? '',
description: json['description'] as String? ?? '',
read: json['read'] as bool? ?? false,
actionUrl: json['actionUrl'] as String?,
data: json['data'] != null
? Map<String, String>.from(json['data'] as Map)
: null,
createdAt: DateTime.parse(json['createdAt'] as String),
);
}
AppNotification copyWith({bool? read}) {
return AppNotification(
id: id,
userId: userId,
type: type,
title: title,
description: description,
read: read ?? this.read,
actionUrl: actionUrl,
data: data,
createdAt: createdAt,
);
}
}

View File

@@ -0,0 +1,103 @@
import 'package:dio/dio.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/features/notifications/data/models/notification_model.dart';
class NotificationRepository {
final Dio _dio = ApiClient.instance.dio;
/// Fetch notifications with optional filter and pagination.
/// GET /notifications?filter=all|unread&page=1&limit=20
Future<NotificationsResponse> getNotifications({
String filter = 'all',
int page = 1,
int limit = 20,
}) async {
try {
final response = await _dio.get('/notifications', queryParameters: {
'filter': filter,
'page': page,
'limit': limit,
});
final data = response.data['data'] as Map<String, dynamic>;
return NotificationsResponse.fromJson(data);
} on DioException catch (e) {
if (e.error is ApiException) throw e.error as ApiException;
throw ApiException(
message: e.message ?? 'Failed to fetch notifications',
statusCode: e.response?.statusCode,
);
}
}
/// Get unread notification count.
/// GET /notifications/unread-count
Future<int> getUnreadCount() async {
try {
final response = await _dio.get('/notifications/unread-count');
final data = response.data['data'] as Map<String, dynamic>;
return data['unreadCount'] as int? ?? 0;
} on DioException catch (e) {
if (e.error is ApiException) throw e.error as ApiException;
throw ApiException(
message: e.message ?? 'Failed to fetch unread count',
statusCode: e.response?.statusCode,
);
}
}
/// Mark a single notification as read.
/// PATCH /notifications/:id/read
Future<void> markAsRead(String notificationId) async {
try {
await _dio.patch('/notifications/$notificationId/read');
} on DioException catch (e) {
if (e.error is ApiException) throw e.error as ApiException;
throw ApiException(
message: e.message ?? 'Failed to mark notification as read',
statusCode: e.response?.statusCode,
);
}
}
/// Mark all notifications as read.
/// PATCH /notifications/read-all
Future<void> markAllAsRead() async {
try {
await _dio.patch('/notifications/read-all');
} on DioException catch (e) {
if (e.error is ApiException) throw e.error as ApiException;
throw ApiException(
message: e.message ?? 'Failed to mark all as read',
statusCode: e.response?.statusCode,
);
}
}
}
class NotificationsResponse {
final List<AppNotification> notifications;
final int unreadCount;
final int totalPages;
final int currentPage;
const NotificationsResponse({
required this.notifications,
required this.unreadCount,
required this.totalPages,
required this.currentPage,
});
factory NotificationsResponse.fromJson(Map<String, dynamic> json) {
final list = (json['notifications'] as List<dynamic>?)
?.map((e) => AppNotification.fromJson(e as Map<String, dynamic>))
.toList() ??
[];
return NotificationsResponse(
notifications: list,
unreadCount: json['unreadCount'] as int? ?? 0,
totalPages: json['totalPages'] as int? ?? 1,
currentPage: json['currentPage'] as int? ?? 1,
);
}
}

View File

@@ -0,0 +1,150 @@
import 'dart:async';
import 'package:flutter_riverpod/flutter_riverpod.dart';
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;
UnreadCountNotifier(this._repo) : super(0) {
refresh();
_timer = Timer.periodic(const Duration(seconds: 30), (_) => refresh());
}
Future<void> refresh() async {
try {
final count = await _repo.getUnreadCount();
if (mounted) state = count;
} catch (_) {
// Silently fail — badge just won't update
}
}
@override
void dispose() {
_timer?.cancel();
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 (_) {}
}
}

View File

@@ -0,0 +1,401 @@
import 'package:flutter/material.dart';
import 'package:flutter_riverpod/flutter_riverpod.dart';
import 'package:real_estate_mobile/core/constants/app_colors.dart';
import 'package:real_estate_mobile/features/notifications/data/models/notification_model.dart';
import 'package:real_estate_mobile/features/notifications/presentation/providers/notification_provider.dart';
class NotificationsScreen extends ConsumerStatefulWidget {
const NotificationsScreen({super.key});
@override
ConsumerState<NotificationsScreen> createState() =>
_NotificationsScreenState();
}
class _NotificationsScreenState extends ConsumerState<NotificationsScreen> {
final ScrollController _scrollController = ScrollController();
String _activeFilter = 'all';
@override
void initState() {
super.initState();
Future.microtask(() {
ref.read(notificationListProvider.notifier).loadNotifications();
});
_scrollController.addListener(_onScroll);
}
void _onScroll() {
if (_scrollController.position.pixels >=
_scrollController.position.maxScrollExtent - 200) {
ref.read(notificationListProvider.notifier).loadMore();
}
}
@override
void dispose() {
_scrollController.dispose();
super.dispose();
}
@override
Widget build(BuildContext context) {
final state = ref.watch(notificationListProvider);
return Column(
children: [
// ── Header bar matching Figma: "Notifications" with bell icon ──
Container(
height: 55,
decoration: BoxDecoration(
borderRadius: BorderRadius.circular(7),
border: Border.all(
color: AppColors.primaryDark.withValues(alpha: 0.1),
width: 0.1,
),
),
child: Row(
mainAxisAlignment: MainAxisAlignment.center,
children: [
Icon(
Icons.notifications,
color: AppColors.accentOrange,
size: 22,
),
const SizedBox(width: 8),
const Text(
'Notifications',
style: TextStyle(
fontFamily: 'Fractul',
fontSize: 17,
fontWeight: FontWeight.w700,
color: AppColors.primaryDark,
),
),
],
),
),
// ── Filter tabs + Mark all as read ──
Padding(
padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 12),
child: Row(
children: [
_buildFilterChip('All', 'all'),
const SizedBox(width: 8),
_buildFilterChip('Unread', 'unread'),
const Spacer(),
GestureDetector(
onTap: () {
ref
.read(notificationListProvider.notifier)
.markAllAsRead();
},
child: const Text(
'Mark all as read',
style: TextStyle(
fontFamily: 'SourceSerif4',
fontSize: 13,
fontWeight: FontWeight.w400,
color: AppColors.accentOrange,
),
),
),
],
),
),
// ── Notification list ──
Expanded(
child: _buildContent(state),
),
],
);
}
Widget _buildFilterChip(String label, String filter) {
final isActive = _activeFilter == filter;
return GestureDetector(
onTap: () {
setState(() => _activeFilter = filter);
ref
.read(notificationListProvider.notifier)
.loadNotifications(filter: filter);
},
child: Container(
padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 6),
decoration: BoxDecoration(
color: isActive ? AppColors.primaryDark : Colors.transparent,
borderRadius: BorderRadius.circular(20),
border: Border.all(
color: isActive
? AppColors.primaryDark
: AppColors.primaryDark.withValues(alpha: 0.2),
),
),
child: Text(
label,
style: TextStyle(
fontFamily: 'Fractul',
fontSize: 13,
fontWeight: FontWeight.w500,
color: isActive ? Colors.white : AppColors.primaryDark,
),
),
),
);
}
Widget _buildContent(NotificationListState state) {
if (state.isLoading) {
return const Center(
child: CircularProgressIndicator(
color: AppColors.accentOrange,
strokeWidth: 2,
),
);
}
if (state.error != null) {
return Center(
child: Column(
mainAxisSize: MainAxisSize.min,
children: [
const Icon(Icons.error_outline,
size: 48, color: AppColors.accentOrange),
const SizedBox(height: 16),
Text(
'Failed to load notifications',
style: const TextStyle(
fontFamily: 'Fractul',
fontSize: 16,
fontWeight: FontWeight.w600,
color: AppColors.primaryDark,
),
),
const SizedBox(height: 12),
TextButton(
onPressed: () {
ref
.read(notificationListProvider.notifier)
.loadNotifications(filter: _activeFilter);
},
child: const Text('Retry',
style: TextStyle(color: AppColors.accentOrange)),
),
],
),
);
}
if (state.notifications.isEmpty) {
return Center(
child: Column(
mainAxisSize: MainAxisSize.min,
children: [
Icon(Icons.notifications_off_outlined,
size: 48,
color: AppColors.primaryDark.withValues(alpha: 0.3)),
const SizedBox(height: 16),
Text(
_activeFilter == 'unread'
? 'No unread notifications'
: 'No notifications yet',
style: TextStyle(
fontFamily: 'Fractul',
fontSize: 16,
fontWeight: FontWeight.w500,
color: AppColors.primaryDark.withValues(alpha: 0.5),
),
),
],
),
);
}
return ListView.builder(
controller: _scrollController,
padding: EdgeInsets.zero,
itemCount: state.notifications.length + (state.isLoadingMore ? 1 : 0),
itemBuilder: (context, index) {
if (index == state.notifications.length) {
return const Padding(
padding: EdgeInsets.all(16),
child: Center(
child: CircularProgressIndicator(
color: AppColors.accentOrange,
strokeWidth: 2,
),
),
);
}
return _buildNotificationTile(state.notifications[index]);
},
);
}
Widget _buildNotificationTile(AppNotification notification) {
return GestureDetector(
onTap: () {
if (!notification.read) {
ref
.read(notificationListProvider.notifier)
.markAsRead(notification.id);
}
},
behavior: HitTestBehavior.opaque,
child: Column(
children: [
Padding(
padding:
const EdgeInsets.symmetric(horizontal: 12, vertical: 14),
child: Row(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
// ── Avatar with unread dot ──
SizedBox(
width: 53,
height: 52,
child: Stack(
children: [
Container(
width: 52,
height: 52,
decoration: BoxDecoration(
shape: BoxShape.circle,
color: _iconBgColor(notification.type),
),
child: Center(
child: Icon(
_iconForType(notification.type),
color: AppColors.primaryDark,
size: 24,
),
),
),
if (!notification.read)
Positioned(
right: 0,
bottom: 4,
child: Container(
width: 8,
height: 8,
decoration: const BoxDecoration(
shape: BoxShape.circle,
color: AppColors.accentOrange,
),
),
),
],
),
),
const SizedBox(width: 8),
// ── Title + Description ──
Expanded(
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(
notification.title,
style: TextStyle(
fontFamily: 'Fractul',
fontSize: 14,
fontWeight:
notification.read ? FontWeight.w500 : FontWeight.w700,
color: AppColors.primaryDark,
),
maxLines: 1,
overflow: TextOverflow.ellipsis,
),
const SizedBox(height: 2),
Text(
notification.description,
style: TextStyle(
fontFamily: 'SourceSerif4',
fontSize: 14,
fontWeight: FontWeight.w400,
color: AppColors.primaryDark
.withValues(alpha: notification.read ? 0.5 : 0.8),
),
maxLines: 2,
overflow: TextOverflow.ellipsis,
),
],
),
),
const SizedBox(width: 8),
// ── Time ago ──
Padding(
padding: const EdgeInsets.only(top: 2),
child: Text(
_timeAgo(notification.createdAt),
style: TextStyle(
fontFamily: 'SourceSerif4',
fontSize: 14,
fontWeight: FontWeight.w400,
color:
AppColors.primaryDark.withValues(alpha: 0.6),
),
),
),
],
),
),
Divider(
height: 0.1,
thickness: 0.1,
color: AppColors.primaryDark.withValues(alpha: 0.1),
),
],
),
);
}
IconData _iconForType(String type) {
switch (type) {
case 'connection':
return Icons.person_outline;
case 'message':
return Icons.chat_bubble_outline;
case 'request':
return Icons.assignment_outlined;
case 'update':
return Icons.notifications_outlined;
case 'system':
return Icons.settings_outlined;
default:
return Icons.notifications_outlined;
}
}
Color _iconBgColor(String type) {
switch (type) {
case 'connection':
return const Color(0xFFE8F5E9);
case 'message':
return const Color(0xFFE3F2FD);
case 'request':
return const Color(0xFFFFF3E0);
case 'update':
return const Color(0xFFF3E5F5);
case 'system':
return const Color(0xFFF5F5F5);
default:
return const Color(0xFFF5F5F5);
}
}
String _timeAgo(DateTime dateTime) {
final now = DateTime.now();
final diff = now.difference(dateTime);
if (diff.inSeconds < 60) return 'Just now';
if (diff.inMinutes < 60) return '${diff.inMinutes}m ago';
if (diff.inHours < 24) return '${diff.inHours}hr ago';
if (diff.inDays < 7) return '${diff.inDays}d ago';
if (diff.inDays < 30) return '${(diff.inDays / 7).floor()}w ago';
return '${(diff.inDays / 30).floor()}mo ago';
}
}

View File

@@ -48,13 +48,23 @@ class ProfileRepository {
}
}
Future<String> getAvatarUploadUrl(String role, String fileName) async {
/// Get presigned upload URL for avatar.
/// Returns { uploadUrl: String, key: String }
Future<Map<String, String>> getAvatarUploadUrl(
String role, String fileName, String contentType) async {
try {
final endpoint = role == 'AGENT'
? '/upload/avatar-presigned-url'
: '/upload/user-avatar-presigned-url';
final response = await _dio.post(endpoint, data: {'fileName': fileName});
return response.data['data']['url'] as String;
final response = await _dio.post(endpoint, data: {
'filename': fileName,
'contentType': contentType,
});
final data = response.data['data'] as Map<String, dynamic>;
return {
'uploadUrl': data['uploadUrl'] as String,
'key': data['key'] as String,
};
} on DioException catch (e) {
if (e.error is ApiException) throw e.error as ApiException;
throw ApiException(
@@ -63,4 +73,257 @@ class ProfileRepository {
);
}
}
/// Upload file bytes directly to S3 presigned URL.
Future<void> uploadToS3(
String uploadUrl, List<int> fileBytes, String contentType) async {
try {
await Dio().put(
uploadUrl,
data: Stream.fromIterable(fileBytes.map((e) => [e])),
options: Options(
headers: {
'Content-Type': contentType,
'Content-Length': fileBytes.length,
},
),
);
} on DioException catch (e) {
throw ApiException(
message: e.message ?? 'Failed to upload file',
statusCode: e.response?.statusCode,
);
}
}
/// Delete avatar from S3: DELETE /upload/{encodedKey}
Future<void> deleteAvatar(String key) async {
try {
final encodedKey = Uri.encodeComponent(key);
await _dio.delete('/upload/$encodedKey');
} on DioException catch (_) {
// S3 delete failure is non-critical
}
}
/// Fetch privacy preferences.
/// Agent: GET /agents/preferences/privacy
/// User: GET /users/preferences/privacy
Future<Map<String, dynamic>> getPrivacyPreferences(String role) async {
try {
final endpoint = role == 'AGENT'
? '/agents/preferences/privacy'
: '/users/preferences/privacy';
final response = await _dio.get(endpoint);
return response.data['data'] as Map<String, dynamic>;
} on DioException catch (e) {
if (e.error is ApiException) throw e.error as ApiException;
throw ApiException(
message: e.message ?? 'Failed to fetch privacy preferences',
statusCode: e.response?.statusCode,
);
}
}
/// Update privacy preferences.
/// Agent: PUT /agents/preferences/privacy
/// User: PUT /users/preferences/privacy
Future<Map<String, dynamic>> updatePrivacyPreferences(
String role,
Map<String, dynamic> data,
) async {
try {
final endpoint = role == 'AGENT'
? '/agents/preferences/privacy'
: '/users/preferences/privacy';
final response = await _dio.put(endpoint, data: data);
return response.data['data'] as Map<String, dynamic>;
} on DioException catch (e) {
if (e.error is ApiException) throw e.error as ApiException;
throw ApiException(
message: e.message ?? 'Failed to update privacy preferences',
statusCode: e.response?.statusCode,
);
}
}
/// Delete account.
/// Agent: DELETE /agents/account
/// User: DELETE /users/account
Future<void> deleteAccount(String role) async {
try {
final endpoint =
role == 'AGENT' ? '/agents/account' : '/users/account';
await _dio.delete(endpoint);
} on DioException catch (e) {
if (e.error is ApiException) throw e.error as ApiException;
throw ApiException(
message: e.message ?? 'Failed to delete account',
statusCode: e.response?.statusCode,
);
}
}
/// Change password: POST /auth/change-password
Future<void> changePassword(
String currentPassword, String newPassword) async {
try {
await _dio.post('/auth/change-password', data: {
'currentPassword': currentPassword,
'newPassword': newPassword,
});
} on DioException catch (e) {
if (e.error is ApiException) throw e.error as ApiException;
throw ApiException(
message: e.response?.data?['message'] ??
e.message ??
'Failed to change password',
statusCode: e.response?.statusCode,
);
}
}
/// Fetch notification preferences.
/// Agent: GET /agents/preferences/notifications
/// User: GET /users/preferences/notifications
Future<Map<String, dynamic>> getNotificationPreferences(String role) async {
try {
final endpoint = role == 'AGENT'
? '/agents/preferences/notifications'
: '/users/preferences/notifications';
final response = await _dio.get(endpoint);
return response.data['data'] as Map<String, dynamic>;
} on DioException catch (e) {
if (e.error is ApiException) throw e.error as ApiException;
throw ApiException(
message: e.message ?? 'Failed to fetch notification preferences',
statusCode: e.response?.statusCode,
);
}
}
/// Update notification preferences.
/// Agent: PUT /agents/preferences/notifications
/// User: PUT /users/preferences/notifications
Future<Map<String, dynamic>> updateNotificationPreferences(
String role,
Map<String, dynamic> data,
) async {
try {
final endpoint = role == 'AGENT'
? '/agents/preferences/notifications'
: '/users/preferences/notifications';
final response = await _dio.put(endpoint, data: data);
return response.data['data'] as Map<String, dynamic>;
} on DioException catch (e) {
if (e.error is ApiException) throw e.error as ApiException;
throw ApiException(
message: e.message ?? 'Failed to update notification preferences',
statusCode: e.response?.statusCode,
);
}
}
/// Generate testimonial link: POST /testimonials/generate-link
Future<String> generateTestimonialLink() async {
try {
final response = await _dio.post('/testimonials/generate-link');
return response.data['data']['token'] as String;
} on DioException catch (e) {
if (e.error is ApiException) throw e.error as ApiException;
throw ApiException(
message: e.message ?? 'Failed to generate testimonial link',
statusCode: e.response?.statusCode,
);
}
}
/// Get agent's testimonials: GET /testimonials/my?sort=latest|oldest
Future<List<Map<String, dynamic>>> getMyTestimonials(
{String sort = 'latest'}) async {
try {
final response =
await _dio.get('/testimonials/my', queryParameters: {'sort': sort});
final list = response.data['data'] as List<dynamic>;
return list.cast<Map<String, dynamic>>();
} on DioException catch (e) {
if (e.error is ApiException) throw e.error as ApiException;
throw ApiException(
message: e.message ?? 'Failed to fetch testimonials',
statusCode: e.response?.statusCode,
);
}
}
/// Get current subscription: GET /stripe/subscription
Future<Map<String, dynamic>?> getSubscription() async {
try {
final response = await _dio.get('/stripe/subscription');
return response.data['data'] as Map<String, dynamic>?;
} on DioException catch (e) {
if (e.response?.statusCode == 404) return null;
if (e.error is ApiException) throw e.error as ApiException;
throw ApiException(
message: e.message ?? 'Failed to fetch subscription',
statusCode: e.response?.statusCode,
);
}
}
/// Create Stripe checkout session: POST /stripe/create-checkout-session
Future<String> createCheckoutSession(String planId) async {
try {
final response = await _dio.post('/stripe/create-checkout-session',
data: {'planId': planId});
return response.data['data']['url'] as String;
} on DioException catch (e) {
if (e.error is ApiException) throw e.error as ApiException;
throw ApiException(
message: e.message ?? 'Failed to create checkout session',
statusCode: e.response?.statusCode,
);
}
}
/// Create Stripe billing portal: POST /stripe/create-portal-session
Future<String> createPortalSession() async {
try {
final response = await _dio.post('/stripe/create-portal-session');
return response.data['data']['url'] as String;
} on DioException catch (e) {
if (e.error is ApiException) throw e.error as ApiException;
throw ApiException(
message: e.message ?? 'Failed to create billing portal',
statusCode: e.response?.statusCode,
);
}
}
/// Cancel subscription: POST /stripe/cancel-subscription
Future<void> cancelSubscription() async {
try {
await _dio.post('/stripe/cancel-subscription');
} on DioException catch (e) {
if (e.error is ApiException) throw e.error as ApiException;
throw ApiException(
message: e.message ?? 'Failed to cancel subscription',
statusCode: e.response?.statusCode,
);
}
}
/// Get available plans: GET /stripe/plans
Future<List<Map<String, dynamic>>> getStripePlans() async {
try {
final response = await _dio.get('/stripe/plans');
final list = response.data['data'] as List<dynamic>;
return list.cast<Map<String, dynamic>>();
} on DioException catch (e) {
if (e.error is ApiException) throw e.error as ApiException;
throw ApiException(
message: e.message ?? 'Failed to fetch plans',
statusCode: e.response?.statusCode,
);
}
}
}

View File

@@ -17,6 +17,7 @@ import 'package:real_estate_mobile/features/contact/presentation/screens/contact
import 'package:real_estate_mobile/features/about/presentation/screens/about_screen.dart';
import 'package:real_estate_mobile/features/agents/presentation/screens/agent_network_screen.dart';
import 'package:real_estate_mobile/features/profile/presentation/screens/profile_settings_screen.dart';
import 'package:real_estate_mobile/features/notifications/presentation/screens/notifications_screen.dart';
final routerProvider = Provider<GoRouter>((ref) {
final authRefreshNotifier = _AuthRefreshNotifier();
@@ -78,52 +79,100 @@ final routerProvider = Provider<GoRouter>((ref) {
routes: [
GoRoute(
path: '/home',
builder: (context, state) => const _HomeRouteWrapper(),
pageBuilder: (context, state) => CustomTransitionPage(
key: state.pageKey,
child: const _HomeRouteWrapper(),
transitionsBuilder: _fadeTransition,
),
),
GoRoute(
path: '/agents/search',
builder: (context, state) {
pageBuilder: (context, state) {
final query = state.uri.queryParameters['q'];
return AgentSearchScreen(initialQuery: query);
return CustomTransitionPage(
key: state.pageKey,
child: AgentSearchScreen(initialQuery: query),
transitionsBuilder: _fadeTransition,
);
},
),
GoRoute(
path: '/agents/detail/:id',
builder: (context, state) {
pageBuilder: (context, state) {
final id = state.pathParameters['id']!;
return AgentDetailScreen(agentId: id);
return CustomTransitionPage(
key: state.pageKey,
child: AgentDetailScreen(agentId: id),
transitionsBuilder: _fadeTransition,
);
},
),
GoRoute(
path: '/messages',
builder: (context, state) => const ConversationsScreen(),
pageBuilder: (context, state) => CustomTransitionPage(
key: state.pageKey,
child: const ConversationsScreen(),
transitionsBuilder: _fadeTransition,
),
),
GoRoute(
path: '/messages/chat/:id',
builder: (context, state) {
pageBuilder: (context, state) {
final id = state.pathParameters['id']!;
return ChatScreen(conversationId: id);
return CustomTransitionPage(
key: state.pageKey,
child: ChatScreen(conversationId: id),
transitionsBuilder: _fadeTransition,
);
},
),
GoRoute(
path: '/faq',
builder: (context, state) => const FaqScreen(),
pageBuilder: (context, state) => CustomTransitionPage(
key: state.pageKey,
child: const FaqScreen(),
transitionsBuilder: _fadeTransition,
),
),
GoRoute(
path: '/contact',
builder: (context, state) => const ContactScreen(),
pageBuilder: (context, state) => CustomTransitionPage(
key: state.pageKey,
child: const ContactScreen(),
transitionsBuilder: _fadeTransition,
),
),
GoRoute(
path: '/about',
builder: (context, state) => const AboutScreen(),
pageBuilder: (context, state) => CustomTransitionPage(
key: state.pageKey,
child: const AboutScreen(),
transitionsBuilder: _fadeTransition,
),
),
GoRoute(
path: '/agent/network',
builder: (context, state) => const AgentNetworkScreen(),
pageBuilder: (context, state) => CustomTransitionPage(
key: state.pageKey,
child: const AgentNetworkScreen(),
transitionsBuilder: _fadeTransition,
),
),
GoRoute(
path: '/profile',
builder: (context, state) => const ProfileSettingsScreen(),
pageBuilder: (context, state) => CustomTransitionPage(
key: state.pageKey,
child: const ProfileSettingsScreen(),
transitionsBuilder: _fadeTransition,
),
),
GoRoute(
path: '/notifications',
pageBuilder: (context, state) => CustomTransitionPage(
key: state.pageKey,
child: const NotificationsScreen(),
transitionsBuilder: _fadeTransition,
),
),
],
),
@@ -159,6 +208,19 @@ class _HomeRouteWrapper extends ConsumerWidget {
}
}
/// Smooth fade transition for tab-to-tab navigation.
Widget _fadeTransition(
BuildContext context,
Animation<double> animation,
Animation<double> secondaryAnimation,
Widget child,
) {
return FadeTransition(
opacity: CurveTween(curve: Curves.easeInOut).animate(animation),
child: child,
);
}
/// Notifier that triggers GoRouter refresh when auth state changes.
class _AuthRefreshNotifier extends ChangeNotifier {
void notify() {