diff --git a/android/app/src/main/AndroidManifest.xml b/android/app/src/main/AndroidManifest.xml index 4bff20e..ceb1fe5 100644 --- a/android/app/src/main/AndroidManifest.xml +++ b/android/app/src/main/AndroidManifest.xml @@ -1,4 +1,7 @@ + + + + + + diff --git a/assets/icons/location_orange_icon.svg b/assets/icons/location_orange_icon.svg new file mode 100644 index 0000000..ae4c3c0 --- /dev/null +++ b/assets/icons/location_orange_icon.svg @@ -0,0 +1,4 @@ + + + + diff --git a/assets/icons/phone_orange_icon.svg b/assets/icons/phone_orange_icon.svg new file mode 100644 index 0000000..578744f --- /dev/null +++ b/assets/icons/phone_orange_icon.svg @@ -0,0 +1,3 @@ + + + diff --git a/assets/images/agent_illustration.png b/assets/images/agent_illustration.png new file mode 100644 index 0000000..8e3affb Binary files /dev/null and b/assets/images/agent_illustration.png differ diff --git a/assets/images/contact_map.jpg b/assets/images/contact_map.jpg new file mode 100644 index 0000000..c88a633 Binary files /dev/null and b/assets/images/contact_map.jpg differ diff --git a/firebase/demo/GoogleService-Info.plist b/firebase/demo/GoogleService-Info.plist new file mode 100644 index 0000000..96bdd74 --- /dev/null +++ b/firebase/demo/GoogleService-Info.plist @@ -0,0 +1,30 @@ + + + + + API_KEY + AIzaSyD67KPio-70XW_yGjrRUyq9ejdZoZA1rmE + GCM_SENDER_ID + 703616926518 + PLIST_VERSION + 1 + BUNDLE_ID + com.requestn.realEstateMobile.demo + PROJECT_ID + real-estate-2d71e + STORAGE_BUCKET + real-estate-2d71e.firebasestorage.app + IS_ADS_ENABLED + + IS_ANALYTICS_ENABLED + + IS_APPINVITE_ENABLED + + IS_GCM_ENABLED + + IS_SIGNIN_ENABLED + + GOOGLE_APP_ID + 1:703616926518:ios:2d2f7d163774722dfca997 + + \ No newline at end of file diff --git a/firebase/demo/google-services.json b/firebase/demo/google-services.json new file mode 100644 index 0000000..c8ca78e --- /dev/null +++ b/firebase/demo/google-services.json @@ -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" +} \ No newline at end of file diff --git a/firebase/dev/GoogleService-Info.plist b/firebase/dev/GoogleService-Info.plist new file mode 100644 index 0000000..fcf5ca2 --- /dev/null +++ b/firebase/dev/GoogleService-Info.plist @@ -0,0 +1,30 @@ + + + + + API_KEY + AIzaSyD67KPio-70XW_yGjrRUyq9ejdZoZA1rmE + GCM_SENDER_ID + 703616926518 + PLIST_VERSION + 1 + BUNDLE_ID + com.requestn.realEstateMobile.dev + PROJECT_ID + real-estate-2d71e + STORAGE_BUCKET + real-estate-2d71e.firebasestorage.app + IS_ADS_ENABLED + + IS_ANALYTICS_ENABLED + + IS_APPINVITE_ENABLED + + IS_GCM_ENABLED + + IS_SIGNIN_ENABLED + + GOOGLE_APP_ID + 1:703616926518:ios:301cac94994fbdc3fca997 + + \ No newline at end of file diff --git a/firebase/dev/google-services.json b/firebase/dev/google-services.json new file mode 100644 index 0000000..40df83f --- /dev/null +++ b/firebase/dev/google-services.json @@ -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" +} \ No newline at end of file diff --git a/firebase/local/GoogleService-Info.plist b/firebase/local/GoogleService-Info.plist new file mode 100644 index 0000000..4421dcb --- /dev/null +++ b/firebase/local/GoogleService-Info.plist @@ -0,0 +1,30 @@ + + + + + API_KEY + AIzaSyD67KPio-70XW_yGjrRUyq9ejdZoZA1rmE + GCM_SENDER_ID + 703616926518 + PLIST_VERSION + 1 + BUNDLE_ID + com.requestn.realEstateMobile.local + PROJECT_ID + real-estate-2d71e + STORAGE_BUCKET + real-estate-2d71e.firebasestorage.app + IS_ADS_ENABLED + + IS_ANALYTICS_ENABLED + + IS_APPINVITE_ENABLED + + IS_GCM_ENABLED + + IS_SIGNIN_ENABLED + + GOOGLE_APP_ID + 1:703616926518:ios:15652d299f9f2d7ffca997 + + \ No newline at end of file diff --git a/firebase/local/google-services.json b/firebase/local/google-services.json new file mode 100644 index 0000000..dfa204c --- /dev/null +++ b/firebase/local/google-services.json @@ -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" +} \ No newline at end of file diff --git a/ios/Runner/Info.plist b/ios/Runner/Info.plist index 3b6cf9e..bf699b4 100644 --- a/ios/Runner/Info.plist +++ b/ios/Runner/Info.plist @@ -47,6 +47,10 @@ + NSPhotoLibraryUsageDescription + We need access to your photo library to set your profile photo. + NSCameraUsageDescription + We need access to your camera to take a profile photo. UIApplicationSupportsIndirectInputEvents UILaunchStoryboardName diff --git a/lib/core/widgets/app_bottom_nav_bar.dart b/lib/core/widgets/app_bottom_nav_bar.dart index 5f4baba..968851a 100644 --- a/lib/core/widgets/app_bottom_nav_bar.dart +++ b/lib/core/widgets/app_bottom_nav_bar.dart @@ -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', diff --git a/lib/features/agents/presentation/screens/agent_home_screen.dart b/lib/features/agents/presentation/screens/agent_home_screen.dart index 7d1a288..e4b700d 100644 --- a/lib/features/agents/presentation/screens/agent_home_screen.dart +++ b/lib/features/agents/presentation/screens/agent_home_screen.dart @@ -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 _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 _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, + ), + ), + ], + ), ), ); } diff --git a/lib/features/contact/presentation/screens/contact_screen.dart b/lib/features/contact/presentation/screens/contact_screen.dart index c949cff..96d98b4 100644 --- a/lib/features/contact/presentation/screens/contact_screen.dart +++ b/lib/features/contact/presentation/screens/contact_screen.dart @@ -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 { 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 { 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 { 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 { 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 { _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 { _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 { GestureDetector( onTap: () {}, child: Container( - width: 120, + width: 139, height: 35, decoration: BoxDecoration( borderRadius: BorderRadius.circular(15), @@ -406,7 +406,7 @@ class _ContactScreenState extends State { } Widget _buildContactDetailRow({ - required IconData icon, + required String svgIcon, required String label, required String value, String? subtitle, @@ -416,10 +416,10 @@ class _ContactScreenState extends State { 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 { ), ), 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 { 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 { ), const SizedBox(width: 8), const Icon( - Icons.arrow_forward, - size: 18, + Icons.arrow_forward_rounded, + size: 16, color: AppColors.primaryDark, ), ], diff --git a/lib/features/home/presentation/widgets/home_header.dart b/lib/features/home/presentation/widgets/home_header.dart index 6efe483..b024a05 100644 --- a/lib/features/home/presentation/widgets/home_header.dart +++ b/lib/features/home/presentation/widgets/home_header.dart @@ -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), diff --git a/lib/features/notifications/data/models/notification_model.dart b/lib/features/notifications/data/models/notification_model.dart new file mode 100644 index 0000000..be033b2 --- /dev/null +++ b/lib/features/notifications/data/models/notification_model.dart @@ -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? 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 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.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, + ); + } +} diff --git a/lib/features/notifications/data/notification_repository.dart b/lib/features/notifications/data/notification_repository.dart new file mode 100644 index 0000000..cf85363 --- /dev/null +++ b/lib/features/notifications/data/notification_repository.dart @@ -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 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; + 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 getUnreadCount() async { + try { + final response = await _dio.get('/notifications/unread-count'); + final data = response.data['data'] as Map; + 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 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 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 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 json) { + final list = (json['notifications'] as List?) + ?.map((e) => AppNotification.fromJson(e as Map)) + .toList() ?? + []; + return NotificationsResponse( + notifications: list, + unreadCount: json['unreadCount'] as int? ?? 0, + totalPages: json['totalPages'] as int? ?? 1, + currentPage: json['currentPage'] as int? ?? 1, + ); + } +} diff --git a/lib/features/notifications/presentation/providers/notification_provider.dart b/lib/features/notifications/presentation/providers/notification_provider.dart new file mode 100644 index 0000000..95cc590 --- /dev/null +++ b/lib/features/notifications/presentation/providers/notification_provider.dart @@ -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((ref) { + return UnreadCountNotifier(NotificationRepository()); +}); + +class UnreadCountNotifier extends StateNotifier { + final NotificationRepository _repo; + Timer? _timer; + + UnreadCountNotifier(this._repo) : super(0) { + refresh(); + _timer = Timer.periodic(const Duration(seconds: 30), (_) => refresh()); + } + + Future 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 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? 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( + (ref) { + return NotificationListNotifier(NotificationRepository(), ref); +}); + +class NotificationListNotifier extends StateNotifier { + final NotificationRepository _repo; + final Ref _ref; + + NotificationListNotifier(this._repo, this._ref) + : super(const NotificationListState()); + + Future 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 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 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 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 (_) {} + } +} diff --git a/lib/features/notifications/presentation/screens/notifications_screen.dart b/lib/features/notifications/presentation/screens/notifications_screen.dart new file mode 100644 index 0000000..931dd37 --- /dev/null +++ b/lib/features/notifications/presentation/screens/notifications_screen.dart @@ -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 createState() => + _NotificationsScreenState(); +} + +class _NotificationsScreenState extends ConsumerState { + 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'; + } +} diff --git a/lib/features/profile/data/profile_repository.dart b/lib/features/profile/data/profile_repository.dart index 697bcb4..70b5edb 100644 --- a/lib/features/profile/data/profile_repository.dart +++ b/lib/features/profile/data/profile_repository.dart @@ -48,13 +48,23 @@ class ProfileRepository { } } - Future getAvatarUploadUrl(String role, String fileName) async { + /// Get presigned upload URL for avatar. + /// Returns { uploadUrl: String, key: String } + Future> 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; + 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 uploadToS3( + String uploadUrl, List 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 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> 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; + } 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> updatePrivacyPreferences( + String role, + Map 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; + } 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 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 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> 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; + } 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> updateNotificationPreferences( + String role, + Map 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; + } 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 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>> getMyTestimonials( + {String sort = 'latest'}) async { + try { + final response = + await _dio.get('/testimonials/my', queryParameters: {'sort': sort}); + final list = response.data['data'] as List; + return list.cast>(); + } 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?> getSubscription() async { + try { + final response = await _dio.get('/stripe/subscription'); + return response.data['data'] as Map?; + } 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 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 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 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>> getStripePlans() async { + try { + final response = await _dio.get('/stripe/plans'); + final list = response.data['data'] as List; + return list.cast>(); + } 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, + ); + } + } } diff --git a/lib/features/profile/presentation/screens/profile_settings_screen.dart b/lib/features/profile/presentation/screens/profile_settings_screen.dart index 5655f1b..0f61657 100644 --- a/lib/features/profile/presentation/screens/profile_settings_screen.dart +++ b/lib/features/profile/presentation/screens/profile_settings_screen.dart @@ -1,8 +1,17 @@ +import 'dart:io'; + import 'package:flutter/material.dart'; +import 'package:flutter/services.dart'; import 'package:flutter_riverpod/flutter_riverpod.dart'; import 'package:go_router/go_router.dart'; +import 'package:image_picker/image_picker.dart'; +import 'package:intl/intl.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/core/widgets/s3_image.dart'; +import 'package:real_estate_mobile/features/profile/data/profile_repository.dart'; import 'package:real_estate_mobile/features/profile/presentation/providers/profile_provider.dart'; +import 'package:url_launcher/url_launcher.dart'; class ProfileSettingsScreen extends ConsumerStatefulWidget { const ProfileSettingsScreen({super.key}); @@ -22,15 +31,52 @@ class _ProfileSettingsScreenState extends ConsumerState { late TextEditingController _phoneController; late TextEditingController _locationController; + // Password controllers + final _currentPasswordController = TextEditingController(); + final _newPasswordController = TextEditingController(); + final _confirmPasswordController = TextEditingController(); + bool _showCurrentPassword = false; + bool _showNewPassword = false; + bool _showConfirmPassword = false; + bool _isChangingPassword = false; + // Notification preferences final Map> _notificationPrefs = { 'newLeadAlerts': {'email': true, 'inApp': true}, - 'messageUpdates': {'email': true, 'inApp': true}, + 'messageUpdates': {'email': false, 'inApp': true}, 'urgentRequests': {'email': true, 'inApp': true}, }; String _digestFrequency = 'instant'; + bool _notificationsLoaded = false; + bool _isLoadingNotifications = false; + bool _isSavingNotifications = false; + + // Privacy preferences + String _profileVisibility = 'public'; + String _contactInfo = 'connections'; + String _activityStatus = 'public'; + bool _shareAnalytics = false; + bool _personalizedAds = false; + bool _thirdPartySharing = false; + bool _isLoadingPrivacy = false; + bool _isSavingPrivacy = false; + bool _privacyLoaded = false; + + // Testimonials (agent only) + List> _testimonials = []; + bool _isLoadingTestimonials = false; + String _testimonialSort = 'latest'; + String? _testimonialLink; + bool _isGeneratingLink = false; + bool _linkCopied = false; + + // Billing (agent only) + Map? _subscription; + bool _isLoadingSubscription = false; bool _controllersInitialized = false; + bool _isUploadingAvatar = false; + File? _localAvatarFile; @override void initState() { @@ -49,6 +95,9 @@ class _ProfileSettingsScreenState extends ConsumerState { _emailController.dispose(); _phoneController.dispose(); _locationController.dispose(); + _currentPasswordController.dispose(); + _newPasswordController.dispose(); + _confirmPasswordController.dispose(); super.dispose(); } @@ -69,19 +118,15 @@ class _ProfileSettingsScreenState extends ConsumerState { _phoneController.text = profile['phone'] as String? ?? ''; if (isAgent) { - // Agent: headline from agentType.name or bio final agentType = profile['agentType'] as Map?; _titleController.text = agentType?['name'] as String? ?? profile['bio'] as String? ?? ''; - // Agent: serviceAreas for location final serviceAreas = profile['serviceAreas']; if (serviceAreas is List && serviceAreas.isNotEmpty) { _locationController.text = serviceAreas.first.toString(); } } else { - // User: headline _titleController.text = profile['headline'] as String? ?? ''; - // User: city/state/country final city = profile['city'] as String? ?? ''; final stateName = profile['state'] as String? ?? ''; final country = profile['country'] as String? ?? ''; @@ -90,6 +135,22 @@ class _ProfileSettingsScreenState extends ConsumerState { } } + List<_TabItem> _getTabs(bool isAgent) { + final tabs = <_TabItem>[ + _TabItem(label: 'Profile Settings', icon: Icons.person_outline), + _TabItem(label: 'Change Password', icon: Icons.lock_outline), + _TabItem(label: 'Notifications', icon: Icons.notifications_none), + _TabItem(label: 'Privacy', icon: Icons.shield_outlined), + ]; + if (isAgent) { + tabs.add( + _TabItem(label: 'Billings & Payments', icon: Icons.payment_outlined)); + tabs.add( + _TabItem(label: 'Add Testimonials', icon: Icons.rate_review_outlined)); + } + return tabs; + } + @override Widget build(BuildContext context) { final profileState = ref.watch(profileProvider); @@ -98,7 +159,6 @@ class _ProfileSettingsScreenState extends ConsumerState { _initControllersFromProfile(profileState); } - // Show success/error snackbars ref.listen(profileProvider, (prev, next) { if (next.successMessage != null && prev?.successMessage == null) { ScaffoldMessenger.of(context).showSnackBar( @@ -120,51 +180,68 @@ class _ProfileSettingsScreenState extends ConsumerState { if (profileState.isLoading) { return const Center( - child: CircularProgressIndicator( - color: AppColors.accentOrange, - ), + child: CircularProgressIndicator(color: AppColors.accentOrange), ); } + final tabs = _getTabs(profileState.isAgent); + final totalTabs = tabs.length; + return Column( children: [ const SizedBox(height: 16), - _buildTabBar(), - _buildProgressBar(), - Expanded( - child: _selectedTab == 0 - ? _buildProfileSettingsTab(profileState) - : _selectedTab == 1 - ? _buildNotificationsTab() - : _buildPrivacyTab(), - ), + _buildTabBar(tabs), + _buildProgressBar(totalTabs), + Expanded(child: _buildSelectedTab(profileState, tabs)), ], ); } + Widget _buildSelectedTab(ProfileState profileState, List<_TabItem> tabs) { + final label = tabs[_selectedTab].label; + switch (label) { + case 'Profile Settings': + return _buildProfileSettingsTab(profileState); + case 'Change Password': + return _buildChangePasswordTab(); + case 'Notifications': + return _buildNotificationsTab(); + case 'Privacy': + return _buildPrivacyTab(); + case 'Billings & Payments': + return _buildBillingTab(); + case 'Add Testimonials': + return _buildTestimonialsTab(); + default: + return const SizedBox.shrink(); + } + } + // ── Tab Bar ── - Widget _buildTabBar() { + Widget _buildTabBar(List<_TabItem> tabs) { return SizedBox( height: 46, - child: ListView( + child: ListView.separated( scrollDirection: Axis.horizontal, padding: const EdgeInsets.symmetric(horizontal: 7), - children: [ - _buildTab(0, 'Profile Settings', Icons.person), - const SizedBox(width: 6), - _buildTab(1, 'Notifications', Icons.notifications), - const SizedBox(width: 6), - _buildTab(2, 'Privacy', Icons.person), - ], + itemCount: tabs.length, + separatorBuilder: (_, _) => const SizedBox(width: 6), + itemBuilder: (_, i) => _buildTab(i, tabs[i], tabs), ), ); } - Widget _buildTab(int index, String label, IconData icon) { + Widget _buildTab(int index, _TabItem tab, List<_TabItem> tabs) { final isActive = _selectedTab == index; return GestureDetector( - onTap: () => setState(() => _selectedTab = index), + onTap: () { + setState(() => _selectedTab = index); + if (tab.label == 'Privacy') _loadPrivacyPreferences(); + if (tab.label == 'Notifications') _loadNotificationPreferences(); + if (tab.label == 'Add Testimonials') _loadTestimonials(); + if (tab.label == 'Billings & Payments') _loadSubscription(); + }, child: Container( width: 162, height: 46, @@ -181,15 +258,20 @@ class _ProfileSettingsScreenState extends ConsumerState { child: Row( mainAxisAlignment: MainAxisAlignment.center, children: [ - Icon(icon, size: 24, color: AppColors.primaryDark), + Icon(tab.icon, + size: 20, + color: isActive ? Colors.white : AppColors.primaryDark), const SizedBox(width: 8), - Text( - label, - style: const TextStyle( - fontFamily: 'SourceSerif4', - fontSize: 14, - fontWeight: FontWeight.w400, - color: AppColors.primaryDark, + Flexible( + child: Text( + tab.label, + style: TextStyle( + fontFamily: 'SourceSerif4', + fontSize: 13, + fontWeight: FontWeight.w400, + color: isActive ? Colors.white : AppColors.primaryDark, + ), + overflow: TextOverflow.ellipsis, ), ), ], @@ -198,8 +280,8 @@ class _ProfileSettingsScreenState extends ConsumerState { ); } - Widget _buildProgressBar() { - final progress = (_selectedTab + 1) / 3; + Widget _buildProgressBar(int totalTabs) { + final progress = (_selectedTab + 1) / totalTabs; return Padding( padding: const EdgeInsets.symmetric(horizontal: 11, vertical: 16), child: Container( @@ -224,16 +306,16 @@ class _ProfileSettingsScreenState extends ConsumerState { ); } - // ── Profile Settings Tab ── + // ══════════════════════════════════════════════════════════════════ + // ── TAB 1: Profile Settings ── + // ══════════════════════════════════════════════════════════════════ Widget _buildProfileSettingsTab(ProfileState profileState) { final profile = profileState.profile; final isAgent = profileState.isAgent; - // Get avatar URL — agent avatar might be an S3 key or full URL String? avatarUrl = profile['avatar'] as String?; if (avatarUrl == null || avatarUrl.isEmpty) { - // Try nested user object (agent profile structure) final user = profile['user'] as Map?; avatarUrl = user?['avatar'] as String?; } @@ -245,7 +327,6 @@ class _ProfileSettingsScreenState extends ConsumerState { return ListView( padding: const EdgeInsets.fromLTRB(31, 10, 31, 30), children: [ - // Section title const Text( 'Public Profile', style: TextStyle( @@ -276,27 +357,32 @@ class _ProfileSettingsScreenState extends ConsumerState { height: 69, decoration: BoxDecoration( shape: BoxShape.circle, - border: Border.all( - color: AppColors.accentOrange, - width: 2, - ), + border: Border.all(color: AppColors.accentOrange, width: 2), ), child: ClipOval( - child: avatarUrl != null && avatarUrl.isNotEmpty - ? Image.network( - avatarUrl, - fit: BoxFit.cover, - errorBuilder: (_, __, ___) => const Icon( - Icons.person, - size: 36, - color: AppColors.primaryDark, + child: _isUploadingAvatar + ? const Center( + child: SizedBox( + width: 24, + height: 24, + child: CircularProgressIndicator( + strokeWidth: 2, + color: AppColors.accentOrange, + ), ), ) - : const Icon( - Icons.person, - size: 36, - color: AppColors.primaryDark, - ), + : _localAvatarFile != null + ? Image.file(_localAvatarFile!, + width: 71, height: 69, fit: BoxFit.cover) + : avatarUrl != null && avatarUrl.isNotEmpty + ? S3Image( + imageUrl: avatarUrl, + width: 71, + height: 69, + fit: BoxFit.cover, + ) + : const Icon(Icons.person, + size: 36, color: AppColors.primaryDark), ), ), const SizedBox(height: 10), @@ -304,53 +390,52 @@ class _ProfileSettingsScreenState extends ConsumerState { mainAxisAlignment: MainAxisAlignment.center, children: [ GestureDetector( - onTap: _pickAvatar, + onTap: _isUploadingAvatar ? null : _pickAvatar, child: Container( - width: 79, + width: 95, height: 24, decoration: BoxDecoration( color: AppColors.accentOrange, borderRadius: BorderRadius.circular(7), ), alignment: Alignment.center, - child: const Text( - 'Upload Now', - style: TextStyle( - fontFamily: 'SourceSerif4', - fontSize: 11, - fontWeight: FontWeight.w400, - color: AppColors.primaryDark, - ), - ), + child: _isUploadingAvatar + ? const SizedBox( + width: 14, + height: 14, + child: CircularProgressIndicator( + strokeWidth: 2, + color: AppColors.primaryDark, + ), + ) + : const Text('Upload Now', + style: TextStyle( + fontFamily: 'SourceSerif4', + fontSize: 11, + fontWeight: FontWeight.w400, + color: AppColors.primaryDark, + )), ), ), const SizedBox(width: 6), GestureDetector( - onTap: () { - ref - .read(profileProvider.notifier) - .updateProfile({'avatar': ''}); - }, + onTap: _isUploadingAvatar ? null : _deleteAvatar, child: Container( width: 79, height: 24, decoration: BoxDecoration( borderRadius: BorderRadius.circular(7), border: Border.all( - color: AppColors.primaryDark, - width: 0.1, - ), + color: AppColors.primaryDark, width: 0.1), ), alignment: Alignment.center, - child: const Text( - 'Delete', - style: TextStyle( - fontFamily: 'SourceSerif4', - fontSize: 11, - fontWeight: FontWeight.w400, - color: AppColors.primaryDark, - ), - ), + child: const Text('Delete', + style: TextStyle( + fontFamily: 'SourceSerif4', + fontSize: 11, + fontWeight: FontWeight.w400, + color: AppColors.primaryDark, + )), ), ), ], @@ -360,13 +445,12 @@ class _ProfileSettingsScreenState extends ConsumerState { ), const SizedBox(height: 49), - // Form fields _buildFormField('Full Name', _fullNameController), const SizedBox(height: 24), _buildFormField( isAgent ? 'Career / Agent Type' : 'Professional Title', _titleController, - enabled: !isAgent, // Agent type is read-only + enabled: !isAgent, ), const SizedBox(height: 24), _buildFormField('Email Address', _emailController, enabled: false), @@ -380,19 +464,87 @@ class _ProfileSettingsScreenState extends ConsumerState { prefixIcon: Icons.location_on, ), const SizedBox(height: 30), - - // Action buttons _buildActionButtons(onSave: _saveProfileSettings), ], ); } - Widget _buildFormField( - String label, - TextEditingController controller, { - bool enabled = true, - TextInputType? keyboardType, - IconData? prefixIcon, + // ══════════════════════════════════════════════════════════════════ + // ── TAB 2: Change Password ── + // ══════════════════════════════════════════════════════════════════ + + Widget _buildChangePasswordTab() { + return ListView( + padding: const EdgeInsets.fromLTRB(27, 10, 27, 30), + children: [ + const Text( + 'Password & Security', + style: TextStyle( + fontFamily: 'Fractul', + fontSize: 15, + fontWeight: FontWeight.w700, + color: AppColors.primaryDark, + ), + ), + const SizedBox(height: 8), + const Text( + 'Update your password to keep your account secure.', + style: TextStyle( + fontFamily: 'SourceSerif4', + fontSize: 14, + fontWeight: FontWeight.w400, + color: AppColors.primaryDark, + ), + ), + const SizedBox(height: 30), + + _buildPasswordField( + label: 'Current Password', + controller: _currentPasswordController, + obscure: !_showCurrentPassword, + onToggle: () => + setState(() => _showCurrentPassword = !_showCurrentPassword), + ), + const SizedBox(height: 24), + _buildPasswordField( + label: 'New Password', + controller: _newPasswordController, + obscure: !_showNewPassword, + onToggle: () => + setState(() => _showNewPassword = !_showNewPassword), + ), + const SizedBox(height: 4), + Text( + 'Min 8 characters, 1 uppercase, 1 lowercase, 1 number', + style: TextStyle( + fontFamily: 'SourceSerif4', + fontSize: 11, + color: AppColors.primaryDark.withValues(alpha: 0.5), + ), + ), + const SizedBox(height: 24), + _buildPasswordField( + label: 'Confirm New Password', + controller: _confirmPasswordController, + obscure: !_showConfirmPassword, + onToggle: () => + setState(() => _showConfirmPassword = !_showConfirmPassword), + ), + const SizedBox(height: 40), + _buildActionButtons( + onSave: _changePassword, + isSavingOverride: _isChangingPassword, + saveLabel: 'Update Password', + ), + ], + ); + } + + Widget _buildPasswordField({ + required String label, + required TextEditingController controller, + required bool obscure, + required VoidCallback onToggle, }) { final borderSide = BorderSide( color: AppColors.primaryDark.withValues(alpha: 0.1), @@ -416,6 +568,1183 @@ class _ProfileSettingsScreenState extends ConsumerState { ), ), const SizedBox(height: 14), + SizedBox( + height: 38, + child: TextField( + controller: controller, + obscureText: obscure, + style: const TextStyle( + fontFamily: 'SourceSerif4', + fontSize: 14, + fontWeight: FontWeight.w400, + color: AppColors.primaryDark, + ), + decoration: InputDecoration( + contentPadding: + const EdgeInsets.symmetric(horizontal: 26, vertical: 8), + border: border, + enabledBorder: border, + focusedBorder: border, + suffixIcon: GestureDetector( + onTap: onToggle, + child: Icon( + obscure ? Icons.visibility_off_outlined : Icons.visibility_outlined, + size: 18, + color: AppColors.primaryDark.withValues(alpha: 0.5), + ), + ), + suffixIconConstraints: const BoxConstraints(minWidth: 40), + ), + ), + ), + ], + ); + } + + Future _changePassword() async { + final current = _currentPasswordController.text.trim(); + final newPw = _newPasswordController.text.trim(); + final confirm = _confirmPasswordController.text.trim(); + + if (current.isEmpty || newPw.isEmpty || confirm.isEmpty) { + _showSnackBar('Please fill in all fields', isError: true); + return; + } + + final passwordRegex = RegExp(r'^(?=.*[a-z])(?=.*[A-Z])(?=.*\d).{8,}$'); + if (!passwordRegex.hasMatch(newPw)) { + _showSnackBar( + 'Password must be at least 8 characters with uppercase, lowercase, and number', + isError: true); + return; + } + + if (newPw != confirm) { + _showSnackBar('Passwords do not match', isError: true); + return; + } + + setState(() => _isChangingPassword = true); + try { + final repo = ref.read(profileRepositoryProvider); + await repo.changePassword(current, newPw); + _currentPasswordController.clear(); + _newPasswordController.clear(); + _confirmPasswordController.clear(); + _showSnackBar('Password changed successfully'); + } catch (e) { + _showSnackBar('$e', isError: true); + } finally { + if (mounted) setState(() => _isChangingPassword = false); + } + } + + // ══════════════════════════════════════════════════════════════════ + // ── TAB 3: Notifications ── + // ══════════════════════════════════════════════════════════════════ + + Future _loadNotificationPreferences() async { + if (_notificationsLoaded) return; + setState(() => _isLoadingNotifications = true); + try { + final role = ref.read(profileProvider).role; + final repo = ref.read(profileRepositoryProvider); + final data = await repo.getNotificationPreferences(role); + final notifications = + data['notifications'] as Map? ?? {}; + setState(() { + for (final key in ['new_lead_alerts', 'message_updates', 'urgent_requests_tours']) { + final mapped = key == 'new_lead_alerts' + ? 'newLeadAlerts' + : key == 'message_updates' + ? 'messageUpdates' + : 'urgentRequests'; + final vals = notifications[key] as Map?; + if (vals != null) { + _notificationPrefs[mapped] = { + 'email': vals['email'] as bool? ?? false, + 'inApp': vals['inApp'] as bool? ?? true, + }; + } + } + _digestFrequency = data['digestFrequency'] as String? ?? 'instant'; + _isLoadingNotifications = false; + _notificationsLoaded = true; + }); + } catch (_) { + setState(() { + _isLoadingNotifications = false; + _notificationsLoaded = true; + }); + } + } + + Widget _buildNotificationsTab() { + if (_isLoadingNotifications) { + return const Center( + child: CircularProgressIndicator(color: AppColors.accentOrange)); + } + + return ListView( + padding: const EdgeInsets.fromLTRB(27, 10, 27, 30), + children: [ + const Text( + 'Notification Preferences', + style: TextStyle( + fontFamily: 'Fractul', + fontSize: 15, + fontWeight: FontWeight.w700, + color: AppColors.primaryDark, + ), + ), + const SizedBox(height: 8), + const Text( + 'Choose what you want to be notified about and how.', + style: TextStyle( + fontFamily: 'SourceSerif4', + fontSize: 14, + fontWeight: FontWeight.w400, + color: AppColors.primaryDark, + ), + ), + const SizedBox(height: 20), + _buildSectionDivider(), + const SizedBox(height: 19), + _buildNotificationCategory( + title: 'New Lead Alerts', + description: 'Notifications about new leads and inquiries.', + prefKey: 'newLeadAlerts', + ), + _buildSectionDivider(), + const SizedBox(height: 13), + _buildNotificationCategory( + title: 'Message Updates', + description: + 'Notices about new messages from clients or other agents.', + prefKey: 'messageUpdates', + ), + _buildSectionDivider(), + const SizedBox(height: 16), + _buildNotificationCategory( + title: 'Urgent Requests & Tours', + description: + 'Immediate notifications for tour requests and time-sensitive items.', + prefKey: 'urgentRequests', + ), + _buildSectionDivider(), + const SizedBox(height: 20), + const Text( + 'Email Digest Frequency', + style: TextStyle( + fontFamily: 'Fractul', + fontSize: 14, + fontWeight: FontWeight.w700, + color: AppColors.primaryDark, + ), + ), + const SizedBox(height: 8), + const Text( + 'Choose how often you receive non-urgent email summaries', + style: TextStyle( + fontFamily: 'SourceSerif4', + fontSize: 14, + fontWeight: FontWeight.w400, + color: AppColors.primaryDark, + ), + ), + const SizedBox(height: 16), + _buildFrequencySelector(), + const SizedBox(height: 40), + _buildActionButtons( + onSave: _saveNotificationSettings, + isSavingOverride: _isSavingNotifications, + ), + ], + ); + } + + Future _saveNotificationSettings() async { + setState(() => _isSavingNotifications = true); + try { + final role = ref.read(profileProvider).role; + final repo = ref.read(profileRepositoryProvider); + await repo.updateNotificationPreferences(role, { + 'notifications': { + 'new_lead_alerts': _notificationPrefs['newLeadAlerts'], + 'message_updates': _notificationPrefs['messageUpdates'], + 'urgent_requests_tours': _notificationPrefs['urgentRequests'], + }, + 'digestFrequency': _digestFrequency, + }); + _showSnackBar('Notification settings saved'); + } catch (e) { + _showSnackBar('Failed to save: $e', isError: true); + } finally { + if (mounted) setState(() => _isSavingNotifications = false); + } + } + + // ══════════════════════════════════════════════════════════════════ + // ── TAB 4: Privacy ── + // ══════════════════════════════════════════════════════════════════ + + Future _loadPrivacyPreferences() async { + if (_privacyLoaded) return; + setState(() => _isLoadingPrivacy = true); + try { + final role = ref.read(profileProvider).role; + final repo = ref.read(profileRepositoryProvider); + final data = await repo.getPrivacyPreferences(role); + final privacy = data['privacySettings'] as Map? ?? {}; + final dataSettings = + data['dataSettings'] as Map? ?? {}; + setState(() { + _profileVisibility = + privacy['profile_visibility'] as String? ?? 'public'; + _contactInfo = privacy['contact_info'] as String? ?? 'connections'; + _activityStatus = privacy['activity_status'] as String? ?? 'public'; + _shareAnalytics = dataSettings['shareAnalytics'] as bool? ?? false; + _personalizedAds = dataSettings['personalizedAds'] as bool? ?? false; + _thirdPartySharing = + dataSettings['thirdPartySharing'] as bool? ?? false; + _isLoadingPrivacy = false; + _privacyLoaded = true; + }); + } catch (_) { + setState(() => _isLoadingPrivacy = false); + _privacyLoaded = true; + } + } + + Future _savePrivacyPreferences() async { + setState(() => _isSavingPrivacy = true); + try { + final role = ref.read(profileProvider).role; + final repo = ref.read(profileRepositoryProvider); + await repo.updatePrivacyPreferences(role, { + 'privacySettings': { + 'profile_visibility': _profileVisibility, + 'contact_info': _contactInfo, + 'activity_status': _activityStatus, + }, + 'dataSettings': { + 'shareAnalytics': _shareAnalytics, + 'personalizedAds': _personalizedAds, + 'thirdPartySharing': _thirdPartySharing, + }, + }); + _showSnackBar('Privacy settings saved'); + } catch (e) { + _showSnackBar('Failed to save: $e', isError: true); + } finally { + if (mounted) setState(() => _isSavingPrivacy = false); + } + } + + Future _deleteAccount() async { + final confirmed = await showDialog( + context: context, + builder: (ctx) => AlertDialog( + title: const Text('Delete Account', + style: TextStyle( + fontFamily: 'Fractul', + fontWeight: FontWeight.w700, + color: AppColors.primaryDark)), + content: const Text( + 'Are you sure you want to delete your account? This action cannot be undone and all your data will be permanently removed.', + style: TextStyle( + fontFamily: 'SourceSerif4', + fontSize: 14, + color: AppColors.primaryDark), + ), + actions: [ + TextButton( + onPressed: () => Navigator.pop(ctx, false), + child: const Text('Cancel')), + TextButton( + onPressed: () => Navigator.pop(ctx, true), + style: TextButton.styleFrom(foregroundColor: Colors.red), + child: const Text('Delete')), + ], + ), + ); + if (confirmed != true || !mounted) return; + + final doubleConfirmed = await showDialog( + context: context, + builder: (ctx) => AlertDialog( + title: const Text('Final Confirmation', + style: TextStyle( + fontFamily: 'Fractul', + fontWeight: FontWeight.w700, + color: Colors.red)), + content: const Text( + 'This will permanently delete your account, profile, and all associated data. Are you absolutely sure?', + style: TextStyle( + fontFamily: 'SourceSerif4', + fontSize: 14, + color: AppColors.primaryDark), + ), + actions: [ + TextButton( + onPressed: () => Navigator.pop(ctx, false), + child: const Text('Cancel')), + TextButton( + onPressed: () => Navigator.pop(ctx, true), + style: TextButton.styleFrom( + foregroundColor: Colors.white, backgroundColor: Colors.red), + child: const Text('Yes, Delete My Account'), + ), + ], + ), + ); + if (doubleConfirmed != true || !mounted) return; + + try { + final role = ref.read(profileProvider).role; + final repo = ref.read(profileRepositoryProvider); + await repo.deleteAccount(role); + if (mounted) ref.read(authProvider.notifier).logout(); + } catch (e) { + if (mounted) _showSnackBar('Failed to delete account: $e', isError: true); + } + } + + Widget _buildPrivacyTab() { + if (_isLoadingPrivacy) { + return const Center( + child: CircularProgressIndicator(color: AppColors.accentOrange)); + } + + return SingleChildScrollView( + padding: const EdgeInsets.symmetric(horizontal: 24, vertical: 16), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + _buildSectionCard( + title: 'Privacy Settings', + subtitle: 'Control your privacy and visibility preferences', + child: Column( + children: [ + _buildDropdownRow( + label: 'Profile Visibility', + description: 'Control who can see your profile', + value: _profileVisibility, + options: const { + 'public': 'Public', + 'connections': 'Connections Only', + 'private': 'Private', + }, + onChanged: (v) => + setState(() => _profileVisibility = v ?? _profileVisibility), + ), + const Divider(height: 24, color: Color(0xFFE8E8E8)), + _buildDropdownRow( + label: 'Contact Information', + description: 'Control who can see your contact details', + value: _contactInfo, + options: const { + 'public': 'Public', + 'connections': 'Connections Only', + 'private': 'Hidden', + }, + onChanged: (v) => + setState(() => _contactInfo = v ?? _contactInfo), + ), + const Divider(height: 24, color: Color(0xFFE8E8E8)), + _buildDropdownRow( + label: 'Activity Status', + description: 'Show when you are active on the platform', + value: _activityStatus, + options: const { + 'public': 'Everyone', + 'connections': 'Connections Only', + 'private': 'No One', + }, + onChanged: (v) => + setState(() => _activityStatus = v ?? _activityStatus), + ), + ], + ), + ), + const SizedBox(height: 20), + _buildSectionCard( + title: 'Data & Personalization', + subtitle: 'Manage how your data is used', + child: Column( + children: [ + _buildToggleRow( + label: 'Share Analytics', + description: + 'Help improve our service by sharing usage data', + value: _shareAnalytics, + onChanged: (v) => setState(() => _shareAnalytics = v), + ), + const Divider(height: 24, color: Color(0xFFE8E8E8)), + _buildToggleRow( + label: 'Personalized Ads', + description: 'See ads tailored to your interests', + value: _personalizedAds, + onChanged: (v) => setState(() => _personalizedAds = v), + ), + const Divider(height: 24, color: Color(0xFFE8E8E8)), + _buildToggleRow( + label: 'Third-Party Data Sharing', + description: + 'Allow sharing data with third-party partners', + value: _thirdPartySharing, + onChanged: (v) => setState(() => _thirdPartySharing = v), + ), + ], + ), + ), + const SizedBox(height: 20), + _buildActionButtons( + onSave: _savePrivacyPreferences, + isSavingOverride: _isSavingPrivacy), + const SizedBox(height: 24), + _buildSectionCard( + title: 'Danger Zone', + subtitle: 'Irreversible and destructive actions', + borderColor: Colors.red.withValues(alpha: 0.3), + titleColor: Colors.red, + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + const Text( + 'Once you delete your account, there is no going back. All your data will be permanently removed.', + style: TextStyle( + fontFamily: 'SourceSerif4', + fontSize: 13, + color: AppColors.hintText), + ), + const SizedBox(height: 16), + SizedBox( + width: double.infinity, + child: OutlinedButton( + onPressed: _deleteAccount, + style: OutlinedButton.styleFrom( + foregroundColor: Colors.red, + side: const BorderSide(color: Colors.red, width: 1.5), + padding: const EdgeInsets.symmetric(vertical: 14), + shape: RoundedRectangleBorder( + borderRadius: BorderRadius.circular(15)), + ), + child: const Text('Delete Account', + style: TextStyle( + fontFamily: 'Fractul', + fontSize: 14, + fontWeight: FontWeight.w700)), + ), + ), + ], + ), + ), + const SizedBox(height: 32), + ], + ), + ); + } + + // ══════════════════════════════════════════════════════════════════ + // ── TAB 5: Billings & Payments (Agent only) ── + // ══════════════════════════════════════════════════════════════════ + + Future _loadSubscription() async { + if (_isLoadingSubscription) return; + setState(() => _isLoadingSubscription = true); + try { + final repo = ref.read(profileRepositoryProvider); + final sub = await repo.getSubscription(); + setState(() { + _subscription = sub; + _isLoadingSubscription = false; + }); + } catch (_) { + setState(() => _isLoadingSubscription = false); + } + } + + Widget _buildBillingTab() { + if (_isLoadingSubscription) { + return const Center( + child: CircularProgressIndicator(color: AppColors.accentOrange)); + } + + final hasActive = _subscription != null && + _subscription!['status'] == 'ACTIVE'; + final plan = _subscription?['plan'] as Map?; + + return ListView( + padding: const EdgeInsets.fromLTRB(24, 10, 24, 30), + children: [ + const Text( + 'Billings & Payments', + style: TextStyle( + fontFamily: 'Fractul', + fontSize: 15, + fontWeight: FontWeight.w700, + color: AppColors.primaryDark, + ), + ), + const SizedBox(height: 8), + const Text( + 'Manage your subscription and billing details.', + style: TextStyle( + fontFamily: 'SourceSerif4', + fontSize: 14, + fontWeight: FontWeight.w400, + color: AppColors.primaryDark, + ), + ), + const SizedBox(height: 24), + + if (hasActive) ...[ + // Active subscription card + _buildSectionCard( + title: 'Current Subscription', + subtitle: 'Your active plan details', + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Row( + children: [ + const Icon(Icons.check_circle, + color: Color(0xFF4CAF50), size: 20), + const SizedBox(width: 8), + Text( + plan?['name'] as String? ?? 'Premium', + style: const TextStyle( + fontFamily: 'Fractul', + fontSize: 16, + fontWeight: FontWeight.w700, + color: AppColors.primaryDark, + ), + ), + const Spacer(), + Container( + padding: const EdgeInsets.symmetric( + horizontal: 10, vertical: 4), + decoration: BoxDecoration( + color: const Color(0xFF4CAF50).withValues(alpha: 0.1), + borderRadius: BorderRadius.circular(10), + ), + child: const Text('Active', + style: TextStyle( + fontFamily: 'Fractul', + fontSize: 12, + fontWeight: FontWeight.w600, + color: Color(0xFF4CAF50))), + ), + ], + ), + if (plan?['amount'] != null) ...[ + const SizedBox(height: 12), + Text( + '\$${plan!['amount']}/${plan['interval'] ?? 'year'}', + style: const TextStyle( + fontFamily: 'Fractul', + fontSize: 22, + fontWeight: FontWeight.w700, + color: AppColors.primaryDark, + ), + ), + ], + if (_subscription?['currentPeriodEnd'] != null) ...[ + const SizedBox(height: 8), + Text( + 'Renews on ${_formatDate(_subscription!['currentPeriodEnd'] as String)}', + style: TextStyle( + fontFamily: 'SourceSerif4', + fontSize: 13, + color: AppColors.primaryDark.withValues(alpha: 0.6), + ), + ), + ], + const SizedBox(height: 20), + // Plan features + ..._buildPlanFeatures(), + const SizedBox(height: 20), + // Manage Subscription + SizedBox( + width: double.infinity, + child: OutlinedButton( + onPressed: _openBillingPortal, + style: OutlinedButton.styleFrom( + foregroundColor: AppColors.primaryDark, + side: BorderSide( + color: AppColors.primaryDark.withValues(alpha: 0.2)), + padding: const EdgeInsets.symmetric(vertical: 14), + shape: RoundedRectangleBorder( + borderRadius: BorderRadius.circular(15)), + ), + child: const Text('Manage Subscription', + style: TextStyle( + fontFamily: 'Fractul', + fontSize: 14, + fontWeight: FontWeight.w600)), + ), + ), + const SizedBox(height: 10), + // Cancel + SizedBox( + width: double.infinity, + child: TextButton( + onPressed: _cancelSubscription, + style: TextButton.styleFrom(foregroundColor: Colors.red), + child: const Text('Cancel Subscription', + style: TextStyle( + fontFamily: 'Fractul', + fontSize: 14, + fontWeight: FontWeight.w500)), + ), + ), + ], + ), + ), + ] else ...[ + // No subscription — upgrade card + _buildSectionCard( + title: 'Get Premium Access', + subtitle: 'Unlock all features with our premium plan', + child: Column( + children: [ + Container( + width: double.infinity, + padding: const EdgeInsets.all(20), + decoration: BoxDecoration( + color: AppColors.accentOrange.withValues(alpha: 0.05), + borderRadius: BorderRadius.circular(15), + border: Border.all( + color: AppColors.accentOrange.withValues(alpha: 0.3)), + ), + child: Column( + children: [ + Container( + padding: const EdgeInsets.symmetric( + horizontal: 12, vertical: 4), + decoration: BoxDecoration( + color: AppColors.accentOrange, + borderRadius: BorderRadius.circular(10), + ), + child: const Text('Best Value', + style: TextStyle( + fontFamily: 'Fractul', + fontSize: 11, + fontWeight: FontWeight.w700, + color: Colors.white)), + ), + const SizedBox(height: 12), + const Text( + '\$499/Year', + style: TextStyle( + fontFamily: 'Fractul', + fontSize: 28, + fontWeight: FontWeight.w700, + color: AppColors.primaryDark, + ), + ), + const SizedBox(height: 16), + ..._buildPlanFeatures(), + ], + ), + ), + const SizedBox(height: 20), + SizedBox( + width: double.infinity, + child: ElevatedButton( + onPressed: _startCheckout, + style: ElevatedButton.styleFrom( + backgroundColor: AppColors.accentOrange, + foregroundColor: AppColors.primaryDark, + padding: const EdgeInsets.symmetric(vertical: 14), + shape: RoundedRectangleBorder( + borderRadius: BorderRadius.circular(15)), + ), + child: const Text('Get Premium', + style: TextStyle( + fontFamily: 'Fractul', + fontSize: 16, + fontWeight: FontWeight.w700)), + ), + ), + ], + ), + ), + ], + const SizedBox(height: 24), + // Need help section + Container( + padding: const EdgeInsets.all(16), + decoration: BoxDecoration( + borderRadius: BorderRadius.circular(15), + border: Border.all( + color: AppColors.primaryDark.withValues(alpha: 0.1)), + ), + child: Row( + children: [ + Icon(Icons.help_outline, + color: AppColors.primaryDark.withValues(alpha: 0.6)), + const SizedBox(width: 12), + const Expanded( + child: Text( + 'Need help with billing? Contact support.', + style: TextStyle( + fontFamily: 'SourceSerif4', + fontSize: 13, + color: AppColors.primaryDark, + ), + ), + ), + ], + ), + ), + ], + ); + } + + List _buildPlanFeatures() { + const features = [ + 'Agent Co-Marketing', + 'Priority 24/7 Support', + 'Whitelabel Client Portals', + 'Advanced CRM Tools & Analytics', + ]; + return features + .map((f) => Padding( + padding: const EdgeInsets.only(bottom: 8), + child: Row( + children: [ + const Icon(Icons.check_circle_outline, + size: 18, color: AppColors.accentOrange), + const SizedBox(width: 10), + Text(f, + style: const TextStyle( + fontFamily: 'SourceSerif4', + fontSize: 14, + color: AppColors.primaryDark)), + ], + ), + )) + .toList(); + } + + Future _openBillingPortal() async { + try { + final repo = ref.read(profileRepositoryProvider); + final url = await repo.createPortalSession(); + if (await canLaunchUrl(Uri.parse(url))) { + await launchUrl(Uri.parse(url), mode: LaunchMode.externalApplication); + } + } catch (e) { + if (mounted) _showSnackBar('Failed to open billing portal: $e', isError: true); + } + } + + Future _startCheckout() async { + try { + final repo = ref.read(profileRepositoryProvider); + final plans = await repo.getStripePlans(); + if (plans.isEmpty) { + _showSnackBar('No plans available', isError: true); + return; + } + final planId = plans.first['id'] as String; + final url = await repo.createCheckoutSession(planId); + if (await canLaunchUrl(Uri.parse(url))) { + await launchUrl(Uri.parse(url), mode: LaunchMode.externalApplication); + } + } catch (e) { + if (mounted) _showSnackBar('Failed to start checkout: $e', isError: true); + } + } + + Future _cancelSubscription() async { + final confirmed = await showDialog( + context: context, + builder: (ctx) => AlertDialog( + title: const Text('Cancel Subscription', + style: TextStyle( + fontFamily: 'Fractul', + fontWeight: FontWeight.w700, + color: AppColors.primaryDark)), + content: const Text( + 'Are you sure you want to cancel your subscription? You will lose access to premium features at the end of the billing period.', + style: TextStyle( + fontFamily: 'SourceSerif4', + fontSize: 14, + color: AppColors.primaryDark), + ), + actions: [ + TextButton( + onPressed: () => Navigator.pop(ctx, false), + child: const Text('Keep Subscription')), + TextButton( + onPressed: () => Navigator.pop(ctx, true), + style: TextButton.styleFrom(foregroundColor: Colors.red), + child: const Text('Yes, Cancel'), + ), + ], + ), + ); + if (confirmed != true || !mounted) return; + + try { + final repo = ref.read(profileRepositoryProvider); + await repo.cancelSubscription(); + _showSnackBar('Subscription cancelled'); + _loadSubscription(); + } catch (e) { + if (mounted) _showSnackBar('Failed to cancel: $e', isError: true); + } + } + + // ══════════════════════════════════════════════════════════════════ + // ── TAB 6: Testimonials (Agent only) ── + // ══════════════════════════════════════════════════════════════════ + + Future _loadTestimonials() async { + if (_isLoadingTestimonials) return; + setState(() => _isLoadingTestimonials = true); + try { + final repo = ref.read(profileRepositoryProvider); + final data = await repo.getMyTestimonials(sort: _testimonialSort); + setState(() { + _testimonials = data; + _isLoadingTestimonials = false; + }); + } catch (_) { + setState(() => _isLoadingTestimonials = false); + } + } + + Future _generateTestimonialLink() async { + setState(() => _isGeneratingLink = true); + try { + final repo = ref.read(profileRepositoryProvider); + final token = await repo.generateTestimonialLink(); + setState(() { + _testimonialLink = 'https://re-quest.co/testimonials/$token'; + _isGeneratingLink = false; + }); + } catch (e) { + setState(() => _isGeneratingLink = false); + if (mounted) _showSnackBar('Failed to generate link: $e', isError: true); + } + } + + Widget _buildTestimonialsTab() { + return ListView( + padding: const EdgeInsets.fromLTRB(24, 10, 24, 30), + children: [ + const Text( + 'Add Testimonials', + style: TextStyle( + fontFamily: 'Fractul', + fontSize: 15, + fontWeight: FontWeight.w700, + color: AppColors.primaryDark, + ), + ), + const SizedBox(height: 8), + const Text( + 'Generate a link for your clients to submit testimonials. Share it via email or messaging.', + style: TextStyle( + fontFamily: 'SourceSerif4', + fontSize: 14, + fontWeight: FontWeight.w400, + color: AppColors.primaryDark, + ), + ), + const SizedBox(height: 24), + + // Generate link section + _buildSectionCard( + title: 'Testimonial Collection Link', + subtitle: 'Share this link with your clients', + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + if (_testimonialLink != null) ...[ + Container( + width: double.infinity, + padding: const EdgeInsets.all(12), + decoration: BoxDecoration( + color: AppColors.primaryDark.withValues(alpha: 0.05), + borderRadius: BorderRadius.circular(10), + border: Border.all( + color: AppColors.primaryDark.withValues(alpha: 0.1)), + ), + child: Text( + _testimonialLink!, + style: const TextStyle( + fontFamily: 'SourceSerif4', + fontSize: 13, + color: AppColors.primaryDark, + ), + ), + ), + const SizedBox(height: 12), + SizedBox( + width: double.infinity, + child: OutlinedButton.icon( + onPressed: () { + Clipboard.setData( + ClipboardData(text: _testimonialLink!)); + setState(() => _linkCopied = true); + Future.delayed(const Duration(seconds: 2), () { + if (mounted) setState(() => _linkCopied = false); + }); + }, + icon: Icon( + _linkCopied ? Icons.check : Icons.copy, size: 16), + label: Text(_linkCopied ? 'Copied!' : 'Copy Link'), + style: OutlinedButton.styleFrom( + foregroundColor: AppColors.accentOrange, + side: const BorderSide(color: AppColors.accentOrange), + shape: RoundedRectangleBorder( + borderRadius: BorderRadius.circular(10)), + ), + ), + ), + ] else ...[ + SizedBox( + width: double.infinity, + child: ElevatedButton( + onPressed: _isGeneratingLink ? null : _generateTestimonialLink, + style: ElevatedButton.styleFrom( + backgroundColor: AppColors.accentOrange, + foregroundColor: AppColors.primaryDark, + padding: const EdgeInsets.symmetric(vertical: 14), + shape: RoundedRectangleBorder( + borderRadius: BorderRadius.circular(15)), + ), + child: _isGeneratingLink + ? const SizedBox( + width: 20, + height: 20, + child: CircularProgressIndicator( + strokeWidth: 2, color: AppColors.primaryDark)) + : const Text('Generate Link', + style: TextStyle( + fontFamily: 'Fractul', + fontSize: 14, + fontWeight: FontWeight.w700)), + ), + ), + ], + ], + ), + ), + const SizedBox(height: 24), + + // Testimonials list header + Row( + children: [ + const Text( + 'Received Testimonials', + style: TextStyle( + fontFamily: 'Fractul', + fontSize: 15, + fontWeight: FontWeight.w700, + color: AppColors.primaryDark, + ), + ), + const Spacer(), + // Sort toggle + GestureDetector( + onTap: () { + setState(() { + _testimonialSort = + _testimonialSort == 'latest' ? 'oldest' : 'latest'; + }); + _loadTestimonials(); + }, + child: Row( + children: [ + Icon(Icons.sort, + size: 16, + color: AppColors.primaryDark.withValues(alpha: 0.6)), + const SizedBox(width: 4), + Text( + _testimonialSort == 'latest' ? 'Latest' : 'Oldest', + style: TextStyle( + fontFamily: 'SourceSerif4', + fontSize: 13, + color: AppColors.primaryDark.withValues(alpha: 0.6), + ), + ), + ], + ), + ), + ], + ), + const SizedBox(height: 12), + + if (_isLoadingTestimonials) + const Center( + child: Padding( + padding: EdgeInsets.all(40), + child: CircularProgressIndicator(color: AppColors.accentOrange), + )) + else if (_testimonials.isEmpty) + Container( + padding: const EdgeInsets.symmetric(vertical: 40), + child: Center( + child: Column( + children: [ + Icon(Icons.rate_review_outlined, + size: 48, + color: AppColors.primaryDark.withValues(alpha: 0.3)), + const SizedBox(height: 12), + const Text( + 'No testimonials yet', + style: TextStyle( + fontFamily: 'Fractul', + fontSize: 16, + fontWeight: FontWeight.w600, + color: AppColors.primaryDark, + ), + ), + const SizedBox(height: 6), + Text( + 'Share your testimonial link with clients to start collecting reviews.', + textAlign: TextAlign.center, + style: TextStyle( + fontFamily: 'SourceSerif4', + fontSize: 14, + color: AppColors.primaryDark.withValues(alpha: 0.6), + ), + ), + ], + ), + ), + ) + else + ..._testimonials + .map((t) => _buildTestimonialCard(t)), + ], + ); + } + + Widget _buildTestimonialCard(Map testimonial) { + final rating = (testimonial['rating'] as num?)?.toInt() ?? 5; + final text = testimonial['text'] as String? ?? ''; + final authorName = testimonial['authorName'] as String? ?? ''; + final authorRole = testimonial['authorRole'] as String? ?? ''; + final createdAt = testimonial['createdAt'] as String?; + + return Container( + margin: const EdgeInsets.only(bottom: 12), + padding: const EdgeInsets.all(16), + decoration: BoxDecoration( + color: Colors.white, + borderRadius: BorderRadius.circular(15), + border: Border.all( + color: AppColors.primaryDark.withValues(alpha: 0.1)), + boxShadow: [ + BoxShadow( + color: const Color(0xFFD9D9D9).withValues(alpha: 0.3), + offset: const Offset(0, 4), + blurRadius: 10, + ), + ], + ), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + // Stars + date + Row( + children: [ + ...List.generate( + 5, + (i) => Icon( + i < rating ? Icons.star : Icons.star_border, + size: 16, + color: i < rating + ? AppColors.accentOrange + : AppColors.primaryDark.withValues(alpha: 0.3), + ), + ), + const Spacer(), + if (createdAt != null) + Text( + _formatRelativeTime(createdAt), + style: TextStyle( + fontFamily: 'SourceSerif4', + fontSize: 12, + color: AppColors.primaryDark.withValues(alpha: 0.5), + ), + ), + ], + ), + const SizedBox(height: 10), + // Text + Text( + text, + style: const TextStyle( + fontFamily: 'SourceSerif4', + fontSize: 14, + color: AppColors.primaryDark, + height: 1.5, + ), + ), + const SizedBox(height: 12), + // Author + Text( + authorName, + style: const TextStyle( + fontFamily: 'Fractul', + fontSize: 14, + fontWeight: FontWeight.w600, + color: AppColors.primaryDark, + ), + ), + if (authorRole.isNotEmpty) + Text( + authorRole, + style: TextStyle( + fontFamily: 'SourceSerif4', + fontSize: 13, + color: AppColors.primaryDark.withValues(alpha: 0.6), + ), + ), + ], + ), + ); + } + + // ══════════════════════════════════════════════════════════════════ + // ── Shared Widgets ── + // ══════════════════════════════════════════════════════════════════ + + Widget _buildFormField( + String label, + TextEditingController controller, { + bool enabled = true, + TextInputType? keyboardType, + IconData? prefixIcon, + }) { + final borderSide = BorderSide( + color: AppColors.primaryDark.withValues(alpha: 0.1), + width: 1, + ); + final border = OutlineInputBorder( + borderRadius: BorderRadius.circular(7), + borderSide: borderSide, + ); + + return Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text(label, + style: const TextStyle( + fontFamily: 'Fractul', + fontSize: 14, + fontWeight: FontWeight.w400, + color: AppColors.primaryDark, + )), + const SizedBox(height: 14), SizedBox( height: 38, child: TextField( @@ -429,10 +1758,8 @@ class _ProfileSettingsScreenState extends ConsumerState { color: AppColors.primaryDark, ), decoration: InputDecoration( - contentPadding: const EdgeInsets.symmetric( - horizontal: 26, - vertical: 8, - ), + contentPadding: + const EdgeInsets.symmetric(horizontal: 26, vertical: 8), border: border, enabledBorder: border, focusedBorder: border, @@ -450,150 +1777,6 @@ class _ProfileSettingsScreenState extends ConsumerState { ); } - void _pickAvatar() { - ScaffoldMessenger.of(context).showSnackBar( - const SnackBar(content: Text('Avatar upload coming soon')), - ); - } - - void _saveProfileSettings() { - final profileState = ref.read(profileProvider); - final isAgent = profileState.isAgent; - - final nameParts = _fullNameController.text.trim().split(RegExp(r'\s+')); - final firstName = nameParts.first; - final lastName = - nameParts.length > 1 ? nameParts.sublist(1).join(' ') : ''; - - final data = { - 'firstName': firstName, - 'lastName': lastName, - 'phone': _phoneController.text.trim(), - }; - - if (isAgent) { - // Agent: serviceAreas as array - final location = _locationController.text.trim(); - if (location.isNotEmpty) { - data['serviceAreas'] = [location]; - } - } else { - // User: city/state/country + headline - data['headline'] = _titleController.text.trim(); - final locationParts = _locationController.text - .split(',') - .map((s) => s.trim()) - .where((s) => s.isNotEmpty) - .toList(); - if (locationParts.isNotEmpty) data['city'] = locationParts[0]; - if (locationParts.length > 1) data['state'] = locationParts[1]; - if (locationParts.length > 2) data['country'] = locationParts[2]; - } - - ref.read(profileProvider.notifier).updateProfile(data); - } - - // ── Notifications Tab ── - - Widget _buildNotificationsTab() { - return ListView( - padding: const EdgeInsets.fromLTRB(27, 10, 27, 30), - children: [ - // Title — Figma: Fractul Bold 15px, y=263 - const Text( - 'Notification Preferences', - style: TextStyle( - fontFamily: 'Fractul', - fontSize: 15, - fontWeight: FontWeight.w700, - color: AppColors.primaryDark, - ), - ), - const SizedBox(height: 8), - // Description — Figma: SourceSerif4 Regular 14px, y=291 - const Text( - 'Choose what you want to be notified about and how.', - style: TextStyle( - fontFamily: 'SourceSerif4', - fontSize: 14, - fontWeight: FontWeight.w400, - color: AppColors.primaryDark, - ), - ), - const SizedBox(height: 20), - - // Divider — Figma: y=322 - _buildSectionDivider(), - const SizedBox(height: 19), - - // New Lead Alerts — Figma: title y=341 - _buildNotificationCategory( - title: 'New Lead Alerts', - description: 'Choose what you want to be notified about and how.', - prefKey: 'newLeadAlerts', - ), - - // Divider — Figma: y=416 - _buildSectionDivider(), - const SizedBox(height: 13), - - // Message Updates — Figma: title y=429 - _buildNotificationCategory( - title: 'Message Updates', - description: - 'Notices about new messages from clients or other agents.', - prefKey: 'messageUpdates', - ), - - // Divider — Figma: y=511 - _buildSectionDivider(), - const SizedBox(height: 16), - - // Urgent Requests & Tours — Figma: title y=527 - _buildNotificationCategory( - title: 'Urgent Requests & Tours', - description: - 'Immediate notifications for tour requests and time-sensitive items.', - prefKey: 'urgentRequests', - ), - - // Divider — Figma: y=621 - _buildSectionDivider(), - const SizedBox(height: 20), - - // Email Digest Frequency — Figma: Fractul Bold 14px, y=641 - const Text( - 'Email Digest Frequency', - style: TextStyle( - fontFamily: 'Fractul', - fontSize: 14, - fontWeight: FontWeight.w700, - color: AppColors.primaryDark, - ), - ), - const SizedBox(height: 8), - // Description — Figma: y=667 - const Text( - 'Choose how often you receive non-urgent email summaries', - style: TextStyle( - fontFamily: 'SourceSerif4', - fontSize: 14, - fontWeight: FontWeight.w400, - color: AppColors.primaryDark, - ), - ), - const SizedBox(height: 16), - - // Frequency selector — Figma: y=731 - _buildFrequencySelector(), - const SizedBox(height: 40), - - // Action buttons — Figma: y=879, rounded-[15px] - _buildNotificationActionButtons(), - ], - ); - } - Widget _buildSectionDivider() { return Divider( color: AppColors.primaryDark.withValues(alpha: 0.15), @@ -613,35 +1796,29 @@ class _ProfileSettingsScreenState extends ConsumerState { child: Row( crossAxisAlignment: CrossAxisAlignment.start, children: [ - // Left: title + description (max width ~220 from Figma) Expanded( child: Column( crossAxisAlignment: CrossAxisAlignment.start, children: [ - Text( - title, - style: const TextStyle( - fontFamily: 'Fractul', - fontSize: 14, - fontWeight: FontWeight.w700, - color: AppColors.primaryDark, - ), - ), + Text(title, + style: const TextStyle( + fontFamily: 'Fractul', + fontSize: 14, + fontWeight: FontWeight.w700, + color: AppColors.primaryDark, + )), const SizedBox(height: 4), - Text( - description, - style: const TextStyle( - fontFamily: 'SourceSerif4', - fontSize: 14, - fontWeight: FontWeight.w400, - color: AppColors.primaryDark, - ), - ), + Text(description, + style: const TextStyle( + fontFamily: 'SourceSerif4', + fontSize: 14, + fontWeight: FontWeight.w400, + color: AppColors.primaryDark, + )), ], ), ), const SizedBox(width: 20), - // Right: checkbox labels — Figma: x=321, Email/In-App stacked ~26px apart Column( crossAxisAlignment: CrossAxisAlignment.start, children: [ @@ -693,8 +1870,7 @@ class _ProfileSettingsScreenState extends ConsumerState { materialTapTargetSize: MaterialTapTargetSize.shrinkWrap, visualDensity: VisualDensity.compact, shape: RoundedRectangleBorder( - borderRadius: BorderRadius.circular(3), - ), + borderRadius: BorderRadius.circular(3)), side: BorderSide( color: AppColors.primaryDark.withValues(alpha: 0.4), width: 1, @@ -702,21 +1878,18 @@ class _ProfileSettingsScreenState extends ConsumerState { ), ), const SizedBox(width: 4), - Text( - label, - style: const TextStyle( - fontFamily: 'SourceSerif4', - fontSize: 14, - fontWeight: FontWeight.w400, - color: AppColors.primaryDark, - ), - ), + Text(label, + style: const TextStyle( + fontFamily: 'SourceSerif4', + fontSize: 14, + fontWeight: FontWeight.w400, + color: AppColors.primaryDark, + )), ], ), ); } - /// Figma: h=32, w=278, rounded-[7px], with vertical dividers and inset orange pill Widget _buildFrequencySelector() { const options = ['instant', 'daily', 'weekly', 'off']; const labels = ['Instant', 'Daily', 'Weekly', 'Off']; @@ -735,7 +1908,6 @@ class _ProfileSettingsScreenState extends ConsumerState { borderRadius: BorderRadius.circular(6), child: Row( children: List.generate(options.length * 2 - 1, (index) { - // Odd indices = vertical dividers if (index.isOdd) { return Container( width: 1, @@ -758,25 +1930,21 @@ class _ProfileSettingsScreenState extends ConsumerState { borderRadius: BorderRadius.circular(15), ), alignment: Alignment.center, - child: Text( - labels[i], - style: const TextStyle( - fontFamily: 'SourceSerif4', - fontSize: 14, - fontWeight: FontWeight.w400, - color: AppColors.primaryDark, - ), - ), + child: Text(labels[i], + style: const TextStyle( + fontFamily: 'SourceSerif4', + fontSize: 14, + fontWeight: FontWeight.w400, + color: AppColors.primaryDark, + )), ) - : Text( - labels[i], + : Text(labels[i], style: const TextStyle( fontFamily: 'SourceSerif4', fontSize: 14, fontWeight: FontWeight.w400, color: AppColors.primaryDark, - ), - ), + )), ), ), ); @@ -786,115 +1954,169 @@ class _ProfileSettingsScreenState extends ConsumerState { ); } - /// Notification tab buttons — Figma: rounded-[15px] (different from profile tab) - Widget _buildNotificationActionButtons() { - final isSaving = ref.watch(profileProvider).isSaving; - + Widget _buildSectionCard({ + required String title, + required String subtitle, + required Widget child, + Color borderColor = const Color(0xFFE8E8E8), + Color titleColor = AppColors.primaryDark, + }) { return Container( + width: double.infinity, + padding: const EdgeInsets.all(20), decoration: BoxDecoration( + color: Colors.white, borderRadius: BorderRadius.circular(15), - border: Border.all( - color: AppColors.primaryDark.withValues(alpha: 0.15), - width: 1, - ), + border: Border.all(color: borderColor, width: 1), ), - padding: const EdgeInsets.symmetric(horizontal: 20, vertical: 20), - child: Row( + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, children: [ - Expanded( - child: GestureDetector( - onTap: () => context.pop(), - child: Container( - height: 31, - decoration: BoxDecoration( - borderRadius: BorderRadius.circular(15), - border: Border.all( - color: AppColors.primaryDark.withValues(alpha: 0.15), - width: 1, - ), - ), - alignment: Alignment.center, - child: const Text( - 'Cancel', - style: TextStyle( - fontFamily: 'SourceSerif4', - fontSize: 14, - fontWeight: FontWeight.w400, - color: AppColors.primaryDark, - ), - ), - ), - ), - ), - const SizedBox(width: 10), - Expanded( - child: GestureDetector( - onTap: isSaving ? null : _saveNotificationSettings, - child: Container( - height: 31, - decoration: BoxDecoration( - color: AppColors.accentOrange, - borderRadius: BorderRadius.circular(15), - ), - alignment: Alignment.center, - child: isSaving - ? const SizedBox( - width: 16, - height: 16, - child: CircularProgressIndicator( - strokeWidth: 2, - color: AppColors.primaryDark, - ), - ) - : const Text( - 'Save Changes', - style: TextStyle( - fontFamily: 'SourceSerif4', - fontSize: 14, - fontWeight: FontWeight.w400, - color: AppColors.primaryDark, - ), - ), - ), - ), - ), + Text(title, + style: TextStyle( + fontFamily: 'Fractul', + fontSize: 16, + fontWeight: FontWeight.w700, + color: titleColor, + )), + const SizedBox(height: 4), + Text(subtitle, + style: const TextStyle( + fontFamily: 'SourceSerif4', + fontSize: 13, + color: AppColors.hintText, + )), + const SizedBox(height: 16), + child, ], ), ); } - void _saveNotificationSettings() { - ScaffoldMessenger.of(context).showSnackBar( - const SnackBar( - content: Text('Notification settings saved'), - backgroundColor: Color(0xFF638559), - ), - ); - } - - // ── Privacy Tab ── - - Widget _buildPrivacyTab() { - return const Center( - child: Padding( - padding: EdgeInsets.all(24), - child: Text( - 'Privacy settings coming soon', - style: TextStyle( - fontFamily: 'SourceSerif4', - fontSize: 16, - fontWeight: FontWeight.w400, - color: AppColors.primaryDark, + Widget _buildDropdownRow({ + required String label, + required String description, + required String value, + required Map options, + required ValueChanged onChanged, + }) { + return Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text(label, + style: const TextStyle( + fontFamily: 'Fractul', + fontSize: 14, + fontWeight: FontWeight.w700, + color: AppColors.primaryDark, + )), + const SizedBox(height: 2), + Text(description, + style: const TextStyle( + fontFamily: 'SourceSerif4', + fontSize: 12, + color: AppColors.hintText, + )), + const SizedBox(height: 8), + Container( + height: 42, + padding: const EdgeInsets.symmetric(horizontal: 14), + decoration: BoxDecoration( + borderRadius: BorderRadius.circular(10), + border: Border.all( + color: AppColors.primaryDark.withValues(alpha: 0.15)), + ), + child: DropdownButtonHideUnderline( + child: DropdownButton( + value: value, + isExpanded: true, + icon: const Icon(Icons.keyboard_arrow_down, + color: AppColors.primaryDark, size: 20), + style: const TextStyle( + fontFamily: 'Fractul', + fontSize: 13, + fontWeight: FontWeight.w500, + color: AppColors.primaryDark, + ), + items: options.entries + .map((e) => + DropdownMenuItem(value: e.key, child: Text(e.value))) + .toList(), + onChanged: onChanged, + ), ), ), - ), + ], ); } - // ── Shared Action Buttons ── + Widget _buildToggleRow({ + required String label, + required String description, + required bool value, + required ValueChanged onChanged, + }) { + return Row( + crossAxisAlignment: CrossAxisAlignment.center, + children: [ + Expanded( + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text(label, + style: const TextStyle( + fontFamily: 'Fractul', + fontSize: 14, + fontWeight: FontWeight.w700, + color: AppColors.primaryDark, + )), + const SizedBox(height: 2), + Text(description, + style: const TextStyle( + fontFamily: 'SourceSerif4', + fontSize: 12, + color: AppColors.hintText, + )), + ], + ), + ), + const SizedBox(width: 12), + GestureDetector( + onTap: () => onChanged(!value), + child: AnimatedContainer( + duration: const Duration(milliseconds: 200), + width: 44, + height: 24, + decoration: BoxDecoration( + borderRadius: BorderRadius.circular(12), + color: value + ? AppColors.accentOrange + : AppColors.primaryDark.withValues(alpha: 0.2), + ), + child: AnimatedAlign( + duration: const Duration(milliseconds: 200), + alignment: + value ? Alignment.centerRight : Alignment.centerLeft, + child: Container( + width: 20, + height: 20, + margin: const EdgeInsets.symmetric(horizontal: 2), + decoration: const BoxDecoration( + color: Colors.white, shape: BoxShape.circle), + ), + ), + ), + ), + ], + ); + } - Widget _buildActionButtons({required VoidCallback onSave}) { - final isSaving = ref.watch(profileProvider).isSaving; + Widget _buildActionButtons({ + required VoidCallback onSave, + bool? isSavingOverride, + String saveLabel = 'Save Changes', + }) { + final isSaving = isSavingOverride ?? ref.watch(profileProvider).isSaving; return Container( height: 71, @@ -921,15 +2143,13 @@ class _ProfileSettingsScreenState extends ConsumerState { ), ), alignment: Alignment.center, - child: const Text( - 'Cancel', - style: TextStyle( - fontFamily: 'Fractul', - fontSize: 14, - fontWeight: FontWeight.w400, - color: AppColors.primaryDark, - ), - ), + child: const Text('Cancel', + style: TextStyle( + fontFamily: 'Fractul', + fontSize: 14, + fontWeight: FontWeight.w400, + color: AppColors.primaryDark, + )), ), ), const SizedBox(width: 10), @@ -948,19 +2168,15 @@ class _ProfileSettingsScreenState extends ConsumerState { width: 16, height: 16, child: CircularProgressIndicator( - strokeWidth: 2, - color: AppColors.primaryDark, - ), + strokeWidth: 2, color: AppColors.primaryDark), ) - : const Text( - 'Save Changes', - style: TextStyle( + : Text(saveLabel, + style: const TextStyle( fontFamily: 'Fractul', fontSize: 14, fontWeight: FontWeight.w400, color: AppColors.primaryDark, - ), - ), + )), ), ), ], @@ -968,4 +2184,148 @@ class _ProfileSettingsScreenState extends ConsumerState { ); } + // ── Avatar methods ── + + Future _pickAvatar() async { + final picker = ImagePicker(); + final picked = await picker.pickImage( + source: ImageSource.gallery, + maxWidth: 800, + maxHeight: 800, + imageQuality: 85, + ); + if (picked == null) return; + + final ext = picked.path.split('.').last.toLowerCase(); + if (!['jpg', 'jpeg', 'png', 'gif'].contains(ext)) { + if (mounted) _showSnackBar('Please select a JPEG, PNG, or GIF image', isError: true); + return; + } + + final fileBytes = await picked.readAsBytes(); + if (fileBytes.length > 2 * 1024 * 1024) { + if (mounted) _showSnackBar('Image must be less than 2MB', isError: true); + return; + } + + setState(() { + _localAvatarFile = File(picked.path); + _isUploadingAvatar = true; + }); + + try { + final profileState = ref.read(profileProvider); + final role = profileState.isAgent ? 'AGENT' : 'USER'; + final contentType = 'image/$ext'; + final fileName = picked.name; + final repo = ProfileRepository(); + + final uploadData = + await repo.getAvatarUploadUrl(role, fileName, contentType); + final uploadUrl = uploadData['uploadUrl']!; + final key = uploadData['key']!; + + await repo.uploadToS3(uploadUrl, fileBytes, contentType); + + await ref + .read(profileProvider.notifier) + .updateProfile({'avatar': key}); + + if (mounted) _showSnackBar('Avatar updated successfully'); + } catch (e) { + if (mounted) _showSnackBar('Failed to upload avatar: $e', isError: true); + setState(() => _localAvatarFile = null); + } finally { + if (mounted) setState(() => _isUploadingAvatar = false); + } + } + + Future _deleteAvatar() async { + final profileState = ref.read(profileProvider); + final currentAvatar = profileState.profile['avatar'] as String?; + + if (currentAvatar != null && currentAvatar.isNotEmpty) { + final repo = ProfileRepository(); + await repo.deleteAvatar(currentAvatar); + } + + await ref.read(profileProvider.notifier).updateProfile({'avatar': null}); + + setState(() => _localAvatarFile = null); + if (mounted) _showSnackBar('Avatar deleted'); + } + + void _saveProfileSettings() { + final profileState = ref.read(profileProvider); + final isAgent = profileState.isAgent; + + final nameParts = _fullNameController.text.trim().split(RegExp(r'\s+')); + final firstName = nameParts.first; + final lastName = + nameParts.length > 1 ? nameParts.sublist(1).join(' ') : ''; + + final data = { + 'firstName': firstName, + 'lastName': lastName, + 'phone': _phoneController.text.trim(), + }; + + if (isAgent) { + final location = _locationController.text.trim(); + if (location.isNotEmpty) data['serviceAreas'] = [location]; + } else { + data['headline'] = _titleController.text.trim(); + final locationParts = _locationController.text + .split(',') + .map((s) => s.trim()) + .where((s) => s.isNotEmpty) + .toList(); + if (locationParts.isNotEmpty) data['city'] = locationParts[0]; + if (locationParts.length > 1) data['state'] = locationParts[1]; + if (locationParts.length > 2) data['country'] = locationParts[2]; + } + + ref.read(profileProvider.notifier).updateProfile(data); + } + + // ── Helpers ── + + void _showSnackBar(String message, {bool isError = false}) { + ScaffoldMessenger.of(context).showSnackBar( + SnackBar( + content: Text(message), + backgroundColor: isError ? Colors.red : const Color(0xFF638559), + ), + ); + } + + String _formatDate(String dateStr) { + try { + final date = DateTime.parse(dateStr); + return DateFormat('MMM d, y').format(date); + } catch (_) { + return dateStr; + } + } + + String _formatRelativeTime(String dateStr) { + try { + final date = DateTime.parse(dateStr); + final now = DateTime.now(); + final diff = now.difference(date); + if (diff.inDays == 0) return 'Today'; + if (diff.inDays == 1) return 'Yesterday'; + if (diff.inDays < 30) return '${diff.inDays} days ago'; + if (diff.inDays < 365) return '${diff.inDays ~/ 30} months ago'; + return DateFormat('MMM d, y').format(date); + } catch (_) { + return ''; + } + } +} + +class _TabItem { + final String label; + final IconData icon; + const _TabItem({required this.label, required this.icon}); } diff --git a/lib/routing/app_router.dart b/lib/routing/app_router.dart index 8e1c66b..e6441cf 100644 --- a/lib/routing/app_router.dart +++ b/lib/routing/app_router.dart @@ -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((ref) { final authRefreshNotifier = _AuthRefreshNotifier(); @@ -78,52 +79,100 @@ final routerProvider = Provider((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 animation, + Animation 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() {