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 createState() => _ProfileSettingsTabState(); } class _ProfileSettingsTabState extends ConsumerState { late TextEditingController _fullNameController; late TextEditingController _titleController; late TextEditingController _emailController; late TextEditingController _phoneController; bool _controllersInitialized = false; bool _isUploadingAvatar = false; File? _localAvatarFile; Map _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?; _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?; 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 _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 _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 _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 = { '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 _openChangeEmailDialog() async { await showModalBottomSheet( context: context, isScrollControlled: true, backgroundColor: Colors.transparent, builder: (sheetCtx) { return _ChangeEmailSheet( onSubmit: (email, password) async { final repo = ref.read(profileRepositoryProvider); return repo.requestEmailChange(email, password); }, onSuccess: (message) { if (mounted) _showSnackBar(message); }, ); }, ); } } /// Bottom-sheet widget for changing email. Owns its own controllers so they /// remain valid throughout the dismiss animation, then disposes cleanly. class _ChangeEmailSheet extends StatefulWidget { final Future Function(String email, String password) onSubmit; final void Function(String message) onSuccess; const _ChangeEmailSheet({ required this.onSubmit, required this.onSuccess, }); @override State<_ChangeEmailSheet> createState() => _ChangeEmailSheetState(); } class _ChangeEmailSheetState extends State<_ChangeEmailSheet> { final _newEmailCtl = TextEditingController(); final _passwordCtl = TextEditingController(); String? _localError; bool _submitting = false; bool _obscurePw = true; @override void dispose() { _newEmailCtl.dispose(); _passwordCtl.dispose(); super.dispose(); } Future _submit() async { final email = _newEmailCtl.text.trim(); final pw = _passwordCtl.text; if (email.isEmpty || pw.isEmpty) { setState(() => _localError = 'Please enter a new email and your current password'); return; } setState(() { _submitting = true; _localError = null; }); try { final message = await widget.onSubmit(email, pw); if (!mounted) return; Navigator.of(context).pop(); widget.onSuccess(message); } catch (e) { if (!mounted) return; setState(() { _submitting = false; _localError = e.toString().replaceFirst('Exception: ', ''); }); } } @override Widget build(BuildContext context) { final bottomInset = MediaQuery.of(context).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), _label('New Email'), const SizedBox(height: 6), TextField( controller: _newEmailCtl, keyboardType: TextInputType.emailAddress, autocorrect: false, enableSuggestions: false, textCapitalization: TextCapitalization.none, autofillHints: const [AutofillHints.email], decoration: _decoration('example@re-quest.com'), ), const SizedBox(height: 14), _label('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. autofillHints: const [], decoration: _decoration( 'Enter your current password', suffixIcon: IconButton( icon: Icon( _obscurePw ? Icons.visibility_off_outlined : Icons.visibility_outlined, color: const Color(0xFF6B7280), size: 20, ), onPressed: () => setState(() => _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(context).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, ), ), ), ), ], ), ], ), ), ), ), ); } Widget _label(String text) => Text( text, style: const TextStyle( fontFamily: 'Fractul', fontSize: 13, fontWeight: FontWeight.w500, color: AppColors.primaryDark, ), ); InputDecoration _decoration(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, ); } }