Files
mobile-app/lib/features/agents/presentation/screens/agent_home_screen.dart

1620 lines
55 KiB
Dart
Raw Normal View History

import 'dart:io';
import 'package:flutter/material.dart';
import 'package:flutter_riverpod/flutter_riverpod.dart';
import 'package:flutter_svg/flutter_svg.dart';
import 'package:image_picker/image_picker.dart';
import 'package:real_estate_mobile/core/constants/app_colors.dart';
import 'package:real_estate_mobile/core/widgets/s3_image.dart';
import 'package:real_estate_mobile/features/agents/data/models/agent_profile.dart';
import 'package:real_estate_mobile/features/agents/presentation/providers/agent_detail_provider.dart';
import 'package:real_estate_mobile/features/profile/data/profile_repository.dart';
import 'package:real_estate_mobile/features/home/presentation/screens/home_screen.dart';
import 'package:go_router/go_router.dart';
/// Provider that fetches the agent's own profile ID from /agents/profile/me
final _agentMyProfileProvider =
FutureProvider.autoDispose<String?>((ref) async {
final repo = ProfileRepository();
try {
final data = await repo.getMyProfile('AGENT');
return data['id'] as String?;
} catch (_) {
return null;
}
});
class AgentHomeScreen extends ConsumerWidget {
const AgentHomeScreen({super.key});
@override
Widget build(BuildContext context, WidgetRef ref) {
final myProfileAsync = ref.watch(_agentMyProfileProvider);
return myProfileAsync.when(
loading: () => const Center(
child: CircularProgressIndicator(
color: AppColors.accentOrange,
strokeWidth: 2,
),
),
error: (_, __) => Center(
child: Column(
mainAxisSize: MainAxisSize.min,
children: [
const Icon(Icons.error_outline,
size: 48, color: AppColors.accentOrange),
const SizedBox(height: 16),
const Text('Failed to load profile',
style: TextStyle(
fontFamily: 'Fractul',
fontSize: 16,
fontWeight: FontWeight.w600,
color: AppColors.primaryDark)),
const SizedBox(height: 12),
TextButton(
onPressed: () => ref.invalidate(_agentMyProfileProvider),
child: const Text('Retry',
style: TextStyle(color: AppColors.accentOrange)),
),
],
),
),
data: (agentProfileId) {
if (agentProfileId == null) {
return const Center(
child: Text('No agent profile found',
style: TextStyle(
fontFamily: 'Fractul',
fontSize: 16,
color: AppColors.primaryDark)),
);
}
return _AgentHomeContent(agentProfileId: agentProfileId);
},
);
}
}
class _AgentHomeContent extends ConsumerStatefulWidget {
final String agentProfileId;
const _AgentHomeContent({required this.agentProfileId});
@override
ConsumerState<_AgentHomeContent> createState() => _AgentHomeContentState();
}
class _AgentHomeContentState extends ConsumerState<_AgentHomeContent> {
bool _isUploadingAvatar = false;
bool _isTogglingAvailability = false;
bool _showEmail = false;
bool _showPhone = false;
final Set<String> _expandedTagSections = {};
File? _localAvatarFile;
Future<void> _pickAndUploadAvatar() async {
final picker = ImagePicker();
final picked = await picker.pickImage(
source: ImageSource.gallery,
maxWidth: 1200,
maxHeight: 1200,
imageQuality: 85,
);
if (picked == null) return;
final file = File(picked.path);
final fileName = picked.name;
final ext = fileName.split('.').last.toLowerCase();
// Validate file type
const allowedTypes = ['jpg', 'jpeg', 'png', 'gif'];
if (!allowedTypes.contains(ext)) {
if (mounted) {
ScaffoldMessenger.of(context).showSnackBar(
const SnackBar(content: Text('Only JPG, PNG, and GIF images are allowed')),
);
}
return;
}
// Validate file size (2MB max)
final fileSize = await file.length();
if (fileSize > 2 * 1024 * 1024) {
if (mounted) {
ScaffoldMessenger.of(context).showSnackBar(
const SnackBar(content: Text('Image must be smaller than 2MB')),
);
}
return;
}
final contentType = ext == 'png'
? 'image/png'
: ext == 'gif'
? 'image/gif'
: 'image/jpeg';
setState(() {
_localAvatarFile = file;
_isUploadingAvatar = true;
});
try {
final repo = ProfileRepository();
// 1. Get presigned upload URL
final uploadData = await repo.getAvatarUploadUrl('AGENT', fileName, contentType);
final uploadUrl = uploadData['uploadUrl']!;
final key = uploadData['key']!;
// 2. Upload to S3
final fileBytes = await file.readAsBytes();
await repo.uploadToS3(uploadUrl, fileBytes, contentType);
// 3. Update profile with new avatar key
await repo.updateProfile('AGENT', {'avatar': key});
// 4. Refresh the agent detail provider
ref.invalidate(agentDetailProvider(widget.agentProfileId));
if (mounted) {
ScaffoldMessenger.of(context).showSnackBar(
const SnackBar(content: Text('Photo updated successfully')),
);
}
} catch (e) {
if (mounted) {
ScaffoldMessenger.of(context).showSnackBar(
SnackBar(content: Text('Failed to upload photo: ${e.toString()}')),
);
}
} finally {
if (mounted) {
setState(() {
_isUploadingAvatar = false;
_localAvatarFile = null;
});
}
}
}
Future<void> _toggleAvailability(bool newValue) async {
if (_isTogglingAvailability) return;
setState(() => _isTogglingAvailability = true);
// Optimistically update the UI immediately
ref
.read(agentDetailProvider(widget.agentProfileId).notifier)
.updateAvailability(newValue);
try {
final repo = ProfileRepository();
await repo.updateProfile('AGENT', {'isAvailable': newValue});
} catch (e) {
// Revert on failure
if (mounted) {
ref
.read(agentDetailProvider(widget.agentProfileId).notifier)
.updateAvailability(!newValue);
ScaffoldMessenger.of(context).showSnackBar(
SnackBar(content: Text('Failed to update availability: $e')),
);
}
} finally {
if (mounted) {
setState(() => _isTogglingAvailability = false);
}
}
}
@override
Widget build(BuildContext context) {
final state = ref.watch(agentDetailProvider(widget.agentProfileId));
if (state.isLoading) {
return const Center(
child: CircularProgressIndicator(color: AppColors.accentOrange));
}
if (state.error != null) {
return _buildError(state.error!);
}
return _buildContent(state, ref);
}
Widget _buildError(String error) {
return Center(
child: Column(
mainAxisSize: MainAxisSize.min,
children: [
const Icon(Icons.error_outline,
size: 48, color: AppColors.accentOrange),
const SizedBox(height: 16),
const Text('Unable to Load Profile',
style: TextStyle(
fontFamily: 'Fractul',
fontSize: 18,
fontWeight: FontWeight.w700,
color: AppColors.primaryDark)),
const SizedBox(height: 12),
TextButton(
onPressed: () =>
ref.invalidate(agentDetailProvider(widget.agentProfileId)),
child: const Text('Retry',
style: TextStyle(color: AppColors.accentOrange)),
),
],
),
);
}
Widget _buildContent(AgentDetailState state, WidgetRef ref) {
final agent = state.agent!;
final scrollController = ref.watch(homeScrollControllerProvider);
return SingleChildScrollView(
controller: scrollController,
child: Column(
children: [
const SizedBox(height: 12),
_buildVerificationBanner(agent),
_buildCoverAndAvatar(agent),
const SizedBox(height: 16),
_buildStatusSection(agent),
const SizedBox(height: 12),
_buildContactInfo(state),
const SizedBox(height: 16),
_buildProfileInfo(agent, state),
const SizedBox(height: 24),
_buildExperienceSection(state),
const SizedBox(height: 24),
_buildInfoCards(state),
const SizedBox(height: 24),
_buildSpecializationSection(state),
const SizedBox(height: 24),
_buildTestimonialsSection(state),
const SizedBox(height: 24),
],
),
);
}
// ── Verification status banner ──
Widget _buildVerificationBanner(AgentProfile agent) {
final status = agent.verificationStatus;
if (status == null || status == 'APPROVED') return const SizedBox.shrink();
if (status == 'REJECTED') {
return Padding(
padding: const EdgeInsets.symmetric(horizontal: 22, vertical: 0),
child: Container(
margin: const EdgeInsets.only(bottom: 12),
padding: const EdgeInsets.all(14),
decoration: BoxDecoration(
color: const Color(0xFFFEF2F2),
border: Border.all(color: const Color(0xFFFECACA)),
borderRadius: BorderRadius.circular(12),
),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Row(
children: [
Icon(Icons.warning_amber_rounded, size: 18, color: Colors.red[700]),
const SizedBox(width: 8),
Text('Profile Verification Rejected',
style: TextStyle(fontFamily: 'Fractul', fontSize: 14, fontWeight: FontWeight.w700, color: Colors.red[800])),
],
),
if (agent.verificationNote != null && agent.verificationNote!.isNotEmpty) ...[
const SizedBox(height: 6),
Text('Reason: ${agent.verificationNote}',
style: TextStyle(fontFamily: 'SourceSerif4', fontSize: 13, color: Colors.red[700])),
],
const SizedBox(height: 8),
Text('Please update your profile and documents, then resubmit.',
style: TextStyle(fontFamily: 'SourceSerif4', fontSize: 13, color: Colors.red[600])),
const SizedBox(height: 10),
GestureDetector(
onTap: () => context.push('/agent/edit-profile'),
child: Container(
padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 8),
decoration: BoxDecoration(
color: AppColors.accentOrange,
borderRadius: BorderRadius.circular(8),
),
child: const Text('Update & Resubmit',
style: TextStyle(fontFamily: 'SourceSerif4', fontSize: 13, color: Colors.white, fontWeight: FontWeight.w600)),
),
),
],
),
),
);
}
if (status == 'PENDING_REVIEW') {
return Padding(
padding: const EdgeInsets.symmetric(horizontal: 22),
child: Container(
margin: const EdgeInsets.only(bottom: 12),
padding: const EdgeInsets.all(14),
decoration: BoxDecoration(
color: const Color(0xFFFEFCE8),
border: Border.all(color: const Color(0xFFFDE68A)),
borderRadius: BorderRadius.circular(12),
),
child: Row(
children: [
Container(width: 10, height: 10, decoration: const BoxDecoration(color: Color(0xFFFBBF24), shape: BoxShape.circle)),
const SizedBox(width: 10),
const Expanded(
child: Text('Profile verification is under review. You will be notified once approved.',
style: TextStyle(fontFamily: 'Fractul', fontSize: 13, fontWeight: FontWeight.w700, color: Color(0xFF854D0E))),
),
],
),
),
);
}
if (status == 'NONE') {
return Padding(
padding: const EdgeInsets.symmetric(horizontal: 22),
child: Container(
margin: const EdgeInsets.only(bottom: 12),
padding: const EdgeInsets.all(14),
decoration: BoxDecoration(
color: const Color(0xFFEFF6FF),
border: Border.all(color: const Color(0xFFBFDBFE)),
borderRadius: BorderRadius.circular(12),
),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Row(
children: [
Icon(Icons.info_outline, size: 18, color: Colors.blue[700]),
const SizedBox(width: 8),
const Expanded(
child: Text('Complete your profile and upload verification documents to get verified.',
style: TextStyle(fontFamily: 'Fractul', fontSize: 13, fontWeight: FontWeight.w700, color: Color(0xFF1E40AF))),
),
],
),
const SizedBox(height: 10),
GestureDetector(
onTap: () => context.push('/agent/edit-profile'),
child: Container(
padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 8),
decoration: BoxDecoration(
color: AppColors.accentOrange,
borderRadius: BorderRadius.circular(8),
),
child: const Text('Complete Profile',
style: TextStyle(fontFamily: 'SourceSerif4', fontSize: 13, color: Colors.white, fontWeight: FontWeight.w600)),
),
),
],
),
),
);
}
return const SizedBox.shrink();
}
// ── Agent profile photo as cover image ──
Widget _buildCoverAndAvatar(AgentProfile agent) {
final screenWidth = MediaQuery.of(context).size.width;
final coverWidth = screenWidth * 0.877; // 377/430 from Figma
return Center(
child: Stack(
children: [
// Agent photo as the cover
ClipRRect(
borderRadius: BorderRadius.circular(20),
child: SizedBox(
width: coverWidth,
height: 346,
child: _localAvatarFile != null
? Image.file(
_localAvatarFile!,
width: coverWidth,
height: 346,
fit: BoxFit.cover,
)
: agent.avatarUrl != null && agent.avatarUrl!.isNotEmpty
? S3Image(
imageUrl: agent.avatarUrl!,
width: coverWidth,
height: 346,
fit: BoxFit.cover,
)
: Container(
color: const Color(0xFFC4D9D4),
alignment: Alignment.center,
child: Icon(Icons.person,
size: 80, color: AppColors.primaryDark),
),
),
),
// Loading overlay during upload
if (_isUploadingAvatar)
Positioned.fill(
child: ClipRRect(
borderRadius: BorderRadius.circular(20),
child: Container(
color: Colors.black.withValues(alpha: 0.4),
child: const Center(
child: CircularProgressIndicator(
color: Colors.white,
strokeWidth: 3,
),
),
),
),
),
// Orange edit icon top-right (tabler:edit style)
Positioned(
top: 8,
right: 6,
child: GestureDetector(
behavior: HitTestBehavior.opaque,
onTap: _isUploadingAvatar ? null : _pickAndUploadAvatar,
child: Padding(
padding: const EdgeInsets.all(8),
child: Container(
width: 28,
height: 26,
decoration: BoxDecoration(
color: Colors.white,
borderRadius: BorderRadius.circular(7),
),
alignment: Alignment.center,
child: SvgPicture.asset(
'assets/icons/edit_icon.svg',
width: 20,
height: 20,
),
),
),
),
),
],
),
);
}
// ── Available / Unavailable status toggle (matches web design) ──
Widget _buildStatusSection(AgentProfile agent) {
final isAvailable = agent.isAvailable;
return Padding(
padding: const EdgeInsets.symmetric(horizontal: 24),
child: Column(
children: [
_buildStatusOption(
label: 'Available.',
isActive: isAvailable,
dotColor: const Color(0xFF4CAF50),
activeColor: const Color(0xFFE8F5E9),
activeBorderColor: const Color(0xFF4CAF50),
onTap: isAvailable ? null : () => _toggleAvailability(true),
),
const SizedBox(height: 12),
_buildStatusOption(
label: 'Unavailable.',
isActive: !isAvailable,
dotColor: const Color(0xFF00293D),
activeColor: const Color(0xFFE8EDF2),
activeBorderColor: const Color(0xFF00293D),
onTap: !isAvailable ? null : () => _toggleAvailability(false),
),
],
),
);
}
Widget _buildStatusOption({
required String label,
required bool isActive,
required Color dotColor,
required Color activeColor,
required Color activeBorderColor,
VoidCallback? onTap,
}) {
return GestureDetector(
onTap: _isTogglingAvailability ? null : onTap,
child: AnimatedContainer(
duration: const Duration(milliseconds: 200),
height: 50,
padding: const EdgeInsets.symmetric(horizontal: 20),
decoration: BoxDecoration(
color: isActive ? activeColor : Colors.white,
borderRadius: BorderRadius.circular(15),
border: Border.all(
color: isActive ? activeBorderColor : const Color(0xFFE0E0E0),
width: isActive ? 1.5 : 1,
),
),
child: Row(
children: [
Container(
width: 12,
height: 12,
decoration: BoxDecoration(
shape: BoxShape.circle,
color: isActive ? dotColor : Colors.white,
border: Border.all(
color: isActive ? dotColor : const Color(0xFFD0D0D0),
width: 1.5,
),
),
),
const SizedBox(width: 10),
Text(
label,
style: TextStyle(
fontFamily: 'Fractul',
fontSize: 14,
fontWeight: isActive ? FontWeight.w700 : FontWeight.w500,
color: AppColors.primaryDark,
),
),
const Spacer(),
if (isActive)
Text(
'Active',
style: TextStyle(
fontFamily: 'Fractul',
fontSize: 13,
fontWeight: FontWeight.w500,
color: activeBorderColor,
),
),
],
),
),
);
}
// ── Contact Info ──
Widget _buildContactInfo(AgentDetailState state) {
final email = state.contactEmail ?? '';
final phone = state.contactPhone ?? '';
return Container(
margin: const EdgeInsets.symmetric(horizontal: 24),
padding: const EdgeInsets.symmetric(horizontal: 20, vertical: 18),
decoration: BoxDecoration(
borderRadius: BorderRadius.circular(15),
border: Border.all(color: AppColors.primaryDark, width: 0.1),
),
child: Column(
mainAxisSize: MainAxisSize.min,
children: [
Row(
children: [
const Text('Email:',
style: TextStyle(
fontFamily: 'Fractul',
fontSize: 14,
fontWeight: FontWeight.w700,
color: AppColors.primaryDark)),
const SizedBox(width: 16),
Expanded(
child: Text(
email.isNotEmpty
? (_showEmail ? email : state.maskEmail(email))
: '-',
style: const TextStyle(
fontFamily: 'SourceSerif4',
fontSize: 14,
color: AppColors.primaryDark),
maxLines: 1,
overflow: TextOverflow.ellipsis,
),
),
if (email.isNotEmpty)
GestureDetector(
onTap: () => setState(() => _showEmail = !_showEmail),
behavior: HitTestBehavior.opaque,
child: Padding(
padding: const EdgeInsets.all(4),
child: Icon(
_showEmail
? Icons.visibility_outlined
: Icons.visibility_off_outlined,
size: 18,
color: AppColors.primaryDark,
),
),
),
],
),
const SizedBox(height: 14),
Row(
children: [
const Text('Ph.No:',
style: TextStyle(
fontFamily: 'Fractul',
fontSize: 14,
fontWeight: FontWeight.w700,
color: AppColors.primaryDark)),
const SizedBox(width: 16),
Expanded(
child: Text(
phone.isNotEmpty
? (_showPhone ? phone : state.maskPhone(phone))
: '-',
style: const TextStyle(
fontFamily: 'SourceSerif4',
fontSize: 14,
color: AppColors.primaryDark),
maxLines: 1,
overflow: TextOverflow.ellipsis,
),
),
if (phone.isNotEmpty)
GestureDetector(
onTap: () => setState(() => _showPhone = !_showPhone),
behavior: HitTestBehavior.opaque,
child: Padding(
padding: const EdgeInsets.all(4),
child: Icon(
_showPhone
? Icons.visibility_outlined
: Icons.visibility_off_outlined,
size: 18,
color: AppColors.primaryDark,
),
),
),
],
),
],
),
);
}
// ── Name, verified badge, title, location, member since, bio ──
Widget _buildProfileInfo(AgentProfile agent, AgentDetailState state) {
// Prefer fieldValues-derived location (state.locationParts, with uppercased
// 2-letter state codes); fall back to agent model location when empty.
final locationDisplay = state.locationParts.isNotEmpty
? state.locationParts.join(', ')
: agent.location;
return Padding(
padding: const EdgeInsets.symmetric(horizontal: 24),
child: Column(
crossAxisAlignment: CrossAxisAlignment.center,
children: [
// Name + Verified badge + (Verified Expert) + edit icon
Row(
mainAxisAlignment: MainAxisAlignment.center,
crossAxisAlignment: CrossAxisAlignment.center,
children: [
Flexible(
child: Text.rich(
TextSpan(
children: [
TextSpan(
text: '${agent.firstName} ${agent.lastName}',
style: const TextStyle(
fontFamily: 'Fractul',
fontSize: 18,
fontWeight: FontWeight.w700,
color: AppColors.primaryDark,
),
),
],
),
overflow: TextOverflow.ellipsis,
),
),
if (agent.isVerified) ...[
const Padding(
padding: EdgeInsets.only(left: 6, right: 4),
child: Icon(Icons.verified,
size: 16, color: Color(0xFF2196F3)),
),
const Text(
'(Verified Expert)',
style: TextStyle(
fontFamily: 'SourceSerif4',
fontSize: 14,
fontWeight: FontWeight.w500,
color: Color(0xFF638559),
),
),
],
const SizedBox(width: 8),
GestureDetector(
behavior: HitTestBehavior.opaque,
onTap: () => context.push('/agent/edit-profile'),
child: Padding(
padding: const EdgeInsets.all(4),
child: SvgPicture.asset(
'assets/icons/edit_icon.svg',
width: 20,
height: 20,
),
),
),
],
),
// Title
if (agent.agentType != null) ...[
const SizedBox(height: 6),
Text(
agent.headline ?? agent.agentType!.name,
style: const TextStyle(
fontFamily: 'Fractul',
fontSize: 14,
color: AppColors.primaryDark,
),
textAlign: TextAlign.center,
),
],
const SizedBox(height: 8),
// Location + Member Since (center-aligned, uniform sizing)
// Show up to 4 locations with "+N more" matching web's ProfileCard.
if (state.locationParts.isNotEmpty ||
locationDisplay.isNotEmpty) ...[
Builder(builder: (_) {
final parts = state.locationParts.isNotEmpty
? state.locationParts
: locationDisplay
.split(',')
.map((s) => s.trim())
.where((s) => s.isNotEmpty)
.toList();
const maxVisible = 4;
final visible = parts.take(maxVisible).join(', ');
final remaining = parts.length - maxVisible;
return GestureDetector(
onTap: remaining > 0
? () => _showLocationModal(context, parts)
: null,
child: Row(
mainAxisAlignment: MainAxisAlignment.center,
crossAxisAlignment: CrossAxisAlignment.center,
children: [
SvgPicture.asset(
'assets/icons/location_filled_icon.svg',
width: 16,
height: 16,
),
const SizedBox(width: 4),
Flexible(
child: Text.rich(
TextSpan(
children: [
TextSpan(
text: visible,
style: const TextStyle(
fontFamily: 'SourceSerif4',
fontSize: 14,
fontWeight: FontWeight.w600,
color: AppColors.primaryDark,
),
),
if (remaining > 0)
TextSpan(
text: ' +$remaining more',
style: const TextStyle(
fontFamily: 'SourceSerif4',
fontSize: 14,
fontWeight: FontWeight.w500,
color: AppColors.accentOrange,
),
),
],
),
textAlign: TextAlign.center,
),
),
],
),
);
}),
const SizedBox(height: 6),
],
Row(
mainAxisAlignment: MainAxisAlignment.center,
crossAxisAlignment: CrossAxisAlignment.center,
children: [
SvgPicture.asset(
'assets/icons/calendar_icon.svg',
width: 16,
height: 16,
colorFilter: const ColorFilter.mode(
AppColors.primaryDark,
BlendMode.srcIn,
),
),
const SizedBox(width: 4),
Text(
agent.memberSinceText,
style: const TextStyle(
fontFamily: 'SourceSerif4',
fontSize: 14,
fontWeight: FontWeight.w600,
color: AppColors.primaryDark,
),
),
],
),
// Bio
if (agent.description.isNotEmpty) ...[
const SizedBox(height: 16),
SizedBox(
width: 338,
child: Text(
agent.description,
style: const TextStyle(
fontFamily: 'SourceSerif4',
fontSize: 14,
color: AppColors.primaryDark,
height: 1.43,
),
textAlign: TextAlign.center,
),
),
],
],
),
);
}
// ── Experience Section ──
// Full service-areas list modal (matches web's ProfileCard location modal)
void _showLocationModal(BuildContext context, List<String> locations) {
showModalBottomSheet(
context: context,
isScrollControlled: true,
backgroundColor: Colors.transparent,
builder: (ctx) => Container(
decoration: const BoxDecoration(
color: Colors.white,
borderRadius: BorderRadius.vertical(top: Radius.circular(20)),
),
padding: EdgeInsets.fromLTRB(
24, 24, 24, 24 + MediaQuery.of(ctx).padding.bottom,
),
child: Column(
mainAxisSize: MainAxisSize.min,
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Row(
mainAxisAlignment: MainAxisAlignment.spaceBetween,
children: [
const Text(
'Service Areas',
style: TextStyle(
fontFamily: 'Fractul',
fontSize: 18,
fontWeight: FontWeight.w700,
color: AppColors.primaryDark,
),
),
GestureDetector(
onTap: () => Navigator.of(ctx).pop(),
child: Icon(
Icons.close,
color: AppColors.primaryDark.withValues(alpha: 0.5),
),
),
],
),
const SizedBox(height: 16),
Wrap(
spacing: 8,
runSpacing: 8,
children: locations
.map((loc) => Container(
padding: const EdgeInsets.symmetric(
horizontal: 12, vertical: 6),
decoration: BoxDecoration(
color: const Color(0xFFE8E8E8),
borderRadius: BorderRadius.circular(10),
),
child: Text(
loc,
style: const TextStyle(
fontFamily: 'SourceSerif4',
fontSize: 13,
color: AppColors.primaryDark,
),
),
))
.toList(),
),
],
),
),
);
}
Widget _buildExperienceSection(AgentDetailState state) {
return Padding(
padding: const EdgeInsets.symmetric(horizontal: 16),
child: Column(
crossAxisAlignment: CrossAxisAlignment.center,
children: [
const Text('Experience',
style: TextStyle(
fontFamily: 'Fractul',
fontSize: 20,
fontWeight: FontWeight.w700,
color: AppColors.primaryDark)),
const SizedBox(height: 16),
_buildExperienceRow(
state.yearsInExperienceLabel, state.yearsInExperience),
const Divider(height: 24, thickness: 0.2),
_buildExperienceRow(
state.contractsClosedLabel, state.contractsClosed),
const Divider(height: 24, thickness: 0.2),
if (state.expertiseYears.isNotEmpty) ...[
Align(
alignment: Alignment.centerLeft,
child: Text(state.expertiseYearsLabel,
style: const TextStyle(
fontFamily: 'Fractul',
fontSize: 14,
fontWeight: FontWeight.w700,
color: AppColors.primaryDark)),
),
const SizedBox(height: 8),
_buildTagsWrap(
state.expertiseYears
.map((e) => e['years']!.isNotEmpty
? '${e['area']} ${e['years']}'
: e['area']!)
.toList(),
sectionKey: 'expertise',
initialCount: 4,
),
],
if (state.certifications.isNotEmpty) ...[
const SizedBox(height: 16),
Align(
alignment: Alignment.centerLeft,
child: Text(state.certificationsLabel,
style: const TextStyle(
fontFamily: 'Fractul',
fontSize: 14,
fontWeight: FontWeight.w700,
color: AppColors.primaryDark)),
),
const SizedBox(height: 8),
_buildCertificationsList(state.certifications),
],
],
),
);
}
// Certifications list with "+N More" (initial 2, matching web)
Widget _buildCertificationsList(List<Map<String, String>> certs) {
const initialCount = 2;
final sectionKey = 'certifications';
final isExpanded = _expandedTagSections.contains(sectionKey);
final display = isExpanded ? certs : certs.take(initialCount).toList();
final remaining = certs.length - initialCount;
Widget certRow(Map<String, String> cert) => Padding(
padding: const EdgeInsets.only(bottom: 12, left: 8),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Row(
children: [
const Text('\u2022 ',
style: TextStyle(
fontSize: 14, color: AppColors.primaryDark)),
Expanded(
child: Text(cert['name']!,
style: const TextStyle(
fontFamily: 'SourceSerif4',
fontSize: 14,
color: AppColors.primaryDark)),
),
],
),
if ((cert['org'] ?? '').isNotEmpty)
Padding(
padding: const EdgeInsets.only(left: 16),
child: Opacity(
opacity: 0.5,
child: Text(cert['org']!,
style: const TextStyle(
fontFamily: 'SourceSerif4',
fontSize: 14,
color: AppColors.primaryDark)),
),
),
],
),
);
return Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
...display.map(certRow),
if (remaining > 0)
Padding(
padding: const EdgeInsets.only(left: 8, top: 4),
child: GestureDetector(
behavior: HitTestBehavior.opaque,
onTap: () => setState(() {
if (isExpanded) {
_expandedTagSections.remove(sectionKey);
} else {
_expandedTagSections.add(sectionKey);
}
}),
child: Text(
isExpanded ? 'Show Less' : '+$remaining More',
style: const TextStyle(
fontFamily: 'SourceSerif4',
fontSize: 14,
fontWeight: FontWeight.w600,
color: AppColors.accentOrange,
),
),
),
),
],
);
}
Widget _buildExperienceRow(String label, String value) {
return Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(label,
style: const TextStyle(
fontFamily: 'Fractul',
fontSize: 14,
fontWeight: FontWeight.w700,
color: AppColors.primaryDark)),
const SizedBox(height: 6),
Padding(
padding: const EdgeInsets.only(left: 16),
child: Row(
children: [
const Text('\u2022 ',
style: TextStyle(
fontSize: 15, color: AppColors.primaryDark)),
Text(value,
style: const TextStyle(
fontFamily: 'SourceSerif4',
fontSize: 15,
fontWeight: FontWeight.w500,
color: AppColors.primaryDark)),
],
),
),
],
);
}
Widget _buildTagsWrap(
List<String> tags, {
required String sectionKey,
int initialCount = 6,
}) {
final isExpanded = _expandedTagSections.contains(sectionKey);
final displayTags =
isExpanded ? tags : tags.take(initialCount).toList();
final remaining = tags.length - initialCount;
return Wrap(
spacing: 8,
runSpacing: 8,
children: [
...displayTags.map((tag) => Container(
padding:
const EdgeInsets.symmetric(horizontal: 12, vertical: 6),
decoration: BoxDecoration(
borderRadius: BorderRadius.circular(15),
border:
Border.all(color: AppColors.primaryDark, width: 0.7),
),
child: Text(tag,
style: const TextStyle(
fontFamily: 'SourceSerif4',
fontSize: 14,
fontWeight: FontWeight.w500,
color: AppColors.primaryDark)),
)),
if (remaining > 0)
GestureDetector(
onTap: () {
setState(() {
if (isExpanded) {
_expandedTagSections.remove(sectionKey);
} else {
_expandedTagSections.add(sectionKey);
}
});
},
behavior: HitTestBehavior.opaque,
child: Container(
padding:
const EdgeInsets.symmetric(horizontal: 12, vertical: 6),
decoration: BoxDecoration(
borderRadius: BorderRadius.circular(15),
border: Border.all(color: AppColors.primaryDark, width: 0.1),
),
child: Text(
isExpanded ? 'Show Less' : '+$remaining More',
style: const TextStyle(
fontFamily: 'SourceSerif4',
fontSize: 15,
fontWeight: FontWeight.w300,
color: AppColors.primaryDark)),
),
),
],
);
}
// ── Info Cards (Availability, Work Env, Testimonial) — matches web ──
Widget _buildInfoCards(AgentDetailState state) {
// Work environment — try fieldValues first, fallback to mock (same as web)
String workEnv = '';
for (final fv in state.fieldValues) {
final slug = fv.fieldSlug.toLowerCase();
if (slug.contains('work_environment') ||
slug.contains('preferred_environment')) {
if (fv.textValue != null) workEnv = fv.textValue!;
if (fv.jsonValue is List) {
workEnv =
(fv.jsonValue as List).map((e) => e.toString()).join(', ');
}
}
}
// Fallback mock data (same as web dashboard)
if (workEnv.isEmpty) {
workEnv =
'Preferred work environment includes on-site property visits, '
'in-depth market research, client consultations, property tours, '
'and active involvement in negotiations to ensure the best outcomes';
}
// Extract first testimonial for preview
String testimonialText = '';
if (state.testimonials.isNotEmpty) {
final first = state.testimonials.first;
testimonialText =
(first['text'] ?? first['content'] ?? first['review'] ?? '')
.toString();
if (testimonialText.length > 150) {
testimonialText = '${testimonialText.substring(0, 150)}...';
}
}
return Padding(
padding: const EdgeInsets.symmetric(horizontal: 36),
child: Column(
children: [
// Availability card (with title — matches web)
_buildInfoCard(
icon: Icons.access_time_filled,
title: state.availabilityLabel,
subtitle: state.availabilitySchedule.isEmpty ? 'Not specified' : '',
items: state.availabilitySchedule.isNotEmpty
? state.availabilitySchedule
: null,
isEmpty: state.availabilitySchedule.isEmpty,
),
const SizedBox(height: 16),
// Work environment
_buildInfoCard(
icon: Icons.landscape_outlined,
title: state.workEnvironmentLabel,
subtitle: '',
description: workEnv,
),
const SizedBox(height: 16),
// Testimonial preview
_buildInfoCard(
icon: Icons.star_rounded,
title: state.bestExperienceLabel,
subtitle: '',
description: testimonialText.isNotEmpty
? '\u201C$testimonialText\u201D'
: 'No testimonials yet',
isEmpty: testimonialText.isEmpty,
),
],
),
);
}
Widget _buildInfoCard({
required IconData icon,
required String title,
required String subtitle,
List<String>? items,
String? description,
bool isEmpty = false,
}) {
return Container(
width: double.infinity,
constraints: const BoxConstraints(minHeight: 217),
padding: const EdgeInsets.all(20),
decoration: BoxDecoration(
color: Colors.white,
borderRadius: BorderRadius.circular(10),
boxShadow: [
BoxShadow(
color: const Color(0xFFD9D9D9).withValues(alpha: 0.5),
offset: const Offset(0, 10),
blurRadius: 20,
),
],
),
child: Column(
mainAxisAlignment: MainAxisAlignment.start,
children: [
Icon(icon, size: 36, color: AppColors.accentOrange),
if (title.isNotEmpty) ...[
const SizedBox(height: 8),
Text(
title,
style: const TextStyle(
fontFamily: 'SourceSerif4',
fontSize: 14,
fontWeight: FontWeight.w600,
color: AppColors.primaryDark,
),
),
],
if (subtitle.isNotEmpty) ...[
const SizedBox(height: 2),
Text(
subtitle,
style: TextStyle(
fontFamily: 'SourceSerif4',
fontSize: 14,
fontWeight: FontWeight.w600,
color: isEmpty
? AppColors.primaryDark.withValues(alpha: 0.4)
: AppColors.primaryDark,
),
),
],
if (items != null && items.isNotEmpty) ...[
const SizedBox(height: 8),
...items.map(
(item) => Padding(
padding: const EdgeInsets.symmetric(vertical: 1),
child: Text(
item,
style: const TextStyle(
fontFamily: 'SourceSerif4',
fontSize: 14,
color: AppColors.primaryDark,
),
),
),
),
],
if (description != null) ...[
const SizedBox(height: 8),
Text(
description,
style: TextStyle(
fontFamily: 'SourceSerif4',
fontSize: 14,
fontWeight: FontWeight.w600,
color: isEmpty
? AppColors.primaryDark.withValues(alpha: 0.4)
: AppColors.primaryDark,
),
textAlign: TextAlign.center,
),
],
],
),
);
}
// ── Specialization Section (grey background) ──
Widget _buildSpecializationSection(AgentDetailState state) {
final cards = state.specializationCards;
if (cards.isEmpty) return const SizedBox.shrink();
return Container(
width: double.infinity,
color: const Color(0xFFE6E6E6),
padding: const EdgeInsets.symmetric(vertical: 24),
child: Column(
children: [
const Text('Specialization',
style: TextStyle(
fontFamily: 'Fractul',
fontSize: 20,
fontWeight: FontWeight.w700,
color: AppColors.primaryDark)),
const SizedBox(height: 4),
const Text('Area Of Expertise and Focus',
style: TextStyle(
fontFamily: 'Fractul',
fontSize: 14,
fontWeight: FontWeight.w500,
color: AppColors.primaryDark)),
const SizedBox(height: 16),
...cards.map((card) => _buildSpecCard(card)),
],
),
);
}
Widget _buildSpecCard(SpecializationCard card) {
final displayName = AgentDetailState.titleCase(card.name);
final sectionKey = 'spec_${card.slug}';
final isExpanded = _expandedTagSections.contains(sectionKey);
final displayValues = isExpanded ? card.values : card.values.take(3).toList();
final hasMore = card.values.length > 3;
return Container(
margin: const EdgeInsets.symmetric(horizontal: 66, vertical: 8),
width: 298,
padding: const EdgeInsets.symmetric(vertical: 16, horizontal: 20),
decoration: BoxDecoration(
color: Colors.white,
borderRadius: BorderRadius.circular(15),
border: Border.all(color: AppColors.primaryDark, width: 0.7),
),
child: Column(
2026-03-30 15:35:35 +05:30
mainAxisSize: MainAxisSize.min,
children: [
const Icon(Icons.category_outlined,
size: 28, color: AppColors.accentOrange),
const SizedBox(height: 10),
Text(displayName,
style: const TextStyle(
fontFamily: 'Fractul',
fontSize: 14,
fontWeight: FontWeight.w700,
color: AppColors.primaryDark)),
const SizedBox(height: 12),
...displayValues.map((val) => Padding(
padding: const EdgeInsets.symmetric(vertical: 4),
child: Text(
AgentDetailState.titleCase(val),
textAlign: TextAlign.center,
style: const TextStyle(
fontFamily: 'SourceSerif4',
fontSize: 14,
color: AppColors.primaryDark,
height: 1.8),
),
)),
if (hasMore) ...[
const SizedBox(height: 8),
GestureDetector(
onTap: () {
setState(() {
if (isExpanded) {
_expandedTagSections.remove(sectionKey);
} else {
_expandedTagSections.add(sectionKey);
}
});
},
behavior: HitTestBehavior.opaque,
child: Container(
padding:
const EdgeInsets.symmetric(horizontal: 16, vertical: 4),
decoration: BoxDecoration(
borderRadius: BorderRadius.circular(15),
border:
Border.all(color: AppColors.accentOrange, width: 1),
),
child: Row(
mainAxisSize: MainAxisSize.min,
children: [
Text(isExpanded ? 'Show Less' : 'Show More',
style: const TextStyle(
fontFamily: 'SourceSerif4',
fontSize: 10,
fontWeight: FontWeight.w700,
color: AppColors.accentOrange)),
const SizedBox(width: 4),
Icon(
isExpanded
? Icons.keyboard_arrow_up
: Icons.keyboard_arrow_down,
size: 14,
color: AppColors.accentOrange),
],
),
),
),
],
],
),
);
}
// ── Testimonials ──
Widget _buildTestimonialsSection(AgentDetailState state) {
if (state.testimonials.isEmpty) return const SizedBox.shrink();
// Calculate average rating dynamically
final totalRating = state.testimonials.fold<double>(
0, (sum, t) => sum + ((t['rating'] as num?)?.toDouble() ?? 5.0),
);
final avgRating = (totalRating / state.testimonials.length).toStringAsFixed(1);
final count = state.testimonials.length;
return Padding(
padding: const EdgeInsets.symmetric(horizontal: 24),
child: Column(
children: [
const Text(
'Testimonials',
style: TextStyle(
fontFamily: 'Fractul',
fontSize: 20,
fontWeight: FontWeight.w700,
color: AppColors.primaryDark,
),
),
const SizedBox(height: 8),
Divider(
color: AppColors.primaryDark.withValues(alpha: 0.15),
height: 1,
),
const SizedBox(height: 8),
Text(
'Clients rate our real estate services $avgRating out of 5 on average, based on $count recent client review${count != 1 ? 's' : ''}.',
style: const TextStyle(
fontFamily: 'SourceSerif4',
fontSize: 14,
color: AppColors.primaryDark,
),
textAlign: TextAlign.center,
),
const SizedBox(height: 16),
SizedBox(
height: 380,
child: ListView.separated(
scrollDirection: Axis.horizontal,
itemCount: state.testimonials.length,
separatorBuilder: (_, __) => const SizedBox(width: 12),
itemBuilder: (_, i) =>
_TestimonialCard(data: state.testimonials[i]),
),
),
],
),
);
}
}
// ── Testimonial Card (matches Figma & web TestimonialCard) ──
class _TestimonialCard extends StatelessWidget {
final Map<String, dynamic> data;
const _TestimonialCard({required this.data});
@override
Widget build(BuildContext context) {
final text = data['text'] as String? ?? '';
final authorName = data['authorName'] as String? ?? '';
final authorRole = data['authorRole'] as String? ?? '';
final rating = (data['rating'] as num?)?.toInt() ?? 5;
return Container(
width: 300,
padding: const EdgeInsets.all(16),
decoration: BoxDecoration(
color: Colors.white,
borderRadius: BorderRadius.circular(15),
border: Border.all(
color: AppColors.primaryDark.withValues(alpha: 0.1),
width: 1,
),
),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
// Quote icon
SvgPicture.asset(
'assets/icons/quote_icon.svg',
width: 23,
height: 13,
),
const SizedBox(height: 12),
// Title snippet (bold)
Text(
text.length > 35 ? '${text.substring(0, 35)} ..' : text,
style: const TextStyle(
fontFamily: 'Fractul',
fontSize: 14,
fontWeight: FontWeight.w500,
color: AppColors.primaryDark,
),
maxLines: 1,
overflow: TextOverflow.ellipsis,
),
const SizedBox(height: 12),
// Full text
Expanded(
child: Text(
text,
style: const TextStyle(
fontFamily: 'SourceSerif4',
fontSize: 14,
fontWeight: FontWeight.w400,
color: AppColors.primaryDark,
height: 1.4,
),
overflow: TextOverflow.fade,
),
),
const SizedBox(height: 12),
// Divider
Divider(
color: AppColors.primaryDark.withValues(alpha: 0.08),
height: 1,
),
const SizedBox(height: 12),
// Star rating
Row(
mainAxisSize: MainAxisSize.min,
children: List.generate(
5,
(i) => Padding(
padding: const EdgeInsets.only(right: 2),
child: Icon(
i < rating ? Icons.star : Icons.star_border,
size: 18,
color: i < rating
? const Color(0xFFFFDE21)
: AppColors.primaryDark.withValues(alpha: 0.3),
),
),
),
),
const SizedBox(height: 8),
// Author name
Text(
authorName,
style: const TextStyle(
fontFamily: 'Fractul',
fontSize: 14,
fontWeight: FontWeight.w400,
color: AppColors.primaryDark,
),
),
// Author role
if (authorRole.isNotEmpty) ...[
const SizedBox(height: 2),
Text(
authorRole,
style: const TextStyle(
fontFamily: 'Fractul',
fontSize: 14,
fontWeight: FontWeight.w500,
color: AppColors.primaryDark,
),
),
],
],
),
);
}
}