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

1463 lines
49 KiB
Dart
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
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),
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) {
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
Row(
mainAxisAlignment: MainAxisAlignment.center,
children: [
if (agent.location.isNotEmpty) ...[
SvgPicture.asset(
'assets/icons/location_filled_icon.svg',
width: 16,
height: 16,
),
const SizedBox(width: 4),
Text(
agent.location,
style: const TextStyle(
fontFamily: 'SourceSerif4',
fontSize: 14,
fontWeight: FontWeight.w700,
color: AppColors.primaryDark,
),
),
const SizedBox(width: 20),
],
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: 13,
fontWeight: FontWeight.w500,
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 ──
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(
'Years in Experience', state.yearsInExperience),
const Divider(height: 24, thickness: 0.2),
_buildExperienceRow(
'Number of contract closed', state.contractsClosed),
const Divider(height: 24, thickness: 0.2),
if (state.licensingAreas.isNotEmpty) ...[
const Align(
alignment: Alignment.centerLeft,
child: Text('Licensing & Areas',
style: TextStyle(
fontFamily: 'Fractul',
fontSize: 14,
fontWeight: FontWeight.w700,
color: AppColors.primaryDark)),
),
const SizedBox(height: 8),
_buildTagsWrap(state.licensingAreas, sectionKey: 'licensing'),
const SizedBox(height: 16),
],
if (state.expertiseYears.isNotEmpty) ...[
const Align(
alignment: Alignment.centerLeft,
child: Text('Areas in expertise & Years',
style: 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'),
],
if (state.certifications.isNotEmpty) ...[
const SizedBox(height: 16),
const Align(
alignment: Alignment.centerLeft,
child: Text('Certifications',
style: TextStyle(
fontFamily: 'Fractul',
fontSize: 14,
fontWeight: FontWeight.w700,
color: AppColors.primaryDark)),
),
const SizedBox(height: 8),
...state.certifications.map((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)),
),
),
],
),
)),
],
],
),
);
}
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}) {
final isExpanded = _expandedTagSections.contains(sectionKey);
final displayTags = isExpanded ? tags : tags.take(6).toList();
final remaining = tags.length - 6;
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: 'Availability',
subtitle: state.availabilityType.isNotEmpty
? state.availabilityType
: 'Not specified',
items: state.availabilitySchedule.isNotEmpty
? state.availabilitySchedule
: null,
isEmpty: state.availabilityType.isEmpty,
),
const SizedBox(height: 16),
// Work environment (no title — matches web)
_buildInfoCard(
icon: Icons.landscape_outlined,
title: '',
subtitle: '',
description: workEnv,
),
const SizedBox(height: 16),
// Testimonial preview (no title — matches web)
_buildInfoCard(
icon: Icons.star_rounded,
title: '',
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,
height: isExpanded ? null : 236,
padding: const EdgeInsets.symmetric(vertical: 16, horizontal: 20),
clipBehavior: Clip.antiAlias,
decoration: BoxDecoration(
color: Colors.white,
borderRadius: BorderRadius.circular(15),
border: Border.all(color: AppColors.primaryDark, width: 0.7),
),
child: Column(
mainAxisSize: isExpanded ? MainAxisSize.min : MainAxisSize.max,
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();
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),
const Text(
'Clients rate our real estate services 4.9 out of 5 on average, based on recent client reviews.',
style: 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,
),
),
],
],
),
);
}
}