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

391 lines
14 KiB
Dart

import 'dart:io';
import 'package:cached_network_image/cached_network_image.dart';
import 'package:flutter/material.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;
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?;
}
// 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),
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,
textCapitalization: TextCapitalization.none),
const SizedBox(height: 24),
ProfileFormField(
label: 'Phone Number',
controller: _phoneController,
keyboardType: TextInputType.phone,
textCapitalization: TextCapitalization.none),
const SizedBox(height: 24),
ProfileFormField(
label: isAgent ? 'Service Areas' : 'Location',
controller: _locationController,
prefixIcon: Icons.location_on,
),
const SizedBox(height: 30),
ProfileActionButtons(
onSave: _saveProfileSettings,
onCancel: _resetForm,
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 _resetForm() {
_controllersInitialized = false;
_localAvatarFile = null;
final profileState = ref.read(profileProvider);
if (profileState.profile.isNotEmpty) {
_initControllersFromProfile(profileState);
}
setState(() {});
}
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),
),
);
}
}