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

1288 lines
42 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: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;
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);
try {
final repo = ProfileRepository();
await repo.updateProfile('AGENT', {'isAvailable': newValue});
// Refresh agent detail to reflect new status
ref.invalidate(agentDetailProvider(widget.agentProfileId));
} catch (e) {
if (mounted) {
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);
}
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) {
final agent = state.agent!;
return SingleChildScrollView(
child: Column(
children: [
const SizedBox(height: 12),
_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),
],
),
);
}
// ── 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 (_isTogglingAvailability && !isActive)
const SizedBox(
width: 16,
height: 16,
child: CircularProgressIndicator(
strokeWidth: 2,
color: AppColors.accentOrange,
),
)
else 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 ? state.maskEmail(email) : '-',
style: const TextStyle(
fontFamily: 'SourceSerif4',
fontSize: 14,
color: AppColors.primaryDark),
),
),
const Icon(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 ? state.maskPhone(phone) : '-',
style: const TextStyle(
fontFamily: 'SourceSerif4',
fontSize: 14,
color: AppColors.primaryDark),
),
),
const Icon(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),
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()),
],
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) {
final displayTags = 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)
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('+$remaining More',
style: const TextStyle(
fontFamily: 'SourceSerif4',
fontSize: 15,
fontWeight: FontWeight.w300,
color: AppColors.primaryDark)),
),
],
);
}
// ── Info Cards (Availability, Work Environment, Experience) ──
Widget _buildInfoCards(AgentDetailState state) {
// Extract work environment and best experience from fieldValues
String workEnv = '';
String bestExp = '';
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(', ');
}
}
if (slug.contains('best_experience') ||
slug.contains('amazing_experience')) {
if (fv.textValue != null) bestExp = fv.textValue!;
}
}
return Padding(
padding: const EdgeInsets.symmetric(horizontal: 36),
child: Column(
children: [
// Availability card
if (state.availabilityType.isNotEmpty)
_buildInfoCard(
icon: Icons.access_time_filled,
title: 'Availability',
subtitle: state.availabilityType,
items: state.availabilitySchedule,
),
if (workEnv.isNotEmpty) ...[
const SizedBox(height: 16),
_buildInfoCard(
icon: Icons.landscape_outlined,
description: workEnv,
),
],
if (bestExp.isNotEmpty) ...[
const SizedBox(height: 16),
_buildInfoCard(
icon: Icons.star_rounded,
description: bestExp,
),
],
],
),
);
}
Widget _buildInfoCard({
required IconData icon,
String title = '',
String subtitle = '',
List<String>? items,
String? description,
}) {
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: const TextStyle(
fontFamily: 'SourceSerif4',
fontSize: 14,
fontWeight: FontWeight.w600,
color: 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 && description.isNotEmpty) ...[
const SizedBox(height: 8),
Text(
description,
style: const TextStyle(
fontFamily: 'SourceSerif4',
fontSize: 14,
fontWeight: FontWeight.w600,
color: 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);
return Container(
margin: const EdgeInsets.symmetric(horizontal: 66, vertical: 8),
width: 298,
constraints: const BoxConstraints(minHeight: 236),
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(
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),
...card.values.take(5).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 (card.values.length > 5) ...[
const SizedBox(height: 8),
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: [
const Text('Show More',
style: TextStyle(
fontFamily: 'SourceSerif4',
fontSize: 10,
fontWeight: FontWeight.w700,
color: AppColors.accentOrange)),
const SizedBox(width: 4),
Icon(Icons.keyboard_arrow_down,
size: 14, color: AppColors.accentOrange),
],
),
),
],
],
),
);
}
// ── Testimonials Section ──
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: 320,
child: ListView.separated(
scrollDirection: Axis.horizontal,
itemCount: state.testimonials.length,
separatorBuilder: (_, __) => const SizedBox(width: 12),
itemBuilder: (_, i) =>
_TestimonialCard(data: state.testimonials[i]),
),
),
],
),
);
}
}
// ── Testimonial Card (same as agent detail screen) ──
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 SizedBox(
width: 320,
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
// Main card body with speech bubble shape
Expanded(
child: Container(
width: double.infinity,
padding: const EdgeInsets.fromLTRB(20, 16, 20, 20),
decoration: BoxDecoration(
color: Colors.white,
borderRadius: BorderRadius.circular(15),
boxShadow: [
BoxShadow(
color: const Color(0xFFD9D9D9).withValues(alpha: 0.5),
offset: const Offset(0, 10),
blurRadius: 20,
),
],
),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
// Quote icon
const Text(
'\u201C',
style: TextStyle(
fontFamily: 'Fractul',
fontSize: 36,
fontWeight: FontWeight.w700,
color: AppColors.primaryDark,
height: 0.8,
),
),
const SizedBox(height: 6),
// Title snippet
Text(
text.length > 35
? '${text.substring(0, 35)} ..'
: text,
style: const TextStyle(
fontFamily: 'Fractul',
fontSize: 14,
fontWeight: FontWeight.w700,
color: AppColors.primaryDark,
),
maxLines: 1,
overflow: TextOverflow.ellipsis,
),
const SizedBox(height: 10),
// Full text
Expanded(
child: Text(
text,
style: const TextStyle(
fontFamily: 'SourceSerif4',
fontSize: 14,
color: AppColors.primaryDark,
height: 1.5,
),
overflow: TextOverflow.fade,
),
),
],
),
),
),
// Speech bubble triangle
Padding(
padding: const EdgeInsets.only(left: 30),
child: CustomPaint(
size: const Size(16, 10),
painter: _TrianglePainter(color: Colors.white),
),
),
const SizedBox(height: 6),
// Stars + Author info
Padding(
padding: const EdgeInsets.only(left: 4),
child: Row(
children: [
// Stars
Row(
mainAxisSize: MainAxisSize.min,
children: List.generate(
5,
(i) => Icon(
i < rating ? Icons.star : Icons.star_border,
size: 16,
color: i < rating
? AppColors.accentOrange
: AppColors.primaryDark.withValues(alpha: 0.3),
),
),
),
const SizedBox(width: 10),
// Author
Expanded(
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(
authorName,
style: const TextStyle(
fontFamily: 'SourceSerif4',
fontSize: 14,
fontWeight: FontWeight.w500,
color: AppColors.primaryDark,
),
),
if (authorRole.isNotEmpty)
Text(
authorRole,
style: const TextStyle(
fontFamily: 'SourceSerif4',
fontSize: 14,
fontWeight: FontWeight.w600,
color: AppColors.primaryDark,
),
),
],
),
),
],
),
),
],
),
);
}
}
// ── Triangle painter for speech bubble ──
class _TrianglePainter extends CustomPainter {
final Color color;
_TrianglePainter({required this.color});
@override
void paint(Canvas canvas, Size size) {
final paint = Paint()
..color = color
..style = PaintingStyle.fill;
final shadowPaint = Paint()
..color = const Color(0xFFD9D9D9).withValues(alpha: 0.16)
..maskFilter = const MaskFilter.blur(BlurStyle.normal, 4);
final path = Path()
..moveTo(0, 0)
..lineTo(size.width / 2, size.height)
..lineTo(size.width, 0)
..close();
canvas.drawPath(path, shadowPaint);
canvas.drawPath(path, paint);
}
@override
bool shouldRepaint(covariant CustomPainter oldDelegate) => false;
}