Files
mobile-app/lib/features/home/presentation/widgets/top_professionals_section.dart

811 lines
27 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:shimmer/shimmer.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/agents_provider.dart';
import 'package:real_estate_mobile/features/home/data/models/landing_page_content.dart';
import 'package:real_estate_mobile/features/home/presentation/providers/home_provider.dart';
/// Local fallback images for professionals when API returns no avatar.
const _fallbackImages = [
'assets/images/professional-1.jpg',
'assets/images/professional-2.jpg',
'assets/images/professional-3.jpg',
];
/// Default CMS content for title/CTA text only.
const _defaultCmsContent = TopProfessionalsContent(
title: '"Meet top real estate professionals"',
ctaText: 'Discover 5,000+ Top Real Estate Agents in Our Network.',
ctaButtonText: 'See All Agents',
agents: [],
lenders: [],
);
class TopProfessionalsSection extends ConsumerStatefulWidget {
const TopProfessionalsSection({super.key});
@override
ConsumerState<TopProfessionalsSection> createState() =>
_TopProfessionalsSectionState();
}
class _TopProfessionalsSectionState
extends ConsumerState<TopProfessionalsSection> {
int _currentPage = 0;
late ScrollController _scrollController;
@override
void initState() {
super.initState();
_scrollController = ScrollController();
_scrollController.addListener(_onScroll);
}
@override
void dispose() {
_scrollController.removeListener(_onScroll);
_scrollController.dispose();
super.dispose();
}
void _onScroll() {
if (!_scrollController.hasClients) return;
final cardWidth = MediaQuery.of(context).size.width - 48;
if (cardWidth <= 0) return;
final page = (_scrollController.offset / cardWidth).round();
if (page != _currentPage) {
setState(() => _currentPage = page);
}
}
@override
Widget build(BuildContext context) {
// CMS data for title/CTA text only
final homeState = ref.watch(homeProvider);
final cmsContent = homeState.content?.topProfessionals ?? _defaultCmsContent;
// Real agent data from GET /agents API (like web does)
final topProState = ref.watch(topProfessionalsProvider);
final activeList = topProState.activeList;
return Padding(
padding: const EdgeInsets.symmetric(horizontal: 24),
child: Column(
children: [
Text(
cmsContent.title.isNotEmpty
? cmsContent.title
: _defaultCmsContent.title,
textAlign: TextAlign.center,
style: const TextStyle(
fontFamily: 'Fractul',
fontSize: 25,
fontWeight: FontWeight.w700,
color: AppColors.primaryDark,
),
),
const SizedBox(height: 24),
// Agents / Lenders Tab
_buildCategoryTab(topProState.activeTab),
const SizedBox(height: 24),
// Content
if (topProState.isLoading)
_buildShimmerCards()
else if (topProState.error != null)
_buildErrorState(topProState.error!)
else if (activeList.isEmpty)
_buildEmptyState(topProState.activeTab)
else ...[
// Professional Cards - horizontal scroll
SizedBox(
height: 480,
child: ListView.builder(
controller: _scrollController,
scrollDirection: Axis.horizontal,
physics: const BouncingScrollPhysics(),
itemCount: activeList.length,
itemBuilder: (context, index) {
final cardWidth = MediaQuery.of(context).size.width - 48;
return SizedBox(
width: cardWidth,
child: Padding(
padding: EdgeInsets.only(
left: index == 0 ? 0 : 8,
right: index == activeList.length - 1 ? 0 : 8,
),
child: _buildProfessionalCard(activeList[index], index: index),
),
);
},
),
),
const SizedBox(height: 12),
// Progress indicator
_buildProgressIndicator(activeList.length),
],
const SizedBox(height: 32),
// See All Agents CTA
_buildSeeAllAgentsCta(cmsContent),
],
),
);
}
Widget _buildCategoryTab(String activeTab) {
return Container(
height: 45,
decoration: BoxDecoration(
borderRadius: BorderRadius.circular(15),
border: Border.all(color: AppColors.primaryDark, width: 0.7),
),
child: Row(
children: [
// Agents tab
Expanded(
child: GestureDetector(
onTap: () {
ref.read(topProfessionalsProvider.notifier).setActiveTab('agents');
setState(() => _currentPage = 0);
if (_scrollController.hasClients) {
_scrollController.jumpTo(0);
}
},
child: Container(
alignment: Alignment.center,
decoration: BoxDecoration(
color: activeTab == 'agents'
? AppColors.accentOrange
: Colors.transparent,
borderRadius: BorderRadius.circular(15),
),
child: Row(
mainAxisSize: MainAxisSize.min,
children: [
SvgPicture.asset(
'assets/icons/agents_tab_icon.svg',
width: 24,
height: 24,
colorFilter: ColorFilter.mode(
activeTab == 'agents'
? Colors.white
: AppColors.primaryDark,
BlendMode.srcIn,
),
placeholderBuilder: (_) => Icon(
Icons.people,
color: activeTab == 'agents'
? Colors.white
: AppColors.primaryDark,
size: 24,
),
),
const SizedBox(width: 8),
Text(
'Agents',
style: TextStyle(
fontFamily: 'SourceSerif4',
fontSize: 17,
fontWeight: FontWeight.w600,
color: activeTab == 'agents'
? Colors.white
: AppColors.primaryDark,
),
),
],
),
),
),
),
// Lenders tab
Expanded(
child: GestureDetector(
onTap: () {
ref.read(topProfessionalsProvider.notifier).setActiveTab('lenders');
setState(() => _currentPage = 0);
if (_scrollController.hasClients) {
_scrollController.jumpTo(0);
}
},
child: Container(
alignment: Alignment.center,
decoration: BoxDecoration(
color: activeTab == 'lenders'
? AppColors.accentOrange
: Colors.transparent,
borderRadius: BorderRadius.circular(15),
),
child: Row(
mainAxisSize: MainAxisSize.min,
children: [
SvgPicture.asset(
'assets/icons/lenders_tab_icon.svg',
width: 24,
height: 24,
colorFilter: ColorFilter.mode(
activeTab == 'lenders'
? Colors.white
: AppColors.primaryDark,
BlendMode.srcIn,
),
placeholderBuilder: (_) => Icon(
Icons.calendar_today,
color: activeTab == 'lenders'
? Colors.white
: AppColors.primaryDark,
size: 24,
),
),
const SizedBox(width: 8),
Text(
'Lenders',
style: TextStyle(
fontFamily: 'SourceSerif4',
fontSize: 17,
fontWeight: FontWeight.w600,
color: activeTab == 'lenders'
? Colors.white
: AppColors.primaryDark,
),
),
],
),
),
),
),
],
),
);
}
Widget _buildImagePlaceholder(int index) {
final assetPath = _fallbackImages[index % _fallbackImages.length];
return Image.asset(
assetPath,
width: double.infinity,
height: double.infinity,
fit: BoxFit.cover,
errorBuilder: (context, error, stackTrace) => _buildAvatarPlaceholder(),
);
}
// -- Professional Card using real AgentProfile data --
Widget _buildProfessionalCard(AgentProfile agent, {int index = 0}) {
final rating = agent.averageRating ?? 0.0;
final expertise = agent.specializations;
final experience = agent.experienceText;
final subtitle = agent.agentType?.name ?? agent.headline ?? '';
return Container(
decoration: BoxDecoration(
color: Colors.white,
borderRadius: BorderRadius.circular(15),
border: Border.all(color: AppColors.primaryDark, width: 0.1),
),
clipBehavior: Clip.antiAlias,
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
// Image expands to fill available space above the content
Expanded(
child: ClipRRect(
borderRadius:
const BorderRadius.vertical(top: Radius.circular(15)),
child: S3Image(
imageUrl: agent.avatarUrl,
width: double.infinity,
height: double.infinity,
fit: BoxFit.cover,
alignment: Alignment.topCenter,
placeholder: (_) => _buildImagePlaceholder(index),
errorWidget: (_) => _buildImagePlaceholder(index),
),
),
),
// Content
Padding(
padding: const EdgeInsets.fromLTRB(16, 12, 16, 12),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
// Row 1: Name only (matches Figma 49:6310)
Text(
agent.fullName,
style: const TextStyle(
fontFamily: 'Fractul',
fontSize: 14,
fontWeight: FontWeight.w700,
color: AppColors.primaryDark,
),
overflow: TextOverflow.ellipsis,
),
const SizedBox(height: 4),
// Row 2: Subtitle/headline
if (subtitle.isNotEmpty)
Text(
'($subtitle)',
style: const TextStyle(
fontFamily: 'SourceSerif4',
fontSize: 10,
fontWeight: FontWeight.w400,
color: AppColors.primaryDark,
),
maxLines: 1,
overflow: TextOverflow.ellipsis,
),
const SizedBox(height: 4),
// Row 3: Verified badge + "Verified local agent" in green (Figma 49:6319)
if (agent.isVerified)
Row(
children: [
SvgPicture.asset(
'assets/icons/verified_badge_icon.svg',
width: 16,
height: 16,
placeholderBuilder: (_) => const Icon(
Icons.verified,
color: Color(0xFF638559),
size: 16,
),
),
const SizedBox(width: 6),
const Text(
'\u201CVerified local agent\u201D',
style: TextStyle(
fontFamily: 'SourceSerif4',
fontSize: 14,
fontWeight: FontWeight.w500,
color: Color(0xFF638559),
),
),
],
),
const SizedBox(height: 8),
// Row 4: Location
if (agent.location.isNotEmpty) ...[
const Text(
'Location:',
style: TextStyle(
fontFamily: 'Fractul',
fontSize: 14,
fontWeight: FontWeight.w700,
color: AppColors.primaryDark,
),
),
const SizedBox(height: 2),
Row(
children: [
SvgPicture.asset(
'assets/icons/location_pin_icon.svg',
width: 14,
height: 14,
placeholderBuilder: (_) => const Icon(
Icons.location_on,
color: AppColors.accentOrange,
size: 14,
),
),
const SizedBox(width: 4),
Expanded(
child: Text(
agent.location,
style: const TextStyle(
fontFamily: 'SourceSerif4',
fontSize: 10,
fontWeight: FontWeight.w400,
color: AppColors.primaryDark,
),
overflow: TextOverflow.ellipsis,
),
),
],
),
const SizedBox(height: 8),
],
// Row 5: Expertise tags
if (expertise.isNotEmpty) ...[
const Text(
'Expertise:',
style: TextStyle(
fontFamily: 'Fractul',
fontSize: 14,
fontWeight: FontWeight.w700,
color: AppColors.primaryDark,
),
),
const SizedBox(height: 6),
Wrap(
spacing: 8,
runSpacing: 6,
children: expertise
.take(6)
.map((tag) => _buildTag(tag))
.toList(),
),
const SizedBox(height: 12),
],
// Experience text
if (experience.isNotEmpty) ...[
RichText(
text: TextSpan(
children: [
const TextSpan(
text: 'Experience: ',
style: TextStyle(
fontFamily: 'Fractul',
fontSize: 14,
fontWeight: FontWeight.w700,
color: AppColors.primaryDark,
),
),
TextSpan(
text: experience,
style: const TextStyle(
fontFamily: 'SourceSerif4',
fontSize: 14,
fontWeight: FontWeight.w400,
color: AppColors.primaryDark,
),
),
],
),
maxLines: 2,
overflow: TextOverflow.ellipsis,
),
const SizedBox(height: 8),
],
// Star rating row at bottom
_buildStarRating(rating),
],
),
),
],
),
);
}
Widget _buildShimmerCards() {
return SizedBox(
height: 480,
child: Shimmer.fromColors(
baseColor: const Color(0xFFE0E0E0),
highlightColor: const Color(0xFFF5F5F5),
child: Container(
decoration: BoxDecoration(
color: Colors.white,
borderRadius: BorderRadius.circular(15),
),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
// Image placeholder
Container(
height: 192,
decoration: const BoxDecoration(
color: Colors.white,
borderRadius: BorderRadius.vertical(top: Radius.circular(15)),
),
),
Padding(
padding: const EdgeInsets.fromLTRB(16, 12, 16, 12),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Container(height: 16, width: 160, color: Colors.white),
const SizedBox(height: 8),
Container(height: 12, width: 100, color: Colors.white),
const SizedBox(height: 12),
Container(height: 14, width: 180, color: Colors.white),
const SizedBox(height: 12),
Container(height: 12, width: 140, color: Colors.white),
const SizedBox(height: 12),
Row(
children: List.generate(
3,
(_) => Container(
height: 28,
width: 70,
margin: const EdgeInsets.only(right: 8),
decoration: BoxDecoration(
color: Colors.white,
borderRadius: BorderRadius.circular(15),
),
),
),
),
const SizedBox(height: 12),
Container(height: 14, width: 200, color: Colors.white),
const SizedBox(height: 12),
Row(
children: List.generate(
5,
(_) => Padding(
padding: const EdgeInsets.only(right: 4),
child: Container(
height: 18,
width: 18,
decoration: const BoxDecoration(
color: Colors.white,
shape: BoxShape.circle,
),
),
),
),
),
],
),
),
],
),
),
),
);
}
Widget _buildStarRating(double rating) {
final fullStars = rating.floor();
final hasHalfStar = (rating - fullStars) >= 0.3;
return Row(
children: List.generate(5, (index) {
if (index < fullStars) {
return Padding(
padding: const EdgeInsets.only(right: 4),
child: SvgPicture.asset(
'assets/icons/star_rating_icon.svg',
width: 18,
height: 18,
placeholderBuilder: (_) => const Icon(
Icons.star,
color: Color(0xFFFFDE21),
size: 18,
),
),
);
} else if (index == fullStars && hasHalfStar) {
return const Padding(
padding: EdgeInsets.only(right: 4),
child: Icon(
Icons.star_half,
color: Color(0xFFFFDE21),
size: 18,
),
);
} else {
return const Padding(
padding: EdgeInsets.only(right: 4),
child: Icon(
Icons.star_border,
color: Color(0xFFFFDE21),
size: 18,
),
);
}
}),
);
}
Widget _buildAvatarPlaceholder() {
return Container(
width: double.infinity,
height: 192,
color: AppColors.gradientStart,
child: const Icon(Icons.person, size: 60, color: AppColors.primaryDark),
);
}
Widget _buildTag(String label) {
return Container(
padding: const EdgeInsets.symmetric(horizontal: 10, vertical: 4),
decoration: BoxDecoration(
borderRadius: BorderRadius.circular(15),
border: Border.all(color: AppColors.primaryDark, width: 0.7),
),
child: Text(
label,
style: const TextStyle(
fontFamily: 'SourceSerif4',
fontSize: 13,
fontWeight: FontWeight.w400,
color: AppColors.primaryDark,
),
),
);
}
Widget _buildProgressIndicator(int totalCards) {
return LayoutBuilder(
builder: (context, constraints) {
final indicatorWidth =
totalCards > 0 ? constraints.maxWidth / totalCards : 103.0;
final offset = totalCards > 0
? (_currentPage * constraints.maxWidth / totalCards)
: 0.0;
return Stack(
children: [
Container(
height: 5,
decoration: BoxDecoration(
color: Colors.white,
borderRadius: BorderRadius.circular(15),
border: Border.all(color: AppColors.primaryDark, width: 0.1),
),
),
AnimatedPositioned(
duration: const Duration(milliseconds: 200),
left: offset,
child: Container(
height: 5,
width: indicatorWidth.clamp(40.0, 150.0),
decoration: BoxDecoration(
color: AppColors.primaryDark,
borderRadius: BorderRadius.circular(15),
),
),
),
],
);
},
);
}
Widget _buildErrorState(String error) {
return Padding(
padding: const EdgeInsets.symmetric(vertical: 40),
child: Column(
children: [
const Icon(
Icons.error_outline,
color: AppColors.accentOrange,
size: 48,
),
const SizedBox(height: 12),
Text(
error,
style: const TextStyle(
fontFamily: 'Fractul',
fontSize: 14,
fontWeight: FontWeight.w400,
color: AppColors.primaryDark,
),
),
const SizedBox(height: 16),
TextButton(
onPressed: () =>
ref.read(topProfessionalsProvider.notifier).refresh(),
child: const Text(
'Retry',
style: TextStyle(
fontFamily: 'Fractul',
fontSize: 14,
fontWeight: FontWeight.w600,
color: AppColors.accentOrange,
),
),
),
],
),
);
}
Widget _buildEmptyState(String activeTab) {
return Padding(
padding: const EdgeInsets.symmetric(vertical: 40),
child: Column(
children: [
Icon(
activeTab == 'agents'
? Icons.people_outline
: Icons.account_balance_outlined,
color: AppColors.hintText,
size: 48,
),
const SizedBox(height: 12),
Text(
'No ${activeTab == 'agents' ? 'agents' : 'lenders'} found',
style: const TextStyle(
fontFamily: 'Fractul',
fontSize: 14,
fontWeight: FontWeight.w400,
color: AppColors.hintText,
),
),
],
),
);
}
Widget _buildSeeAllAgentsCta(TopProfessionalsContent content) {
final ctaText = content.ctaText.isNotEmpty
? content.ctaText
: _defaultCmsContent.ctaText;
final ctaButtonText = content.ctaButtonText.isNotEmpty
? content.ctaButtonText
: _defaultCmsContent.ctaButtonText;
return Container(
width: double.infinity,
padding: const EdgeInsets.all(24),
decoration: BoxDecoration(
gradient: const LinearGradient(
begin: Alignment.topCenter,
end: Alignment.bottomCenter,
colors: [AppColors.gradientStart, AppColors.gradientEnd],
),
borderRadius: BorderRadius.circular(15),
border: Border.all(color: AppColors.primaryDark, width: 0.2),
boxShadow: const [
BoxShadow(
color: Color(0xFFD9D9D9),
offset: Offset(10, 0),
blurRadius: 20,
),
],
),
child: Column(
children: [
Image.asset(
'assets/icons/network_icon.png',
width: 30,
height: 23,
errorBuilder: (_, e, s) => const Icon(
Icons.hub,
color: AppColors.primaryDark,
size: 30,
),
),
const SizedBox(height: 12),
Text(
ctaText,
textAlign: TextAlign.center,
style: const TextStyle(
fontFamily: 'SourceSerif4',
fontSize: 14,
fontWeight: FontWeight.w400,
color: AppColors.primaryDark,
),
),
const SizedBox(height: 16),
SizedBox(
width: double.infinity,
child: ElevatedButton(
onPressed: () => context.push('/agents/search'),
style: ElevatedButton.styleFrom(
backgroundColor: AppColors.accentOrange,
foregroundColor: AppColors.primaryDark,
padding: const EdgeInsets.symmetric(vertical: 14),
shape: RoundedRectangleBorder(
borderRadius: BorderRadius.circular(10),
),
),
child: Text(
ctaButtonText,
style: const TextStyle(
fontFamily: 'Fractul',
fontSize: 16,
fontWeight: FontWeight.w700,
),
),
),
),
],
),
);
}
}