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

1962 lines
62 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 '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,
),
),
),
),
),
),
],
),
],
);
}
}