2026-03-09 08:14:10 +05:30
|
|
|
import 'dart:io';
|
|
|
|
|
|
2026-03-14 15:26:08 +05:30
|
|
|
import 'package:cached_network_image/cached_network_image.dart';
|
2026-03-09 08:14:10 +05:30
|
|
|
import 'package:flutter/material.dart';
|
|
|
|
|
import 'package:flutter_riverpod/flutter_riverpod.dart';
|
|
|
|
|
import 'package:go_router/go_router.dart';
|
|
|
|
|
import 'package:image_picker/image_picker.dart';
|
|
|
|
|
import 'package:real_estate_mobile/core/constants/app_colors.dart';
|
2026-03-14 15:26:08 +05:30
|
|
|
import 'package:real_estate_mobile/core/utils/image_url_resolver.dart';
|
2026-03-09 08:14:10 +05:30
|
|
|
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;
|
|
|
|
|
late TextEditingController _locationController;
|
|
|
|
|
|
|
|
|
|
bool _controllersInitialized = false;
|
|
|
|
|
bool _isUploadingAvatar = false;
|
|
|
|
|
File? _localAvatarFile;
|
|
|
|
|
|
|
|
|
|
@override
|
|
|
|
|
void initState() {
|
|
|
|
|
super.initState();
|
|
|
|
|
_fullNameController = TextEditingController();
|
|
|
|
|
_titleController = TextEditingController();
|
|
|
|
|
_emailController = TextEditingController();
|
|
|
|
|
_phoneController = TextEditingController();
|
|
|
|
|
_locationController = TextEditingController();
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
@override
|
|
|
|
|
void dispose() {
|
|
|
|
|
_fullNameController.dispose();
|
|
|
|
|
_titleController.dispose();
|
|
|
|
|
_emailController.dispose();
|
|
|
|
|
_phoneController.dispose();
|
|
|
|
|
_locationController.dispose();
|
|
|
|
|
super.dispose();
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
void _initControllersFromProfile(ProfileState profileState) {
|
|
|
|
|
if (_controllersInitialized) return;
|
|
|
|
|
_controllersInitialized = true;
|
|
|
|
|
|
|
|
|
|
final profile = profileState.profile;
|
|
|
|
|
final isAgent = profileState.isAgent;
|
|
|
|
|
|
|
|
|
|
final firstName = profile['firstName'] as String? ?? '';
|
|
|
|
|
final lastName = profile['lastName'] as String? ?? '';
|
|
|
|
|
_fullNameController.text = '$firstName $lastName'.trim();
|
|
|
|
|
_emailController.text = profile['email'] as String? ??
|
|
|
|
|
(profile['user'] is Map
|
|
|
|
|
? (profile['user'] as Map)['email'] as String? ?? ''
|
|
|
|
|
: '');
|
|
|
|
|
_phoneController.text = profile['phone'] as String? ?? '';
|
|
|
|
|
|
|
|
|
|
if (isAgent) {
|
|
|
|
|
final agentType = profile['agentType'] as Map<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(', ');
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
@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?;
|
|
|
|
|
}
|
|
|
|
|
|
2026-03-14 15:26:08 +05:30
|
|
|
// 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);
|
|
|
|
|
}
|
|
|
|
|
|
2026-03-09 08:14:10 +05:30
|
|
|
final descriptionText = isAgent
|
|
|
|
|
? 'This information will be displayed on your public agent page.'
|
|
|
|
|
: 'This information will be displayed on your profile.';
|
|
|
|
|
|
|
|
|
|
return ListView(
|
|
|
|
|
padding: const EdgeInsets.fromLTRB(31, 10, 31, 30),
|
|
|
|
|
children: [
|
|
|
|
|
const Text(
|
|
|
|
|
'Public Profile',
|
|
|
|
|
style: TextStyle(
|
|
|
|
|
fontFamily: 'Fractul',
|
|
|
|
|
fontSize: 15,
|
|
|
|
|
fontWeight: FontWeight.w700,
|
|
|
|
|
color: AppColors.primaryDark,
|
|
|
|
|
),
|
|
|
|
|
),
|
|
|
|
|
const SizedBox(height: 8),
|
|
|
|
|
Text(
|
|
|
|
|
descriptionText,
|
|
|
|
|
style: const TextStyle(
|
|
|
|
|
fontFamily: 'SourceSerif4',
|
|
|
|
|
fontSize: 14,
|
|
|
|
|
fontWeight: FontWeight.w400,
|
|
|
|
|
color: AppColors.primaryDark,
|
|
|
|
|
),
|
|
|
|
|
),
|
|
|
|
|
const SizedBox(height: 39),
|
|
|
|
|
|
|
|
|
|
// Avatar
|
|
|
|
|
Center(
|
|
|
|
|
child: Column(
|
|
|
|
|
children: [
|
|
|
|
|
Container(
|
|
|
|
|
width: 71,
|
|
|
|
|
height: 69,
|
|
|
|
|
decoration: BoxDecoration(
|
|
|
|
|
shape: BoxShape.circle,
|
|
|
|
|
border: Border.all(color: AppColors.accentOrange, width: 2),
|
|
|
|
|
),
|
|
|
|
|
child: ClipOval(
|
|
|
|
|
child: _isUploadingAvatar
|
|
|
|
|
? const Center(
|
|
|
|
|
child: SizedBox(
|
|
|
|
|
width: 24,
|
|
|
|
|
height: 24,
|
|
|
|
|
child: CircularProgressIndicator(
|
|
|
|
|
strokeWidth: 2,
|
|
|
|
|
color: AppColors.accentOrange,
|
|
|
|
|
),
|
|
|
|
|
),
|
|
|
|
|
)
|
|
|
|
|
: _localAvatarFile != null
|
|
|
|
|
? Image.file(_localAvatarFile!,
|
|
|
|
|
width: 71, height: 69, fit: BoxFit.cover)
|
|
|
|
|
: avatarUrl != null && avatarUrl.isNotEmpty
|
|
|
|
|
? S3Image(
|
|
|
|
|
imageUrl: avatarUrl,
|
|
|
|
|
width: 71,
|
|
|
|
|
height: 69,
|
|
|
|
|
fit: BoxFit.cover,
|
|
|
|
|
)
|
|
|
|
|
: const Icon(Icons.person,
|
|
|
|
|
size: 36, color: AppColors.primaryDark),
|
|
|
|
|
),
|
|
|
|
|
),
|
|
|
|
|
const SizedBox(height: 10),
|
|
|
|
|
Row(
|
|
|
|
|
mainAxisAlignment: MainAxisAlignment.center,
|
|
|
|
|
children: [
|
|
|
|
|
GestureDetector(
|
|
|
|
|
onTap: _isUploadingAvatar ? null : _pickAvatar,
|
|
|
|
|
child: Container(
|
|
|
|
|
width: 95,
|
|
|
|
|
height: 24,
|
|
|
|
|
decoration: BoxDecoration(
|
|
|
|
|
color: AppColors.accentOrange,
|
|
|
|
|
borderRadius: BorderRadius.circular(7),
|
|
|
|
|
),
|
|
|
|
|
alignment: Alignment.center,
|
|
|
|
|
child: _isUploadingAvatar
|
|
|
|
|
? const SizedBox(
|
|
|
|
|
width: 14,
|
|
|
|
|
height: 14,
|
|
|
|
|
child: CircularProgressIndicator(
|
|
|
|
|
strokeWidth: 2,
|
|
|
|
|
color: AppColors.primaryDark,
|
|
|
|
|
),
|
|
|
|
|
)
|
|
|
|
|
: const Text('Upload Now',
|
|
|
|
|
style: TextStyle(
|
|
|
|
|
fontFamily: 'SourceSerif4',
|
|
|
|
|
fontSize: 11,
|
|
|
|
|
fontWeight: FontWeight.w400,
|
|
|
|
|
color: AppColors.primaryDark,
|
|
|
|
|
)),
|
|
|
|
|
),
|
|
|
|
|
),
|
|
|
|
|
const SizedBox(width: 6),
|
|
|
|
|
GestureDetector(
|
|
|
|
|
onTap: _isUploadingAvatar ? null : _deleteAvatar,
|
|
|
|
|
child: Container(
|
|
|
|
|
width: 79,
|
|
|
|
|
height: 24,
|
|
|
|
|
decoration: BoxDecoration(
|
|
|
|
|
borderRadius: BorderRadius.circular(7),
|
|
|
|
|
border: Border.all(
|
|
|
|
|
color: AppColors.primaryDark, width: 0.1),
|
|
|
|
|
),
|
|
|
|
|
alignment: Alignment.center,
|
|
|
|
|
child: const Text('Delete',
|
|
|
|
|
style: TextStyle(
|
|
|
|
|
fontFamily: 'SourceSerif4',
|
|
|
|
|
fontSize: 11,
|
|
|
|
|
fontWeight: FontWeight.w400,
|
|
|
|
|
color: AppColors.primaryDark,
|
|
|
|
|
)),
|
|
|
|
|
),
|
|
|
|
|
),
|
|
|
|
|
],
|
|
|
|
|
),
|
|
|
|
|
],
|
|
|
|
|
),
|
|
|
|
|
),
|
|
|
|
|
const SizedBox(height: 49),
|
|
|
|
|
|
|
|
|
|
ProfileFormField(label: 'Full Name', controller: _fullNameController),
|
|
|
|
|
const SizedBox(height: 24),
|
|
|
|
|
ProfileFormField(
|
|
|
|
|
label: isAgent ? 'Career / Agent Type' : 'Professional Title',
|
|
|
|
|
controller: _titleController,
|
|
|
|
|
enabled: !isAgent,
|
|
|
|
|
),
|
|
|
|
|
const SizedBox(height: 24),
|
|
|
|
|
ProfileFormField(
|
|
|
|
|
label: 'Email Address',
|
|
|
|
|
controller: _emailController,
|
|
|
|
|
enabled: false),
|
|
|
|
|
const SizedBox(height: 24),
|
|
|
|
|
ProfileFormField(
|
|
|
|
|
label: 'Phone Number',
|
|
|
|
|
controller: _phoneController,
|
|
|
|
|
keyboardType: TextInputType.phone),
|
|
|
|
|
const SizedBox(height: 24),
|
|
|
|
|
ProfileFormField(
|
|
|
|
|
label: isAgent ? 'Service Areas' : 'Location',
|
|
|
|
|
controller: _locationController,
|
|
|
|
|
prefixIcon: Icons.location_on,
|
|
|
|
|
),
|
|
|
|
|
const SizedBox(height: 30),
|
|
|
|
|
ProfileActionButtons(
|
|
|
|
|
onSave: _saveProfileSettings,
|
2026-03-14 17:25:33 +05:30
|
|
|
onCancel: () => context.go('/home'),
|
2026-03-09 08:14:10 +05:30
|
|
|
isSaving: profileState.isSaving,
|
|
|
|
|
),
|
|
|
|
|
],
|
|
|
|
|
);
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
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);
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
void _showSnackBar(String message, {bool isError = false}) {
|
|
|
|
|
ScaffoldMessenger.of(context).showSnackBar(
|
|
|
|
|
SnackBar(
|
|
|
|
|
content: Text(message),
|
|
|
|
|
backgroundColor: isError ? Colors.red : const Color(0xFF638559),
|
|
|
|
|
),
|
|
|
|
|
);
|
|
|
|
|
}
|
|
|
|
|
}
|