Files
mobile-app/lib/features/profile/presentation/widgets/profile_settings_tab.dart

691 lines
25 KiB
Dart

import 'dart:io';
import 'package:cached_network_image/cached_network_image.dart';
import 'package:flutter/material.dart';
import 'package:flutter/services.dart';
import 'package:flutter_riverpod/flutter_riverpod.dart';
import 'package:image_picker/image_picker.dart';
import 'package:real_estate_mobile/core/constants/app_colors.dart';
import 'package:real_estate_mobile/core/utils/image_url_resolver.dart';
import 'package:real_estate_mobile/core/widgets/s3_image.dart';
import 'package:real_estate_mobile/features/profile/data/profile_repository.dart';
import 'package:real_estate_mobile/features/profile/presentation/providers/profile_provider.dart';
import 'package:real_estate_mobile/features/profile/presentation/widgets/profile_shared_widgets.dart';
class ProfileSettingsTab extends ConsumerStatefulWidget {
const ProfileSettingsTab({super.key});
@override
ConsumerState<ProfileSettingsTab> createState() => _ProfileSettingsTabState();
}
class _ProfileSettingsTabState extends ConsumerState<ProfileSettingsTab> {
late TextEditingController _fullNameController;
late TextEditingController _titleController;
late TextEditingController _emailController;
late TextEditingController _phoneController;
bool _controllersInitialized = false;
bool _isUploadingAvatar = false;
File? _localAvatarFile;
Map<String, String> _savedValues = {};
@override
void initState() {
super.initState();
_fullNameController = TextEditingController();
_titleController = TextEditingController();
_emailController = TextEditingController();
_phoneController = TextEditingController();
// Listen for changes to enable/disable Cancel & Save buttons
_fullNameController.addListener(_onFieldChanged);
_titleController.addListener(_onFieldChanged);
_phoneController.addListener(_onFieldChanged);
}
void _onFieldChanged() {
setState(() {}); // triggers rebuild so _hasChanges is re-evaluated
}
@override
void dispose() {
_fullNameController.removeListener(_onFieldChanged);
_titleController.removeListener(_onFieldChanged);
_phoneController.removeListener(_onFieldChanged);
_fullNameController.dispose();
_titleController.dispose();
_emailController.dispose();
_phoneController.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? ?? '';
} else {
_titleController.text = profile['headline'] as String? ?? '';
}
_savedValues = {
'fullName': _fullNameController.text,
'title': _titleController.text,
'email': _emailController.text,
'phone': _phoneController.text,
};
}
bool get _hasChanges {
return _fullNameController.text != (_savedValues['fullName'] ?? '') ||
_titleController.text != (_savedValues['title'] ?? '') ||
_phoneController.text != (_savedValues['phone'] ?? '');
}
@override
Widget build(BuildContext context) {
final profileState = ref.watch(profileProvider);
if (!profileState.isLoading && profileState.profile.isNotEmpty) {
_initControllersFromProfile(profileState);
}
final profile = profileState.profile;
final isAgent = profileState.isAgent;
String? avatarUrl = profile['avatar'] as String?;
if (avatarUrl == null || avatarUrl.isEmpty) {
final user = profile['user'] as Map<String, dynamic>?;
avatarUrl = user?['avatar'] as String?;
}
// Evict stale presigned URL cache and disk cache for the avatar
// so S3Image always resolves a fresh presigned URL on profile load.
if (avatarUrl != null && avatarUrl.isNotEmpty) {
ImageUrlResolver.instance.evict(avatarUrl);
CachedNetworkImage.evictFromCache(avatarUrl);
}
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: 20),
// 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: 24),
ProfileFormField(label: 'Full Name', controller: _fullNameController),
if (isAgent) ...[
const SizedBox(height: 24),
ProfileFormField(
label: 'Career / Agent Type',
controller: _titleController,
enabled: false,
),
],
const SizedBox(height: 24),
Row(
crossAxisAlignment: CrossAxisAlignment.end,
children: [
Expanded(
child: ProfileFormField(
label: 'Email Address',
controller: _emailController,
enabled: false,
textCapitalization: TextCapitalization.none),
),
const SizedBox(width: 8),
Padding(
padding: const EdgeInsets.only(bottom: 4),
child: TextButton(
onPressed: _openChangeEmailDialog,
style: TextButton.styleFrom(
foregroundColor: AppColors.accentOrange,
padding: const EdgeInsets.symmetric(horizontal: 12, vertical: 10),
),
child: const Text(
'Change',
style: TextStyle(
fontFamily: 'SourceSerif4',
fontWeight: FontWeight.w600,
fontSize: 13,
),
),
),
),
],
),
const SizedBox(height: 24),
ProfileFormField(
label: 'Phone Number',
controller: _phoneController,
keyboardType: TextInputType.phone,
textCapitalization: TextCapitalization.none,
// Digits only, max 10 (US format) — matches admin panel rule
inputFormatters: [FilteringTextInputFormatter.digitsOnly],
maxLength: 10,
),
const SizedBox(height: 30),
ProfileActionButtons(
onSave: _saveProfileSettings,
onCancel: _resetForm,
isSaving: profileState.isSaving,
hasChanges: _hasChanges,
),
],
);
}
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', 'webp'].contains(ext)) {
if (mounted) _showSnackBar('Please select a JPEG, PNG, or WebP 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 mimeExt = ext == 'jpg' ? 'jpeg' : ext;
final contentType = 'image/$mimeExt';
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 _resetForm() {
if (!_hasChanges) return;
// Revert to last saved values
_fullNameController.text = _savedValues['fullName'] ?? '';
_titleController.text = _savedValues['title'] ?? '';
_phoneController.text = _savedValues['phone'] ?? '';
setState(() {});
}
Future<void> _saveProfileSettings() async {
// Validate phone: must be empty (optional) OR exactly 10 digits
final phone = _phoneController.text.trim();
if (phone.isNotEmpty && phone.length != 10) {
_showSnackBar('Phone number must be exactly 10 digits', isError: true);
throw Exception('Invalid phone number');
}
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': phone,
};
await ref.read(profileProvider.notifier).updateProfile(data);
// Update saved values so Cancel won't revert after successful save
if (mounted) {
setState(() {
_savedValues = {
'fullName': _fullNameController.text,
'title': _titleController.text,
'email': _emailController.text,
'phone': _phoneController.text,
};
});
}
}
void _showSnackBar(String message, {bool isError = false}) {
ScaffoldMessenger.of(context).showSnackBar(
SnackBar(
content: Text(message),
backgroundColor: isError ? Colors.red : const Color(0xFF638559),
),
);
}
Future<void> _openChangeEmailDialog() async {
final newEmailCtl = TextEditingController();
final passwordCtl = TextEditingController();
String? localError;
bool submitting = false;
bool obscurePw = true;
await showModalBottomSheet<void>(
context: context,
isScrollControlled: true,
backgroundColor: Colors.transparent,
builder: (sheetCtx) {
return StatefulBuilder(
builder: (sheetCtx, setSheetState) {
Future<void> submit() async {
final email = newEmailCtl.text.trim();
final pw = passwordCtl.text;
if (email.isEmpty || pw.isEmpty) {
setSheetState(() => localError =
'Please enter a new email and your current password');
return;
}
setSheetState(() {
submitting = true;
localError = null;
});
try {
final repo = ref.read(profileRepositoryProvider);
final message = await repo.requestEmailChange(email, pw);
if (sheetCtx.mounted) Navigator.of(sheetCtx).pop();
if (mounted) _showSnackBar(message);
} catch (e) {
setSheetState(() {
submitting = false;
localError = e.toString().replaceFirst('Exception: ', '');
});
}
}
// Add bottom inset so the sheet rises above the keyboard.
final bottomInset = MediaQuery.of(sheetCtx).viewInsets.bottom;
return Padding(
padding: EdgeInsets.only(bottom: bottomInset),
child: Container(
decoration: const BoxDecoration(
color: Colors.white,
borderRadius: BorderRadius.vertical(
top: Radius.circular(24),
),
),
padding: const EdgeInsets.fromLTRB(24, 12, 24, 24),
child: SafeArea(
top: false,
child: SingleChildScrollView(
child: Column(
mainAxisSize: MainAxisSize.min,
crossAxisAlignment: CrossAxisAlignment.stretch,
children: [
// Drag handle
Center(
child: Container(
width: 40,
height: 4,
margin: const EdgeInsets.only(bottom: 16),
decoration: BoxDecoration(
color: const Color(0xFFE5E7EB),
borderRadius: BorderRadius.circular(2),
),
),
),
const Text(
'Change Email Address',
style: TextStyle(
fontFamily: 'Fractul',
fontSize: 18,
fontWeight: FontWeight.w700,
color: AppColors.primaryDark,
),
),
const SizedBox(height: 8),
const Text(
'A verification link will be sent to your new email. Your email changes only after you click the link.',
style: TextStyle(
fontFamily: 'SourceSerif4',
fontSize: 13,
color: Color(0xFF6B7280),
height: 1.4,
),
),
const SizedBox(height: 20),
_buildSheetLabel('New Email'),
const SizedBox(height: 6),
TextField(
controller: newEmailCtl,
keyboardType: TextInputType.emailAddress,
autocorrect: false,
enableSuggestions: false,
textCapitalization: TextCapitalization.none,
autofillHints: const [AutofillHints.email],
decoration: _sheetInputDecoration(
'new@example.com',
),
),
const SizedBox(height: 14),
_buildSheetLabel('Current Password'),
const SizedBox(height: 6),
TextField(
controller: passwordCtl,
obscureText: obscurePw,
autocorrect: false,
enableSuggestions: false,
// Empty autofillHints prevents iOS from showing the
// "Strong Password" suggestion bar, which was adding
// extra bottom inset.
autofillHints: const <String>[],
decoration: _sheetInputDecoration(
'Enter your current password',
suffixIcon: IconButton(
icon: Icon(
obscurePw
? Icons.visibility_off_outlined
: Icons.visibility_outlined,
color: const Color(0xFF6B7280),
size: 20,
),
onPressed: () => setSheetState(
() => obscurePw = !obscurePw,
),
),
),
),
if (localError != null) ...[
const SizedBox(height: 12),
Text(
localError!,
style: const TextStyle(
fontFamily: 'SourceSerif4',
color: Colors.red,
fontSize: 12,
),
),
],
const SizedBox(height: 24),
Row(
children: [
Expanded(
child: OutlinedButton(
onPressed: submitting
? null
: () => Navigator.of(sheetCtx).pop(),
style: OutlinedButton.styleFrom(
side: const BorderSide(
color: Color(0xFFE5E7EB),
),
padding: const EdgeInsets.symmetric(
vertical: 14,
),
shape: RoundedRectangleBorder(
borderRadius: BorderRadius.circular(12),
),
),
child: const Text(
'Cancel',
style: TextStyle(
fontFamily: 'Fractul',
fontSize: 14,
fontWeight: FontWeight.w600,
color: AppColors.primaryDark,
),
),
),
),
const SizedBox(width: 12),
Expanded(
child: ElevatedButton(
onPressed: submitting ? null : submit,
style: ElevatedButton.styleFrom(
backgroundColor: AppColors.accentOrange,
foregroundColor: Colors.white,
padding: const EdgeInsets.symmetric(
vertical: 14,
),
shape: RoundedRectangleBorder(
borderRadius: BorderRadius.circular(12),
),
elevation: 0,
),
child: Text(
submitting ? 'Sending...' : 'Send Link',
style: const TextStyle(
fontFamily: 'Fractul',
fontSize: 14,
fontWeight: FontWeight.w600,
),
),
),
),
],
),
],
),
),
),
),
);
},
);
},
);
newEmailCtl.dispose();
passwordCtl.dispose();
}
Widget _buildSheetLabel(String text) {
return Text(
text,
style: const TextStyle(
fontFamily: 'Fractul',
fontSize: 13,
fontWeight: FontWeight.w500,
color: AppColors.primaryDark,
),
);
}
InputDecoration _sheetInputDecoration(String hint, {Widget? suffixIcon}) {
return InputDecoration(
hintText: hint,
hintStyle: const TextStyle(
fontFamily: 'SourceSerif4',
fontSize: 14,
color: Color(0xFF9CA3AF),
),
filled: true,
fillColor: const Color(0xFFF9FAFB),
contentPadding:
const EdgeInsets.symmetric(horizontal: 14, vertical: 14),
border: OutlineInputBorder(
borderRadius: BorderRadius.circular(12),
borderSide: const BorderSide(color: Color(0xFFE5E7EB)),
),
enabledBorder: OutlineInputBorder(
borderRadius: BorderRadius.circular(12),
borderSide: const BorderSide(color: Color(0xFFE5E7EB)),
),
focusedBorder: OutlineInputBorder(
borderRadius: BorderRadius.circular(12),
borderSide: const BorderSide(
color: AppColors.accentOrange,
width: 1.5,
),
),
suffixIcon: suffixIcon,
);
}
}