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">
<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
android:label="@string/app_name"
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>
</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>
<true/>
<key>UILaunchStoryboardName</key>

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() {