Files
mobile-app/lib/features/profile/presentation/screens/profile_settings_screen.dart

2672 lines
93 KiB
Dart
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
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';
class ProfileSettingsScreen extends ConsumerStatefulWidget {
const ProfileSettingsScreen({super.key});
@override
ConsumerState<ProfileSettingsScreen> createState() =>
_ProfileSettingsScreenState();
}
class _ProfileSettingsScreenState extends ConsumerState<ProfileSettingsScreen> {
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<String, Map<String, bool>> _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<Map<String, dynamic>> _testimonials = [];
bool _isLoadingTestimonials = false;
String _testimonialSort = 'latest';
String? _testimonialLink;
bool _isGeneratingLink = false;
bool _linkCopied = false;
// Billing (agent only)
Map<String, dynamic>? _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<String, dynamic>?;
_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),
_TabItem(label: 'Change Password', icon: Icons.lock_outline),
_TabItem(label: 'Notifications', icon: Icons.notifications_none),
_TabItem(label: 'Privacy', icon: Icons.shield_outlined),
];
if (isAgent) {
tabs.add(
_TabItem(label: 'Billings & Payments', icon: Icons.payment_outlined));
tabs.add(
_TabItem(label: 'Add Testimonials', icon: Icons.rate_review_outlined));
}
return tabs;
}
@override
Widget build(BuildContext context) {
final profileState = ref.watch(profileProvider);
if (!profileState.isLoading && profileState.profile.isNotEmpty) {
_initControllersFromProfile(profileState);
}
ref.listen<ProfileState>(profileProvider, (prev, next) {
if (next.successMessage != null && prev?.successMessage == null) {
ScaffoldMessenger.of(context).showSnackBar(
SnackBar(
content: Text(next.successMessage!),
backgroundColor: const Color(0xFF638559),
),
);
}
if (next.errorMessage != null && prev?.errorMessage == null) {
ScaffoldMessenger.of(context).showSnackBar(
SnackBar(
content: Text(next.errorMessage!),
backgroundColor: Colors.red,
),
);
}
});
if (profileState.isLoading) {
return const Center(
child: CircularProgressIndicator(color: AppColors.accentOrange),
);
}
final tabs = _getTabs(profileState.isAgent);
final totalTabs = tabs.length;
return Column(
children: [
const SizedBox(height: 16),
_buildTabBar(tabs),
_buildProgressBar(totalTabs),
Expanded(child: _buildSelectedTab(profileState, tabs)),
],
);
}
Widget _buildSelectedTab(ProfileState profileState, List<_TabItem> tabs) {
final label = tabs[_selectedTab].label;
switch (label) {
case 'Profile Settings':
return _buildProfileSettingsTab(profileState);
case 'Change Password':
return _buildChangePasswordTab();
case 'Notifications':
return _buildNotificationsTab();
case 'Privacy':
return _buildPrivacyTab();
case 'Billings & Payments':
return _buildBillingTab();
case 'Add Testimonials':
return _buildTestimonialsTab();
default:
return const SizedBox.shrink();
}
}
// ── Tab Bar ──
Widget _buildTabBar(List<_TabItem> tabs) {
return SizedBox(
height: 46,
child: ListView.separated(
scrollDirection: Axis.horizontal,
padding: const EdgeInsets.symmetric(horizontal: 7),
itemCount: tabs.length,
separatorBuilder: (_, _) => const SizedBox(width: 6),
itemBuilder: (_, i) => _buildTab(i, tabs[i], tabs),
),
);
}
Widget _buildTab(int index, _TabItem tab, List<_TabItem> tabs) {
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();
},
child: Container(
width: 162,
height: 46,
decoration: BoxDecoration(
color: isActive ? AppColors.accentOrange : Colors.transparent,
borderRadius: BorderRadius.circular(15),
border: Border.all(
color: isActive
? AppColors.accentOrange
: AppColors.primaryDark.withValues(alpha: 0.2),
width: 0.1,
),
),
child: Row(
mainAxisAlignment: MainAxisAlignment.center,
children: [
Icon(tab.icon,
size: 20,
color: isActive ? Colors.white : AppColors.primaryDark),
const SizedBox(width: 8),
Flexible(
child: Text(
tab.label,
style: TextStyle(
fontFamily: 'SourceSerif4',
fontSize: 13,
fontWeight: FontWeight.w400,
color: isActive ? Colors.white : AppColors.primaryDark,
),
overflow: TextOverflow.ellipsis,
),
),
],
),
),
);
}
Widget _buildProgressBar(int totalTabs) {
final progress = (_selectedTab + 1) / totalTabs;
return Padding(
padding: const EdgeInsets.symmetric(horizontal: 11, vertical: 16),
child: Container(
height: 5,
decoration: BoxDecoration(
color: const Color(0xFFD9D9D9),
borderRadius: BorderRadius.circular(15),
),
child: Align(
alignment: Alignment.centerLeft,
child: FractionallySizedBox(
widthFactor: progress,
child: Container(
decoration: BoxDecoration(
color: AppColors.primaryDark.withValues(alpha: 0.75),
borderRadius: BorderRadius.circular(15),
),
),
),
),
),
);
}
// ══════════════════════════════════════════════════════════════════
// ── 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<String, dynamic>?;
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<void> _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<void> _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<String, dynamic>? ?? {};
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<String, dynamic>?;
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<void> _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<void> _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<String, dynamic>? ?? {};
final dataSettings =
data['dataSettings'] as Map<String, dynamic>? ?? {};
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<void> _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<void> _deleteAccount() async {
final confirmed = await showDialog<bool>(
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<bool>(
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<void> _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<String, dynamic>?;
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<Widget> _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<void> _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<Map<String, dynamic>?>(
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<void> _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<Map<String, dynamic>?>(
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<void> _cancelSubscription() async {
final confirmed = await showDialog<bool>(
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<void> _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<void> _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<String, dynamic> 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<bool?> 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<String, String> options,
required ValueChanged<String?> 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<String>(
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<bool> 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<void> _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<void> _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 = <String, dynamic>{
'firstName': firstName,
'lastName': lastName,
'phone': _phoneController.text.trim(),
};
if (isAgent) {
final location = _locationController.text.trim();
if (location.isNotEmpty) data['serviceAreas'] = [location];
} else {
data['headline'] = _titleController.text.trim();
final locationParts = _locationController.text
.split(',')
.map((s) => s.trim())
.where((s) => s.isNotEmpty)
.toList();
if (locationParts.isNotEmpty) data['city'] = locationParts[0];
if (locationParts.length > 1) data['state'] = locationParts[1];
if (locationParts.length > 2) data['country'] = locationParts[2];
}
ref.read(profileProvider.notifier).updateProfile(data);
}
// ── Helpers ──
void _showSnackBar(String message, {bool isError = false}) {
ScaffoldMessenger.of(context).showSnackBar(
SnackBar(
content: Text(message),
backgroundColor: isError ? Colors.red : const Color(0xFF638559),
),
);
}
String _formatDate(String dateStr) {
try {
final date = DateTime.parse(dateStr);
return DateFormat('MMM d, y').format(date);
} catch (_) {
return dateStr;
}
}
String _formatRelativeTime(String dateStr) {
try {
final date = DateTime.parse(dateStr);
final now = DateTime.now();
final diff = now.difference(date);
if (diff.inDays == 0) return 'Today';
if (diff.inDays == 1) return 'Yesterday';
if (diff.inDays < 30) return '${diff.inDays} days ago';
if (diff.inDays < 365) return '${diff.inDays ~/ 30} months ago';
return DateFormat('MMM d, y').format(date);
} catch (_) {
return '';
}
}
}
class _TabItem {
final String label;
final IconData icon;
const _TabItem({required this.label, required this.icon});
}
/// 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;
}