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

1422 lines
45 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: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';
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;
@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 SingleChildScrollView(
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 ──
if (_hasExperience(state)) ...[
const SizedBox(height: 24),
_buildExperienceSection(state),
],
// ── Info Cards (Availability, Work Env, Best Experience) ──
const SizedBox(height: 24),
_buildInfoCards(state),
// ── Specialization Section ──
if (state.specializationCards.isNotEmpty) ...[
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 ──
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: [
// Available row
_buildStatusRow(
isAvailable: true,
label: 'Available.',
active: isAvailable,
connState: connState,
onConnect: isAvailable
? () => _handleConnect(state)
: null,
),
const SizedBox(height: 10),
// Unavailable row
_buildStatusRow(
isAvailable: false,
label: 'Unavailable.',
active: !isAvailable,
connState: connState,
onConnect: !isAvailable
? () => _handleConnect(state)
: null,
),
],
),
);
}
Widget _buildStatusRow({
required bool isAvailable,
required String label,
required bool active,
required String connState,
VoidCallback? onConnect,
}) {
return Container(
height: 55,
decoration: BoxDecoration(
borderRadius: BorderRadius.circular(15),
border: Border.all(
color: AppColors.primaryDark.withValues(alpha: 0.15), width: 1),
),
child: Row(
children: [
const SizedBox(width: 20),
// Status dot
Container(
width: 14,
height: 12,
decoration: BoxDecoration(
shape: BoxShape.circle,
color: active
? (isAvailable ? const Color(0xFF4CAF50) : Colors.transparent)
: Colors.transparent,
border: Border.all(
color: active
? (isAvailable
? const Color(0xFF4CAF50)
: AppColors.primaryDark.withValues(alpha: 0.3))
: AppColors.primaryDark.withValues(alpha: 0.15),
width: 2,
),
),
),
const SizedBox(width: 12),
Text(
label,
style: TextStyle(
fontFamily: 'Fractul',
fontSize: 12,
fontWeight: FontWeight.w700,
color: active
? AppColors.primaryDark
: AppColors.primaryDark.withValues(alpha: 0.4),
),
),
const Spacer(),
// Connect button
if (active) _buildConnectButton(connState, isAvailable, onConnect),
const SizedBox(width: 4),
],
),
);
}
Widget _buildConnectButton(
String connState, bool isAvailable, VoidCallback? onConnect) {
Color bgColor;
Color textColor;
String label;
bool enabled;
switch (connState) {
case 'pending':
bgColor = const Color(0xFFFFF3CD);
textColor = const Color(0xFF856404);
label = 'Pending';
enabled = false;
break;
case 'accepted':
bgColor = AppColors.primaryDark;
textColor = const Color(0xFFF0F5FC);
label = 'Unlink';
enabled = true;
break;
default:
bgColor = isAvailable ? AppColors.accentOrange : AppColors.primaryDark;
textColor = isAvailable ? AppColors.primaryDark : const Color(0xFFF0F5FC);
label = 'Connect';
enabled = true;
}
return GestureDetector(
onTap: enabled ? onConnect : null,
child: Container(
height: 55,
width: 164,
decoration: BoxDecoration(
color: bgColor,
borderRadius: BorderRadius.circular(15),
border: isAvailable && connState == 'none'
? Border.all(color: AppColors.accentOrange)
: null,
),
alignment: Alignment.center,
child: Text(
label,
style: TextStyle(
fontFamily: 'Fractul',
fontSize: 14,
fontWeight: FontWeight.w400,
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) => Padding(
padding: EdgeInsets.only(
bottom: MediaQuery.of(ctx).viewInsets.bottom),
child: Container(
decoration: const BoxDecoration(
color: Colors.white,
borderRadius: BorderRadius.vertical(top: Radius.circular(20)),
),
padding: const EdgeInsets.all(24),
child: Column(
mainAxisSize: MainAxisSize.min,
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Row(
mainAxisAlignment: MainAxisAlignment.spaceBetween,
children: [
Text(
'Connect with ${state.agent?.fullName ?? ''}',
style: const TextStyle(
fontFamily: 'Fractul',
fontSize: 18,
fontWeight: FontWeight.w700,
color: AppColors.primaryDark,
),
),
GestureDetector(
onTap: () => Navigator.pop(ctx),
child: const Icon(Icons.close, color: AppColors.primaryDark),
),
],
),
const SizedBox(height: 8),
const Text(
'Send a connection request to this agent.',
style: TextStyle(
fontFamily: 'SourceSerif4',
fontSize: 14,
color: AppColors.primaryDark,
),
),
const SizedBox(height: 16),
TextField(
controller: controller,
maxLines: 4,
maxLength: 1000,
decoration: InputDecoration(
hintText: 'Introduce yourself... (optional)',
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.15)),
),
),
),
const SizedBox(height: 16),
SizedBox(
width: double.infinity,
height: 50,
child: ElevatedButton(
onPressed: () async {
Navigator.pop(ctx);
await ref
.read(agentDetailProvider(widget.agentId).notifier)
.connect(message: controller.text);
},
style: ElevatedButton.styleFrom(
backgroundColor: AppColors.accentOrange,
shape: RoundedRectangleBorder(
borderRadius: BorderRadius.circular(15)),
),
child: const Text(
'Send Request',
style: TextStyle(
fontFamily: 'Fractul',
fontSize: 16,
fontWeight: FontWeight.w700,
color: Colors.white,
),
),
),
),
],
),
),
),
);
}
// ── 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,
),
),
),
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) on same line
Row(
mainAxisAlignment: MainAxisAlignment.center,
crossAxisAlignment: CrossAxisAlignment.baseline,
textBaseline: TextBaseline.alphabetic,
children: [
Text(
agent.firstName,
style: const TextStyle(
fontFamily: 'Fractul',
fontSize: 18,
fontWeight: FontWeight.w700,
color: AppColors.primaryDark,
),
),
const SizedBox(width: 4),
if (agent.isVerified) ...[
const Padding(
padding: EdgeInsets.only(bottom: 1),
child: Icon(Icons.verified, size: 16, color: Color(0xFF2196F3)),
),
const SizedBox(width: 4),
],
Text(
agent.lastName,
style: const TextStyle(
fontFamily: 'Fractul',
fontSize: 18,
fontWeight: FontWeight.w400,
color: AppColors.primaryDark,
),
),
if (agent.isVerified) ...[
const SizedBox(width: 6),
const 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
Row(
mainAxisAlignment: MainAxisAlignment.center,
children: [
if (agent.location.isNotEmpty) ...[
Icon(Icons.location_on,
size: 16, color: AppColors.primaryDark.withValues(alpha: 0.7)),
const SizedBox(width: 4),
Text(
agent.location,
style: const TextStyle(
fontFamily: 'SourceSerif4',
fontSize: 14,
fontWeight: FontWeight.w700,
color: AppColors.primaryDark,
),
),
const SizedBox(width: 20),
],
Icon(Icons.calendar_today,
size: 14, color: AppColors.primaryDark.withValues(alpha: 0.7)),
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 ──
bool _hasExperience(AgentDetailState s) =>
s.yearsInExperience != '-' ||
s.contractsClosed != '-' ||
s.licensingAreas.isNotEmpty ||
s.expertiseYears.isNotEmpty ||
s.certifications.isNotEmpty;
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
if (state.yearsInExperience != '-')
_buildExperienceItem(
'Years in Experience', [state.yearsInExperience]),
// Contracts
if (state.contractsClosed != '-')
_buildExperienceItem(
'Number of contracts closed', [state.contractsClosed]),
// Licensing Areas
if (state.licensingAreas.isNotEmpty)
_buildLicensingAreas(state.licensingAreas),
// Expertise Years
if (state.expertiseYears.isNotEmpty)
_buildExpertiseYears(state.expertiseYears),
// Certifications
if (state.certifications.isNotEmpty)
_buildCertifications(state.certifications),
],
),
);
}
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,
style: const TextStyle(
fontFamily: 'SourceSerif4',
fontSize: 15,
fontWeight: FontWeight.w500,
color: AppColors.primaryDark,
),
),
),
],
),
)),
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 Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
const Text(
'Certifications',
style: TextStyle(
fontFamily: 'Fractul',
fontSize: 14,
fontWeight: FontWeight.w700,
color: AppColors.primaryDark,
),
),
const SizedBox(height: 16),
...certs.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),
),
),
),
],
),
)),
const SizedBox(height: 4),
],
);
}
// ── 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
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,
title: '',
subtitle: '',
description: workEnv,
),
],
if (bestExp.isNotEmpty) ...[
const SizedBox(height: 16),
_buildInfoCard(
icon: Icons.star_rounded,
title: '',
subtitle: '',
description: bestExp,
),
],
],
),
);
}
Widget _buildInfoCard({
required IconData icon,
required String title,
required 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) ...[
const SizedBox(height: 8),
Text(
description,
style: const TextStyle(
fontFamily: 'SourceSerif4',
fontSize: 14,
fontWeight: FontWeight.w600,
color: 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),
...state.specializationCards
.map((card) => Padding(
padding: const EdgeInsets.only(bottom: 12),
child: _SpecializationCardWidget(card: card),
)),
],
),
);
}
// ── 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: 320,
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 = true),
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: AppColors.primaryDark.withValues(alpha: 0.15),
width: 1),
),
child: Text(
'+${widget.items.length - widget.initialCount} More',
style: const TextStyle(
fontFamily: 'SourceSerif4',
fontSize: 15,
fontWeight: FontWeight.w300,
color: AppColors.primaryDark,
),
),
),
),
),
],
),
const SizedBox(height: 14),
Divider(color: AppColors.primaryDark.withValues(alpha: 0.15), height: 1),
const SizedBox(height: 10),
],
);
}
}
// ── 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 ──
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).withAlpha(40)
..maskFilter = const MaskFilter.blur(BlurStyle.normal, 4);
final path = Path()
..moveTo(0, 0)
..lineTo(size.width, 0)
..lineTo(size.width / 2, size.height)
..close();
canvas.drawPath(path, shadowPaint);
canvas.drawPath(path, paint);
}
@override
bool shouldRepaint(covariant CustomPainter oldDelegate) => false;
}