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

2332 lines
81 KiB
Dart
Raw Normal View History

import 'dart:io';
import 'package:flutter/material.dart';
import 'package:flutter/services.dart';
import 'package:flutter_riverpod/flutter_riverpod.dart';
import 'package:go_router/go_router.dart';
import 'package:image_picker/image_picker.dart';
import 'package:intl/intl.dart';
import 'package:real_estate_mobile/core/constants/app_colors.dart';
import 'package:real_estate_mobile/features/auth/presentation/providers/auth_provider.dart';
import 'package:real_estate_mobile/core/widgets/s3_image.dart';
import 'package:real_estate_mobile/features/profile/data/profile_repository.dart';
import 'package:real_estate_mobile/features/profile/presentation/providers/profile_provider.dart';
import 'package:url_launcher/url_launcher.dart';
class ProfileSettingsScreen extends ConsumerStatefulWidget {
const ProfileSettingsScreen({super.key});
@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: [
const Text(
'Billings & Payments',
style: TextStyle(
fontFamily: 'Fractul',
fontSize: 15,
fontWeight: FontWeight.w700,
color: AppColors.primaryDark,
),
),
const SizedBox(height: 8),
const Text(
'Manage your subscription and billing details.',
style: TextStyle(
fontFamily: 'SourceSerif4',
fontSize: 14,
fontWeight: FontWeight.w400,
color: AppColors.primaryDark,
),
),
const SizedBox(height: 24),
if (hasActive) ...[
// Active subscription card
_buildSectionCard(
title: 'Current Subscription',
subtitle: 'Your active plan details',
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Row(
children: [
const Icon(Icons.check_circle,
color: Color(0xFF4CAF50), size: 20),
const SizedBox(width: 8),
Text(
plan?['name'] as String? ?? 'Premium',
style: const TextStyle(
fontFamily: 'Fractul',
fontSize: 16,
fontWeight: FontWeight.w700,
color: AppColors.primaryDark,
),
),
const Spacer(),
Container(
padding: const EdgeInsets.symmetric(
horizontal: 10, vertical: 4),
decoration: BoxDecoration(
color: const Color(0xFF4CAF50).withValues(alpha: 0.1),
borderRadius: BorderRadius.circular(10),
),
child: const Text('Active',
style: TextStyle(
fontFamily: 'Fractul',
fontSize: 12,
fontWeight: FontWeight.w600,
color: Color(0xFF4CAF50))),
),
],
),
if (plan?['amount'] != null) ...[
const SizedBox(height: 12),
Text(
'\$${plan!['amount']}/${plan['interval'] ?? 'year'}',
style: const TextStyle(
fontFamily: 'Fractul',
fontSize: 22,
fontWeight: FontWeight.w700,
color: AppColors.primaryDark,
),
),
],
if (_subscription?['currentPeriodEnd'] != null) ...[
const SizedBox(height: 8),
Text(
'Renews on ${_formatDate(_subscription!['currentPeriodEnd'] as String)}',
style: TextStyle(
fontFamily: 'SourceSerif4',
fontSize: 13,
color: AppColors.primaryDark.withValues(alpha: 0.6),
),
),
],
const SizedBox(height: 20),
// Plan features
..._buildPlanFeatures(),
const SizedBox(height: 20),
// Manage Subscription
SizedBox(
width: double.infinity,
child: OutlinedButton(
onPressed: _openBillingPortal,
style: OutlinedButton.styleFrom(
foregroundColor: AppColors.primaryDark,
side: BorderSide(
color: AppColors.primaryDark.withValues(alpha: 0.2)),
padding: const EdgeInsets.symmetric(vertical: 14),
shape: RoundedRectangleBorder(
borderRadius: BorderRadius.circular(15)),
),
child: const Text('Manage Subscription',
style: TextStyle(
fontFamily: 'Fractul',
fontSize: 14,
fontWeight: FontWeight.w600)),
),
),
const SizedBox(height: 10),
// Cancel
SizedBox(
width: double.infinity,
child: TextButton(
onPressed: _cancelSubscription,
style: TextButton.styleFrom(foregroundColor: Colors.red),
child: const Text('Cancel Subscription',
style: TextStyle(
fontFamily: 'Fractul',
fontSize: 14,
fontWeight: FontWeight.w500)),
),
),
],
),
),
] else ...[
// No subscription — upgrade card
_buildSectionCard(
title: 'Get Premium Access',
subtitle: 'Unlock all features with our premium plan',
child: Column(
children: [
Container(
width: double.infinity,
padding: const EdgeInsets.all(20),
decoration: BoxDecoration(
color: AppColors.accentOrange.withValues(alpha: 0.05),
borderRadius: BorderRadius.circular(15),
border: Border.all(
color: AppColors.accentOrange.withValues(alpha: 0.3)),
),
child: Column(
children: [
Container(
padding: const EdgeInsets.symmetric(
horizontal: 12, vertical: 4),
decoration: BoxDecoration(
color: AppColors.accentOrange,
borderRadius: BorderRadius.circular(10),
),
child: const Text('Best Value',
style: TextStyle(
fontFamily: 'Fractul',
fontSize: 11,
fontWeight: FontWeight.w700,
color: Colors.white)),
),
const SizedBox(height: 12),
const Text(
'\$499/Year',
style: TextStyle(
fontFamily: 'Fractul',
fontSize: 28,
fontWeight: FontWeight.w700,
color: AppColors.primaryDark,
),
),
const SizedBox(height: 16),
..._buildPlanFeatures(),
],
),
),
const SizedBox(height: 20),
SizedBox(
width: double.infinity,
child: ElevatedButton(
onPressed: _startCheckout,
style: ElevatedButton.styleFrom(
backgroundColor: AppColors.accentOrange,
foregroundColor: AppColors.primaryDark,
padding: const EdgeInsets.symmetric(vertical: 14),
shape: RoundedRectangleBorder(
borderRadius: BorderRadius.circular(15)),
),
child: const Text('Get Premium',
style: TextStyle(
fontFamily: 'Fractul',
fontSize: 16,
fontWeight: FontWeight.w700)),
),
),
],
),
),
],
const SizedBox(height: 24),
// Need help section
Container(
padding: const EdgeInsets.all(16),
decoration: BoxDecoration(
borderRadius: BorderRadius.circular(15),
border: Border.all(
color: AppColors.primaryDark.withValues(alpha: 0.1)),
),
child: Row(
children: [
Icon(Icons.help_outline,
color: AppColors.primaryDark.withValues(alpha: 0.6)),
const SizedBox(width: 12),
const Expanded(
child: Text(
'Need help with billing? Contact support.',
style: TextStyle(
fontFamily: 'SourceSerif4',
fontSize: 13,
color: AppColors.primaryDark,
),
),
),
],
),
),
],
);
}
List<Widget> _buildPlanFeatures() {
const features = [
'Agent Co-Marketing',
'Priority 24/7 Support',
'Whitelabel Client Portals',
'Advanced CRM Tools & Analytics',
];
return features
.map((f) => Padding(
padding: const EdgeInsets.only(bottom: 8),
child: Row(
children: [
const Icon(Icons.check_circle_outline,
size: 18, color: AppColors.accentOrange),
const SizedBox(width: 10),
Text(f,
style: const TextStyle(
fontFamily: 'SourceSerif4',
fontSize: 14,
color: AppColors.primaryDark)),
],
),
))
.toList();
}
Future<void> _openBillingPortal() async {
try {
final repo = ref.read(profileRepositoryProvider);
final url = await repo.createPortalSession();
if (await canLaunchUrl(Uri.parse(url))) {
await launchUrl(Uri.parse(url), mode: LaunchMode.externalApplication);
}
} catch (e) {
if (mounted) _showSnackBar('Failed to open billing portal: $e', isError: true);
}
}
Future<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 (await canLaunchUrl(Uri.parse(url))) {
await launchUrl(Uri.parse(url), mode: LaunchMode.externalApplication);
}
} catch (e) {
if (mounted) _showSnackBar('Failed to start checkout: $e', isError: true);
}
}
Future<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: [
const Text(
'Add Testimonials',
style: TextStyle(
fontFamily: 'Fractul',
fontSize: 15,
fontWeight: FontWeight.w700,
color: AppColors.primaryDark,
),
),
const SizedBox(height: 8),
const Text(
'Generate a link for your clients to submit testimonials. Share it via email or messaging.',
style: TextStyle(
fontFamily: 'SourceSerif4',
fontSize: 14,
fontWeight: FontWeight.w400,
color: AppColors.primaryDark,
),
),
const SizedBox(height: 24),
// Generate link section
_buildSectionCard(
title: 'Testimonial Collection Link',
subtitle: 'Share this link with your clients',
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
if (_testimonialLink != null) ...[
Container(
width: double.infinity,
padding: const EdgeInsets.all(12),
decoration: BoxDecoration(
color: AppColors.primaryDark.withValues(alpha: 0.05),
borderRadius: BorderRadius.circular(10),
border: Border.all(
color: AppColors.primaryDark.withValues(alpha: 0.1)),
),
child: Text(
_testimonialLink!,
style: const TextStyle(
fontFamily: 'SourceSerif4',
fontSize: 13,
color: AppColors.primaryDark,
),
),
),
const SizedBox(height: 12),
SizedBox(
width: double.infinity,
child: OutlinedButton.icon(
onPressed: () {
Clipboard.setData(
ClipboardData(text: _testimonialLink!));
setState(() => _linkCopied = true);
Future.delayed(const Duration(seconds: 2), () {
if (mounted) setState(() => _linkCopied = false);
});
},
icon: Icon(
_linkCopied ? Icons.check : Icons.copy, size: 16),
label: Text(_linkCopied ? 'Copied!' : 'Copy Link'),
style: OutlinedButton.styleFrom(
foregroundColor: AppColors.accentOrange,
side: const BorderSide(color: AppColors.accentOrange),
shape: RoundedRectangleBorder(
borderRadius: BorderRadius.circular(10)),
),
),
),
] else ...[
SizedBox(
width: double.infinity,
child: ElevatedButton(
onPressed: _isGeneratingLink ? null : _generateTestimonialLink,
style: ElevatedButton.styleFrom(
backgroundColor: AppColors.accentOrange,
foregroundColor: AppColors.primaryDark,
padding: const EdgeInsets.symmetric(vertical: 14),
shape: RoundedRectangleBorder(
borderRadius: BorderRadius.circular(15)),
),
child: _isGeneratingLink
? const SizedBox(
width: 20,
height: 20,
child: CircularProgressIndicator(
strokeWidth: 2, color: AppColors.primaryDark))
: const Text('Generate Link',
style: TextStyle(
fontFamily: 'Fractul',
fontSize: 14,
fontWeight: FontWeight.w700)),
),
),
],
],
),
),
const SizedBox(height: 24),
// Testimonials list header
Row(
children: [
const Text(
'Received Testimonials',
style: TextStyle(
fontFamily: 'Fractul',
fontSize: 15,
fontWeight: FontWeight.w700,
color: AppColors.primaryDark,
),
),
const Spacer(),
// Sort toggle
GestureDetector(
onTap: () {
setState(() {
_testimonialSort =
_testimonialSort == 'latest' ? 'oldest' : 'latest';
});
_loadTestimonials();
},
child: Row(
children: [
Icon(Icons.sort,
size: 16,
color: AppColors.primaryDark.withValues(alpha: 0.6)),
const SizedBox(width: 4),
Text(
_testimonialSort == 'latest' ? 'Latest' : 'Oldest',
style: TextStyle(
fontFamily: 'SourceSerif4',
fontSize: 13,
color: AppColors.primaryDark.withValues(alpha: 0.6),
),
),
],
),
),
],
),
const SizedBox(height: 12),
if (_isLoadingTestimonials)
const Center(
child: Padding(
padding: EdgeInsets.all(40),
child: CircularProgressIndicator(color: AppColors.accentOrange),
))
else if (_testimonials.isEmpty)
Container(
padding: const EdgeInsets.symmetric(vertical: 40),
child: Center(
child: Column(
children: [
Icon(Icons.rate_review_outlined,
size: 48,
color: AppColors.primaryDark.withValues(alpha: 0.3)),
const SizedBox(height: 12),
const Text(
'No testimonials yet',
style: TextStyle(
fontFamily: 'Fractul',
fontSize: 16,
fontWeight: FontWeight.w600,
color: AppColors.primaryDark,
),
),
const SizedBox(height: 6),
Text(
'Share your testimonial link with clients to start collecting reviews.',
textAlign: TextAlign.center,
style: TextStyle(
fontFamily: 'SourceSerif4',
fontSize: 14,
color: AppColors.primaryDark.withValues(alpha: 0.6),
),
),
],
),
),
)
else
..._testimonials
.map((t) => _buildTestimonialCard(t)),
],
);
}
Widget _buildTestimonialCard(Map<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(15),
border: Border.all(
color: AppColors.primaryDark.withValues(alpha: 0.1)),
boxShadow: [
BoxShadow(
color: const Color(0xFFD9D9D9).withValues(alpha: 0.3),
offset: const Offset(0, 4),
blurRadius: 10,
),
],
),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
// Stars + date
Row(
children: [
...List.generate(
5,
(i) => Icon(
i < rating ? Icons.star : Icons.star_border,
size: 16,
color: i < rating
? AppColors.accentOrange
: AppColors.primaryDark.withValues(alpha: 0.3),
),
),
const Spacer(),
if (createdAt != null)
Text(
_formatRelativeTime(createdAt),
style: TextStyle(
fontFamily: 'SourceSerif4',
fontSize: 12,
color: AppColors.primaryDark.withValues(alpha: 0.5),
),
),
],
),
const SizedBox(height: 10),
// Text
Text(
text,
style: const TextStyle(
fontFamily: 'SourceSerif4',
fontSize: 14,
color: AppColors.primaryDark,
height: 1.5,
),
),
const SizedBox(height: 12),
// Author
Text(
authorName,
style: const TextStyle(
fontFamily: 'Fractul',
fontSize: 14,
fontWeight: FontWeight.w600,
color: AppColors.primaryDark,
),
),
if (authorRole.isNotEmpty)
Text(
authorRole,
style: TextStyle(
fontFamily: 'SourceSerif4',
fontSize: 13,
color: AppColors.primaryDark.withValues(alpha: 0.6),
),
),
],
),
);
}
// ══════════════════════════════════════════════════════════════════
// ── Shared Widgets ──
// ══════════════════════════════════════════════════════════════════
Widget _buildFormField(
String label,
TextEditingController controller, {
bool enabled = true,
TextInputType? keyboardType,
IconData? prefixIcon,
}) {
final borderSide = BorderSide(
color: AppColors.primaryDark.withValues(alpha: 0.1),
width: 1,
);
final border = OutlineInputBorder(
borderRadius: BorderRadius.circular(7),
borderSide: borderSide,
);
return Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(label,
style: const TextStyle(
fontFamily: 'Fractul',
fontSize: 14,
fontWeight: FontWeight.w400,
color: AppColors.primaryDark,
)),
const SizedBox(height: 14),
SizedBox(
height: 38,
child: TextField(
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});
}