From b193cbb9cfb0e73dbad47cf762e3a37e9f8ca8f8 Mon Sep 17 00:00:00 2001 From: pradeepkumar Date: Mon, 9 Mar 2026 08:14:10 +0530 Subject: [PATCH] feat: add new profile settings tabs for testimonials, billing, password, notifications, and privacy. --- .../screens/profile_settings_screen.dart | 2520 +---------------- .../presentation/widgets/billing_tab.dart | 682 +++++ .../widgets/change_password_tab.dart | 208 ++ .../widgets/notifications_tab.dart | 360 +++ .../presentation/widgets/privacy_tab.dart | 301 ++ .../widgets/profile_settings_tab.dart | 370 +++ .../widgets/profile_shared_widgets.dart | 348 +++ .../widgets/testimonials_tab.dart | 391 +++ 8 files changed, 2678 insertions(+), 2502 deletions(-) create mode 100644 lib/features/profile/presentation/widgets/billing_tab.dart create mode 100644 lib/features/profile/presentation/widgets/change_password_tab.dart create mode 100644 lib/features/profile/presentation/widgets/notifications_tab.dart create mode 100644 lib/features/profile/presentation/widgets/privacy_tab.dart create mode 100644 lib/features/profile/presentation/widgets/profile_settings_tab.dart create mode 100644 lib/features/profile/presentation/widgets/profile_shared_widgets.dart create mode 100644 lib/features/profile/presentation/widgets/testimonials_tab.dart diff --git a/lib/features/profile/presentation/screens/profile_settings_screen.dart b/lib/features/profile/presentation/screens/profile_settings_screen.dart index dfef6ac..ce94f8e 100644 --- a/lib/features/profile/presentation/screens/profile_settings_screen.dart +++ b/lib/features/profile/presentation/screens/profile_settings_screen.dart @@ -1,19 +1,13 @@ -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:flutter_svg/flutter_svg.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'; -import 'package:real_estate_mobile/features/profile/presentation/screens/stripe_checkout_screen.dart'; +import 'package:real_estate_mobile/features/profile/presentation/widgets/profile_settings_tab.dart'; +import 'package:real_estate_mobile/features/profile/presentation/widgets/change_password_tab.dart'; +import 'package:real_estate_mobile/features/profile/presentation/widgets/notifications_tab.dart'; +import 'package:real_estate_mobile/features/profile/presentation/widgets/privacy_tab.dart'; +import 'package:real_estate_mobile/features/profile/presentation/widgets/billing_tab.dart'; +import 'package:real_estate_mobile/features/profile/presentation/widgets/testimonials_tab.dart'; class ProfileSettingsScreen extends ConsumerStatefulWidget { const ProfileSettingsScreen({super.key}); @@ -26,117 +20,6 @@ class ProfileSettingsScreen extends ConsumerStatefulWidget { class _ProfileSettingsScreenState extends ConsumerState { int _selectedTab = 0; - // Profile form controllers - late TextEditingController _fullNameController; - late TextEditingController _titleController; - late TextEditingController _emailController; - 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': 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() { - super.initState(); - _fullNameController = TextEditingController(); - _titleController = TextEditingController(); - _emailController = TextEditingController(); - _phoneController = TextEditingController(); - _locationController = TextEditingController(); - } - - @override - void dispose() { - _fullNameController.dispose(); - _titleController.dispose(); - _emailController.dispose(); - _phoneController.dispose(); - _locationController.dispose(); - _currentPasswordController.dispose(); - _newPasswordController.dispose(); - _confirmPasswordController.dispose(); - super.dispose(); - } - - void _initControllersFromProfile(ProfileState profileState) { - if (_controllersInitialized) return; - _controllersInitialized = true; - - final profile = profileState.profile; - final isAgent = profileState.isAgent; - - final firstName = profile['firstName'] as String? ?? ''; - final lastName = profile['lastName'] as String? ?? ''; - _fullNameController.text = '$firstName $lastName'.trim(); - _emailController.text = profile['email'] as String? ?? - (profile['user'] is Map - ? (profile['user'] as Map)['email'] as String? ?? '' - : ''); - _phoneController.text = profile['phone'] as String? ?? ''; - - if (isAgent) { - final agentType = profile['agentType'] as Map?; - _titleController.text = - agentType?['name'] as String? ?? profile['bio'] as String? ?? ''; - final serviceAreas = profile['serviceAreas']; - if (serviceAreas is List && serviceAreas.isNotEmpty) { - _locationController.text = serviceAreas.first.toString(); - } - } else { - _titleController.text = profile['headline'] as String? ?? ''; - final city = profile['city'] as String? ?? ''; - final stateName = profile['state'] as String? ?? ''; - final country = profile['country'] as String? ?? ''; - final parts = [city, stateName, country].where((s) => s.isNotEmpty); - _locationController.text = parts.join(', '); - } - } - List<_TabItem> _getTabs(bool isAgent) { final tabs = <_TabItem>[ _TabItem(label: 'Profile Settings', icon: Icons.person_outline), @@ -157,10 +40,6 @@ class _ProfileSettingsScreenState extends ConsumerState { Widget build(BuildContext context) { final profileState = ref.watch(profileProvider); - if (!profileState.isLoading && profileState.profile.isNotEmpty) { - _initControllersFromProfile(profileState); - } - ref.listen(profileProvider, (prev, next) { if (next.successMessage != null && prev?.successMessage == null) { ScaffoldMessenger.of(context).showSnackBar( @@ -194,33 +73,31 @@ class _ProfileSettingsScreenState extends ConsumerState { const SizedBox(height: 16), _buildTabBar(tabs), _buildProgressBar(totalTabs), - Expanded(child: _buildSelectedTab(profileState, tabs)), + Expanded(child: _buildSelectedTab(tabs)), ], ); } - Widget _buildSelectedTab(ProfileState profileState, List<_TabItem> tabs) { + Widget _buildSelectedTab(List<_TabItem> tabs) { final label = tabs[_selectedTab].label; switch (label) { case 'Profile Settings': - return _buildProfileSettingsTab(profileState); + return const ProfileSettingsTab(); case 'Change Password': - return _buildChangePasswordTab(); + return const ChangePasswordTab(); case 'Notifications': - return _buildNotificationsTab(); + return const NotificationsTab(); case 'Privacy': - return _buildPrivacyTab(); + return const PrivacyTab(); case 'Billings & Payments': - return _buildBillingTab(); + return const BillingTab(); case 'Add Testimonials': - return _buildTestimonialsTab(); + return const TestimonialsTab(); default: return const SizedBox.shrink(); } } - // ── Tab Bar ── - Widget _buildTabBar(List<_TabItem> tabs) { return SizedBox( height: 46, @@ -228,22 +105,16 @@ class _ProfileSettingsScreenState extends ConsumerState { scrollDirection: Axis.horizontal, padding: const EdgeInsets.symmetric(horizontal: 7), itemCount: tabs.length, - separatorBuilder: (_, _) => const SizedBox(width: 6), - itemBuilder: (_, i) => _buildTab(i, tabs[i], tabs), + separatorBuilder: (_, __) => const SizedBox(width: 6), + itemBuilder: (_, i) => _buildTab(i, tabs[i]), ), ); } - Widget _buildTab(int index, _TabItem tab, List<_TabItem> tabs) { + Widget _buildTab(int index, _TabItem tab) { final isActive = _selectedTab == index; return GestureDetector( - 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(); - }, + onTap: () => setState(() => _selectedTab = index), child: Container( width: 162, height: 46, @@ -307,2337 +178,6 @@ class _ProfileSettingsScreenState extends ConsumerState { ), ); } - - // ══════════════════════════════════════════════════════════════════ - // ── TAB 1: Profile Settings ── - // ══════════════════════════════════════════════════════════════════ - - Widget _buildProfileSettingsTab(ProfileState profileState) { - final profile = profileState.profile; - final isAgent = profileState.isAgent; - - String? avatarUrl = profile['avatar'] as String?; - if (avatarUrl == null || avatarUrl.isEmpty) { - final user = profile['user'] as Map?; - avatarUrl = user?['avatar'] as String?; - } - - final descriptionText = isAgent - ? 'This information will be displayed on your public agent page.' - : 'This information will be displayed on your profile.'; - - return ListView( - padding: const EdgeInsets.fromLTRB(31, 10, 31, 30), - children: [ - const Text( - 'Public Profile', - style: TextStyle( - fontFamily: 'Fractul', - fontSize: 15, - fontWeight: FontWeight.w700, - color: AppColors.primaryDark, - ), - ), - const SizedBox(height: 8), - Text( - descriptionText, - style: const TextStyle( - fontFamily: 'SourceSerif4', - fontSize: 14, - fontWeight: FontWeight.w400, - color: AppColors.primaryDark, - ), - ), - const SizedBox(height: 39), - - // Avatar - Center( - child: Column( - children: [ - Container( - width: 71, - height: 69, - decoration: BoxDecoration( - shape: BoxShape.circle, - border: Border.all(color: AppColors.accentOrange, width: 2), - ), - child: ClipOval( - child: _isUploadingAvatar - ? const Center( - child: SizedBox( - width: 24, - height: 24, - child: CircularProgressIndicator( - strokeWidth: 2, - color: AppColors.accentOrange, - ), - ), - ) - : _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), - Row( - mainAxisAlignment: MainAxisAlignment.center, - children: [ - GestureDetector( - onTap: _isUploadingAvatar ? null : _pickAvatar, - child: Container( - width: 95, - height: 24, - decoration: BoxDecoration( - color: AppColors.accentOrange, - borderRadius: BorderRadius.circular(7), - ), - alignment: Alignment.center, - 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: _isUploadingAvatar ? null : _deleteAvatar, - child: Container( - width: 79, - height: 24, - decoration: BoxDecoration( - borderRadius: BorderRadius.circular(7), - border: Border.all( - 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, - )), - ), - ), - ], - ), - ], - ), - ), - const SizedBox(height: 49), - - _buildFormField('Full Name', _fullNameController), - const SizedBox(height: 24), - _buildFormField( - isAgent ? 'Career / Agent Type' : 'Professional Title', - _titleController, - enabled: !isAgent, - ), - const SizedBox(height: 24), - _buildFormField('Email Address', _emailController, enabled: false), - const SizedBox(height: 24), - _buildFormField('Phone Number', _phoneController, - keyboardType: TextInputType.phone), - const SizedBox(height: 24), - _buildFormField( - isAgent ? 'Service Areas' : 'Location', - _locationController, - prefixIcon: Icons.location_on, - ), - const SizedBox(height: 30), - _buildActionButtons(onSave: _saveProfileSettings), - ], - ); - } - - // ══════════════════════════════════════════════════════════════════ - // ── 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), - 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( - 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: [ - // Title section matching Figma - const Center( - child: Text( - 'Subscription & Payments', - style: TextStyle( - fontFamily: 'Fractul', - fontSize: 25, - fontWeight: FontWeight.w700, - color: AppColors.primaryDark, - ), - ), - ), - const SizedBox(height: 10), - const Center( - child: Text( - 'Manage Your billing cycles , upgrade your\nplan , or boost individual listings', - textAlign: TextAlign.center, - style: TextStyle( - fontFamily: 'SourceSerif4', - fontSize: 14, - fontWeight: FontWeight.w400, - color: AppColors.primaryDark, - ), - ), - ), - const SizedBox(height: 24), - - if (hasActive) ...[ - // Section title - const Text( - 'Current Plan', - style: TextStyle( - fontFamily: 'Fractul', - fontSize: 20, - fontWeight: FontWeight.w700, - color: AppColors.primaryDark, - ), - ), - const SizedBox(height: 16), - // Active subscription card - Container( - padding: const EdgeInsets.all(20), - decoration: BoxDecoration( - borderRadius: BorderRadius.circular(10), - border: Border.all( - color: AppColors.primaryDark.withValues(alpha: 0.5), - width: 0.5, - ), - ), - child: Column( - crossAxisAlignment: CrossAxisAlignment.start, - children: [ - // Annual Subscription badge - Container( - padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 10), - decoration: BoxDecoration( - color: AppColors.accentOrange, - borderRadius: BorderRadius.circular(15), - ), - child: Text( - plan?['name'] as String? ?? 'Annual Subscription', - style: const TextStyle( - fontFamily: 'SourceSerif4', - fontSize: 14, - color: Colors.white, - ), - ), - ), - const SizedBox(height: 20), - Row( - children: [ - const Icon(Icons.check_circle, - color: Color(0xFF4CAF50), size: 20), - const SizedBox(width: 8), - const Text( - 'Active', - style: TextStyle( - fontFamily: 'Fractul', - fontSize: 14, - fontWeight: FontWeight.w600, - color: Color(0xFF4CAF50), - ), - ), - const Spacer(), - if (_subscription?['currentPeriodEnd'] != null) - Text( - 'Renews ${_formatDate(_subscription!['currentPeriodEnd'] as String)}', - style: TextStyle( - fontFamily: 'SourceSerif4', - fontSize: 13, - color: AppColors.primaryDark.withValues(alpha: 0.6), - ), - ), - ], - ), - if (plan?['amount'] != null) ...[ - const SizedBox(height: 16), - RichText( - text: TextSpan( - children: [ - TextSpan( - text: '\$${plan!['amount']}', - style: const TextStyle( - fontFamily: 'Fractul', - fontSize: 35, - fontWeight: FontWeight.w700, - color: AppColors.primaryDark, - ), - ), - const TextSpan( - text: ' / ', - style: TextStyle( - fontFamily: 'Fractul', - fontSize: 20, - fontWeight: FontWeight.w700, - color: AppColors.primaryDark, - ), - ), - TextSpan( - text: plan['interval'] as String? ?? 'Year', - style: TextStyle( - fontFamily: 'Fractul', - fontSize: 20, - fontWeight: FontWeight.w700, - color: AppColors.primaryDark.withValues(alpha: 0.5), - ), - ), - ], - ), - ), - ], - const SizedBox(height: 20), - // Manage Subscription button - SizedBox( - width: 155, - child: ElevatedButton( - onPressed: _openBillingPortal, - style: ElevatedButton.styleFrom( - backgroundColor: AppColors.accentOrange, - padding: const EdgeInsets.symmetric(vertical: 12), - shape: RoundedRectangleBorder( - borderRadius: BorderRadius.circular(15)), - ), - child: const Text('Manage Subscription', - style: TextStyle( - fontFamily: 'Fractul', - fontSize: 13, - fontWeight: FontWeight.w700, - color: Colors.white)), - ), - ), - const SizedBox(height: 24), - // Include Features - const Text( - 'Include Features', - style: TextStyle( - fontFamily: 'Fractul', - fontSize: 20, - fontWeight: FontWeight.w700, - color: AppColors.primaryDark, - ), - ), - const SizedBox(height: 16), - ..._buildPlanFeatures(), - const SizedBox(height: 16), - // Cancel - Center( - 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 ...[ - // Section title - const Text( - 'Annual Membership Plans', - style: TextStyle( - fontFamily: 'Fractul', - fontSize: 20, - fontWeight: FontWeight.w700, - color: AppColors.primaryDark, - ), - ), - const SizedBox(height: 16), - // Plan card matching Figma - Stack( - clipBehavior: Clip.none, - children: [ - Container( - padding: const EdgeInsets.fromLTRB(20, 20, 20, 20), - decoration: BoxDecoration( - borderRadius: BorderRadius.circular(10), - border: Border.all( - color: AppColors.primaryDark.withValues(alpha: 0.5), - width: 0.5, - ), - ), - child: Column( - crossAxisAlignment: CrossAxisAlignment.start, - children: [ - // Annual Subscription badge - Container( - padding: - const EdgeInsets.symmetric(horizontal: 16, vertical: 10), - decoration: BoxDecoration( - color: AppColors.accentOrange, - borderRadius: BorderRadius.circular(15), - ), - child: const Text( - 'Annual Subscription', - style: TextStyle( - fontFamily: 'SourceSerif4', - fontSize: 14, - color: Colors.white, - ), - ), - ), - const SizedBox(height: 24), - // Professional Annual Plan - const Text( - 'Professional Annual Plan', - style: TextStyle( - fontFamily: 'Fractul', - fontSize: 20, - fontWeight: FontWeight.w700, - color: AppColors.primaryDark, - ), - ), - const SizedBox(height: 8), - const Text( - 'Complete solutions for real estate\nagents and lenders to grow their\nbusiness.', - style: TextStyle( - fontFamily: 'SourceSerif4', - fontSize: 14, - color: AppColors.primaryDark, - ), - ), - const SizedBox(height: 20), - // Price - RichText( - text: TextSpan( - children: [ - const TextSpan( - text: '\$499', - style: TextStyle( - fontFamily: 'Fractul', - fontSize: 35, - fontWeight: FontWeight.w700, - color: Colors.black, - ), - ), - const TextSpan( - text: ' / ', - style: TextStyle( - fontFamily: 'Fractul', - fontSize: 20, - fontWeight: FontWeight.w700, - color: Colors.black, - ), - ), - TextSpan( - text: 'Year', - style: TextStyle( - fontFamily: 'Fractul', - fontSize: 20, - fontWeight: FontWeight.w700, - color: Colors.black.withValues(alpha: 0.5), - ), - ), - ], - ), - ), - const SizedBox(height: 20), - // Get Premium Access button + illustration - Row( - crossAxisAlignment: CrossAxisAlignment.start, - children: [ - SizedBox( - width: 155, - height: 43, - child: ElevatedButton( - onPressed: _startCheckout, - style: ElevatedButton.styleFrom( - backgroundColor: AppColors.accentOrange, - side: const BorderSide(color: AppColors.accentOrange), - padding: EdgeInsets.zero, - shape: RoundedRectangleBorder( - borderRadius: BorderRadius.circular(15)), - ), - child: const Text('Get Premium Access', - style: TextStyle( - fontFamily: 'Fractul', - fontSize: 14, - fontWeight: FontWeight.w700, - color: Colors.white)), - ), - ), - const Spacer(), - // Wallet illustration - ClipRRect( - borderRadius: BorderRadius.circular(70), - child: Image.asset( - 'assets/icons/wallet_illustration.jpg', - width: 120, - height: 120, - fit: BoxFit.cover, - ), - ), - ], - ), - const SizedBox(height: 16), - // Include Features - const Text( - 'Include Features', - style: TextStyle( - fontFamily: 'Fractul', - fontSize: 20, - fontWeight: FontWeight.w700, - color: AppColors.primaryDark, - ), - ), - const SizedBox(height: 16), - ..._buildPlanFeatures(), - ], - ), - ), - // "Best value" ribbon on top-right corner - Positioned( - top: 0, - right: 0, - child: ClipRRect( - borderRadius: const BorderRadius.only( - topRight: Radius.circular(10), - ), - child: SizedBox( - width: 62, - height: 134, - child: Stack( - children: [ - CustomPaint( - size: const Size(62, 134), - painter: _RibbonPainter(), - ), - // Orange circle at top of ribbon - Positioned( - top: 6, - left: 12, - child: Container( - width: 15, - height: 14, - decoration: const BoxDecoration( - color: AppColors.accentOrange, - shape: BoxShape.circle, - ), - ), - ), - // "Best value" text rotated ~67 degrees - Positioned( - top: 24, - left: 6, - child: Transform.rotate( - angle: 1.17, - child: const Text( - 'Best value', - style: TextStyle( - fontFamily: 'Fractul', - fontSize: 14, - color: Colors.white, - ), - ), - ), - ), - ], - ), - ), - ), - ), - ], - ), - ], - - const SizedBox(height: 24), - - // Need Help section matching Figma - Container( - clipBehavior: Clip.antiAlias, - padding: const EdgeInsets.fromLTRB(20, 30, 20, 0), - decoration: BoxDecoration( - borderRadius: BorderRadius.circular(15), - border: Border.all( - color: AppColors.primaryDark.withValues(alpha: 0.7), - width: 0.7, - ), - ), - child: Column( - children: [ - const Text( - 'Need Help ?', - style: TextStyle( - fontFamily: 'Fractul', - fontSize: 20, - fontWeight: FontWeight.w700, - color: AppColors.primaryDark, - ), - ), - const SizedBox(height: 10), - const Text( - 'Our billing experts are here to help you\nwith any questions\n about your plan.', - textAlign: TextAlign.center, - style: TextStyle( - fontFamily: 'SourceSerif4', - fontSize: 14, - color: AppColors.primaryDark, - ), - ), - const SizedBox(height: 16), - GestureDetector( - onTap: () async { - final uri = Uri.parse('mailto:support@re-quest.co'); - if (await canLaunchUrl(uri)) { - await launchUrl(uri); - } - }, - child: Row( - mainAxisAlignment: MainAxisAlignment.center, - children: [ - Text( - 'support@example.com', - style: TextStyle( - fontFamily: 'SourceSerif4', - fontSize: 14, - fontWeight: FontWeight.w700, - color: AppColors.accentOrange, - decoration: TextDecoration.underline, - decorationColor: AppColors.accentOrange, - ), - ), - const SizedBox(width: 4), - Transform.rotate( - angle: 0.72, // ~41 degrees matching Figma - child: const Icon(Icons.arrow_upward, - size: 16, color: AppColors.accentOrange), - ), - ], - ), - ), - const SizedBox(height: 20), - // Businessman illustration - ClipRRect( - borderRadius: BorderRadius.circular(57), - child: Image.asset( - 'assets/icons/need_help_illustration.jpg', - width: 261, - height: 261, - fit: BoxFit.cover, - ), - ), - ], - ), - ), - ], - ); - } - - List _buildPlanFeatures() { - const features = [ - ('Agent -Co-Marketing', true), // first uses diamond icon - ('Priority 24/7 Support', false), - ('Whitelabel Client Portals', false), - ('Advanced CRM Tools & Analytics', false), - ]; - return features - .map((f) => Padding( - padding: const EdgeInsets.only(bottom: 20), - child: Row( - children: [ - SvgPicture.asset( - f.$2 - ? 'assets/icons/diamond_icon.svg' - : 'assets/icons/feature_check_icon.svg', - width: 18, - height: 18, - ), - const SizedBox(width: 12), - Text(f.$1, - 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 (!mounted) return; - // Open billing portal in in-app browser - await Navigator.of(context).push?>( - MaterialPageRoute( - builder: (_) => StripeCheckoutScreen( - url: url, - title: 'Manage Subscription', - ), - ), - ); - // Refresh subscription when user returns - if (mounted) { - _loadSubscription(); - } - } 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 (!mounted) return; - // Open Stripe checkout in in-app browser - final result = await Navigator.of(context).push?>( - MaterialPageRoute( - builder: (_) => StripeCheckoutScreen( - url: url, - title: 'Checkout', - successUrlPattern: 'confirmation', - ), - ), - ); - // If checkout succeeded, navigate to payment success screen - if (result != null && result['success'] == true && mounted) { - final sessionId = result['sessionId'] as String?; - context.go('/payment-success${sessionId != null ? '?session_id=$sessionId' : ''}'); - } else if (mounted) { - _loadSubscription(); - } - } 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: [ - // Title matching Figma - const Center( - child: Text( - 'Add Testimonials', - style: TextStyle( - fontFamily: 'Fractul', - fontSize: 25, - fontWeight: FontWeight.w700, - color: AppColors.primaryDark, - ), - ), - ), - const SizedBox(height: 10), - const Center( - child: Text( - 'What people are saying about you', - textAlign: TextAlign.center, - style: TextStyle( - fontFamily: 'SourceSerif4', - fontSize: 14, - fontWeight: FontWeight.w400, - color: AppColors.primaryDark, - ), - ), - ), - const SizedBox(height: 20), - - // Create Testimonials section - const Center( - child: Text( - 'Create Testimonials', - style: TextStyle( - fontFamily: 'Fractul', - fontSize: 25, - fontWeight: FontWeight.w700, - color: AppColors.primaryDark, - ), - ), - ), - const SizedBox(height: 16), - - // Generate Link button (full-width, orange) - SizedBox( - width: double.infinity, - height: 43, - child: ElevatedButton( - onPressed: _isGeneratingLink ? null : _generateTestimonialLink, - style: ElevatedButton.styleFrom( - backgroundColor: AppColors.accentOrange, - padding: const EdgeInsets.symmetric(vertical: 10), - shape: RoundedRectangleBorder( - borderRadius: BorderRadius.circular(15)), - ), - child: _isGeneratingLink - ? const SizedBox( - width: 20, - height: 20, - child: CircularProgressIndicator( - strokeWidth: 2, color: Colors.white)) - : const Text('Generate Link', - style: TextStyle( - fontFamily: 'Fractul', - fontSize: 14, - fontWeight: FontWeight.w700, - color: Colors.white)), - ), - ), - const SizedBox(height: 16), - - // Link URL field with Copy button - if (_testimonialLink != null) - Container( - height: 43, - decoration: BoxDecoration( - borderRadius: BorderRadius.circular(15), - border: Border.all( - color: AppColors.primaryDark.withValues(alpha: 0.1), - ), - ), - child: Row( - children: [ - Expanded( - child: Padding( - padding: const EdgeInsets.symmetric(horizontal: 12), - child: Text( - _testimonialLink!, - overflow: TextOverflow.ellipsis, - style: const TextStyle( - fontFamily: 'Fractul', - fontSize: 14, - fontWeight: FontWeight.w300, - color: Colors.black, - ), - ), - ), - ), - Container( - width: 1, - height: 43, - color: AppColors.primaryDark.withValues(alpha: 0.1), - ), - GestureDetector( - onTap: () { - Clipboard.setData( - ClipboardData(text: _testimonialLink!)); - setState(() => _linkCopied = true); - Future.delayed(const Duration(seconds: 2), () { - if (mounted) setState(() => _linkCopied = false); - }); - }, - child: Padding( - padding: const EdgeInsets.symmetric(horizontal: 10), - child: Row( - children: [ - Icon( - _linkCopied ? Icons.check : Icons.copy_outlined, - size: 18, - color: Colors.black, - ), - const SizedBox(width: 4), - Text( - _linkCopied ? 'Copied!' : 'Copy', - style: const TextStyle( - fontFamily: 'SourceSerif4', - fontSize: 14, - color: Colors.black, - ), - ), - ], - ), - ), - ), - ], - ), - ), - const SizedBox(height: 30), - - // Reviews header with sort - Row( - children: [ - const Text( - 'Reviews', - style: TextStyle( - fontFamily: 'Fractul', - fontSize: 14, - fontWeight: FontWeight.w500, - color: Colors.black, - ), - ), - const Spacer(), - GestureDetector( - onTap: () { - setState(() { - _testimonialSort = - _testimonialSort == 'latest' ? 'oldest' : 'latest'; - }); - _loadTestimonials(); - }, - child: Row( - children: [ - Text( - 'Sort By : ${_testimonialSort == 'latest' ? 'Latest' : 'Oldest'}', - style: const TextStyle( - fontFamily: 'Fractul', - fontSize: 14, - fontWeight: FontWeight.w500, - color: Colors.black, - ), - ), - const SizedBox(width: 4), - const Icon(Icons.arrow_downward, - size: 14, color: Colors.black), - ], - ), - ), - ], - ), - const SizedBox(height: 16), - - // Reviews list in grey container - 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, horizontal: 16), - decoration: BoxDecoration( - color: const Color(0xFFD9D9D9).withValues(alpha: 0.25), - borderRadius: BorderRadius.circular(7), - ), - 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 - Container( - padding: const EdgeInsets.all(16), - decoration: BoxDecoration( - color: const Color(0xFFD9D9D9).withValues(alpha: 0.25), - borderRadius: BorderRadius.circular(7), - ), - child: Column( - children: _testimonials - .map((t) => _buildTestimonialCard(t)) - .toList(), - ), - ), - ], - ); - } - - 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(7), - ), - child: Column( - crossAxisAlignment: CrossAxisAlignment.start, - children: [ - // Stars - 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 SizedBox(height: 10), - // Review text - Text( - text, - style: const TextStyle( - fontFamily: 'SourceSerif4', - fontSize: 14, - color: Colors.black, - height: 1.5, - ), - ), - const SizedBox(height: 12), - // Author line: "Name , Role · time ago" - RichText( - text: TextSpan( - children: [ - TextSpan( - text: authorName, - style: const TextStyle( - fontFamily: 'Fractul', - fontSize: 14, - fontWeight: FontWeight.w700, - color: AppColors.primaryDark, - ), - ), - if (authorRole.isNotEmpty || createdAt != null) - TextSpan( - text: ' , ${authorRole.isNotEmpty ? authorRole : ''}${createdAt != null ? ' · ${_formatRelativeTime(createdAt)}' : ''}', - style: const TextStyle( - fontFamily: 'Fractul', - fontSize: 14, - fontWeight: FontWeight.w300, - color: AppColors.primaryDark, - ), - ), - ], - ), - ), - ], - ), - ); - } - - // ══════════════════════════════════════════════════════════════════ - // ── 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( - controller: controller, - enabled: enabled, - keyboardType: keyboardType, - 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, - disabledBorder: border, - prefixIcon: prefixIcon != null - ? Icon(prefixIcon, size: 18, color: AppColors.accentOrange) - : null, - prefixIconConstraints: prefixIcon != null - ? const BoxConstraints(minWidth: 40) - : null, - ), - ), - ), - ], - ); - } - - Widget _buildSectionDivider() { - return Divider( - color: AppColors.primaryDark.withValues(alpha: 0.15), - height: 1, - thickness: 1, - ); - } - - Widget _buildNotificationCategory({ - required String title, - required String description, - required String prefKey, - }) { - final prefs = _notificationPrefs[prefKey]!; - return Padding( - padding: const EdgeInsets.only(bottom: 16), - child: Row( - crossAxisAlignment: CrossAxisAlignment.start, - children: [ - Expanded( - child: Column( - crossAxisAlignment: CrossAxisAlignment.start, - children: [ - 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, - )), - ], - ), - ), - const SizedBox(width: 20), - Column( - crossAxisAlignment: CrossAxisAlignment.start, - children: [ - _buildCheckboxRow( - label: 'Email', - value: prefs['email']!, - onChanged: (val) { - setState(() { - _notificationPrefs[prefKey]!['email'] = val ?? false; - }); - }, - ), - const SizedBox(height: 6), - _buildCheckboxRow( - label: 'In-App', - value: prefs['inApp']!, - onChanged: (val) { - setState(() { - _notificationPrefs[prefKey]!['inApp'] = val ?? false; - }); - }, - ), - ], - ), - ], - ), - ); - } - - Widget _buildCheckboxRow({ - required String label, - required bool value, - required ValueChanged onChanged, - }) { - return GestureDetector( - onTap: () => onChanged(!value), - behavior: HitTestBehavior.opaque, - child: Row( - mainAxisSize: MainAxisSize.min, - children: [ - SizedBox( - width: 18, - height: 18, - child: Checkbox( - value: value, - onChanged: onChanged, - activeColor: AppColors.primaryDark, - checkColor: Colors.white, - materialTapTargetSize: MaterialTapTargetSize.shrinkWrap, - visualDensity: VisualDensity.compact, - shape: RoundedRectangleBorder( - borderRadius: BorderRadius.circular(3)), - side: BorderSide( - color: AppColors.primaryDark.withValues(alpha: 0.4), - width: 1, - ), - ), - ), - const SizedBox(width: 4), - Text(label, - style: const TextStyle( - fontFamily: 'SourceSerif4', - fontSize: 14, - fontWeight: FontWeight.w400, - color: AppColors.primaryDark, - )), - ], - ), - ); - } - - Widget _buildFrequencySelector() { - const options = ['instant', 'daily', 'weekly', 'off']; - const labels = ['Instant', 'Daily', 'Weekly', 'Off']; - - return Container( - height: 32, - width: 278, - decoration: BoxDecoration( - borderRadius: BorderRadius.circular(7), - border: Border.all( - color: AppColors.primaryDark.withValues(alpha: 0.15), - width: 1, - ), - ), - child: ClipRRect( - borderRadius: BorderRadius.circular(6), - child: Row( - children: List.generate(options.length * 2 - 1, (index) { - if (index.isOdd) { - return Container( - width: 1, - height: 32, - color: AppColors.primaryDark.withValues(alpha: 0.15), - ); - } - final i = index ~/ 2; - final isSelected = _digestFrequency == options[i]; - return Expanded( - child: GestureDetector( - onTap: () => setState(() => _digestFrequency = options[i]), - child: Center( - child: isSelected - ? Container( - height: 22, - margin: const EdgeInsets.symmetric(horizontal: 4), - decoration: BoxDecoration( - color: AppColors.accentOrange, - borderRadius: BorderRadius.circular(15), - ), - alignment: Alignment.center, - child: Text(labels[i], - style: const TextStyle( - fontFamily: 'SourceSerif4', - fontSize: 14, - fontWeight: FontWeight.w400, - color: AppColors.primaryDark, - )), - ) - : Text(labels[i], - style: const TextStyle( - fontFamily: 'SourceSerif4', - fontSize: 14, - fontWeight: FontWeight.w400, - color: AppColors.primaryDark, - )), - ), - ), - ); - }), - ), - ), - ); - } - - 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: borderColor, width: 1), - ), - child: Column( - crossAxisAlignment: CrossAxisAlignment.start, - children: [ - 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, - ], - ), - ); - } - - 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, - ), - ), - ), - ], - ); - } - - 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, - bool? isSavingOverride, - String saveLabel = 'Save Changes', - }) { - final isSaving = isSavingOverride ?? ref.watch(profileProvider).isSaving; - - return Container( - height: 71, - decoration: BoxDecoration( - borderRadius: BorderRadius.circular(15), - border: Border.all( - color: AppColors.primaryDark.withValues(alpha: 0.1), - width: 1, - ), - ), - child: Row( - mainAxisAlignment: MainAxisAlignment.center, - children: [ - GestureDetector( - onTap: () => context.pop(), - child: Container( - width: 143, - height: 31, - decoration: BoxDecoration( - borderRadius: BorderRadius.circular(7), - border: Border.all( - color: AppColors.primaryDark.withValues(alpha: 0.1), - width: 1, - ), - ), - alignment: Alignment.center, - child: const Text('Cancel', - style: TextStyle( - fontFamily: 'Fractul', - fontSize: 14, - fontWeight: FontWeight.w400, - color: AppColors.primaryDark, - )), - ), - ), - const SizedBox(width: 10), - GestureDetector( - onTap: isSaving ? null : onSave, - child: Container( - width: 143, - height: 31, - decoration: BoxDecoration( - color: AppColors.accentOrange, - borderRadius: BorderRadius.circular(7), - ), - alignment: Alignment.center, - child: isSaving - ? const SizedBox( - width: 16, - height: 16, - child: CircularProgressIndicator( - strokeWidth: 2, color: AppColors.primaryDark), - ) - : Text(saveLabel, - style: const TextStyle( - fontFamily: 'Fractul', - fontSize: 14, - fontWeight: FontWeight.w400, - color: AppColors.primaryDark, - )), - ), - ), - ], - ), - ); - } - - // ── 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 { @@ -2645,27 +185,3 @@ class _TabItem { final IconData icon; const _TabItem({required this.label, required this.icon}); } - -/// Custom painter for the "Best value" ribbon triangle (matches Figma SVG). -/// Ribbon painter matching Figma SVG: M28.5 0H0L62 134V73.99L28.5 0Z -/// Shape: a pennant/flag anchored top-right of the plan card. -class _RibbonPainter extends CustomPainter { - @override - void paint(Canvas canvas, Size size) { - final paint = Paint()..color = AppColors.accentOrange; - // Figma SVG path within 62×134 viewport: - // M28.5,0 → H0 → L62,134 → V73.99 → L28.5,0 → Z - final w = size.width; // 62 - final h = size.height; // 134 - final path = Path() - ..moveTo(w * (28.5 / 62), 0) // (28.5, 0) top point of ribbon - ..lineTo(0, 0) // (0, 0) top-left corner - ..lineTo(w, h) // (62, 134) bottom-right tip - ..lineTo(w, h * (73.99 / 134)) // (62, ~74) right edge notch - ..close(); - canvas.drawPath(path, paint); - } - - @override - bool shouldRepaint(covariant CustomPainter oldDelegate) => false; -} diff --git a/lib/features/profile/presentation/widgets/billing_tab.dart b/lib/features/profile/presentation/widgets/billing_tab.dart new file mode 100644 index 0000000..a9c53a7 --- /dev/null +++ b/lib/features/profile/presentation/widgets/billing_tab.dart @@ -0,0 +1,682 @@ +import 'package:flutter/material.dart'; +import 'package:flutter_riverpod/flutter_riverpod.dart'; +import 'package:flutter_svg/flutter_svg.dart'; +import 'package:go_router/go_router.dart'; +import 'package:intl/intl.dart'; +import 'package:url_launcher/url_launcher.dart'; +import 'package:real_estate_mobile/core/constants/app_colors.dart'; +import 'package:real_estate_mobile/features/profile/presentation/providers/profile_provider.dart'; +import 'package:real_estate_mobile/features/profile/presentation/screens/stripe_checkout_screen.dart'; + +class BillingTab extends ConsumerStatefulWidget { + const BillingTab({super.key}); + + @override + ConsumerState createState() => _BillingTabState(); +} + +class _BillingTabState extends ConsumerState { + Map? _subscription; + bool _isLoadingSubscription = false; + + @override + void initState() { + super.initState(); + _loadSubscription(); + } + + 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); + } + } + + @override + Widget build(BuildContext context) { + 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 Center( + child: Text( + 'Subscription & Payments', + style: TextStyle( + fontFamily: 'Fractul', + fontSize: 25, + fontWeight: FontWeight.w700, + color: AppColors.primaryDark, + ), + ), + ), + const SizedBox(height: 10), + const Center( + child: Text( + 'Manage Your billing cycles , upgrade your\nplan , or boost individual listings', + textAlign: TextAlign.center, + style: TextStyle( + fontFamily: 'SourceSerif4', + fontSize: 14, + fontWeight: FontWeight.w400, + color: AppColors.primaryDark, + ), + ), + ), + const SizedBox(height: 24), + + if (hasActive) ...[ + const Text( + 'Current Plan', + style: TextStyle( + fontFamily: 'Fractul', + fontSize: 20, + fontWeight: FontWeight.w700, + color: AppColors.primaryDark, + ), + ), + const SizedBox(height: 16), + _buildActiveSubscriptionCard(plan), + ] else ...[ + const Text( + 'Annual Membership Plans', + style: TextStyle( + fontFamily: 'Fractul', + fontSize: 20, + fontWeight: FontWeight.w700, + color: AppColors.primaryDark, + ), + ), + const SizedBox(height: 16), + _buildPlanCard(), + ], + + const SizedBox(height: 24), + _buildNeedHelpSection(), + ], + ); + } + + Widget _buildActiveSubscriptionCard(Map? plan) { + return Container( + padding: const EdgeInsets.all(20), + decoration: BoxDecoration( + borderRadius: BorderRadius.circular(10), + border: Border.all( + color: AppColors.primaryDark.withValues(alpha: 0.5), + width: 0.5, + ), + ), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Container( + padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 10), + decoration: BoxDecoration( + color: AppColors.accentOrange, + borderRadius: BorderRadius.circular(15), + ), + child: Text( + plan?['name'] as String? ?? 'Annual Subscription', + style: const TextStyle( + fontFamily: 'SourceSerif4', + fontSize: 14, + color: Colors.white, + ), + ), + ), + const SizedBox(height: 20), + Row( + children: [ + const Icon(Icons.check_circle, + color: Color(0xFF4CAF50), size: 20), + const SizedBox(width: 8), + const Text( + 'Active', + style: TextStyle( + fontFamily: 'Fractul', + fontSize: 14, + fontWeight: FontWeight.w600, + color: Color(0xFF4CAF50), + ), + ), + const Spacer(), + if (_subscription?['currentPeriodEnd'] != null) + Text( + 'Renews ${_formatDate(_subscription!['currentPeriodEnd'] as String)}', + style: TextStyle( + fontFamily: 'SourceSerif4', + fontSize: 13, + color: AppColors.primaryDark.withValues(alpha: 0.6), + ), + ), + ], + ), + if (plan?['amount'] != null) ...[ + const SizedBox(height: 16), + RichText( + text: TextSpan( + children: [ + TextSpan( + text: '\$${plan!['amount']}', + style: const TextStyle( + fontFamily: 'Fractul', + fontSize: 35, + fontWeight: FontWeight.w700, + color: AppColors.primaryDark, + ), + ), + const TextSpan( + text: ' / ', + style: TextStyle( + fontFamily: 'Fractul', + fontSize: 20, + fontWeight: FontWeight.w700, + color: AppColors.primaryDark, + ), + ), + TextSpan( + text: plan['interval'] as String? ?? 'Year', + style: TextStyle( + fontFamily: 'Fractul', + fontSize: 20, + fontWeight: FontWeight.w700, + color: AppColors.primaryDark.withValues(alpha: 0.5), + ), + ), + ], + ), + ), + ], + const SizedBox(height: 20), + SizedBox( + width: 155, + child: ElevatedButton( + onPressed: _openBillingPortal, + style: ElevatedButton.styleFrom( + backgroundColor: AppColors.accentOrange, + padding: const EdgeInsets.symmetric(vertical: 12), + shape: RoundedRectangleBorder( + borderRadius: BorderRadius.circular(15)), + ), + child: const Text('Manage Subscription', + style: TextStyle( + fontFamily: 'Fractul', + fontSize: 13, + fontWeight: FontWeight.w700, + color: Colors.white)), + ), + ), + const SizedBox(height: 24), + const Text( + 'Include Features', + style: TextStyle( + fontFamily: 'Fractul', + fontSize: 20, + fontWeight: FontWeight.w700, + color: AppColors.primaryDark, + ), + ), + const SizedBox(height: 16), + ..._buildPlanFeatures(), + const SizedBox(height: 16), + Center( + child: TextButton( + onPressed: _cancelSubscription, + style: TextButton.styleFrom(foregroundColor: Colors.red), + child: const Text('Cancel Subscription', + style: TextStyle( + fontFamily: 'Fractul', + fontSize: 14, + fontWeight: FontWeight.w500)), + ), + ), + ], + ), + ); + } + + Widget _buildPlanCard() { + return Stack( + clipBehavior: Clip.none, + children: [ + Container( + padding: const EdgeInsets.fromLTRB(20, 20, 20, 20), + decoration: BoxDecoration( + borderRadius: BorderRadius.circular(10), + border: Border.all( + color: AppColors.primaryDark.withValues(alpha: 0.5), + width: 0.5, + ), + ), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Container( + padding: + const EdgeInsets.symmetric(horizontal: 16, vertical: 10), + decoration: BoxDecoration( + color: AppColors.accentOrange, + borderRadius: BorderRadius.circular(15), + ), + child: const Text( + 'Annual Subscription', + style: TextStyle( + fontFamily: 'SourceSerif4', + fontSize: 14, + color: Colors.white, + ), + ), + ), + const SizedBox(height: 24), + const Text( + 'Professional Annual Plan', + style: TextStyle( + fontFamily: 'Fractul', + fontSize: 20, + fontWeight: FontWeight.w700, + color: AppColors.primaryDark, + ), + ), + const SizedBox(height: 8), + const Text( + 'Complete solutions for real estate\nagents and lenders to grow their\nbusiness.', + style: TextStyle( + fontFamily: 'SourceSerif4', + fontSize: 14, + color: AppColors.primaryDark, + ), + ), + const SizedBox(height: 20), + RichText( + text: TextSpan( + children: [ + const TextSpan( + text: '\$499', + style: TextStyle( + fontFamily: 'Fractul', + fontSize: 35, + fontWeight: FontWeight.w700, + color: Colors.black, + ), + ), + const TextSpan( + text: ' / ', + style: TextStyle( + fontFamily: 'Fractul', + fontSize: 20, + fontWeight: FontWeight.w700, + color: Colors.black, + ), + ), + TextSpan( + text: 'Year', + style: TextStyle( + fontFamily: 'Fractul', + fontSize: 20, + fontWeight: FontWeight.w700, + color: Colors.black.withValues(alpha: 0.5), + ), + ), + ], + ), + ), + const SizedBox(height: 20), + Row( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + SizedBox( + width: 155, + height: 43, + child: ElevatedButton( + onPressed: _startCheckout, + style: ElevatedButton.styleFrom( + backgroundColor: AppColors.accentOrange, + side: const BorderSide(color: AppColors.accentOrange), + padding: EdgeInsets.zero, + shape: RoundedRectangleBorder( + borderRadius: BorderRadius.circular(15)), + ), + child: const Text('Get Premium Access', + style: TextStyle( + fontFamily: 'Fractul', + fontSize: 14, + fontWeight: FontWeight.w700, + color: Colors.white)), + ), + ), + const Spacer(), + ClipRRect( + borderRadius: BorderRadius.circular(70), + child: Image.asset( + 'assets/icons/wallet_illustration.jpg', + width: 120, + height: 120, + fit: BoxFit.cover, + ), + ), + ], + ), + const SizedBox(height: 16), + const Text( + 'Include Features', + style: TextStyle( + fontFamily: 'Fractul', + fontSize: 20, + fontWeight: FontWeight.w700, + color: AppColors.primaryDark, + ), + ), + const SizedBox(height: 16), + ..._buildPlanFeatures(), + ], + ), + ), + Positioned( + top: 0, + right: 0, + child: ClipRRect( + borderRadius: const BorderRadius.only( + topRight: Radius.circular(10), + ), + child: SizedBox( + width: 62, + height: 134, + child: Stack( + children: [ + CustomPaint( + size: const Size(62, 134), + painter: _RibbonPainter(), + ), + // White dot at top of ribbon + Positioned( + top: 5, + left: 10, + child: Container( + width: 14, + height: 14, + decoration: const BoxDecoration( + color: Colors.white, + shape: BoxShape.circle, + ), + ), + ), + // "Best value" text centered on the diagonal + Positioned.fill( + child: Center( + child: Transform.rotate( + angle: 1.17, // ~67 degrees + child: const Text( + 'Best value', + textAlign: TextAlign.center, + style: TextStyle( + fontFamily: 'Fractul', + fontSize: 14, + color: Colors.white, + ), + ), + ), + ), + ), + ], + ), + ), + ), + ), + ], + ); + } + + Widget _buildNeedHelpSection() { + return Container( + clipBehavior: Clip.antiAlias, + padding: const EdgeInsets.fromLTRB(20, 30, 20, 0), + decoration: BoxDecoration( + borderRadius: BorderRadius.circular(15), + border: Border.all( + color: AppColors.primaryDark.withValues(alpha: 0.7), + width: 0.7, + ), + ), + child: Column( + children: [ + const Text( + 'Need Help ?', + style: TextStyle( + fontFamily: 'Fractul', + fontSize: 20, + fontWeight: FontWeight.w700, + color: AppColors.primaryDark, + ), + ), + const SizedBox(height: 10), + const Text( + 'Our billing experts are here to help you\nwith any questions\n about your plan.', + textAlign: TextAlign.center, + style: TextStyle( + fontFamily: 'SourceSerif4', + fontSize: 14, + color: AppColors.primaryDark, + ), + ), + const SizedBox(height: 16), + GestureDetector( + onTap: () async { + final uri = Uri.parse('mailto:support@re-quest.co'); + if (await canLaunchUrl(uri)) { + await launchUrl(uri); + } + }, + child: Row( + mainAxisAlignment: MainAxisAlignment.center, + children: [ + Text( + 'support@example.com', + style: TextStyle( + fontFamily: 'SourceSerif4', + fontSize: 14, + fontWeight: FontWeight.w700, + color: AppColors.accentOrange, + decoration: TextDecoration.underline, + decorationColor: AppColors.accentOrange, + ), + ), + const SizedBox(width: 4), + Transform.rotate( + angle: 0.72, + child: const Icon(Icons.arrow_upward, + size: 16, color: AppColors.accentOrange), + ), + ], + ), + ), + const SizedBox(height: 20), + ClipRRect( + borderRadius: BorderRadius.circular(57), + child: Image.asset( + 'assets/icons/need_help_illustration.jpg', + width: 261, + height: 261, + fit: BoxFit.cover, + ), + ), + ], + ), + ); + } + + List _buildPlanFeatures() { + const features = [ + ('Agent -Co-Marketing', true), + ('Priority 24/7 Support', false), + ('Whitelabel Client Portals', false), + ('Advanced CRM Tools & Analytics', false), + ]; + return features + .map((f) => Padding( + padding: const EdgeInsets.only(bottom: 20), + child: Row( + children: [ + SvgPicture.asset( + f.$2 + ? 'assets/icons/diamond_icon.svg' + : 'assets/icons/feature_check_icon.svg', + width: 18, + height: 18, + ), + const SizedBox(width: 12), + Text(f.$1, + 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 (!mounted) return; + await Navigator.of(context).push?>( + MaterialPageRoute( + builder: (_) => StripeCheckoutScreen( + url: url, + title: 'Manage Subscription', + ), + ), + ); + if (mounted) { + _loadSubscription(); + } + } 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 (!mounted) return; + final result = await Navigator.of(context).push?>( + MaterialPageRoute( + builder: (_) => StripeCheckoutScreen( + url: url, + title: 'Checkout', + successUrlPattern: 'confirmation', + ), + ), + ); + if (result != null && result['success'] == true && mounted) { + final sessionId = result['sessionId'] as String?; + context.go('/payment-success${sessionId != null ? '?session_id=$sessionId' : ''}'); + } else if (mounted) { + _loadSubscription(); + } + } 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); + } + } + + String _formatDate(String dateStr) { + try { + final date = DateTime.parse(dateStr); + return DateFormat('MMM d, y').format(date); + } catch (_) { + return dateStr; + } + } + + void _showSnackBar(String message, {bool isError = false}) { + ScaffoldMessenger.of(context).showSnackBar( + SnackBar( + content: Text(message), + backgroundColor: isError ? Colors.red : const Color(0xFF638559), + ), + ); + } +} + +class _RibbonPainter extends CustomPainter { + @override + void paint(Canvas canvas, Size size) { + final paint = Paint()..color = AppColors.accentOrange; + final w = size.width; + final h = size.height; + final path = Path() + ..moveTo(w * (28.5 / 62), 0) + ..lineTo(0, 0) + ..lineTo(w, h) + ..lineTo(w, h * (73.99 / 134)) + ..close(); + canvas.drawPath(path, paint); + } + + @override + bool shouldRepaint(covariant CustomPainter oldDelegate) => false; +} diff --git a/lib/features/profile/presentation/widgets/change_password_tab.dart b/lib/features/profile/presentation/widgets/change_password_tab.dart new file mode 100644 index 0000000..865dc37 --- /dev/null +++ b/lib/features/profile/presentation/widgets/change_password_tab.dart @@ -0,0 +1,208 @@ +import 'package:flutter/material.dart'; +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/profile/presentation/providers/profile_provider.dart'; +import 'package:real_estate_mobile/features/profile/presentation/widgets/profile_shared_widgets.dart'; + +class ChangePasswordTab extends ConsumerStatefulWidget { + const ChangePasswordTab({super.key}); + + @override + ConsumerState createState() => _ChangePasswordTabState(); +} + +class _ChangePasswordTabState extends ConsumerState { + final _currentPasswordController = TextEditingController(); + final _newPasswordController = TextEditingController(); + final _confirmPasswordController = TextEditingController(); + bool _showCurrentPassword = false; + bool _showNewPassword = false; + bool _showConfirmPassword = false; + bool _isChangingPassword = false; + + @override + void dispose() { + _currentPasswordController.dispose(); + _newPasswordController.dispose(); + _confirmPasswordController.dispose(); + super.dispose(); + } + + @override + Widget build(BuildContext context) { + 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), + ProfileActionButtons( + onSave: _changePassword, + onCancel: () => context.pop(), + isSaving: _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), + 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( + 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); + } + } + + void _showSnackBar(String message, {bool isError = false}) { + ScaffoldMessenger.of(context).showSnackBar( + SnackBar( + content: Text(message), + backgroundColor: isError ? Colors.red : const Color(0xFF638559), + ), + ); + } +} diff --git a/lib/features/profile/presentation/widgets/notifications_tab.dart b/lib/features/profile/presentation/widgets/notifications_tab.dart new file mode 100644 index 0000000..bd50b1a --- /dev/null +++ b/lib/features/profile/presentation/widgets/notifications_tab.dart @@ -0,0 +1,360 @@ +import 'package:flutter/material.dart'; +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/profile/presentation/providers/profile_provider.dart'; +import 'package:real_estate_mobile/features/profile/presentation/widgets/profile_shared_widgets.dart'; + +class NotificationsTab extends ConsumerStatefulWidget { + const NotificationsTab({super.key}); + + @override + ConsumerState createState() => _NotificationsTabState(); +} + +class _NotificationsTabState extends ConsumerState { + final Map> _notificationPrefs = { + 'newLeadAlerts': {'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; + + @override + void initState() { + super.initState(); + _loadNotificationPreferences(); + } + + 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; + }); + } + } + + @override + Widget build(BuildContext context) { + 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), + ProfileActionButtons( + onSave: _saveNotificationSettings, + onCancel: () => context.pop(), + isSaving: _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); + } + } + + Widget _buildSectionDivider() { + return Divider( + color: AppColors.primaryDark.withValues(alpha: 0.15), + height: 1, + thickness: 1, + ); + } + + Widget _buildNotificationCategory({ + required String title, + required String description, + required String prefKey, + }) { + final prefs = _notificationPrefs[prefKey]!; + return Padding( + padding: const EdgeInsets.only(bottom: 16), + child: Row( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Expanded( + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + 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, + )), + ], + ), + ), + const SizedBox(width: 20), + Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + _buildCheckboxRow( + label: 'Email', + value: prefs['email']!, + onChanged: (val) { + setState(() { + _notificationPrefs[prefKey]!['email'] = val ?? false; + }); + }, + ), + const SizedBox(height: 6), + _buildCheckboxRow( + label: 'In-App', + value: prefs['inApp']!, + onChanged: (val) { + setState(() { + _notificationPrefs[prefKey]!['inApp'] = val ?? false; + }); + }, + ), + ], + ), + ], + ), + ); + } + + Widget _buildCheckboxRow({ + required String label, + required bool value, + required ValueChanged onChanged, + }) { + return GestureDetector( + onTap: () => onChanged(!value), + behavior: HitTestBehavior.opaque, + child: Row( + mainAxisSize: MainAxisSize.min, + children: [ + SizedBox( + width: 18, + height: 18, + child: Checkbox( + value: value, + onChanged: onChanged, + activeColor: AppColors.primaryDark, + checkColor: Colors.white, + materialTapTargetSize: MaterialTapTargetSize.shrinkWrap, + visualDensity: VisualDensity.compact, + shape: RoundedRectangleBorder( + borderRadius: BorderRadius.circular(3)), + side: BorderSide( + color: AppColors.primaryDark.withValues(alpha: 0.4), + width: 1, + ), + ), + ), + const SizedBox(width: 4), + Text(label, + style: const TextStyle( + fontFamily: 'SourceSerif4', + fontSize: 14, + fontWeight: FontWeight.w400, + color: AppColors.primaryDark, + )), + ], + ), + ); + } + + Widget _buildFrequencySelector() { + const options = ['instant', 'daily', 'weekly', 'off']; + const labels = ['Instant', 'Daily', 'Weekly', 'Off']; + + return Container( + height: 32, + width: 278, + decoration: BoxDecoration( + borderRadius: BorderRadius.circular(7), + border: Border.all( + color: AppColors.primaryDark.withValues(alpha: 0.15), + width: 1, + ), + ), + child: ClipRRect( + borderRadius: BorderRadius.circular(6), + child: Row( + children: List.generate(options.length * 2 - 1, (index) { + if (index.isOdd) { + return Container( + width: 1, + height: 32, + color: AppColors.primaryDark.withValues(alpha: 0.15), + ); + } + final i = index ~/ 2; + final isSelected = _digestFrequency == options[i]; + return Expanded( + child: GestureDetector( + onTap: () => setState(() => _digestFrequency = options[i]), + child: Center( + child: isSelected + ? Container( + height: 22, + margin: const EdgeInsets.symmetric(horizontal: 4), + decoration: BoxDecoration( + color: AppColors.accentOrange, + borderRadius: BorderRadius.circular(15), + ), + alignment: Alignment.center, + child: Text(labels[i], + style: const TextStyle( + fontFamily: 'SourceSerif4', + fontSize: 14, + fontWeight: FontWeight.w400, + color: AppColors.primaryDark, + )), + ) + : Text(labels[i], + style: const TextStyle( + fontFamily: 'SourceSerif4', + fontSize: 14, + fontWeight: FontWeight.w400, + color: AppColors.primaryDark, + )), + ), + ), + ); + }), + ), + ), + ); + } + + void _showSnackBar(String message, {bool isError = false}) { + ScaffoldMessenger.of(context).showSnackBar( + SnackBar( + content: Text(message), + backgroundColor: isError ? Colors.red : const Color(0xFF638559), + ), + ); + } +} diff --git a/lib/features/profile/presentation/widgets/privacy_tab.dart b/lib/features/profile/presentation/widgets/privacy_tab.dart new file mode 100644 index 0000000..ae1697f --- /dev/null +++ b/lib/features/profile/presentation/widgets/privacy_tab.dart @@ -0,0 +1,301 @@ +import 'package:flutter/material.dart'; +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/profile/presentation/providers/profile_provider.dart'; +import 'package:real_estate_mobile/features/profile/presentation/widgets/profile_shared_widgets.dart'; + +class PrivacyTab extends ConsumerStatefulWidget { + const PrivacyTab({super.key}); + + @override + ConsumerState createState() => _PrivacyTabState(); +} + +class _PrivacyTabState extends ConsumerState { + 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; + + @override + void initState() { + super.initState(); + _loadPrivacyPreferences(); + } + + 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; + } + } + + @override + Widget build(BuildContext context) { + 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: [ + ProfileSectionCard( + title: 'Privacy Settings', + subtitle: 'Control your privacy and visibility preferences', + child: Column( + children: [ + ProfileDropdownRow( + 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)), + ProfileDropdownRow( + 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)), + ProfileDropdownRow( + 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), + ProfileSectionCard( + title: 'Data & Personalization', + subtitle: 'Manage how your data is used', + child: Column( + children: [ + ProfileToggleRow( + 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)), + ProfileToggleRow( + label: 'Personalized Ads', + description: 'See ads tailored to your interests', + value: _personalizedAds, + onChanged: (v) => setState(() => _personalizedAds = v), + ), + const Divider(height: 24, color: Color(0xFFE8E8E8)), + ProfileToggleRow( + label: 'Third-Party Data Sharing', + description: + 'Allow sharing data with third-party partners', + value: _thirdPartySharing, + onChanged: (v) => setState(() => _thirdPartySharing = v), + ), + ], + ), + ), + const SizedBox(height: 20), + ProfileActionButtons( + onSave: _savePrivacyPreferences, + onCancel: () => context.pop(), + isSaving: _isSavingPrivacy), + const SizedBox(height: 24), + ProfileSectionCard( + 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), + ], + ), + ); + } + + 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); + } + } + + void _showSnackBar(String message, {bool isError = false}) { + ScaffoldMessenger.of(context).showSnackBar( + SnackBar( + content: Text(message), + backgroundColor: isError ? Colors.red : const Color(0xFF638559), + ), + ); + } +} diff --git a/lib/features/profile/presentation/widgets/profile_settings_tab.dart b/lib/features/profile/presentation/widgets/profile_settings_tab.dart new file mode 100644 index 0000000..a66c9e5 --- /dev/null +++ b/lib/features/profile/presentation/widgets/profile_settings_tab.dart @@ -0,0 +1,370 @@ +import 'dart:io'; + +import 'package:flutter/material.dart'; +import 'package:flutter_riverpod/flutter_riverpod.dart'; +import 'package:go_router/go_router.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/profile/data/profile_repository.dart'; +import 'package:real_estate_mobile/features/profile/presentation/providers/profile_provider.dart'; +import 'package:real_estate_mobile/features/profile/presentation/widgets/profile_shared_widgets.dart'; + +class ProfileSettingsTab extends ConsumerStatefulWidget { + const ProfileSettingsTab({super.key}); + + @override + ConsumerState createState() => _ProfileSettingsTabState(); +} + +class _ProfileSettingsTabState extends ConsumerState { + late TextEditingController _fullNameController; + late TextEditingController _titleController; + late TextEditingController _emailController; + late TextEditingController _phoneController; + late TextEditingController _locationController; + + bool _controllersInitialized = false; + bool _isUploadingAvatar = false; + File? _localAvatarFile; + + @override + void initState() { + super.initState(); + _fullNameController = TextEditingController(); + _titleController = TextEditingController(); + _emailController = TextEditingController(); + _phoneController = TextEditingController(); + _locationController = TextEditingController(); + } + + @override + void dispose() { + _fullNameController.dispose(); + _titleController.dispose(); + _emailController.dispose(); + _phoneController.dispose(); + _locationController.dispose(); + super.dispose(); + } + + void _initControllersFromProfile(ProfileState profileState) { + if (_controllersInitialized) return; + _controllersInitialized = true; + + final profile = profileState.profile; + final isAgent = profileState.isAgent; + + final firstName = profile['firstName'] as String? ?? ''; + final lastName = profile['lastName'] as String? ?? ''; + _fullNameController.text = '$firstName $lastName'.trim(); + _emailController.text = profile['email'] as String? ?? + (profile['user'] is Map + ? (profile['user'] as Map)['email'] as String? ?? '' + : ''); + _phoneController.text = profile['phone'] as String? ?? ''; + + if (isAgent) { + final agentType = profile['agentType'] as Map?; + _titleController.text = + agentType?['name'] as String? ?? profile['bio'] as String? ?? ''; + final serviceAreas = profile['serviceAreas']; + if (serviceAreas is List && serviceAreas.isNotEmpty) { + _locationController.text = serviceAreas.first.toString(); + } + } else { + _titleController.text = profile['headline'] as String? ?? ''; + final city = profile['city'] as String? ?? ''; + final stateName = profile['state'] as String? ?? ''; + final country = profile['country'] as String? ?? ''; + final parts = [city, stateName, country].where((s) => s.isNotEmpty); + _locationController.text = parts.join(', '); + } + } + + @override + Widget build(BuildContext context) { + final profileState = ref.watch(profileProvider); + + if (!profileState.isLoading && profileState.profile.isNotEmpty) { + _initControllersFromProfile(profileState); + } + + final profile = profileState.profile; + final isAgent = profileState.isAgent; + + String? avatarUrl = profile['avatar'] as String?; + if (avatarUrl == null || avatarUrl.isEmpty) { + final user = profile['user'] as Map?; + avatarUrl = user?['avatar'] as String?; + } + + final descriptionText = isAgent + ? 'This information will be displayed on your public agent page.' + : 'This information will be displayed on your profile.'; + + return ListView( + padding: const EdgeInsets.fromLTRB(31, 10, 31, 30), + children: [ + const Text( + 'Public Profile', + style: TextStyle( + fontFamily: 'Fractul', + fontSize: 15, + fontWeight: FontWeight.w700, + color: AppColors.primaryDark, + ), + ), + const SizedBox(height: 8), + Text( + descriptionText, + style: const TextStyle( + fontFamily: 'SourceSerif4', + fontSize: 14, + fontWeight: FontWeight.w400, + color: AppColors.primaryDark, + ), + ), + const SizedBox(height: 39), + + // Avatar + Center( + child: Column( + children: [ + Container( + width: 71, + height: 69, + decoration: BoxDecoration( + shape: BoxShape.circle, + border: Border.all(color: AppColors.accentOrange, width: 2), + ), + child: ClipOval( + child: _isUploadingAvatar + ? const Center( + child: SizedBox( + width: 24, + height: 24, + child: CircularProgressIndicator( + strokeWidth: 2, + color: AppColors.accentOrange, + ), + ), + ) + : _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), + Row( + mainAxisAlignment: MainAxisAlignment.center, + children: [ + GestureDetector( + onTap: _isUploadingAvatar ? null : _pickAvatar, + child: Container( + width: 95, + height: 24, + decoration: BoxDecoration( + color: AppColors.accentOrange, + borderRadius: BorderRadius.circular(7), + ), + alignment: Alignment.center, + 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: _isUploadingAvatar ? null : _deleteAvatar, + child: Container( + width: 79, + height: 24, + decoration: BoxDecoration( + borderRadius: BorderRadius.circular(7), + border: Border.all( + 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, + )), + ), + ), + ], + ), + ], + ), + ), + const SizedBox(height: 49), + + ProfileFormField(label: 'Full Name', controller: _fullNameController), + const SizedBox(height: 24), + ProfileFormField( + label: isAgent ? 'Career / Agent Type' : 'Professional Title', + controller: _titleController, + enabled: !isAgent, + ), + const SizedBox(height: 24), + ProfileFormField( + label: 'Email Address', + controller: _emailController, + enabled: false), + const SizedBox(height: 24), + ProfileFormField( + label: 'Phone Number', + controller: _phoneController, + keyboardType: TextInputType.phone), + const SizedBox(height: 24), + ProfileFormField( + label: isAgent ? 'Service Areas' : 'Location', + controller: _locationController, + prefixIcon: Icons.location_on, + ), + const SizedBox(height: 30), + ProfileActionButtons( + onSave: _saveProfileSettings, + onCancel: () => context.pop(), + isSaving: profileState.isSaving, + ), + ], + ); + } + + 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); + } + + void _showSnackBar(String message, {bool isError = false}) { + ScaffoldMessenger.of(context).showSnackBar( + SnackBar( + content: Text(message), + backgroundColor: isError ? Colors.red : const Color(0xFF638559), + ), + ); + } +} diff --git a/lib/features/profile/presentation/widgets/profile_shared_widgets.dart b/lib/features/profile/presentation/widgets/profile_shared_widgets.dart new file mode 100644 index 0000000..6d4fc10 --- /dev/null +++ b/lib/features/profile/presentation/widgets/profile_shared_widgets.dart @@ -0,0 +1,348 @@ +import 'package:flutter/material.dart'; +import 'package:real_estate_mobile/core/constants/app_colors.dart'; + +class ProfileFormField extends StatelessWidget { + final String label; + final TextEditingController controller; + final bool enabled; + final TextInputType? keyboardType; + final IconData? prefixIcon; + + const ProfileFormField({ + super.key, + required this.label, + required this.controller, + this.enabled = true, + this.keyboardType, + this.prefixIcon, + }); + + @override + Widget build(BuildContext context) { + 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( + controller: controller, + enabled: enabled, + keyboardType: keyboardType, + 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, + disabledBorder: border, + prefixIcon: prefixIcon != null + ? Icon(prefixIcon, size: 18, color: AppColors.accentOrange) + : null, + prefixIconConstraints: prefixIcon != null + ? const BoxConstraints(minWidth: 40) + : null, + ), + ), + ), + ], + ); + } +} + +class ProfileActionButtons extends StatelessWidget { + final VoidCallback onSave; + final VoidCallback onCancel; + final bool isSaving; + final String saveLabel; + + const ProfileActionButtons({ + super.key, + required this.onSave, + required this.onCancel, + this.isSaving = false, + this.saveLabel = 'Save Changes', + }); + + @override + Widget build(BuildContext context) { + return Container( + height: 71, + decoration: BoxDecoration( + borderRadius: BorderRadius.circular(15), + border: Border.all( + color: AppColors.primaryDark.withValues(alpha: 0.1), + width: 1, + ), + ), + child: Row( + mainAxisAlignment: MainAxisAlignment.center, + children: [ + GestureDetector( + onTap: onCancel, + child: Container( + width: 143, + height: 31, + decoration: BoxDecoration( + borderRadius: BorderRadius.circular(7), + border: Border.all( + color: AppColors.primaryDark.withValues(alpha: 0.1), + width: 1, + ), + ), + alignment: Alignment.center, + child: const Text('Cancel', + style: TextStyle( + fontFamily: 'Fractul', + fontSize: 14, + fontWeight: FontWeight.w400, + color: AppColors.primaryDark, + )), + ), + ), + const SizedBox(width: 10), + GestureDetector( + onTap: isSaving ? null : onSave, + child: Container( + width: 143, + height: 31, + decoration: BoxDecoration( + color: AppColors.accentOrange, + borderRadius: BorderRadius.circular(7), + ), + alignment: Alignment.center, + child: isSaving + ? const SizedBox( + width: 16, + height: 16, + child: CircularProgressIndicator( + strokeWidth: 2, color: AppColors.primaryDark), + ) + : Text(saveLabel, + style: const TextStyle( + fontFamily: 'Fractul', + fontSize: 14, + fontWeight: FontWeight.w400, + color: AppColors.primaryDark, + )), + ), + ), + ], + ), + ); + } +} + +class ProfileSectionCard extends StatelessWidget { + final String title; + final String subtitle; + final Widget child; + final Color borderColor; + final Color titleColor; + + const ProfileSectionCard({ + super.key, + required this.title, + required this.subtitle, + required this.child, + this.borderColor = const Color(0xFFE8E8E8), + this.titleColor = AppColors.primaryDark, + }); + + @override + Widget build(BuildContext context) { + return Container( + width: double.infinity, + padding: const EdgeInsets.all(20), + decoration: BoxDecoration( + color: Colors.white, + borderRadius: BorderRadius.circular(15), + border: Border.all(color: borderColor, width: 1), + ), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + 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, + ], + ), + ); + } +} + +class ProfileDropdownRow extends StatelessWidget { + final String label; + final String description; + final String value; + final Map options; + final ValueChanged onChanged; + + const ProfileDropdownRow({ + super.key, + required this.label, + required this.description, + required this.value, + required this.options, + required this.onChanged, + }); + + @override + Widget build(BuildContext context) { + 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, + ), + ), + ), + ], + ); + } +} + +class ProfileToggleRow extends StatelessWidget { + final String label; + final String description; + final bool value; + final ValueChanged onChanged; + + const ProfileToggleRow({ + super.key, + required this.label, + required this.description, + required this.value, + required this.onChanged, + }); + + @override + Widget build(BuildContext context) { + 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), + ), + ), + ), + ), + ], + ); + } +} diff --git a/lib/features/profile/presentation/widgets/testimonials_tab.dart b/lib/features/profile/presentation/widgets/testimonials_tab.dart new file mode 100644 index 0000000..f41a8be --- /dev/null +++ b/lib/features/profile/presentation/widgets/testimonials_tab.dart @@ -0,0 +1,391 @@ +import 'package:flutter/material.dart'; +import 'package:flutter/services.dart'; +import 'package:flutter_riverpod/flutter_riverpod.dart'; +import 'package:intl/intl.dart'; +import 'package:real_estate_mobile/core/constants/app_colors.dart'; +import 'package:real_estate_mobile/features/profile/presentation/providers/profile_provider.dart'; + +class TestimonialsTab extends ConsumerStatefulWidget { + const TestimonialsTab({super.key}); + + @override + ConsumerState createState() => _TestimonialsTabState(); +} + +class _TestimonialsTabState extends ConsumerState { + List> _testimonials = []; + bool _isLoadingTestimonials = false; + String _testimonialSort = 'latest'; + String? _testimonialLink; + bool _isGeneratingLink = false; + bool _linkCopied = false; + + @override + void initState() { + super.initState(); + _loadTestimonials(); + } + + 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); + } + } + + @override + Widget build(BuildContext context) { + return ListView( + padding: const EdgeInsets.fromLTRB(24, 10, 24, 30), + children: [ + const Center( + child: Text( + 'Add Testimonials', + style: TextStyle( + fontFamily: 'Fractul', + fontSize: 25, + fontWeight: FontWeight.w700, + color: AppColors.primaryDark, + ), + ), + ), + const SizedBox(height: 10), + const Center( + child: Text( + 'What people are saying about you', + textAlign: TextAlign.center, + style: TextStyle( + fontFamily: 'SourceSerif4', + fontSize: 14, + fontWeight: FontWeight.w400, + color: AppColors.primaryDark, + ), + ), + ), + const SizedBox(height: 20), + + const Center( + child: Text( + 'Create Testimonials', + style: TextStyle( + fontFamily: 'Fractul', + fontSize: 25, + fontWeight: FontWeight.w700, + color: AppColors.primaryDark, + ), + ), + ), + const SizedBox(height: 16), + + SizedBox( + width: double.infinity, + height: 43, + child: ElevatedButton( + onPressed: _isGeneratingLink ? null : _generateTestimonialLink, + style: ElevatedButton.styleFrom( + backgroundColor: AppColors.accentOrange, + padding: const EdgeInsets.symmetric(vertical: 10), + shape: RoundedRectangleBorder( + borderRadius: BorderRadius.circular(15)), + ), + child: _isGeneratingLink + ? const SizedBox( + width: 20, + height: 20, + child: CircularProgressIndicator( + strokeWidth: 2, color: Colors.white)) + : const Text('Generate Link', + style: TextStyle( + fontFamily: 'Fractul', + fontSize: 14, + fontWeight: FontWeight.w700, + color: Colors.white)), + ), + ), + const SizedBox(height: 16), + + if (_testimonialLink != null) + Container( + height: 43, + decoration: BoxDecoration( + borderRadius: BorderRadius.circular(15), + border: Border.all( + color: AppColors.primaryDark.withValues(alpha: 0.1), + ), + ), + child: Row( + children: [ + Expanded( + child: Padding( + padding: const EdgeInsets.symmetric(horizontal: 12), + child: Text( + _testimonialLink!, + overflow: TextOverflow.ellipsis, + style: const TextStyle( + fontFamily: 'Fractul', + fontSize: 14, + fontWeight: FontWeight.w300, + color: Colors.black, + ), + ), + ), + ), + Container( + width: 1, + height: 43, + color: AppColors.primaryDark.withValues(alpha: 0.1), + ), + GestureDetector( + onTap: () { + Clipboard.setData( + ClipboardData(text: _testimonialLink!)); + setState(() => _linkCopied = true); + Future.delayed(const Duration(seconds: 2), () { + if (mounted) setState(() => _linkCopied = false); + }); + }, + child: Padding( + padding: const EdgeInsets.symmetric(horizontal: 10), + child: Row( + children: [ + Icon( + _linkCopied ? Icons.check : Icons.copy_outlined, + size: 18, + color: Colors.black, + ), + const SizedBox(width: 4), + Text( + _linkCopied ? 'Copied!' : 'Copy', + style: const TextStyle( + fontFamily: 'SourceSerif4', + fontSize: 14, + color: Colors.black, + ), + ), + ], + ), + ), + ), + ], + ), + ), + const SizedBox(height: 30), + + Row( + children: [ + const Text( + 'Reviews', + style: TextStyle( + fontFamily: 'Fractul', + fontSize: 14, + fontWeight: FontWeight.w500, + color: Colors.black, + ), + ), + const Spacer(), + GestureDetector( + onTap: () { + setState(() { + _testimonialSort = + _testimonialSort == 'latest' ? 'oldest' : 'latest'; + }); + _loadTestimonials(); + }, + child: Row( + children: [ + Text( + 'Sort By : ${_testimonialSort == 'latest' ? 'Latest' : 'Oldest'}', + style: const TextStyle( + fontFamily: 'Fractul', + fontSize: 14, + fontWeight: FontWeight.w500, + color: Colors.black, + ), + ), + const SizedBox(width: 4), + const Icon(Icons.arrow_downward, + size: 14, color: Colors.black), + ], + ), + ), + ], + ), + const SizedBox(height: 16), + + 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, horizontal: 16), + decoration: BoxDecoration( + color: const Color(0xFFD9D9D9).withValues(alpha: 0.25), + borderRadius: BorderRadius.circular(7), + ), + 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 + Container( + padding: const EdgeInsets.all(16), + decoration: BoxDecoration( + color: const Color(0xFFD9D9D9).withValues(alpha: 0.25), + borderRadius: BorderRadius.circular(7), + ), + child: Column( + children: _testimonials + .map((t) => _buildTestimonialCard(t)) + .toList(), + ), + ), + ], + ); + } + + 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(7), + ), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + 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 SizedBox(height: 10), + Text( + text, + style: const TextStyle( + fontFamily: 'SourceSerif4', + fontSize: 14, + color: Colors.black, + height: 1.5, + ), + ), + const SizedBox(height: 12), + RichText( + text: TextSpan( + children: [ + TextSpan( + text: authorName, + style: const TextStyle( + fontFamily: 'Fractul', + fontSize: 14, + fontWeight: FontWeight.w700, + color: AppColors.primaryDark, + ), + ), + if (authorRole.isNotEmpty || createdAt != null) + TextSpan( + text: ' , ${authorRole.isNotEmpty ? authorRole : ''}${createdAt != null ? ' · ${_formatRelativeTime(createdAt)}' : ''}', + style: const TextStyle( + fontFamily: 'Fractul', + fontSize: 14, + fontWeight: FontWeight.w300, + color: AppColors.primaryDark, + ), + ), + ], + ), + ), + ], + ), + ); + } + + 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 ''; + } + } + + void _showSnackBar(String message, {bool isError = false}) { + ScaffoldMessenger.of(context).showSnackBar( + SnackBar( + content: Text(message), + backgroundColor: isError ? Colors.red : const Color(0xFF638559), + ), + ); + } +}