569 lines
20 KiB
Dart
569 lines
20 KiB
Dart
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/network/api_client.dart';
|
|
import 'package:real_estate_mobile/core/utils/image_url_resolver.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';
|
|
|
|
/// Default features content — matches web's hardcoded defaultContent fallback.
|
|
const _defaultFeatures = FeaturesContent(
|
|
title: 'Find Trusted Real Estate Professionals On Demand',
|
|
subtitle:
|
|
'Quickly connect with the right agents exactly when you need them.',
|
|
features: [
|
|
FeatureItem(
|
|
iconPath: 'hire_quickly',
|
|
title: 'Hire Quickly',
|
|
description:
|
|
'Connect with experienced local real estate agents who match your needs and preferences. Our simple process helps you get started quickly and move forward without delays or unnecessary complexity.',
|
|
),
|
|
FeatureItem(
|
|
iconPath: 'verified',
|
|
title: 'Verified Agents',
|
|
description:
|
|
'All agents are carefully identity-verified and background-checked to ensure trust and safety. You can confidently work with professionals who meet quality and reliability standards.',
|
|
),
|
|
FeatureItem(
|
|
iconPath: 'top_rated',
|
|
title: 'Top Rated',
|
|
description:
|
|
'Work with highly rated agents known for strong expertise and positive client feedback. Their consistent performance helps you make informed and confident property decisions.',
|
|
),
|
|
FeatureItem(
|
|
iconPath: 'trusted',
|
|
title: 'Trusted by Thousands',
|
|
description:
|
|
'Thousands of buyers, sellers, and renters trust our platform to find reliable agents. Our professionals help people navigate property journeys with confidence and ease.',
|
|
),
|
|
],
|
|
);
|
|
|
|
/// Map of feature icon keys to local SVG asset paths and Material fallback icons.
|
|
/// Supports both default slug keys and CMS web-style icon paths.
|
|
const _iconMap = {
|
|
// Default slug-based keys
|
|
'hire_quickly': ('assets/icons/hire_quickly_icon.svg', Icons.bolt),
|
|
'verified': ('assets/icons/verified_icon.svg', Icons.verified_user_outlined),
|
|
'top_rated': ('assets/icons/top_rated_icon.svg', Icons.star_outline),
|
|
'trusted': ('assets/icons/trusted_icon.svg', Icons.people_outline),
|
|
// CMS web-path keys (from CMS iconPath field)
|
|
'/assets/icons/hourglass-icon.svg': ('assets/icons/hire_quickly_icon.svg', Icons.bolt),
|
|
'/assets/icons/verified-badge-icon.svg': ('assets/icons/verified_icon.svg', Icons.verified_user_outlined),
|
|
'/assets/icons/star-orange-icon.svg': ('assets/icons/top_rated_icon.svg', Icons.star_outline),
|
|
'/assets/icons/trusted-people-icon.svg': ('assets/icons/trusted_icon.svg', Icons.people_outline),
|
|
};
|
|
|
|
/// Static agent cards — matches web's hardcoded agents in FeaturesSection.
|
|
const _staticAgents = [
|
|
_StaticAgent(
|
|
name: 'Anderson',
|
|
role: 'Rental & Investment Consultant',
|
|
rating: '4.9',
|
|
location: 'New York',
|
|
experience: '7+ years in the real estate industry.',
|
|
image: 'assets/images/agent-anderson.jpg',
|
|
),
|
|
_StaticAgent(
|
|
name: 'Deepak',
|
|
role: 'Rental & Investment Consultant',
|
|
rating: '4.9',
|
|
location: 'New York',
|
|
experience: '7+ years in the real estate industry.',
|
|
image: 'assets/images/agent-deepak.jpg',
|
|
),
|
|
_StaticAgent(
|
|
name: 'Daniel',
|
|
role: 'Rental & Investment Consultant',
|
|
rating: '4.9',
|
|
location: 'New York',
|
|
experience: '7+ years in the real estate industry.',
|
|
image: 'assets/images/agent-daniel.jpg',
|
|
),
|
|
];
|
|
|
|
class _StaticAgent {
|
|
final String id;
|
|
final String name;
|
|
final String role;
|
|
final String rating;
|
|
final String location;
|
|
final String experience;
|
|
final String image;
|
|
final bool isVerified;
|
|
|
|
const _StaticAgent({
|
|
this.id = '',
|
|
required this.name,
|
|
required this.role,
|
|
required this.rating,
|
|
required this.location,
|
|
required this.experience,
|
|
required this.image,
|
|
this.isVerified = true,
|
|
});
|
|
}
|
|
|
|
class FeaturesSection extends ConsumerStatefulWidget {
|
|
const FeaturesSection({super.key});
|
|
|
|
@override
|
|
ConsumerState<FeaturesSection> createState() => _FeaturesSectionState();
|
|
}
|
|
|
|
class _FeaturesSectionState extends ConsumerState<FeaturesSection> {
|
|
int _currentPage = 0;
|
|
late ScrollController _agentScrollController;
|
|
List<_StaticAgent> _agents = List.from(_staticAgents);
|
|
bool _agentsLoaded = false;
|
|
|
|
@override
|
|
void initState() {
|
|
super.initState();
|
|
_agentScrollController = ScrollController();
|
|
_agentScrollController.addListener(_onAgentScroll);
|
|
_fetchFeaturedAgents();
|
|
}
|
|
|
|
@override
|
|
void dispose() {
|
|
_agentScrollController.removeListener(_onAgentScroll);
|
|
_agentScrollController.dispose();
|
|
super.dispose();
|
|
}
|
|
|
|
Future<void> _fetchFeaturedAgents() async {
|
|
try {
|
|
final dio = ApiClient.instance.dio;
|
|
final response = await dio.get('/cms/page/landing');
|
|
final sections = response.data['data'] as List<dynamic>?;
|
|
if (sections == null) return;
|
|
|
|
for (final section in sections) {
|
|
if (section['sectionKey'] == 'features') {
|
|
final content = section['content'] as Map<String, dynamic>?;
|
|
final featuredAgents = content?['featuredAgents'] as List<dynamic>?;
|
|
if (featuredAgents == null || featuredAgents.isEmpty) return;
|
|
|
|
final List<_StaticAgent> fetched = [];
|
|
for (final agent in featuredAgents) {
|
|
String image = agent['imageUrl'] as String? ?? '';
|
|
if (image.isNotEmpty && !image.startsWith('http') && !image.startsWith('/')) {
|
|
try {
|
|
image = await ImageUrlResolver.instance.resolve(image) ?? '';
|
|
} catch (_) {}
|
|
}
|
|
|
|
fetched.add(_StaticAgent(
|
|
id: agent['id'] as String? ?? '',
|
|
name: agent['name'] as String? ?? '',
|
|
role: agent['role'] as String? ?? '',
|
|
rating: agent['rating'] as String? ?? '',
|
|
location: agent['location'] as String? ?? '',
|
|
experience: agent['experience'] as String? ?? '',
|
|
image: image,
|
|
));
|
|
}
|
|
|
|
if (fetched.isNotEmpty && mounted) {
|
|
setState(() {
|
|
_agents = fetched;
|
|
_agentsLoaded = true;
|
|
});
|
|
}
|
|
break;
|
|
}
|
|
}
|
|
} catch (_) {
|
|
// Keep fallback static agents
|
|
}
|
|
}
|
|
|
|
void _onAgentScroll() {
|
|
if (!_agentScrollController.hasClients) return;
|
|
const cardWidth = 254.0 + 16.0;
|
|
final page = (_agentScrollController.offset / cardWidth).round();
|
|
if (page != _currentPage && page >= 0 && page < _agents.length) {
|
|
setState(() => _currentPage = page);
|
|
}
|
|
}
|
|
|
|
@override
|
|
Widget build(BuildContext context) {
|
|
final ref = this.ref;
|
|
final homeState = ref.watch(homeProvider);
|
|
final cmsFeatures = homeState.content?.features;
|
|
|
|
// Use CMS data if available with non-empty features, otherwise fallback
|
|
final data = (cmsFeatures != null && cmsFeatures.features.isNotEmpty)
|
|
? cmsFeatures
|
|
: _defaultFeatures;
|
|
|
|
final title = data.title.isNotEmpty ? data.title : _defaultFeatures.title;
|
|
final subtitle =
|
|
data.subtitle.isNotEmpty ? data.subtitle : _defaultFeatures.subtitle;
|
|
|
|
return Column(
|
|
children: [
|
|
Padding(
|
|
padding: const EdgeInsets.symmetric(horizontal: 24),
|
|
child: Column(
|
|
children: [
|
|
Text(
|
|
title,
|
|
textAlign: TextAlign.center,
|
|
style: const TextStyle(
|
|
fontFamily: 'Fractul',
|
|
fontSize: 25,
|
|
fontWeight: FontWeight.w700,
|
|
color: AppColors.primaryDark,
|
|
),
|
|
),
|
|
const SizedBox(height: 8),
|
|
Text(
|
|
subtitle,
|
|
textAlign: TextAlign.center,
|
|
style: const TextStyle(
|
|
fontFamily: 'SourceSerif4',
|
|
fontSize: 14,
|
|
fontWeight: FontWeight.w400,
|
|
color: AppColors.primaryDark,
|
|
),
|
|
),
|
|
const SizedBox(height: 24),
|
|
...data.features.map((feature) => Padding(
|
|
padding: const EdgeInsets.only(bottom: 12),
|
|
child: _buildFeatureCard(feature),
|
|
)),
|
|
],
|
|
),
|
|
),
|
|
const SizedBox(height: 24),
|
|
// Static agent cards carousel (same as web)
|
|
// Height = image (220) + content padding/text (~210) — covers all
|
|
// variants (with multi-line location, "+more" buttons, etc.).
|
|
SizedBox(
|
|
height: 430,
|
|
child: ListView.builder(
|
|
controller: _agentScrollController,
|
|
scrollDirection: Axis.horizontal,
|
|
padding: const EdgeInsets.symmetric(horizontal: 24),
|
|
itemCount: _agents.length,
|
|
itemBuilder: (context, index) => Padding(
|
|
padding: const EdgeInsets.only(right: 16),
|
|
child: _buildStaticAgentCard(_agents[index]),
|
|
),
|
|
),
|
|
),
|
|
const SizedBox(height: 16),
|
|
// Dot indicators
|
|
Row(
|
|
mainAxisAlignment: MainAxisAlignment.center,
|
|
children: List.generate(_agents.length, (index) {
|
|
final isActive = index == _currentPage;
|
|
return Container(
|
|
width: isActive ? 10 : 8,
|
|
height: isActive ? 10 : 8,
|
|
margin: const EdgeInsets.symmetric(horizontal: 4),
|
|
decoration: BoxDecoration(
|
|
shape: BoxShape.circle,
|
|
color: isActive
|
|
? AppColors.primaryDark
|
|
: AppColors.primaryDark.withValues(alpha: 0.3),
|
|
),
|
|
);
|
|
}),
|
|
),
|
|
],
|
|
);
|
|
}
|
|
|
|
Widget _buildFeatureCard(FeatureItem feature) {
|
|
// Resolve icon: try icon map first, then default
|
|
final iconEntry = _iconMap[feature.iconPath];
|
|
final svgPath = iconEntry?.$1 ?? 'assets/icons/hire_quickly_icon.svg';
|
|
final fallbackIcon = iconEntry?.$2 ?? Icons.bolt;
|
|
|
|
return Container(
|
|
width: double.infinity,
|
|
padding: const EdgeInsets.all(20),
|
|
decoration: BoxDecoration(
|
|
borderRadius: BorderRadius.circular(15),
|
|
border: Border.all(
|
|
color: AppColors.primaryDark,
|
|
width: 0.1,
|
|
),
|
|
),
|
|
child: Column(
|
|
crossAxisAlignment: CrossAxisAlignment.start,
|
|
children: [
|
|
Row(
|
|
children: [
|
|
SvgPicture.asset(
|
|
svgPath,
|
|
width: 35,
|
|
height: 35,
|
|
placeholderBuilder: (_) => Icon(
|
|
fallbackIcon,
|
|
size: 35,
|
|
color: AppColors.accentOrange,
|
|
),
|
|
),
|
|
const SizedBox(width: 12),
|
|
Expanded(
|
|
child: Text(
|
|
feature.title,
|
|
style: const TextStyle(
|
|
fontFamily: 'Fractul',
|
|
fontSize: 20,
|
|
fontWeight: FontWeight.w600,
|
|
color: AppColors.primaryDark,
|
|
),
|
|
),
|
|
),
|
|
],
|
|
),
|
|
const SizedBox(height: 12),
|
|
Text(
|
|
feature.description,
|
|
style: const TextStyle(
|
|
fontFamily: 'SourceSerif4',
|
|
fontSize: 14,
|
|
fontWeight: FontWeight.w400,
|
|
color: AppColors.primaryDark,
|
|
),
|
|
),
|
|
],
|
|
),
|
|
);
|
|
}
|
|
|
|
Widget _buildStaticAgentCard(_StaticAgent agent) {
|
|
final card = Container(
|
|
width: 254,
|
|
decoration: BoxDecoration(
|
|
color: Colors.white,
|
|
borderRadius: BorderRadius.circular(15),
|
|
border: Border.all(
|
|
color: AppColors.primaryDark.withValues(alpha: 0.1),
|
|
width: 1,
|
|
),
|
|
boxShadow: const [
|
|
BoxShadow(
|
|
color: Color(0x33D9D9D9),
|
|
blurRadius: 10,
|
|
offset: Offset(0, 4),
|
|
),
|
|
],
|
|
),
|
|
clipBehavior: Clip.antiAlias,
|
|
child: Column(
|
|
crossAxisAlignment: CrossAxisAlignment.start,
|
|
children: [
|
|
// Image — taller box + top-anchored crop so faces stay visible
|
|
// for portrait photos (heads were getting cropped off the top).
|
|
ClipRRect(
|
|
borderRadius:
|
|
const BorderRadius.vertical(top: Radius.circular(15)),
|
|
child: agent.image.startsWith('http')
|
|
? Image.network(
|
|
agent.image,
|
|
width: 254,
|
|
height: 220,
|
|
fit: BoxFit.cover,
|
|
alignment: Alignment.topCenter,
|
|
errorBuilder: (_, __, ___) => Container(
|
|
width: 254,
|
|
height: 220,
|
|
color: const Color(0xFFC4D9D4),
|
|
child: const Icon(Icons.person,
|
|
size: 60, color: AppColors.primaryDark),
|
|
),
|
|
)
|
|
: Image.asset(
|
|
(agent.image.isNotEmpty
|
|
? agent.image
|
|
: 'assets/images/agent-anderson.jpg')
|
|
.replaceFirst(RegExp(r'^/'), ''),
|
|
width: 254,
|
|
height: 220,
|
|
fit: BoxFit.cover,
|
|
alignment: Alignment.topCenter,
|
|
errorBuilder: (_, __, ___) => Container(
|
|
width: 254,
|
|
height: 220,
|
|
color: const Color(0xFFC4D9D4),
|
|
child: const Icon(Icons.person,
|
|
size: 60, color: AppColors.primaryDark),
|
|
),
|
|
),
|
|
),
|
|
|
|
// Content
|
|
Padding(
|
|
padding: const EdgeInsets.all(16),
|
|
child: Column(
|
|
crossAxisAlignment: CrossAxisAlignment.start,
|
|
children: [
|
|
// Name
|
|
Text(
|
|
agent.name,
|
|
style: const TextStyle(
|
|
fontFamily: 'Fractul',
|
|
fontSize: 16,
|
|
fontWeight: FontWeight.w700,
|
|
color: AppColors.primaryDark,
|
|
),
|
|
),
|
|
const SizedBox(height: 2),
|
|
// Role
|
|
Text(
|
|
agent.role,
|
|
style: const TextStyle(
|
|
fontFamily: 'Fractul',
|
|
fontSize: 14,
|
|
fontWeight: FontWeight.w400,
|
|
color: AppColors.primaryDark,
|
|
),
|
|
),
|
|
const SizedBox(height: 8),
|
|
// Verified + Rating row
|
|
Wrap(
|
|
spacing: 12,
|
|
runSpacing: 4,
|
|
children: [
|
|
Row(
|
|
mainAxisSize: MainAxisSize.min,
|
|
children: [
|
|
SvgPicture.asset(
|
|
'assets/icons/verified_badge_icon.svg',
|
|
width: 14,
|
|
height: 14,
|
|
placeholderBuilder: (_) => const Icon(
|
|
Icons.verified,
|
|
color: Color(0xFF638559),
|
|
size: 14,
|
|
),
|
|
),
|
|
const SizedBox(width: 4),
|
|
const Text(
|
|
'Verified Agent',
|
|
style: TextStyle(
|
|
fontFamily: 'SourceSerif4',
|
|
fontSize: 14,
|
|
fontWeight: FontWeight.w500,
|
|
color: AppColors.primaryDark,
|
|
),
|
|
),
|
|
],
|
|
),
|
|
Row(
|
|
mainAxisSize: MainAxisSize.min,
|
|
children: [
|
|
SvgPicture.asset(
|
|
'assets/icons/star_rating_icon.svg',
|
|
width: 14,
|
|
height: 14,
|
|
placeholderBuilder: (_) => const Icon(
|
|
Icons.star,
|
|
color: Color(0xFFFFDE21),
|
|
size: 14,
|
|
),
|
|
),
|
|
const SizedBox(width: 4),
|
|
Text(
|
|
'${agent.rating} Rating',
|
|
style: const TextStyle(
|
|
fontFamily: 'SourceSerif4',
|
|
fontSize: 14,
|
|
fontWeight: FontWeight.w400,
|
|
color: AppColors.primaryDark,
|
|
),
|
|
),
|
|
],
|
|
),
|
|
],
|
|
),
|
|
const SizedBox(height: 8),
|
|
// Location
|
|
Row(
|
|
crossAxisAlignment: CrossAxisAlignment.start,
|
|
children: [
|
|
Padding(
|
|
padding: const EdgeInsets.only(top: 2),
|
|
child: SvgPicture.asset(
|
|
'assets/icons/location_filled_icon.svg',
|
|
width: 15,
|
|
height: 15,
|
|
placeholderBuilder: (_) => const Icon(
|
|
Icons.location_on,
|
|
color: AppColors.accentOrange,
|
|
size: 15,
|
|
),
|
|
),
|
|
),
|
|
const SizedBox(width: 4),
|
|
Expanded(
|
|
child: Text(
|
|
agent.location,
|
|
maxLines: 2,
|
|
overflow: TextOverflow.ellipsis,
|
|
style: const TextStyle(
|
|
fontFamily: 'SourceSerif4',
|
|
fontSize: 14,
|
|
fontWeight: FontWeight.w500,
|
|
color: AppColors.primaryDark,
|
|
),
|
|
),
|
|
),
|
|
],
|
|
),
|
|
const SizedBox(height: 8),
|
|
// Experience
|
|
RichText(
|
|
text: TextSpan(
|
|
children: [
|
|
const TextSpan(
|
|
text: 'Experience: ',
|
|
style: TextStyle(
|
|
fontFamily: 'SourceSerif4',
|
|
fontSize: 14,
|
|
fontWeight: FontWeight.w700,
|
|
color: AppColors.primaryDark,
|
|
),
|
|
),
|
|
TextSpan(
|
|
text: agent.experience,
|
|
style: const TextStyle(
|
|
fontFamily: 'SourceSerif4',
|
|
fontSize: 14,
|
|
fontWeight: FontWeight.w400,
|
|
color: AppColors.primaryDark,
|
|
),
|
|
),
|
|
],
|
|
),
|
|
),
|
|
],
|
|
),
|
|
),
|
|
],
|
|
),
|
|
);
|
|
|
|
if (agent.id.isNotEmpty) {
|
|
return Material(
|
|
color: Colors.transparent,
|
|
child: InkWell(
|
|
borderRadius: BorderRadius.circular(15),
|
|
onTap: () => context.push('/agents/detail/${agent.id}'),
|
|
child: card,
|
|
),
|
|
);
|
|
}
|
|
return card;
|
|
}
|
|
}
|