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

1962 lines
62 KiB
Dart
Raw Normal View History

import 'package:flutter/material.dart';
import 'package:flutter_riverpod/flutter_riverpod.dart';
import 'package:flutter_svg/flutter_svg.dart';
import 'package:go_router/go_router.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/auth/presentation/providers/auth_provider.dart';
import 'package:real_estate_mobile/features/messaging/presentation/providers/messaging_provider.dart';
class AgentDetailScreen extends ConsumerStatefulWidget {
final String agentId;
const AgentDetailScreen({super.key, required this.agentId});
@override
ConsumerState<AgentDetailScreen> createState() => _AgentDetailScreenState();
}
class _AgentDetailScreenState extends ConsumerState<AgentDetailScreen> {
bool _emailVisible = false;
bool _phoneVisible = false;
bool _bioExpanded = false;
@override
void initState() {
super.initState();
_loadConnectionIfAuth();
}
void _loadConnectionIfAuth() {
WidgetsBinding.instance.addPostFrameCallback((_) {
final authState = ref.read(authProvider);
if (authState.status == AuthStatus.authenticated) {
ref
.read(agentDetailProvider(widget.agentId).notifier)
.loadConnectionStatus();
}
});
}
@override
Widget build(BuildContext context) {
final state = ref.watch(agentDetailProvider(widget.agentId));
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: Padding(
padding: const EdgeInsets.all(24),
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: () => context.pop(),
child: const Text(
'Go Back',
style: TextStyle(color: AppColors.accentOrange),
),
),
],
),
),
);
}
Widget _buildContent(AgentDetailState state) {
final agent = state.agent!;
return RefreshIndicator(
color: AppColors.accentOrange,
onRefresh: () => ref
.read(agentDetailProvider(widget.agentId).notifier)
.refresh(),
child: SingleChildScrollView(
physics: const AlwaysScrollableScrollPhysics(),
child: Column(
children: [
// ── Avatar Section ──
const SizedBox(height: 16),
_buildAvatarSection(agent),
// ── Status + Connect Buttons ──
const SizedBox(height: 16),
_buildStatusButtons(state),
// ── Contact Info ──
const SizedBox(height: 12),
_buildContactInfo(state),
// ── Profile Info ──
const SizedBox(height: 16),
_buildProfileInfo(agent, state),
// ── Experience Section (always show, like web) ──
const SizedBox(height: 24),
_buildExperienceSection(state),
// ── Info Cards (Availability, Work Env, Best Experience) ──
const SizedBox(height: 24),
_buildInfoCards(state),
// ── Specialization Section (always show, like web) ──
const SizedBox(height: 24),
_buildSpecializationSection(state),
// ── Testimonials Section ──
if (state.testimonials.isNotEmpty) ...[
const SizedBox(height: 24),
_buildTestimonialsSection(state),
],
const SizedBox(height: 30),
],
),
),
);
}
// ── Avatar ──
Widget _buildAvatarSection(AgentProfile agent) {
final screenWidth = MediaQuery.of(context).size.width;
final avatarWidth = screenWidth * 0.877; // 377/430 from Figma
return Center(
child: ClipRRect(
borderRadius: BorderRadius.circular(20),
child: SizedBox(
width: avatarWidth,
height: 346,
child: Stack(
fit: StackFit.expand,
children: [
S3Image(
imageUrl: agent.avatarUrl,
width: avatarWidth,
height: 346,
fit: BoxFit.cover,
errorWidget: (_) => Container(
color: const Color(0xFFC4D9D4),
child: const Icon(
Icons.person,
size: 80,
color: AppColors.primaryDark,
),
),
),
// Gradient overlay
Positioned(
bottom: 0,
left: 0,
right: 0,
height: 100,
child: Container(
decoration: BoxDecoration(
gradient: LinearGradient(
begin: Alignment.bottomCenter,
end: Alignment.topCenter,
colors: [
Colors.black.withValues(alpha: 0.5),
Colors.transparent,
],
),
),
),
),
],
),
),
),
);
}
// ── Status + Connect (single row matching web public view) ──
Widget _buildStatusButtons(AgentDetailState state) {
final agent = state.agent!;
final isAvailable = agent.isAvailable;
final connState = state.connectionState;
return Padding(
padding: const EdgeInsets.symmetric(horizontal: 38),
child: Column(
children: [
Container(
height: 50,
decoration: BoxDecoration(
borderRadius: BorderRadius.circular(15),
border: Border.all(
color: AppColors.primaryDark.withValues(alpha: 0.1),
width: 1,
),
),
child: Row(
children: [
const SizedBox(width: 16),
// Status dot
Container(
width: 12,
height: 12,
decoration: BoxDecoration(
shape: BoxShape.circle,
color: isAvailable
? const Color(0xFF22C55E)
: Colors.white,
border: Border.all(
color: isAvailable
? const Color(0xFF22C55E)
: AppColors.primaryDark.withValues(alpha: 0.3),
width: 1.5,
),
),
),
const SizedBox(width: 8),
Text(
isAvailable ? 'Available.' : 'Unavailable.',
style: const TextStyle(
fontFamily: 'Fractul',
fontSize: 14,
fontWeight: FontWeight.w700,
color: AppColors.primaryDark,
),
),
const Spacer(),
// Connect/Pending/Unlink button
_buildConnectButton(
connState,
isAvailable,
() => _handleConnect(state),
),
const SizedBox(width: 4),
],
),
),
// Start Message button when connected
if (connState == 'accepted') ...[
const SizedBox(height: 12),
_buildStartMessageButton(agent),
],
],
),
);
}
bool _isStartingConversation = false;
void _handleStartMessage(AgentProfile agent) async {
if (_isStartingConversation) return;
setState(() => _isStartingConversation = true);
// Capture notifier before async gap to avoid using ref after dispose
final notifier = ref.read(messagingProvider.notifier);
try {
final conversationId = await notifier.startConversation(agent.id);
if (!mounted) return;
if (conversationId != null) {
context.push('/messages/chat/$conversationId');
}
} finally {
if (mounted) setState(() => _isStartingConversation = false);
}
}
Widget _buildStartMessageButton(AgentProfile agent) {
return GestureDetector(
onTap: _isStartingConversation ? null : () => _handleStartMessage(agent),
child: Container(
height: 46,
decoration: BoxDecoration(
color: AppColors.accentOrange,
borderRadius: BorderRadius.circular(15),
),
alignment: Alignment.center,
child: _isStartingConversation
? const SizedBox(
width: 20,
height: 20,
child: CircularProgressIndicator(
strokeWidth: 2,
color: Colors.white,
),
)
: const Row(
mainAxisAlignment: MainAxisAlignment.center,
children: [
Icon(Icons.chat_bubble_outline, size: 18, color: Colors.white),
SizedBox(width: 8),
Text(
'Start Message',
style: TextStyle(
fontFamily: 'Fractul',
fontSize: 14,
fontWeight: FontWeight.w600,
color: Colors.white,
),
),
],
),
),
);
}
Widget _buildConnectButton(
String connState,
bool isAvailable,
VoidCallback? onConnect,
) {
Color bgColor;
Color textColor;
String label;
bool enabled;
switch (connState) {
case 'pending':
bgColor = const Color(0xFFFEF3C7); // yellow-100
textColor = const Color(0xFF92600F);
label = 'Pending';
enabled = false;
break;
case 'accepted':
bgColor = const Color(0xFFEF4444); // red-500
textColor = Colors.white;
label = 'Unlink';
enabled = true;
break;
default:
bgColor = isAvailable
? AppColors.accentOrange
: AppColors.primaryDark.withValues(alpha: 0.7);
textColor = Colors.white;
label = 'Connect';
enabled = isAvailable;
}
return GestureDetector(
onTap: enabled ? onConnect : null,
child: Container(
height: 55,
width: 140,
decoration: BoxDecoration(
color: bgColor,
borderRadius: BorderRadius.circular(15),
),
alignment: Alignment.center,
child: Text(
label,
style: TextStyle(
fontFamily: 'Fractul',
fontSize: 14,
fontWeight: FontWeight.w600,
color: textColor,
),
),
),
);
}
void _handleConnect(AgentDetailState state) {
final authState = ref.read(authProvider);
if (authState.status != AuthStatus.authenticated) {
context.push('/login');
return;
}
if (state.connectionState == 'accepted') {
ref.read(agentDetailProvider(widget.agentId).notifier).cancelConnection();
return;
}
_showConnectModal(state);
}
void _showConnectModal(AgentDetailState state) {
final controller = TextEditingController();
showModalBottomSheet(
context: context,
isScrollControlled: true,
backgroundColor: Colors.transparent,
builder: (ctx) => _ConnectModalContent(
agentName: state.agent?.fullName ?? '',
controller: controller,
onSend: (message) async {
final success = await ref
.read(agentDetailProvider(widget.agentId).notifier)
.connect(message: message);
return success;
},
),
);
}
// ── Location Modal (matching web "Service Areas" modal) ──
void _showLocationModal(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(),
),
],
),
),
);
}
// ── Contact Info ──
Widget _buildContactInfo(AgentDetailState state) {
final email = state.contactEmail;
final phone = state.contactPhone;
if (email == null && phone == null) return const SizedBox.shrink();
return Padding(
padding: const EdgeInsets.symmetric(horizontal: 38),
child: Container(
height: 98,
padding: const EdgeInsets.symmetric(horizontal: 18, vertical: 16),
decoration: BoxDecoration(
borderRadius: BorderRadius.circular(15),
border: Border.all(
color: AppColors.primaryDark.withValues(alpha: 0.15),
width: 1,
),
),
child: Column(
mainAxisAlignment: MainAxisAlignment.center,
children: [
if (email != null)
_buildContactRow(
label: 'Email:',
value: _emailVisible ? email : state.maskEmail(email),
onToggle: () => setState(() => _emailVisible = !_emailVisible),
visible: _emailVisible,
),
if (email != null && phone != null) const SizedBox(height: 12),
if (phone != null)
_buildContactRow(
label: 'Ph.No:',
value: _phoneVisible ? phone : state.maskPhone(phone),
onToggle: () => setState(() => _phoneVisible = !_phoneVisible),
visible: _phoneVisible,
),
],
),
),
);
}
Widget _buildContactRow({
required String label,
required String value,
required VoidCallback onToggle,
required bool visible,
}) {
return Row(
children: [
Text(
label,
style: const TextStyle(
fontFamily: 'Fractul',
fontSize: 14,
fontWeight: FontWeight.w700,
color: AppColors.primaryDark,
),
),
const SizedBox(width: 8),
Expanded(
child: Text(
value,
style: const TextStyle(
fontFamily: 'SourceSerif4',
fontSize: 14,
color: AppColors.primaryDark,
),
maxLines: 1,
overflow: TextOverflow.ellipsis,
),
),
const SizedBox(width: 8),
GestureDetector(
onTap: onToggle,
child: Icon(
visible ? Icons.visibility : Icons.visibility_off,
size: 18,
color: AppColors.primaryDark.withValues(alpha: 0.6),
),
),
],
);
}
// ── Profile Info (Name, Title, Location, Bio) ──
Widget _buildProfileInfo(AgentProfile agent, AgentDetailState state) {
return Padding(
padding: const EdgeInsets.symmetric(horizontal: 38),
child: Column(
crossAxisAlignment: CrossAxisAlignment.center,
children: [
// Name + Verified badge + (Verified Expert) — badge after last name
Wrap(
alignment: WrapAlignment.center,
crossAxisAlignment: WrapCrossAlignment.center,
spacing: 4,
runSpacing: 4,
children: [
Text(
'${agent.firstName} ${agent.lastName}',
style: const TextStyle(
fontFamily: 'Fractul',
fontSize: 18,
fontWeight: FontWeight.w700,
color: AppColors.primaryDark,
),
),
if (agent.isVerified) ...[
Row(
mainAxisSize: MainAxisSize.min,
children: const [
Icon(
Icons.verified,
size: 16,
color: Color(0xFF2196F3),
),
SizedBox(width: 4),
Text(
'(Verified Expert)',
style: TextStyle(
fontFamily: 'SourceSerif4',
fontSize: 14,
fontWeight: FontWeight.w500,
color: Color(0xFF638559),
),
),
],
),
],
],
),
// 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 (same line, matching web ProfileCard)
Builder(builder: (_) {
final parts = state.locationParts;
const maxVisible = 1;
final firstPart = parts.isNotEmpty ? parts.first : '';
final remaining = parts.length - maxVisible;
return Row(
mainAxisAlignment: MainAxisAlignment.center,
children: [
if (parts.isNotEmpty) ...[
SvgPicture.asset(
'assets/icons/location_pin_icon.svg',
width: 18,
height: 18,
),
const SizedBox(width: 4),
ConstrainedBox(
constraints: const BoxConstraints(maxWidth: 120),
child: Text(
firstPart,
style: const TextStyle(
fontFamily: 'SourceSerif4',
fontSize: 14,
fontWeight: FontWeight.w700,
color: AppColors.primaryDark,
),
overflow: TextOverflow.ellipsis,
maxLines: 1,
),
),
if (remaining > 0)
GestureDetector(
onTap: () => _showLocationModal(parts),
child: Text(
' +$remaining more',
style: const TextStyle(
fontFamily: 'SourceSerif4',
fontSize: 14,
fontWeight: FontWeight.w500,
color: AppColors.accentOrange,
),
),
),
const SizedBox(width: 12),
],
SvgPicture.asset(
'assets/icons/calendar_icon.svg',
width: 18,
height: 18,
),
const SizedBox(width: 4),
Flexible(
child: Text(
agent.memberSinceText,
style: const TextStyle(
fontFamily: 'SourceSerif4',
fontSize: 13,
fontWeight: FontWeight.w500,
color: AppColors.primaryDark,
),
overflow: TextOverflow.ellipsis,
maxLines: 1,
),
),
],
);
}),
// Expertise tags (matching web ProfileCard)
if (state.expertiseTags.isNotEmpty) ...[
const SizedBox(height: 14),
Wrap(
alignment: WrapAlignment.center,
spacing: 8,
runSpacing: 8,
children: state.expertiseTags
.map((tag) => Container(
padding: const EdgeInsets.symmetric(
horizontal: 12, vertical: 5),
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.w400,
color: AppColors.primaryDark,
),
),
))
.toList(),
),
],
// Bio with Show More / Show Less
if (state.descriptionText.isNotEmpty) ...[
const SizedBox(height: 16),
SizedBox(
width: 338,
child: Text.rich(
TextSpan(
children: [
TextSpan(
text: _bioExpanded || state.descriptionText.length <= 150
? state.descriptionText
: '${state.descriptionText.substring(0, 150)}... ',
),
if (state.descriptionText.length > 150)
WidgetSpan(
child: GestureDetector(
onTap: () =>
setState(() => _bioExpanded = !_bioExpanded),
child: Text(
_bioExpanded ? ' Show Less' : 'Show More',
style: const TextStyle(
fontFamily: 'Fractul',
fontSize: 14,
fontWeight: FontWeight.w700,
color: AppColors.primaryDark,
decoration: TextDecoration.underline,
),
),
),
),
],
style: const TextStyle(
fontFamily: 'SourceSerif4',
fontSize: 14,
color: AppColors.primaryDark,
height: 1.43,
),
),
textAlign: TextAlign.center,
),
),
],
],
),
);
}
// ── Experience ──
Widget _buildExperienceSection(AgentDetailState state) {
return Padding(
padding: const EdgeInsets.symmetric(horizontal: 17),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
const Center(
child: Text(
'Experience',
style: TextStyle(
fontFamily: 'Fractul',
fontSize: 20,
fontWeight: FontWeight.w700,
color: AppColors.primaryDark,
),
),
),
const SizedBox(height: 22),
// Years (always show, matching web)
_buildExperienceItem('Years in Experience', [
state.yearsInExperience,
]),
// Contracts (always show, matching web)
_buildExperienceItem('Number of contracts closed', [
state.contractsClosed,
]),
// Licensing Areas (always show)
state.licensingAreas.isNotEmpty
? _buildLicensingAreas(state.licensingAreas)
: _buildEmptySection('Licensing & Areas', 'No licensed areas added'),
// Expertise Years (always show)
state.expertiseYears.isNotEmpty
? _buildExpertiseYears(state.expertiseYears)
: _buildEmptySection('Areas in expertise & Years', 'No expertise areas added'),
// Certifications (always show)
state.certifications.isNotEmpty
? _buildCertifications(state.certifications)
: _buildEmptySection('Certifications', 'No certifications added'),
],
),
);
}
Widget _buildExperienceItem(String title, List<String> items) {
return Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(
title,
style: const TextStyle(
fontFamily: 'Fractul',
fontSize: 14,
fontWeight: FontWeight.w700,
color: AppColors.primaryDark,
),
),
const SizedBox(height: 14),
...items.map(
(item) => Padding(
padding: const EdgeInsets.only(left: 10, bottom: 4),
child: Row(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
const Padding(
padding: EdgeInsets.only(top: 2),
child: Text(
'\u2022 ',
style: TextStyle(
fontSize: 15,
color: AppColors.primaryDark,
),
),
),
Expanded(
child: Text(
item == '-' ? 'Not specified' : item,
style: TextStyle(
fontFamily: 'SourceSerif4',
fontSize: 15,
fontWeight: FontWeight.w500,
color: item == '-'
? AppColors.primaryDark.withValues(alpha: 0.4)
: AppColors.primaryDark,
),
),
),
],
),
),
),
const SizedBox(height: 16),
Divider(
color: AppColors.primaryDark.withValues(alpha: 0.15),
height: 1,
),
const SizedBox(height: 10),
],
);
}
Widget _buildEmptySection(String title, String emptyText) {
return Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(
title,
style: const TextStyle(
fontFamily: 'Fractul',
fontSize: 14,
fontWeight: FontWeight.w700,
color: AppColors.primaryDark,
),
),
const SizedBox(height: 14),
Padding(
padding: const EdgeInsets.only(left: 10),
child: Text(
emptyText,
style: TextStyle(
fontFamily: 'SourceSerif4',
fontSize: 14,
fontWeight: FontWeight.w400,
color: AppColors.primaryDark.withValues(alpha: 0.4),
),
),
),
const SizedBox(height: 16),
Divider(
color: AppColors.primaryDark.withValues(alpha: 0.15),
height: 1,
),
const SizedBox(height: 10),
],
);
}
Widget _buildLicensingAreas(List<String> areas) {
return _ExpandableChipsSection(
title: 'Licensing & Areas',
items: areas,
initialCount: 6,
);
}
Widget _buildExpertiseYears(List<Map<String, String>> items) {
final chips = items.map((e) {
final years = e['years'] ?? '';
return years.isNotEmpty ? '${e['area']} $years' : e['area'] ?? '';
}).toList();
return _ExpandableChipsSection(
title: 'Areas in expertise & Years',
items: chips,
initialCount: 4,
);
}
Widget _buildCertifications(List<Map<String, String>> certs) {
return _ExpandableCertificationsSection(certs: certs);
}
// ── Info Cards (Availability, Work Env, Best Experience) ──
Widget _buildInfoCards(AgentDetailState state) {
// 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 (always show)
_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 (always show)
_buildInfoCard(
icon: Icons.landscape_outlined,
title: 'Work Environment',
subtitle: '',
description: workEnv.isNotEmpty ? workEnv : 'Not specified',
isEmpty: workEnv.isEmpty,
),
const SizedBox(height: 16),
// Best experience (always show)
_buildInfoCard(
icon: Icons.star_rounded,
title: 'Best Experience',
subtitle: '',
description: bestExp.isNotEmpty ? bestExp : 'Not specified',
isEmpty: bestExp.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 ──
Widget _buildSpecializationSection(AgentDetailState state) {
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),
if (state.specializationCards.isNotEmpty)
...state.specializationCards.map(
(card) => Padding(
padding: const EdgeInsets.only(bottom: 12),
child: _SpecializationCardWidget(card: card),
),
)
else
Text(
'No specialization data added',
style: TextStyle(
fontFamily: 'SourceSerif4',
fontSize: 14,
fontWeight: FontWeight.w400,
color: AppColors.primaryDark.withValues(alpha: 0.4),
),
),
],
),
);
}
// ── Testimonials ──
Widget _buildTestimonialsSection(AgentDetailState state) {
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]),
),
),
],
),
);
}
}
// ── Expandable Chips Section ──
class _ExpandableChipsSection extends StatefulWidget {
final String title;
final List<String> items;
final int initialCount;
const _ExpandableChipsSection({
required this.title,
required this.items,
this.initialCount = 6,
});
@override
State<_ExpandableChipsSection> createState() =>
_ExpandableChipsSectionState();
}
class _ExpandableChipsSectionState extends State<_ExpandableChipsSection> {
bool _expanded = false;
@override
Widget build(BuildContext context) {
final showItems = _expanded
? widget.items
: widget.items.take(widget.initialCount).toList();
final hasMore = widget.items.length > widget.initialCount;
return Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(
widget.title,
style: const TextStyle(
fontFamily: 'Fractul',
fontSize: 14,
fontWeight: FontWeight.w700,
color: AppColors.primaryDark,
),
),
const SizedBox(height: 14),
Wrap(
spacing: 10,
runSpacing: 10,
children: [
...showItems.map(
(item) => IntrinsicWidth(
child: Container(
height: 28,
padding: const EdgeInsets.symmetric(horizontal: 10),
alignment: Alignment.center,
decoration: BoxDecoration(
borderRadius: BorderRadius.circular(15),
border: Border.all(
color: AppColors.primaryDark,
width: 0.7,
),
),
child: Text(
item,
style: const TextStyle(
fontFamily: 'SourceSerif4',
fontSize: 14,
fontWeight: FontWeight.w500,
color: AppColors.primaryDark,
),
),
),
),
),
if (hasMore || _expanded)
GestureDetector(
onTap: () => setState(() => _expanded = !_expanded),
child: IntrinsicWidth(
child: Container(
height: 28,
padding: const EdgeInsets.symmetric(horizontal: 10),
alignment: Alignment.center,
decoration: BoxDecoration(
borderRadius: BorderRadius.circular(15),
border: Border.all(
color: _expanded
? AppColors.accentOrange
: AppColors.primaryDark.withValues(alpha: 0.15),
width: 1,
),
),
child: Row(
mainAxisSize: MainAxisSize.min,
children: [
Text(
_expanded
? 'Show Less'
: '+${widget.items.length - widget.initialCount} More',
style: TextStyle(
fontFamily: 'SourceSerif4',
fontSize: _expanded ? 13 : 15,
fontWeight: _expanded
? FontWeight.w700
: FontWeight.w300,
color: _expanded
? AppColors.accentOrange
: AppColors.primaryDark,
),
),
if (_expanded) ...[
const SizedBox(width: 2),
const Icon(
Icons.keyboard_arrow_up,
size: 14,
color: AppColors.accentOrange,
),
],
],
),
),
),
),
],
),
const SizedBox(height: 14),
Divider(
color: AppColors.primaryDark.withValues(alpha: 0.15),
height: 1,
),
const SizedBox(height: 10),
],
);
}
}
// ── Expandable Certifications Section ──
class _ExpandableCertificationsSection extends StatefulWidget {
final List<Map<String, String>> certs;
final int initialCount;
const _ExpandableCertificationsSection({
required this.certs,
this.initialCount = 2,
});
@override
State<_ExpandableCertificationsSection> createState() =>
_ExpandableCertificationsSectionState();
}
class _ExpandableCertificationsSectionState
extends State<_ExpandableCertificationsSection> {
bool _expanded = false;
@override
Widget build(BuildContext context) {
final showCerts = _expanded
? widget.certs
: widget.certs.take(widget.initialCount).toList();
final hasMore = widget.certs.length > widget.initialCount;
return Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
const Text(
'Certifications',
style: TextStyle(
fontFamily: 'Fractul',
fontSize: 14,
fontWeight: FontWeight.w700,
color: AppColors.primaryDark,
),
),
const SizedBox(height: 16),
...showCerts.map(
(cert) => Padding(
padding: const EdgeInsets.only(left: 14, bottom: 16),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Row(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
const Padding(
padding: EdgeInsets.only(top: 2),
child: 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: 20, top: 2),
child: Text(
cert['org']!.toUpperCase(),
style: TextStyle(
fontFamily: 'SourceSerif4',
fontSize: 14,
color: AppColors.primaryDark.withValues(alpha: 0.5),
),
),
),
],
),
),
),
if (hasMore || _expanded)
Center(
child: GestureDetector(
onTap: () => setState(() => _expanded = !_expanded),
child: Container(
padding: const EdgeInsets.symmetric(
horizontal: 14,
vertical: 4,
),
decoration: BoxDecoration(
borderRadius: BorderRadius.circular(15),
border: Border.all(color: AppColors.accentOrange, width: 1),
),
child: Row(
mainAxisSize: MainAxisSize.min,
children: [
Text(
_expanded
? 'Show Less'
: '+${widget.certs.length - widget.initialCount} More',
style: const TextStyle(
fontFamily: 'SourceSerif4',
fontSize: 10,
fontWeight: FontWeight.w700,
color: AppColors.accentOrange,
),
),
const SizedBox(width: 2),
Icon(
_expanded
? Icons.keyboard_arrow_up
: Icons.keyboard_arrow_down,
size: 14,
color: AppColors.accentOrange,
),
],
),
),
),
),
const SizedBox(height: 4),
],
);
}
}
// ── Specialization Card ──
class _SpecializationCardWidget extends StatefulWidget {
final SpecializationCard card;
const _SpecializationCardWidget({required this.card});
@override
State<_SpecializationCardWidget> createState() =>
_SpecializationCardWidgetState();
}
class _SpecializationCardWidgetState extends State<_SpecializationCardWidget> {
bool _expanded = false;
static const _initialCount = 3;
IconData get _icon {
final slug = widget.card.slug.toLowerCase();
if (slug.contains('loan') || slug.contains('price')) {
return Icons.attach_money;
}
if (slug.contains('property')) return Icons.apartment;
if (slug.contains('hobby') || slug.contains('interest')) {
return Icons.favorite_border;
}
return Icons.home_outlined;
}
@override
Widget build(BuildContext context) {
final values = _expanded
? widget.card.values
: widget.card.values.take(_initialCount).toList();
final hasMore = widget.card.values.length > _initialCount;
return Container(
width: 298,
constraints: const BoxConstraints(minHeight: 236),
padding: const EdgeInsets.symmetric(vertical: 16),
decoration: BoxDecoration(
color: Colors.white,
borderRadius: BorderRadius.circular(15),
border: Border.all(
color: AppColors.primaryDark.withValues(alpha: 0.7),
width: 0.7,
),
),
child: Column(
children: [
Icon(_icon, size: 32, color: AppColors.accentOrange),
const SizedBox(height: 8),
Text(
widget.card.name,
style: const TextStyle(
fontFamily: 'Fractul',
fontSize: 14,
fontWeight: FontWeight.w700,
color: AppColors.primaryDark,
),
),
const SizedBox(height: 12),
...values.map(
(v) => Padding(
padding: const EdgeInsets.symmetric(vertical: 4),
child: Text(
AgentDetailState.titleCase(v),
style: const TextStyle(
fontFamily: 'SourceSerif4',
fontSize: 14,
color: AppColors.primaryDark,
height: 1.6,
),
textAlign: TextAlign.center,
),
),
),
if (hasMore) ...[
const SizedBox(height: 10),
GestureDetector(
onTap: () => setState(() => _expanded = !_expanded),
child: Container(
padding: const EdgeInsets.symmetric(
horizontal: 14,
vertical: 4,
),
decoration: BoxDecoration(
borderRadius: BorderRadius.circular(15),
border: Border.all(color: AppColors.accentOrange, width: 1),
),
child: Row(
mainAxisSize: MainAxisSize.min,
children: [
Text(
_expanded ? 'Show Less' : 'Show More',
style: const TextStyle(
fontFamily: 'SourceSerif4',
fontSize: 10,
fontWeight: FontWeight.w700,
color: AppColors.accentOrange,
),
),
const SizedBox(width: 2),
Icon(
_expanded
? Icons.keyboard_arrow_up
: Icons.keyboard_arrow_down,
size: 14,
color: AppColors.accentOrange,
),
],
),
),
),
],
],
),
);
}
}
// ── 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,
),
),
],
],
),
);
}
}
// ── Connect Modal Content (matches web ConnectRequestModal) ──
class _ConnectModalContent extends StatefulWidget {
final String agentName;
final TextEditingController controller;
final Future<bool> Function(String? message) onSend;
const _ConnectModalContent({
required this.agentName,
required this.controller,
required this.onSend,
});
@override
State<_ConnectModalContent> createState() => _ConnectModalContentState();
}
class _ConnectModalContentState extends State<_ConnectModalContent> {
bool _isSubmitting = false;
bool _success = false;
String? _error;
void _closeModal() {
if (mounted) {
Navigator.of(context, rootNavigator: false).pop();
}
}
Future<void> _handleSend() async {
setState(() {
_isSubmitting = true;
_error = null;
});
try {
final success = await widget.onSend(widget.controller.text.trim());
if (success) {
setState(() => _success = true);
// Auto-close after 2 seconds (matching web)
Future.delayed(const Duration(seconds: 2), () {
_closeModal();
});
} else {
setState(() => _error = 'Failed to send connection request');
}
} catch (e) {
setState(() => _error = e.toString());
} finally {
if (mounted) setState(() => _isSubmitting = false);
}
}
@override
Widget build(BuildContext context) {
return Padding(
padding: EdgeInsets.only(bottom: MediaQuery.of(context).viewInsets.bottom),
child: Container(
constraints: BoxConstraints(
maxHeight: MediaQuery.of(context).size.height * 0.85,
),
decoration: const BoxDecoration(
color: Colors.white,
borderRadius: BorderRadius.vertical(top: Radius.circular(20)),
),
child: SafeArea(
top: false,
child: SingleChildScrollView(
padding: const EdgeInsets.fromLTRB(24, 24, 24, 24),
child: _success ? _buildSuccessContent() : _buildFormContent(),
),
),
),
);
}
Widget _buildSuccessContent() {
return Column(
mainAxisSize: MainAxisSize.min,
children: [
const SizedBox(height: 16),
Container(
width: 64,
height: 64,
decoration: BoxDecoration(
color: const Color(0xFFDCFCE7), // green-100
shape: BoxShape.circle,
),
child: const Icon(
Icons.check,
size: 32,
color: Color(0xFF22C55E), // green-500
),
),
const SizedBox(height: 16),
const Text(
'Request Sent!',
style: TextStyle(
fontFamily: 'Fractul',
fontSize: 18,
fontWeight: FontWeight.w700,
color: AppColors.primaryDark,
),
),
const SizedBox(height: 8),
Text(
'Your connection request has been sent to ${widget.agentName}.',
style: TextStyle(
fontFamily: 'SourceSerif4',
fontSize: 14,
color: AppColors.primaryDark.withValues(alpha: 0.7),
),
textAlign: TextAlign.center,
),
const SizedBox(height: 24),
],
);
}
Widget _buildFormContent() {
return Column(
mainAxisSize: MainAxisSize.min,
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Row(
mainAxisAlignment: MainAxisAlignment.spaceBetween,
children: [
Expanded(
child: Text(
'Connect with ${widget.agentName}',
style: const TextStyle(
fontFamily: 'Fractul',
fontSize: 18,
fontWeight: FontWeight.w700,
color: AppColors.primaryDark,
),
),
),
GestureDetector(
onTap: _closeModal,
child: Icon(
Icons.close,
color: AppColors.primaryDark.withValues(alpha: 0.5),
),
),
],
),
const SizedBox(height: 4),
Text(
'Send a connection request to start communicating with this agent.',
style: TextStyle(
fontFamily: 'SourceSerif4',
fontSize: 14,
color: AppColors.primaryDark.withValues(alpha: 0.7),
),
),
const SizedBox(height: 16),
const Text(
'Message (Optional)',
style: TextStyle(
fontFamily: 'Fractul',
fontSize: 14,
fontWeight: FontWeight.w500,
color: AppColors.primaryDark,
),
),
const SizedBox(height: 8),
TextField(
controller: widget.controller,
maxLines: 4,
maxLength: 1000,
decoration: InputDecoration(
hintText:
'Introduce yourself and explain what you\'re looking for...',
hintStyle: TextStyle(
fontFamily: 'SourceSerif4',
fontSize: 14,
color: AppColors.primaryDark.withValues(alpha: 0.4),
),
border: OutlineInputBorder(
borderRadius: BorderRadius.circular(10),
borderSide: BorderSide(
color: AppColors.primaryDark.withValues(alpha: 0.2),
),
),
enabledBorder: OutlineInputBorder(
borderRadius: BorderRadius.circular(10),
borderSide: BorderSide(
color: AppColors.primaryDark.withValues(alpha: 0.2),
),
),
focusedBorder: OutlineInputBorder(
borderRadius: BorderRadius.circular(10),
borderSide: const BorderSide(color: AppColors.accentOrange),
),
),
),
if (_error != null) ...[
const SizedBox(height: 8),
Container(
width: double.infinity,
padding: const EdgeInsets.all(12),
decoration: BoxDecoration(
color: const Color(0xFFFEF2F2), // red-50
borderRadius: BorderRadius.circular(10),
border: Border.all(color: const Color(0xFFFECACA)), // red-200
),
child: Text(
_error!,
style: const TextStyle(
fontFamily: 'SourceSerif4',
fontSize: 14,
color: Color(0xFFDC2626), // red-600
),
),
),
],
const SizedBox(height: 16),
Row(
children: [
// Cancel button
Expanded(
child: SizedBox(
height: 48,
child: OutlinedButton(
onPressed: _closeModal,
style: OutlinedButton.styleFrom(
side: BorderSide(
color: AppColors.primaryDark.withValues(alpha: 0.2),
),
shape: RoundedRectangleBorder(
borderRadius: BorderRadius.circular(24),
),
),
child: const Text(
'Cancel',
style: TextStyle(
fontFamily: 'Fractul',
fontSize: 14,
fontWeight: FontWeight.w600,
color: AppColors.primaryDark,
),
),
),
),
),
const SizedBox(width: 12),
// Send Request button
Expanded(
child: SizedBox(
height: 48,
child: ElevatedButton(
onPressed: _isSubmitting ? null : _handleSend,
style: ElevatedButton.styleFrom(
backgroundColor: AppColors.accentOrange,
foregroundColor: Colors.white,
disabledBackgroundColor:
AppColors.accentOrange.withValues(alpha: 0.5),
disabledForegroundColor: Colors.white,
shape: RoundedRectangleBorder(
borderRadius: BorderRadius.circular(24),
),
padding: const EdgeInsets.symmetric(horizontal: 16),
),
child: FittedBox(
fit: BoxFit.scaleDown,
child: Text(
_isSubmitting ? 'Sending...' : 'Send Request',
style: const TextStyle(
fontFamily: 'Fractul',
fontSize: 14,
fontWeight: FontWeight.w600,
color: Colors.white,
),
),
),
),
),
),
],
),
],
);
}
}