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

@@ -1,4 +1,7 @@
<manifest xmlns:android="http://schemas.android.com/apk/res/android"> <manifest xmlns:android="http://schemas.android.com/apk/res/android">
<uses-permission android:name="android.permission.CAMERA"/>
<uses-permission android:name="android.permission.READ_EXTERNAL_STORAGE" android:maxSdkVersion="32"/>
<uses-permission android:name="android.permission.READ_MEDIA_IMAGES"/>
<application <application
android:label="@string/app_name" android:label="@string/app_name"
android:name="${applicationName}" android:name="${applicationName}"

View File

@@ -0,0 +1,4 @@
<svg width="20" height="20" viewBox="0 0 24 24" fill="none" xmlns="http://www.w3.org/2000/svg">
<path d="M4 4H20C21.1 4 22 4.9 22 6V18C22 19.1 21.1 20 20 20H4C2.9 20 2 19.1 2 18V6C2 4.9 2.9 4 4 4Z" fill="#E58625"/>
<path d="M22 6L12 13L2 6" stroke="#fff" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"/>
</svg>

After

Width:  |  Height:  |  Size: 328 B

View File

@@ -0,0 +1,4 @@
<svg width="20" height="20" viewBox="0 0 24 24" fill="none" xmlns="http://www.w3.org/2000/svg">
<path d="M21 10C21 17 12 23 12 23C12 23 3 17 3 10C3 7.61305 3.94821 5.32387 5.63604 3.63604C7.32387 1.94821 9.61305 1 12 1C14.3869 1 16.6761 1.94821 18.364 3.63604C20.0518 5.32387 21 7.61305 21 10Z" fill="#E58625"/>
<circle cx="12" cy="10" r="3" fill="#fff"/>
</svg>

After

Width:  |  Height:  |  Size: 363 B

View File

@@ -0,0 +1,3 @@
<svg width="20" height="20" viewBox="0 0 24 24" fill="none" xmlns="http://www.w3.org/2000/svg">
<path d="M22 16.92V19.92C22.0011 20.1985 21.9441 20.4742 21.8325 20.7294C21.7209 20.9845 21.5573 21.2136 21.3521 21.4019C21.1468 21.5901 20.9046 21.7335 20.6407 21.8227C20.3769 21.9119 20.0974 21.9451 19.82 21.92C16.7428 21.5856 13.787 20.5341 11.19 18.85C8.77383 17.3147 6.72533 15.2662 5.19 12.85C3.49998 10.2412 2.44824 7.27099 2.12 4.18C2.09501 3.90347 2.12787 3.62476 2.2165 3.36162C2.30513 3.09849 2.44757 2.85669 2.63477 2.65162C2.82196 2.44655 3.04981 2.28271 3.30379 2.17052C3.55778 2.05833 3.83234 2.00026 4.11 2H7.11C7.59531 1.99522 8.06579 2.16708 8.43376 2.48353C8.80173 2.79999 9.04207 3.23945 9.11 3.72C9.23662 4.68007 9.47145 5.62273 9.81 6.53C9.94454 6.88792 9.97366 7.27691 9.89391 7.65088C9.81415 8.02485 9.62886 8.36811 9.36 8.64L8.09 9.91C9.51356 12.4135 11.5865 14.4864 14.09 15.91L15.36 14.64C15.6319 14.3711 15.9752 14.1858 16.3491 14.1061C16.7231 14.0263 17.1121 14.0555 17.47 14.19C18.3773 14.5286 19.3199 14.7634 20.28 14.89C20.7658 14.9585 21.2094 15.2032 21.5265 15.5775C21.8437 15.9518 22.0122 16.4296 22 16.92Z" fill="#E58625"/>
</svg>

After

Width:  |  Height:  |  Size: 1.1 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.3 MiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 13 MiB

View File

@@ -0,0 +1,30 @@
<?xml version="1.0" encoding="UTF-8"?>
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
<plist version="1.0">
<dict>
<key>API_KEY</key>
<string>AIzaSyD67KPio-70XW_yGjrRUyq9ejdZoZA1rmE</string>
<key>GCM_SENDER_ID</key>
<string>703616926518</string>
<key>PLIST_VERSION</key>
<string>1</string>
<key>BUNDLE_ID</key>
<string>com.requestn.realEstateMobile.demo</string>
<key>PROJECT_ID</key>
<string>real-estate-2d71e</string>
<key>STORAGE_BUCKET</key>
<string>real-estate-2d71e.firebasestorage.app</string>
<key>IS_ADS_ENABLED</key>
<false></false>
<key>IS_ANALYTICS_ENABLED</key>
<false></false>
<key>IS_APPINVITE_ENABLED</key>
<true></true>
<key>IS_GCM_ENABLED</key>
<true></true>
<key>IS_SIGNIN_ENABLED</key>
<true></true>
<key>GOOGLE_APP_ID</key>
<string>1:703616926518:ios:2d2f7d163774722dfca997</string>
</dict>
</plist>

View File

@@ -0,0 +1,48 @@
{
"project_info": {
"project_number": "703616926518",
"project_id": "real-estate-2d71e",
"storage_bucket": "real-estate-2d71e.firebasestorage.app"
},
"client": [
{
"client_info": {
"mobilesdk_app_id": "1:703616926518:android:270639d6887d2cb4fca997",
"android_client_info": {
"package_name": "com.requestn.real_estate_mobile.demo"
}
},
"oauth_client": [],
"api_key": [
{
"current_key": "AIzaSyAwb_9fc7n9gbN_IQqWeYRkHSSykzVHCyc"
}
],
"services": {
"appinvite_service": {
"other_platform_oauth_client": []
}
}
},
{
"client_info": {
"mobilesdk_app_id": "1:703616926518:android:4342b796fbabb690fca997",
"android_client_info": {
"package_name": "com.requestn.real_estate_mobile.dev"
}
},
"oauth_client": [],
"api_key": [
{
"current_key": "AIzaSyAwb_9fc7n9gbN_IQqWeYRkHSSykzVHCyc"
}
],
"services": {
"appinvite_service": {
"other_platform_oauth_client": []
}
}
}
],
"configuration_version": "1"
}

View File

@@ -0,0 +1,30 @@
<?xml version="1.0" encoding="UTF-8"?>
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
<plist version="1.0">
<dict>
<key>API_KEY</key>
<string>AIzaSyD67KPio-70XW_yGjrRUyq9ejdZoZA1rmE</string>
<key>GCM_SENDER_ID</key>
<string>703616926518</string>
<key>PLIST_VERSION</key>
<string>1</string>
<key>BUNDLE_ID</key>
<string>com.requestn.realEstateMobile.dev</string>
<key>PROJECT_ID</key>
<string>real-estate-2d71e</string>
<key>STORAGE_BUCKET</key>
<string>real-estate-2d71e.firebasestorage.app</string>
<key>IS_ADS_ENABLED</key>
<false></false>
<key>IS_ANALYTICS_ENABLED</key>
<false></false>
<key>IS_APPINVITE_ENABLED</key>
<true></true>
<key>IS_GCM_ENABLED</key>
<true></true>
<key>IS_SIGNIN_ENABLED</key>
<true></true>
<key>GOOGLE_APP_ID</key>
<string>1:703616926518:ios:301cac94994fbdc3fca997</string>
</dict>
</plist>

View File

@@ -0,0 +1,29 @@
{
"project_info": {
"project_number": "703616926518",
"project_id": "real-estate-2d71e",
"storage_bucket": "real-estate-2d71e.firebasestorage.app"
},
"client": [
{
"client_info": {
"mobilesdk_app_id": "1:703616926518:android:4342b796fbabb690fca997",
"android_client_info": {
"package_name": "com.requestn.real_estate_mobile.dev"
}
},
"oauth_client": [],
"api_key": [
{
"current_key": "AIzaSyAwb_9fc7n9gbN_IQqWeYRkHSSykzVHCyc"
}
],
"services": {
"appinvite_service": {
"other_platform_oauth_client": []
}
}
}
],
"configuration_version": "1"
}

View File

@@ -0,0 +1,30 @@
<?xml version="1.0" encoding="UTF-8"?>
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
<plist version="1.0">
<dict>
<key>API_KEY</key>
<string>AIzaSyD67KPio-70XW_yGjrRUyq9ejdZoZA1rmE</string>
<key>GCM_SENDER_ID</key>
<string>703616926518</string>
<key>PLIST_VERSION</key>
<string>1</string>
<key>BUNDLE_ID</key>
<string>com.requestn.realEstateMobile.local</string>
<key>PROJECT_ID</key>
<string>real-estate-2d71e</string>
<key>STORAGE_BUCKET</key>
<string>real-estate-2d71e.firebasestorage.app</string>
<key>IS_ADS_ENABLED</key>
<false></false>
<key>IS_ANALYTICS_ENABLED</key>
<false></false>
<key>IS_APPINVITE_ENABLED</key>
<true></true>
<key>IS_GCM_ENABLED</key>
<true></true>
<key>IS_SIGNIN_ENABLED</key>
<true></true>
<key>GOOGLE_APP_ID</key>
<string>1:703616926518:ios:15652d299f9f2d7ffca997</string>
</dict>
</plist>

View File

@@ -0,0 +1,67 @@
{
"project_info": {
"project_number": "703616926518",
"project_id": "real-estate-2d71e",
"storage_bucket": "real-estate-2d71e.firebasestorage.app"
},
"client": [
{
"client_info": {
"mobilesdk_app_id": "1:703616926518:android:270639d6887d2cb4fca997",
"android_client_info": {
"package_name": "com.requestn.real_estate_mobile.demo"
}
},
"oauth_client": [],
"api_key": [
{
"current_key": "AIzaSyAwb_9fc7n9gbN_IQqWeYRkHSSykzVHCyc"
}
],
"services": {
"appinvite_service": {
"other_platform_oauth_client": []
}
}
},
{
"client_info": {
"mobilesdk_app_id": "1:703616926518:android:4342b796fbabb690fca997",
"android_client_info": {
"package_name": "com.requestn.real_estate_mobile.dev"
}
},
"oauth_client": [],
"api_key": [
{
"current_key": "AIzaSyAwb_9fc7n9gbN_IQqWeYRkHSSykzVHCyc"
}
],
"services": {
"appinvite_service": {
"other_platform_oauth_client": []
}
}
},
{
"client_info": {
"mobilesdk_app_id": "1:703616926518:android:2eb242f07b174c25fca997",
"android_client_info": {
"package_name": "com.requestn.real_estate_mobile.local"
}
},
"oauth_client": [],
"api_key": [
{
"current_key": "AIzaSyAwb_9fc7n9gbN_IQqWeYRkHSSykzVHCyc"
}
],
"services": {
"appinvite_service": {
"other_platform_oauth_client": []
}
}
}
],
"configuration_version": "1"
}

View File

@@ -47,6 +47,10 @@
</array> </array>
</dict> </dict>
</dict> </dict>
<key>NSPhotoLibraryUsageDescription</key>
<string>We need access to your photo library to set your profile photo.</string>
<key>NSCameraUsageDescription</key>
<string>We need access to your camera to take a profile photo.</string>
<key>UIApplicationSupportsIndirectInputEvents</key> <key>UIApplicationSupportsIndirectInputEvents</key>
<true/> <true/>
<key>UILaunchStoryboardName</key> <key>UILaunchStoryboardName</key>

View File

@@ -9,7 +9,10 @@ import 'package:real_estate_mobile/features/auth/presentation/providers/auth_pro
int _currentTabIndex(BuildContext context) { int _currentTabIndex(BuildContext context) {
final location = GoRouterState.of(context).matchedLocation; final location = GoRouterState.of(context).matchedLocation;
if (location.startsWith('/messages')) return 2; 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; if (location.startsWith('/profile')) return 3;
return 0; // home and everything else return 0; // home and everything else
} }
@@ -45,7 +48,16 @@ class AppBottomNavBar extends ConsumerWidget {
svgPath: 'assets/icons/nav_list_icon.svg', svgPath: 'assets/icons/nav_list_icon.svg',
fallbackIcon: Icons.list, fallbackIcon: Icons.list,
isSelected: selectedIndex == 1, 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( _NavItem(
svgPath: 'assets/icons/nav_messages_icon.svg', svgPath: 'assets/icons/nav_messages_icon.svg',

View File

@@ -1,6 +1,9 @@
import 'dart:io';
import 'package:flutter/material.dart'; import 'package:flutter/material.dart';
import 'package:flutter_riverpod/flutter_riverpod.dart'; import 'package:flutter_riverpod/flutter_riverpod.dart';
import 'package:flutter_svg/flutter_svg.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/constants/app_colors.dart';
import 'package:real_estate_mobile/core/widgets/s3_image.dart'; import 'package:real_estate_mobile/core/widgets/s3_image.dart';
import 'package:real_estate_mobile/features/agents/data/models/agent_profile.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> { 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 @override
Widget build(BuildContext context) { Widget build(BuildContext context) {
final state = ref.watch(agentDetailProvider(widget.agentProfileId)); final state = ref.watch(agentDetailProvider(widget.agentProfileId));
@@ -161,7 +276,14 @@ class _AgentHomeContentState extends ConsumerState<_AgentHomeContent> {
child: SizedBox( child: SizedBox(
width: coverWidth, width: coverWidth,
height: 346, height: 346,
child: agent.avatarUrl != null && agent.avatarUrl!.isNotEmpty child: _localAvatarFile != null
? Image.file(
_localAvatarFile!,
width: coverWidth,
height: 346,
fit: BoxFit.cover,
)
: agent.avatarUrl != null && agent.avatarUrl!.isNotEmpty
? S3Image( ? S3Image(
imageUrl: agent.avatarUrl!, imageUrl: agent.avatarUrl!,
width: coverWidth, width: coverWidth,
@@ -176,10 +298,31 @@ class _AgentHomeContentState extends ConsumerState<_AgentHomeContent> {
), ),
), ),
), ),
// 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) // Orange edit icon top-right (tabler:edit style)
Positioned( Positioned(
top: 12, top: 8,
right: 10, right: 6,
child: GestureDetector(
behavior: HitTestBehavior.opaque,
onTap: _isUploadingAvatar ? null : _pickAndUploadAvatar,
child: Padding(
padding: const EdgeInsets.all(8),
child: Container( child: Container(
width: 28, width: 28,
height: 26, height: 26,
@@ -195,95 +338,112 @@ class _AgentHomeContentState extends ConsumerState<_AgentHomeContent> {
), ),
), ),
), ),
),
),
], ],
), ),
); );
} }
// ── Available / Unavailable status ── // ── Available / Unavailable status toggle (matches web design) ──
Widget _buildStatusSection(AgentProfile agent) { Widget _buildStatusSection(AgentProfile agent) {
final isAvailable = agent.isAvailable;
return Padding( return Padding(
padding: const EdgeInsets.symmetric(horizontal: 24), padding: const EdgeInsets.symmetric(horizontal: 24),
child: Column( child: Column(
children: [ children: [
_buildStatusRow( _buildStatusOption(
isAvailable: true,
label: 'Available.', label: 'Available.',
buttonLabel: 'Connect', isActive: isAvailable,
buttonColor: AppColors.primaryDark, dotColor: const Color(0xFF4CAF50),
buttonTextColor: Colors.white, activeColor: const Color(0xFFE8F5E9),
activeBorderColor: const Color(0xFF4CAF50),
onTap: isAvailable ? null : () => _toggleAvailability(true),
), ),
const SizedBox(height: 12), const SizedBox(height: 12),
_buildStatusRow( _buildStatusOption(
isAvailable: false,
label: 'Unavailable.', label: 'Unavailable.',
buttonLabel: 'Connect', isActive: !isAvailable,
buttonColor: AppColors.accentOrange, dotColor: const Color(0xFF00293D),
buttonTextColor: AppColors.primaryDark, activeColor: const Color(0xFFE8EDF2),
activeBorderColor: const Color(0xFF00293D),
onTap: !isAvailable ? null : () => _toggleAvailability(false),
), ),
], ],
), ),
); );
} }
Widget _buildStatusRow({ Widget _buildStatusOption({
required bool isAvailable,
required String label, required String label,
required String buttonLabel, required bool isActive,
required Color buttonColor, required Color dotColor,
required Color buttonTextColor, required Color activeColor,
required Color activeBorderColor,
VoidCallback? onTap,
}) { }) {
return Container( return GestureDetector(
height: 55, onTap: _isTogglingAvailability ? null : onTap,
child: AnimatedContainer(
duration: const Duration(milliseconds: 200),
height: 50,
padding: const EdgeInsets.symmetric(horizontal: 20),
decoration: BoxDecoration( decoration: BoxDecoration(
color: Colors.white, color: isActive ? activeColor : Colors.white,
borderRadius: BorderRadius.circular(15), borderRadius: BorderRadius.circular(15),
border: Border.all(color: const Color(0xFFE0E0E0), width: 1), border: Border.all(
color: isActive ? activeBorderColor : const Color(0xFFE0E0E0),
width: isActive ? 1.5 : 1,
),
), ),
child: Row( child: Row(
children: [ children: [
const SizedBox(width: 20),
Container( Container(
width: 12, width: 12,
height: 12, height: 12,
decoration: BoxDecoration( decoration: BoxDecoration(
shape: BoxShape.circle, shape: BoxShape.circle,
color: isAvailable color: isActive ? dotColor : Colors.white,
? const Color(0xFF4CAF50) border: Border.all(
: const Color(0xFFD0D0D0), color: isActive ? dotColor : const Color(0xFFD0D0D0),
width: 1.5,
),
), ),
), ),
const SizedBox(width: 10), const SizedBox(width: 10),
Text( Text(
label, label,
style: const TextStyle( style: TextStyle(
fontFamily: 'Fractul', fontFamily: 'Fractul',
fontSize: 12, fontSize: 14,
fontWeight: FontWeight.w700, fontWeight: isActive ? FontWeight.w700 : FontWeight.w500,
color: AppColors.primaryDark, color: AppColors.primaryDark,
), ),
), ),
const Spacer(), const Spacer(),
Container( if (_isTogglingAvailability && !isActive)
height: 55, const SizedBox(
width: 164, width: 16,
decoration: BoxDecoration( height: 16,
color: buttonColor, child: CircularProgressIndicator(
borderRadius: BorderRadius.circular(15), strokeWidth: 2,
color: AppColors.accentOrange,
), ),
alignment: Alignment.center, )
child: Text( else if (isActive)
buttonLabel, Text(
'Active',
style: TextStyle( style: TextStyle(
fontFamily: 'Fractul', fontFamily: 'Fractul',
fontSize: 14, fontSize: 13,
fontWeight: FontWeight.w400, fontWeight: FontWeight.w500,
color: buttonTextColor, color: activeBorderColor,
),
), ),
), ),
], ],
), ),
),
); );
} }

View File

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

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 { try {
final endpoint = role == 'AGENT' final endpoint = role == 'AGENT'
? '/upload/avatar-presigned-url' ? '/upload/avatar-presigned-url'
: '/upload/user-avatar-presigned-url'; : '/upload/user-avatar-presigned-url';
final response = await _dio.post(endpoint, data: {'fileName': fileName}); final response = await _dio.post(endpoint, data: {
return response.data['data']['url'] as String; '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) { } on DioException catch (e) {
if (e.error is ApiException) throw e.error as ApiException; if (e.error is ApiException) throw e.error as ApiException;
throw 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/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/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/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 routerProvider = Provider<GoRouter>((ref) {
final authRefreshNotifier = _AuthRefreshNotifier(); final authRefreshNotifier = _AuthRefreshNotifier();
@@ -78,52 +79,100 @@ final routerProvider = Provider<GoRouter>((ref) {
routes: [ routes: [
GoRoute( GoRoute(
path: '/home', path: '/home',
builder: (context, state) => const _HomeRouteWrapper(), pageBuilder: (context, state) => CustomTransitionPage(
key: state.pageKey,
child: const _HomeRouteWrapper(),
transitionsBuilder: _fadeTransition,
),
), ),
GoRoute( GoRoute(
path: '/agents/search', path: '/agents/search',
builder: (context, state) { pageBuilder: (context, state) {
final query = state.uri.queryParameters['q']; final query = state.uri.queryParameters['q'];
return AgentSearchScreen(initialQuery: query); return CustomTransitionPage(
key: state.pageKey,
child: AgentSearchScreen(initialQuery: query),
transitionsBuilder: _fadeTransition,
);
}, },
), ),
GoRoute( GoRoute(
path: '/agents/detail/:id', path: '/agents/detail/:id',
builder: (context, state) { pageBuilder: (context, state) {
final id = state.pathParameters['id']!; final id = state.pathParameters['id']!;
return AgentDetailScreen(agentId: id); return CustomTransitionPage(
key: state.pageKey,
child: AgentDetailScreen(agentId: id),
transitionsBuilder: _fadeTransition,
);
}, },
), ),
GoRoute( GoRoute(
path: '/messages', path: '/messages',
builder: (context, state) => const ConversationsScreen(), pageBuilder: (context, state) => CustomTransitionPage(
key: state.pageKey,
child: const ConversationsScreen(),
transitionsBuilder: _fadeTransition,
),
), ),
GoRoute( GoRoute(
path: '/messages/chat/:id', path: '/messages/chat/:id',
builder: (context, state) { pageBuilder: (context, state) {
final id = state.pathParameters['id']!; final id = state.pathParameters['id']!;
return ChatScreen(conversationId: id); return CustomTransitionPage(
key: state.pageKey,
child: ChatScreen(conversationId: id),
transitionsBuilder: _fadeTransition,
);
}, },
), ),
GoRoute( GoRoute(
path: '/faq', path: '/faq',
builder: (context, state) => const FaqScreen(), pageBuilder: (context, state) => CustomTransitionPage(
key: state.pageKey,
child: const FaqScreen(),
transitionsBuilder: _fadeTransition,
),
), ),
GoRoute( GoRoute(
path: '/contact', path: '/contact',
builder: (context, state) => const ContactScreen(), pageBuilder: (context, state) => CustomTransitionPage(
key: state.pageKey,
child: const ContactScreen(),
transitionsBuilder: _fadeTransition,
),
), ),
GoRoute( GoRoute(
path: '/about', path: '/about',
builder: (context, state) => const AboutScreen(), pageBuilder: (context, state) => CustomTransitionPage(
key: state.pageKey,
child: const AboutScreen(),
transitionsBuilder: _fadeTransition,
),
), ),
GoRoute( GoRoute(
path: '/agent/network', path: '/agent/network',
builder: (context, state) => const AgentNetworkScreen(), pageBuilder: (context, state) => CustomTransitionPage(
key: state.pageKey,
child: const AgentNetworkScreen(),
transitionsBuilder: _fadeTransition,
),
), ),
GoRoute( GoRoute(
path: '/profile', 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. /// Notifier that triggers GoRouter refresh when auth state changes.
class _AuthRefreshNotifier extends ChangeNotifier { class _AuthRefreshNotifier extends ChangeNotifier {
void notify() { void notify() {