diff --git a/assets/icons/.!92353!network_icon.svg b/assets/icons/.!92353!network_icon.svg new file mode 100644 index 0000000..e69de29 diff --git a/assets/icons/agents_tab_icon.svg b/assets/icons/agents_tab_icon.svg index b2efd0b..5888625 100644 --- a/assets/icons/agents_tab_icon.svg +++ b/assets/icons/agents_tab_icon.svg @@ -1,21 +1,21 @@ - - + + - - - + + + - + - - + + - - + + diff --git a/assets/icons/hire_quickly_icon.svg b/assets/icons/hire_quickly_icon.svg index 036ae4a..c006e74 100644 --- a/assets/icons/hire_quickly_icon.svg +++ b/assets/icons/hire_quickly_icon.svg @@ -1,3 +1,3 @@ - + diff --git a/assets/icons/lenders_tab_icon.svg b/assets/icons/lenders_tab_icon.svg index 9640947..30c4e56 100644 --- a/assets/icons/lenders_tab_icon.svg +++ b/assets/icons/lenders_tab_icon.svg @@ -1,5 +1,5 @@ - + diff --git a/assets/icons/location_filled_icon.svg b/assets/icons/location_filled_icon.svg index c35a3d2..5fc7e37 100644 --- a/assets/icons/location_filled_icon.svg +++ b/assets/icons/location_filled_icon.svg @@ -1,5 +1,5 @@ - + diff --git a/assets/icons/location_pin_icon.svg b/assets/icons/location_pin_icon.svg index d920e1c..8fa58d1 100644 --- a/assets/icons/location_pin_icon.svg +++ b/assets/icons/location_pin_icon.svg @@ -1,3 +1,3 @@ - + diff --git a/assets/icons/nav_home_icon.svg b/assets/icons/nav_home_icon.svg new file mode 100644 index 0000000..39164ba --- /dev/null +++ b/assets/icons/nav_home_icon.svg @@ -0,0 +1,3 @@ + + + diff --git a/assets/icons/nav_list_icon.svg b/assets/icons/nav_list_icon.svg new file mode 100644 index 0000000..e8ff8d0 --- /dev/null +++ b/assets/icons/nav_list_icon.svg @@ -0,0 +1,8 @@ + + + + + + + + diff --git a/assets/icons/nav_messages_icon.svg b/assets/icons/nav_messages_icon.svg new file mode 100644 index 0000000..db2c060 --- /dev/null +++ b/assets/icons/nav_messages_icon.svg @@ -0,0 +1,3 @@ + + + diff --git a/assets/icons/nav_profile_icon.svg b/assets/icons/nav_profile_icon.svg new file mode 100644 index 0000000..aa3a060 --- /dev/null +++ b/assets/icons/nav_profile_icon.svg @@ -0,0 +1,5 @@ + + + + + diff --git a/assets/icons/network_icon.png b/assets/icons/network_icon.png new file mode 100644 index 0000000..d4205c2 Binary files /dev/null and b/assets/icons/network_icon.png differ diff --git a/assets/icons/notification_icon.svg b/assets/icons/notification_icon.svg index 9993c80..5eb0600 100644 --- a/assets/icons/notification_icon.svg +++ b/assets/icons/notification_icon.svg @@ -1,7 +1,7 @@ - - - + + + diff --git a/assets/icons/search_icon.svg b/assets/icons/search_icon.svg index 4572b2c..c32029a 100644 --- a/assets/icons/search_icon.svg +++ b/assets/icons/search_icon.svg @@ -1,3 +1,3 @@ - + diff --git a/assets/icons/star_rating_icon.svg b/assets/icons/star_rating_icon.svg index 6ed4e0f..f0d4000 100644 --- a/assets/icons/star_rating_icon.svg +++ b/assets/icons/star_rating_icon.svg @@ -1,5 +1,5 @@ - + diff --git a/assets/icons/testimonial_bg.svg b/assets/icons/testimonial_bg.svg index b09317c..c4a91d7 100644 --- a/assets/icons/testimonial_bg.svg +++ b/assets/icons/testimonial_bg.svg @@ -1,5 +1,5 @@ - + diff --git a/assets/icons/top_rated_icon.svg b/assets/icons/top_rated_icon.svg index 3ccea76..87b97a8 100644 --- a/assets/icons/top_rated_icon.svg +++ b/assets/icons/top_rated_icon.svg @@ -1,5 +1,5 @@ - + diff --git a/assets/icons/trusted_icon.svg b/assets/icons/trusted_icon.svg index b1686b0..3204dea 100644 --- a/assets/icons/trusted_icon.svg +++ b/assets/icons/trusted_icon.svg @@ -1,5 +1,5 @@ - + diff --git a/assets/icons/verified_badge_icon.svg b/assets/icons/verified_badge_icon.svg index 101d464..4a3a0fe 100644 --- a/assets/icons/verified_badge_icon.svg +++ b/assets/icons/verified_badge_icon.svg @@ -1,5 +1,5 @@ - + diff --git a/assets/icons/verified_icon.svg b/assets/icons/verified_icon.svg index 0efa27b..9b9b097 100644 --- a/assets/icons/verified_icon.svg +++ b/assets/icons/verified_icon.svg @@ -1,5 +1,5 @@ - + diff --git a/assets/images/professional-1.jpg b/assets/images/professional-1.jpg new file mode 100644 index 0000000..4c913aa Binary files /dev/null and b/assets/images/professional-1.jpg differ diff --git a/assets/images/professional-2.jpg b/assets/images/professional-2.jpg new file mode 100644 index 0000000..b6c4e31 Binary files /dev/null and b/assets/images/professional-2.jpg differ diff --git a/assets/images/professional-3.jpg b/assets/images/professional-3.jpg new file mode 100644 index 0000000..45c8145 Binary files /dev/null and b/assets/images/professional-3.jpg differ diff --git a/lib/core/constants/api_constants.dart b/lib/core/constants/api_constants.dart index 44289c3..b5d9819 100644 --- a/lib/core/constants/api_constants.dart +++ b/lib/core/constants/api_constants.dart @@ -15,4 +15,7 @@ class ApiConstants { // Agents static const String agents = '/agents'; static const String agentTypes = '/agent-types'; + + // CMS + static const String cmsPageLanding = '/cms/page/landing'; } diff --git a/lib/features/agents/data/agents_repository.dart b/lib/features/agents/data/agents_repository.dart index 8824e7f..3b73730 100644 --- a/lib/features/agents/data/agents_repository.dart +++ b/lib/features/agents/data/agents_repository.dart @@ -20,15 +20,19 @@ class SearchAgentsResponse { required this.totalPages, }); + /// Parses: { data: [...], meta: { total, page, limit, totalPages } } factory SearchAgentsResponse.fromJson(Map json) { + final meta = json['meta'] as Map? ?? {}; return SearchAgentsResponse( - data: (json['data'] as List) - .map((e) => AgentProfile.fromJson(e as Map)) - .toList(), - total: json['total'] as int? ?? 0, - page: json['page'] as int? ?? 1, - limit: json['limit'] as int? ?? 10, - totalPages: json['totalPages'] as int? ?? 1, + data: (json['data'] as List?) + ?.map( + (e) => AgentProfile.fromJson(e as Map)) + .toList() ?? + [], + total: meta['total'] as int? ?? 0, + page: meta['page'] as int? ?? 1, + limit: meta['limit'] as int? ?? 10, + totalPages: meta['totalPages'] as int? ?? 1, ); } } @@ -36,6 +40,8 @@ class SearchAgentsResponse { class AgentsRepository { final Dio _dio = ApiClient.instance.dio; + /// Search agents: GET /agents + /// Response: { success, data: { data: [...], meta: {...} } } Future searchAgents({ String? agentTypeId, int page = 1, @@ -58,8 +64,10 @@ class AgentsRepository { queryParameters: queryParams, ); - final data = response.data['data'] as Map; - return SearchAgentsResponse.fromJson(data); + // Backend wraps: { success: true, data: { data: [...], meta: {...} } } + final responseData = response.data; + final payload = responseData['data'] as Map; + return SearchAgentsResponse.fromJson(payload); } on DioException catch (e) { if (e.error is ApiException) { throw e.error as ApiException; @@ -71,9 +79,13 @@ class AgentsRepository { } } + /// Get agent types: GET /agent-types + /// Response: { success, data: [...] } Future> getAgentTypes() async { try { final response = await _dio.get(ApiConstants.agentTypes); + + // Backend wraps: { success: true, data: [...] } final data = response.data['data'] as List; return data .map((e) => AgentType.fromJson(e as Map)) diff --git a/lib/features/agents/data/models/agent_profile.dart b/lib/features/agents/data/models/agent_profile.dart index a22412f..e98cb72 100644 --- a/lib/features/agents/data/models/agent_profile.dart +++ b/lib/features/agents/data/models/agent_profile.dart @@ -2,164 +2,183 @@ import 'package:real_estate_mobile/features/agents/data/models/agent_type.dart'; class AgentProfile { final String id; + final String? userId; final String firstName; final String lastName; - final String slug; - final String? email; - final String? phone; + final String? headline; final String? bio; final String? avatar; - final String? licenseNumber; - final int? experience; - final List specializations; - final List languages; - final List serviceAreas; + final String? companyName; + final String? phone; + final String? website; final String? city; final String? state; final String? country; - final double? rating; - final int reviewCount; + final double? averageRating; + final int totalReviews; final bool isVerified; final bool isFeatured; - final bool isAvailable; - final String? agentTypeId; + final bool isActive; final AgentType? agentType; + final AgentUser? user; final List fieldValues; const AgentProfile({ required this.id, + this.userId, required this.firstName, required this.lastName, - required this.slug, - this.email, - this.phone, + this.headline, this.bio, this.avatar, - this.licenseNumber, - this.experience, - this.specializations = const [], - this.languages = const [], - this.serviceAreas = const [], + this.companyName, + this.phone, + this.website, this.city, this.state, this.country, - this.rating, - this.reviewCount = 0, + this.averageRating, + this.totalReviews = 0, this.isVerified = false, this.isFeatured = false, - this.isAvailable = false, - this.agentTypeId, + this.isActive = true, this.agentType, + this.user, this.fieldValues = const [], }); String get fullName => '$firstName $lastName'.trim(); + /// Get avatar URL — check agent avatar first, then user avatar + String? get avatarUrl => avatar ?? user?.avatar; + String get location { if (city != null && state != null) return '$city, $state'; if (city != null) return city!; if (state != null) return state!; - if (serviceAreas.isNotEmpty) return serviceAreas.first; return ''; } + /// Get experience from field values String get experienceText { - if (experience == null) return ''; - return '$experience+ years in the real estate industry.'; + for (final fv in fieldValues) { + if (fv.fieldSlug.contains('experience') || + fv.fieldName.toLowerCase().contains('experience')) { + if (fv.textValue != null) return fv.textValue!; + if (fv.numberValue != null) { + return '${fv.numberValue!.toInt()}+ years in the real estate industry.'; + } + } + } + return ''; + } + + /// Get specializations from field values + List get specializations { + for (final fv in fieldValues) { + final slug = fv.fieldSlug.toLowerCase(); + if (slug.contains('specialization') || + slug.contains('property_type') || + slug.contains('loan_type')) { + if (fv.jsonValue is List) { + return (fv.jsonValue as List).map((e) => e.toString()).toList(); + } + if (fv.textValue != null) { + return fv.textValue!.split(',').map((s) => s.trim()).toList(); + } + } + } + return []; } factory AgentProfile.fromJson(Map json) { return AgentProfile( id: json['id'] as String, + userId: json['userId'] as String?, firstName: json['firstName'] as String? ?? '', lastName: json['lastName'] as String? ?? '', - slug: json['slug'] as String? ?? '', - email: json['email'] as String?, - phone: json['phone'] as String?, + headline: json['headline'] as String?, bio: json['bio'] as String?, avatar: json['avatar'] as String?, - licenseNumber: json['licenseNumber'] as String?, - experience: json['experience'] as int?, - specializations: _parseStringList(json['specializations']), - languages: _parseStringList(json['languages']), - serviceAreas: _parseStringList(json['serviceAreas']), + companyName: json['companyName'] as String?, + phone: json['phone'] as String?, + website: json['website'] as String?, city: json['city'] as String?, state: json['state'] as String?, country: json['country'] as String?, - rating: (json['rating'] as num?)?.toDouble(), - reviewCount: json['reviewCount'] as int? ?? 0, + averageRating: (json['averageRating'] as num?)?.toDouble(), + totalReviews: json['totalReviews'] as int? ?? 0, isVerified: json['isVerified'] as bool? ?? false, isFeatured: json['isFeatured'] as bool? ?? false, - isAvailable: json['isAvailable'] as bool? ?? false, - agentTypeId: json['agentTypeId'] as String?, + isActive: json['isActive'] as bool? ?? true, agentType: json['agentType'] != null ? AgentType.fromJson(json['agentType'] as Map) : null, + user: json['user'] != null + ? AgentUser.fromJson(json['user'] as Map) + : null, fieldValues: (json['fieldValues'] as List?) ?.map((e) => AgentFieldValue.fromJson(e as Map)) .toList() ?? const [], ); } +} - static List _parseStringList(dynamic value) { - if (value == null) return const []; - if (value is List) return value.map((e) => e.toString()).toList(); - return const []; +class AgentUser { + final String id; + final String email; + final String? avatar; + + const AgentUser({ + required this.id, + required this.email, + this.avatar, + }); + + factory AgentUser.fromJson(Map json) { + return AgentUser( + id: json['id'] as String, + email: json['email'] as String? ?? '', + avatar: json['avatar'] as String?, + ); } } class AgentFieldValue { final String id; - final String fieldId; + final String fieldSlug; + final String fieldName; + final String fieldType; final String? textValue; final num? numberValue; final bool? booleanValue; final dynamic jsonValue; final String? dateValue; - final AgentField field; const AgentFieldValue({ required this.id, - required this.fieldId, + required this.fieldSlug, + required this.fieldName, + required this.fieldType, this.textValue, this.numberValue, this.booleanValue, this.jsonValue, this.dateValue, - required this.field, }); factory AgentFieldValue.fromJson(Map json) { return AgentFieldValue( - id: json['id'] as String, - fieldId: json['fieldId'] as String, + id: json['id'] as String? ?? '', + fieldSlug: json['fieldSlug'] as String? ?? '', + fieldName: json['fieldName'] as String? ?? '', + fieldType: json['fieldType'] as String? ?? '', textValue: json['textValue'] as String?, numberValue: json['numberValue'] as num?, booleanValue: json['booleanValue'] as bool?, jsonValue: json['jsonValue'], dateValue: json['dateValue'] as String?, - field: AgentField.fromJson(json['field'] as Map), - ); - } -} - -class AgentField { - final String slug; - final String name; - final String fieldType; - - const AgentField({ - required this.slug, - required this.name, - required this.fieldType, - }); - - factory AgentField.fromJson(Map json) { - return AgentField( - slug: json['slug'] as String, - name: json['name'] as String, - fieldType: json['fieldType'] as String, ); } } diff --git a/lib/features/agents/data/models/agent_type.dart b/lib/features/agents/data/models/agent_type.dart index ed8fb5c..0eec6a8 100644 --- a/lib/features/agents/data/models/agent_type.dart +++ b/lib/features/agents/data/models/agent_type.dart @@ -1,22 +1,29 @@ class AgentType { final String id; final String name; - final String slug; final String? description; + final bool isActive; + final int sortOrder; + final int agentCount; const AgentType({ required this.id, required this.name, - required this.slug, this.description, + this.isActive = true, + this.sortOrder = 0, + this.agentCount = 0, }); factory AgentType.fromJson(Map json) { + final count = json['_count'] as Map?; return AgentType( id: json['id'] as String, name: json['name'] as String, - slug: json['slug'] as String, description: json['description'] as String?, + isActive: json['isActive'] as bool? ?? true, + sortOrder: json['sortOrder'] as int? ?? 0, + agentCount: count?['agents'] as int? ?? 0, ); } } diff --git a/lib/features/agents/presentation/providers/agents_provider.dart b/lib/features/agents/presentation/providers/agents_provider.dart index e8bb00b..961b56e 100644 --- a/lib/features/agents/presentation/providers/agents_provider.dart +++ b/lib/features/agents/presentation/providers/agents_provider.dart @@ -8,12 +8,6 @@ final agentsRepositoryProvider = Provider((ref) { return AgentsRepository(); }); -// Agent types provider -final agentTypesProvider = FutureProvider>((ref) async { - final repository = ref.watch(agentsRepositoryProvider); - return repository.getAgentTypes(); -}); - // Top professionals state class TopProfessionalsState { final List agents; @@ -70,16 +64,30 @@ class TopProfessionalsNotifier extends StateNotifier { final agentTypes = await _repository.getAgentTypes(); state = state.copyWith(agentTypes: agentTypes); - // Find agent and lender type IDs + // Find agent and lender type IDs by name String? agentTypeId; String? lenderTypeId; for (final type in agentTypes) { - final slug = type.slug.toLowerCase(); - if (slug.contains('agent')) { - agentTypeId = type.id; - } else if (slug.contains('lender')) { + final name = type.name.toLowerCase(); + if (name.contains('lender') || name.contains('lending')) { lenderTypeId = type.id; + } else if (name.contains('agent') || + name.contains('realtor') || + name.contains('broker')) { + agentTypeId = type.id; + } + } + + // If no specific agent type found, use first type as agents + if (agentTypeId == null && agentTypes.isNotEmpty) { + agentTypeId = agentTypes.first.id; + // Look for lender in remaining + for (final type in agentTypes) { + if (type.id != agentTypeId) { + lenderTypeId = type.id; + break; + } } } @@ -91,23 +99,24 @@ class TopProfessionalsNotifier extends StateNotifier { sortBy: 'averageRating', sortOrder: 'desc', ), - _repository.searchAgents( - agentTypeId: lenderTypeId, - limit: 10, - sortBy: 'averageRating', - sortOrder: 'desc', - ), + if (lenderTypeId != null) + _repository.searchAgents( + agentTypeId: lenderTypeId, + limit: 10, + sortBy: 'averageRating', + sortOrder: 'desc', + ), ]); state = state.copyWith( agents: results[0].data, - lenders: results[1].data, + lenders: results.length > 1 ? results[1].data : [], isLoading: false, ); } catch (e) { state = state.copyWith( isLoading: false, - error: 'Failed to load professionals', + error: e.toString(), ); } } diff --git a/lib/features/home/data/home_repository.dart b/lib/features/home/data/home_repository.dart new file mode 100644 index 0000000..f6f383a --- /dev/null +++ b/lib/features/home/data/home_repository.dart @@ -0,0 +1,32 @@ +import 'package:dio/dio.dart'; +import 'package:real_estate_mobile/core/constants/api_constants.dart'; +import 'package:real_estate_mobile/core/network/api_client.dart'; +import 'package:real_estate_mobile/core/network/api_exceptions.dart'; +import 'package:real_estate_mobile/features/home/data/models/landing_page_content.dart'; + +class HomeRepository { + final Dio _dio = ApiClient.instance.dio; + + /// Fetch landing page CMS content: GET /cms/page/landing + /// Response: { success: true, data: [ { sectionKey, content, ... }, ... ] } + Future getLandingPageContent() async { + try { + final response = await _dio.get(ApiConstants.cmsPageLanding); + + final data = response.data['data'] as List; + final sections = data + .map((e) => CmsSection.fromJson(e as Map)) + .toList(); + + return LandingPageContent.fromSections(sections); + } on DioException catch (e) { + if (e.error is ApiException) { + throw e.error as ApiException; + } + throw ApiException( + message: e.message ?? 'Failed to fetch landing page content', + statusCode: e.response?.statusCode, + ); + } + } +} diff --git a/lib/features/home/data/models/landing_page_content.dart b/lib/features/home/data/models/landing_page_content.dart new file mode 100644 index 0000000..f03f361 --- /dev/null +++ b/lib/features/home/data/models/landing_page_content.dart @@ -0,0 +1,294 @@ +/// Represents a single CMS section from the landing page. +class CmsSection { + final String id; + final String pageSlug; + final String sectionKey; + final Map content; + final bool isPublished; + + const CmsSection({ + required this.id, + required this.pageSlug, + required this.sectionKey, + required this.content, + this.isPublished = true, + }); + + factory CmsSection.fromJson(Map json) { + return CmsSection( + id: json['id'] as String, + pageSlug: json['pageSlug'] as String? ?? '', + sectionKey: json['sectionKey'] as String? ?? '', + content: json['content'] as Map? ?? {}, + isPublished: json['isPublished'] as bool? ?? true, + ); + } +} + +// --- Top Professionals Section --- + +class TopProfessionalsContent { + final String title; + final String ctaText; + final String ctaButtonText; + final List agents; + final List lenders; + + const TopProfessionalsContent({ + this.title = '"Meet top real estate professionals"', + this.ctaText = 'Discover 5,000+ Top Real Estate Agents in Our Network.', + this.ctaButtonText = 'Browse Experts', + this.agents = const [], + this.lenders = const [], + }); + + factory TopProfessionalsContent.fromJson(Map json) { + return TopProfessionalsContent( + title: json['title'] as String? ?? '"Meet top real estate professionals"', + ctaText: json['ctaText'] as String? ?? + 'Discover 5,000+ Top Real Estate Agents in Our Network.', + ctaButtonText: json['ctaButtonText'] as String? ?? 'Browse Experts', + agents: (json['agents'] as List?) + ?.map((e) => + ProfessionalItem.fromJson(e as Map)) + .toList() ?? + [], + lenders: (json['lenders'] as List?) + ?.map((e) => + ProfessionalItem.fromJson(e as Map)) + .toList() ?? + [], + ); + } +} + +class ProfessionalItem { + final String name; + final String subtitle; + final String location; + final String experience; + final List expertise; + final String imageUrl; + + const ProfessionalItem({ + required this.name, + this.subtitle = '', + this.location = '', + this.experience = '', + this.expertise = const [], + this.imageUrl = '', + }); + + factory ProfessionalItem.fromJson(Map json) { + return ProfessionalItem( + name: json['name'] as String? ?? '', + subtitle: json['subtitle'] as String? ?? '', + location: json['location'] as String? ?? '', + experience: json['experience'] as String? ?? '', + expertise: (json['expertise'] as List?) + ?.map((e) => e.toString()) + .toList() ?? + [], + imageUrl: json['imageUrl'] as String? ?? '', + ); + } +} + +// --- Hero Section --- + +class HeroContent { + final String headline; + final String description; + final String ctaButtonText; + final String helperText; + + const HeroContent({ + this.headline = '', + this.description = '', + this.ctaButtonText = '', + this.helperText = '', + }); + + factory HeroContent.fromJson(Map json) { + return HeroContent( + headline: json['headline'] as String? ?? '', + description: json['description'] as String? ?? '', + ctaButtonText: json['ctaButtonText'] as String? ?? '', + helperText: json['helperText'] as String? ?? '', + ); + } +} + +// --- Features Section --- + +class FeaturesContent { + final String title; + final String subtitle; + final List features; + + const FeaturesContent({ + this.title = '', + this.subtitle = '', + this.features = const [], + }); + + factory FeaturesContent.fromJson(Map json) { + return FeaturesContent( + title: json['title'] as String? ?? '', + subtitle: json['subtitle'] as String? ?? '', + features: (json['features'] as List?) + ?.map( + (e) => FeatureItem.fromJson(e as Map)) + .toList() ?? + [], + ); + } +} + +class FeatureItem { + final String iconPath; + final String title; + final String description; + + const FeatureItem({ + this.iconPath = '', + this.title = '', + this.description = '', + }); + + factory FeatureItem.fromJson(Map json) { + return FeatureItem( + iconPath: json['iconPath'] as String? ?? '', + title: json['title'] as String? ?? '', + description: json['description'] as String? ?? '', + ); + } +} + +// --- Testimonials Section --- + +class TestimonialsContent { + final String title; + final String subtitle; + final String ratingInfo; + final List stats; + final List testimonials; + + const TestimonialsContent({ + this.title = '', + this.subtitle = '', + this.ratingInfo = '', + this.stats = const [], + this.testimonials = const [], + }); + + factory TestimonialsContent.fromJson(Map json) { + return TestimonialsContent( + title: json['title'] as String? ?? '', + subtitle: json['subtitle'] as String? ?? '', + ratingInfo: json['ratingInfo'] as String? ?? '', + stats: (json['stats'] as List?) + ?.map((e) => StatItem.fromJson(e as Map)) + .toList() ?? + [], + testimonials: (json['testimonials'] as List?) + ?.map((e) => + TestimonialItem.fromJson(e as Map)) + .toList() ?? + [], + ); + } +} + +class StatItem { + final String iconPath; + final String boldText; + final String normalText; + + const StatItem({ + this.iconPath = '', + this.boldText = '', + this.normalText = '', + }); + + factory StatItem.fromJson(Map json) { + return StatItem( + iconPath: json['iconPath'] as String? ?? '', + boldText: json['boldText'] as String? ?? '', + normalText: json['normalText'] as String? ?? '', + ); + } +} + +class TestimonialItem { + final int rating; + final String title; + final String content; + final String author; + final String role; + + const TestimonialItem({ + this.rating = 5, + this.title = '', + this.content = '', + this.author = '', + this.role = '', + }); + + factory TestimonialItem.fromJson(Map json) { + return TestimonialItem( + rating: json['rating'] as int? ?? 5, + title: json['title'] as String? ?? '', + content: json['content'] as String? ?? '', + author: json['author'] as String? ?? '', + role: json['role'] as String? ?? '', + ); + } +} + +/// Parsed landing page content from all CMS sections. +class LandingPageContent { + final HeroContent? hero; + final FeaturesContent? features; + final TopProfessionalsContent? topProfessionals; + final TestimonialsContent? testimonials; + + const LandingPageContent({ + this.hero, + this.features, + this.topProfessionals, + this.testimonials, + }); + + factory LandingPageContent.fromSections(List sections) { + HeroContent? hero; + FeaturesContent? features; + TopProfessionalsContent? topProfessionals; + TestimonialsContent? testimonials; + + for (final section in sections) { + switch (section.sectionKey) { + case 'hero': + hero = HeroContent.fromJson(section.content); + break; + case 'features': + features = FeaturesContent.fromJson(section.content); + break; + case 'topProfessionals': + topProfessionals = + TopProfessionalsContent.fromJson(section.content); + break; + case 'testimonials': + testimonials = TestimonialsContent.fromJson(section.content); + break; + } + } + + return LandingPageContent( + hero: hero, + features: features, + topProfessionals: topProfessionals, + testimonials: testimonials, + ); + } +} diff --git a/lib/features/home/presentation/providers/home_provider.dart b/lib/features/home/presentation/providers/home_provider.dart new file mode 100644 index 0000000..c91e5e1 --- /dev/null +++ b/lib/features/home/presentation/providers/home_provider.dart @@ -0,0 +1,58 @@ +import 'package:flutter_riverpod/flutter_riverpod.dart'; +import 'package:real_estate_mobile/features/home/data/home_repository.dart'; +import 'package:real_estate_mobile/features/home/data/models/landing_page_content.dart'; + +final homeRepositoryProvider = Provider((ref) { + return HomeRepository(); +}); + +class HomeState { + final LandingPageContent? content; + final bool isLoading; + final String? error; + + const HomeState({ + this.content, + this.isLoading = false, + this.error, + }); + + HomeState copyWith({ + LandingPageContent? content, + bool? isLoading, + String? error, + }) { + return HomeState( + content: content ?? this.content, + isLoading: isLoading ?? this.isLoading, + error: error, + ); + } +} + +class HomeNotifier extends StateNotifier { + final HomeRepository _repository; + + HomeNotifier(this._repository) : super(const HomeState()) { + loadContent(); + } + + Future loadContent() async { + state = state.copyWith(isLoading: true, error: null); + + try { + final content = await _repository.getLandingPageContent(); + state = state.copyWith(content: content, isLoading: false); + } catch (e) { + state = state.copyWith( + isLoading: false, + error: 'Failed to load content', + ); + } + } +} + +final homeProvider = StateNotifierProvider((ref) { + final repository = ref.watch(homeRepositoryProvider); + return HomeNotifier(repository); +}); diff --git a/lib/features/home/presentation/screens/home_screen.dart b/lib/features/home/presentation/screens/home_screen.dart index 58975cd..eeaf3c2 100644 --- a/lib/features/home/presentation/screens/home_screen.dart +++ b/lib/features/home/presentation/screens/home_screen.dart @@ -1,4 +1,5 @@ import 'package:flutter/material.dart'; +import 'package:flutter_svg/flutter_svg.dart'; import 'package:real_estate_mobile/core/constants/app_colors.dart'; import 'package:real_estate_mobile/features/home/presentation/widgets/features_section.dart'; import 'package:real_estate_mobile/features/home/presentation/widgets/hero_section.dart'; @@ -7,44 +8,153 @@ import 'package:real_estate_mobile/features/home/presentation/widgets/testimonia import 'package:real_estate_mobile/features/home/presentation/widgets/top_professionals_section.dart'; import 'package:real_estate_mobile/features/home/presentation/widgets/trust_stats_section.dart'; -class HomeScreen extends StatelessWidget { +class HomeScreen extends StatefulWidget { const HomeScreen({super.key}); + @override + State createState() => _HomeScreenState(); +} + +class _HomeScreenState extends State { + int _selectedIndex = 0; + @override Widget build(BuildContext context) { return Scaffold( - body: SingleChildScrollView( - child: Column( - children: [ - // Header - SafeArea( - bottom: false, - child: const HomeHeader(), + body: _buildBody(), + bottomNavigationBar: _buildBottomNavBar(), + ); + } + + Widget _buildBody() { + // Only Home tab (index 0) has content for now + switch (_selectedIndex) { + case 0: + return _buildHomeContent(); + case 1: + case 2: + case 3: + default: + return Center( + child: Text( + ['Home', 'Listings', 'Messages', 'Profile'][_selectedIndex], + style: const TextStyle( + fontFamily: 'Fractul', + fontSize: 18, + fontWeight: FontWeight.w600, + color: AppColors.primaryDark, ), + ), + ); + } + } - // Hero Section with Search - const HeroSection(), - const SizedBox(height: 40), + Widget _buildHomeContent() { + return SingleChildScrollView( + child: Column( + children: [ + // Header + SafeArea( + bottom: false, + child: const HomeHeader(), + ), - // Features Section - const FeaturesSection(), - const SizedBox(height: 40), + // Hero Section with Search + const HeroSection(), + const SizedBox(height: 40), - // Top Professionals Section - const TopProfessionalsSection(), - const SizedBox(height: 40), + // Features Section + const FeaturesSection(), + const SizedBox(height: 40), - // Trust & Stats Section - const TrustStatsSection(), - const SizedBox(height: 40), + // Top Professionals Section + const TopProfessionalsSection(), + const SizedBox(height: 40), - // Testimonials Section - const TestimonialsSection(), - const SizedBox(height: 40), + // Trust & Stats Section + const TrustStatsSection(), + const SizedBox(height: 40), - // Footer - _buildFooter(), - ], + // Testimonials Section + const TestimonialsSection(), + const SizedBox(height: 40), + + // Footer + _buildFooter(), + ], + ), + ); + } + + // ── Bottom Navigation Bar (matches Figma node 49:6485) ── + Widget _buildBottomNavBar() { + return Container( + decoration: const BoxDecoration( + color: Colors.white, + border: Border( + top: BorderSide(color: Color(0xFFE8E8E8), width: 1), + ), + ), + child: SafeArea( + top: false, + child: Padding( + padding: const EdgeInsets.symmetric(horizontal: 24, vertical: 12), + child: Row( + mainAxisAlignment: MainAxisAlignment.spaceBetween, + children: [ + _buildNavItem( + index: 0, + svgPath: 'assets/icons/nav_home_icon.svg', + fallbackIcon: Icons.home, + ), + _buildNavItem( + index: 1, + svgPath: 'assets/icons/nav_list_icon.svg', + fallbackIcon: Icons.list, + ), + _buildNavItem( + index: 2, + svgPath: 'assets/icons/nav_messages_icon.svg', + fallbackIcon: Icons.chat_bubble, + ), + _buildNavItem( + index: 3, + svgPath: 'assets/icons/nav_profile_icon.svg', + fallbackIcon: Icons.person_outline, + ), + ], + ), + ), + ), + ); + } + + Widget _buildNavItem({ + required int index, + required String svgPath, + required IconData fallbackIcon, + }) { + final isSelected = _selectedIndex == index; + + return GestureDetector( + onTap: () { + setState(() => _selectedIndex = index); + }, + behavior: HitTestBehavior.opaque, + child: Padding( + padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 8), + child: SvgPicture.asset( + svgPath, + width: 24, + height: 24, + colorFilter: isSelected + ? const ColorFilter.mode(AppColors.primaryDark, BlendMode.srcIn) + : const ColorFilter.mode(AppColors.accentOrange, BlendMode.srcIn), + placeholderBuilder: (_) => Icon( + fallbackIcon, + size: 24, + color: isSelected ? AppColors.primaryDark : AppColors.accentOrange, + ), ), ), ); diff --git a/lib/features/home/presentation/widgets/top_professionals_section.dart b/lib/features/home/presentation/widgets/top_professionals_section.dart index 60e443d..342cf4e 100644 --- a/lib/features/home/presentation/widgets/top_professionals_section.dart +++ b/lib/features/home/presentation/widgets/top_professionals_section.dart @@ -1,25 +1,62 @@ +import 'package:cached_network_image/cached_network_image.dart'; import 'package:flutter/material.dart'; import 'package:flutter_riverpod/flutter_riverpod.dart'; import 'package:flutter_svg/flutter_svg.dart'; +import 'package:real_estate_mobile/config/app_config.dart'; import 'package:real_estate_mobile/core/constants/app_colors.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'; -class TopProfessionalsSection extends ConsumerWidget { +/// Local fallback images for professionals when CMS returns no image. +const _fallbackImages = [ + 'assets/images/professional-1.jpg', + 'assets/images/professional-2.jpg', + 'assets/images/professional-3.jpg', +]; + +class TopProfessionalsSection extends ConsumerStatefulWidget { const TopProfessionalsSection({super.key}); @override - Widget build(BuildContext context, WidgetRef ref) { - final state = ref.watch(topProfessionalsProvider); + ConsumerState createState() => + _TopProfessionalsSectionState(); +} + +class _TopProfessionalsSectionState + extends ConsumerState { + String _activeTab = 'agents'; + int _currentPage = 0; + late PageController _pageController; + + @override + void initState() { + super.initState(); + _pageController = PageController(); + } + + @override + void dispose() { + _pageController.dispose(); + super.dispose(); + } + + @override + Widget build(BuildContext context) { + final homeState = ref.watch(homeProvider); + final topProfessionals = homeState.content?.topProfessionals; + + final activeList = _activeTab == 'agents' + ? (topProfessionals?.agents ?? []) + : (topProfessionals?.lenders ?? []); return Padding( padding: const EdgeInsets.symmetric(horizontal: 24), child: Column( children: [ - const Text( - '"Meet top real estate professionals"', + Text( + topProfessionals?.title ?? '"Meet top real estate professionals"', textAlign: TextAlign.center, - style: TextStyle( + style: const TextStyle( fontFamily: 'Fractul', fontSize: 25, fontWeight: FontWeight.w700, @@ -29,65 +66,75 @@ class TopProfessionalsSection extends ConsumerWidget { const SizedBox(height: 24), // Agents / Lenders Tab - _buildCategoryTab(ref, state.activeTab), + _buildCategoryTab(), const SizedBox(height: 24), // Content - if (state.isLoading) + if (homeState.isLoading) const Padding( padding: EdgeInsets.symmetric(vertical: 40), child: CircularProgressIndicator( color: AppColors.accentOrange, ), ) - else if (state.error != null) - _buildErrorState(ref, state.error!) - else if (state.activeList.isEmpty) - _buildEmptyState(state.activeTab) + else if (homeState.error != null) + _buildErrorState(homeState.error!) + else if (activeList.isEmpty) + _buildEmptyState() else ...[ - // Agent Cards - horizontal scroll + // Professional Cards - horizontal scroll SizedBox( - height: 480, + height: 500, child: PageView.builder( - controller: PageController(viewportFraction: 1.0), - itemCount: state.activeList.length, + controller: _pageController, + itemCount: activeList.length, + onPageChanged: (index) { + setState(() => _currentPage = index); + }, itemBuilder: (context, index) { - return _buildAgentCard(state.activeList[index]); + return _buildProfessionalCard(activeList[index], index: index); }, ), ), const SizedBox(height: 12), // Progress indicator - _buildProgressIndicator(state.activeList.length), + _buildProgressIndicator(activeList.length), ], const SizedBox(height: 32), // See All Agents CTA - _buildSeeAllAgentsCta(), + _buildSeeAllAgentsCta(topProfessionals), ], ), ); } - Widget _buildCategoryTab(WidgetRef ref, String activeTab) { + Widget _buildCategoryTab() { return Container( + height: 45, decoration: BoxDecoration( borderRadius: BorderRadius.circular(15), - border: Border.all(color: AppColors.primaryDark, width: 0.1), + border: Border.all(color: AppColors.primaryDark, width: 0.7), ), child: Row( children: [ // Agents tab Expanded( child: GestureDetector( - onTap: () => ref - .read(topProfessionalsProvider.notifier) - .setActiveTab('agents'), + onTap: () { + setState(() { + _activeTab = 'agents'; + _currentPage = 0; + }); + if (_pageController.hasClients) { + _pageController.jumpToPage(0); + } + }, child: Container( padding: const EdgeInsets.symmetric(vertical: 12), decoration: BoxDecoration( - color: activeTab == 'agents' + color: _activeTab == 'agents' ? AppColors.accentOrange : Colors.transparent, borderRadius: BorderRadius.circular(15), @@ -99,9 +146,15 @@ class TopProfessionalsSection extends ConsumerWidget { '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' + color: _activeTab == 'agents' ? Colors.white : AppColors.primaryDark, size: 24, @@ -114,7 +167,7 @@ class TopProfessionalsSection extends ConsumerWidget { fontFamily: 'SourceSerif4', fontSize: 17, fontWeight: FontWeight.w600, - color: activeTab == 'agents' + color: _activeTab == 'agents' ? Colors.white : AppColors.primaryDark, ), @@ -127,13 +180,19 @@ class TopProfessionalsSection extends ConsumerWidget { // Lenders tab Expanded( child: GestureDetector( - onTap: () => ref - .read(topProfessionalsProvider.notifier) - .setActiveTab('lenders'), + onTap: () { + setState(() { + _activeTab = 'lenders'; + _currentPage = 0; + }); + if (_pageController.hasClients) { + _pageController.jumpToPage(0); + } + }, child: Container( padding: const EdgeInsets.symmetric(vertical: 12), decoration: BoxDecoration( - color: activeTab == 'lenders' + color: _activeTab == 'lenders' ? AppColors.accentOrange : Colors.transparent, borderRadius: BorderRadius.circular(15), @@ -145,9 +204,15 @@ class TopProfessionalsSection extends ConsumerWidget { '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' + color: _activeTab == 'lenders' ? Colors.white : AppColors.primaryDark, size: 24, @@ -160,7 +225,7 @@ class TopProfessionalsSection extends ConsumerWidget { fontFamily: 'SourceSerif4', fontSize: 17, fontWeight: FontWeight.w600, - color: activeTab == 'lenders' + color: _activeTab == 'lenders' ? Colors.white : AppColors.primaryDark, ), @@ -175,7 +240,50 @@ class TopProfessionalsSection extends ConsumerWidget { ); } - Widget _buildAgentCard(AgentProfile agent) { + /// Resolves an image URL: full URL → use as-is, relative → prepend base URL. + String? _resolveImageUrl(String imageUrl) { + if (imageUrl.isEmpty) return null; + if (imageUrl.startsWith('http://') || imageUrl.startsWith('https://')) { + return imageUrl; + } + final baseUrl = AppConfig.apiBaseUrl; + if (baseUrl.isNotEmpty) { + return '$baseUrl$imageUrl'; + } + return null; + } + + /// Builds the card image: CachedNetworkImage for valid URLs, local asset fallback otherwise. + Widget _buildCardImage(ProfessionalItem professional, int index) { + final resolvedUrl = _resolveImageUrl(professional.imageUrl); + + if (resolvedUrl != null) { + return CachedNetworkImage( + imageUrl: resolvedUrl, + width: double.infinity, + height: 192, + fit: BoxFit.cover, + placeholder: (context, url) => _buildImagePlaceholder(index), + errorWidget: (context, url, error) => _buildImagePlaceholder(index), + ); + } + return _buildImagePlaceholder(index); + } + + /// Shows a local fallback professional image (cycles through 3 images). + Widget _buildImagePlaceholder(int index) { + final assetPath = _fallbackImages[index % _fallbackImages.length]; + return Image.asset( + assetPath, + width: double.infinity, + height: 192, + fit: BoxFit.cover, + errorBuilder: (context, error, stackTrace) => _buildAvatarPlaceholder(), + ); + } + + // ── Unified Professional Card (matches Figma design for both tabs) ── + Widget _buildProfessionalCard(ProfessionalItem professional, {int index = 0}) { return Container( decoration: BoxDecoration( color: Colors.white, @@ -185,27 +293,21 @@ class TopProfessionalsSection extends ConsumerWidget { child: Column( crossAxisAlignment: CrossAxisAlignment.start, children: [ - // Agent image + // Image ClipRRect( borderRadius: const BorderRadius.vertical(top: Radius.circular(15)), - child: agent.avatar != null && agent.avatar!.isNotEmpty - ? Image.network( - agent.avatar!, - width: double.infinity, - height: 192, - fit: BoxFit.cover, - errorBuilder: (_, __, ___) => _buildAvatarPlaceholder(), - ) - : _buildAvatarPlaceholder(), + child: _buildCardImage(professional, index), ), + + // Content Expanded( child: Padding( - padding: const EdgeInsets.all(16), + padding: const EdgeInsets.fromLTRB(16, 12, 16, 12), child: Column( crossAxisAlignment: CrossAxisAlignment.start, children: [ - // Name and rating row + // Row 1: Name + verified badge + star Rating Row( children: [ Expanded( @@ -213,7 +315,7 @@ class TopProfessionalsSection extends ConsumerWidget { children: [ Flexible( child: Text( - agent.fullName, + professional.name, style: const TextStyle( fontFamily: 'Fractul', fontSize: 14, @@ -223,75 +325,101 @@ class TopProfessionalsSection extends ConsumerWidget { overflow: TextOverflow.ellipsis, ), ), - if (agent.isVerified) ...[ - const SizedBox(width: 8), - SvgPicture.asset( - 'assets/icons/verified_badge_icon.svg', - width: 16, - height: 16, - placeholderBuilder: (_) => const Icon( - Icons.verified, - color: Colors.blue, - size: 16, - ), + const SizedBox(width: 6), + SvgPicture.asset( + 'assets/icons/verified_badge_icon.svg', + width: 16, + height: 16, + placeholderBuilder: (_) => const Icon( + Icons.verified, + color: Color(0xFF1DA1F2), + size: 16, ), - ], + ), ], ), ), - if (agent.rating != null) ...[ - SvgPicture.asset( - 'assets/icons/star_rating_icon.svg', - width: 16, - height: 16, - placeholderBuilder: (_) => const Icon( - Icons.star, - color: AppColors.accentOrange, - size: 16, - ), + SvgPicture.asset( + 'assets/icons/star_rating_icon.svg', + width: 16, + height: 16, + placeholderBuilder: (_) => const Icon( + Icons.star, + color: Color(0xFFFFDE21), + size: 16, ), - const SizedBox(width: 4), - Text( - '${agent.rating!.toStringAsFixed(1)} Rating', - style: const TextStyle( - fontFamily: 'SourceSerif4', - fontSize: 14, - fontWeight: FontWeight.w400, - color: AppColors.primaryDark, - ), + ), + const SizedBox(width: 4), + const Text( + 'Rating', + style: TextStyle( + fontFamily: 'SourceSerif4', + fontSize: 14, + fontWeight: FontWeight.w400, + color: AppColors.primaryDark, ), - ], + ), ], ), const SizedBox(height: 4), - // Verified text - if (agent.isVerified) + // Row 2: (Subtitle) in parentheses + if (professional.subtitle.isNotEmpty) Text( - '"Verified local ${agent.agentType?.name.toLowerCase() ?? 'agent'}"', + '(${professional.subtitle})', style: const TextStyle( fontFamily: 'SourceSerif4', - fontSize: 14, - fontWeight: FontWeight.w500, - color: Color(0xFF638559), + 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 + Row( + 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 local agent"', + style: TextStyle( + fontFamily: 'SourceSerif4', + fontSize: 10, + fontWeight: FontWeight.w400, + color: Color(0xFF638559), + ), + ), + ], + ), const SizedBox(height: 8), - // Location - if (agent.location.isNotEmpty) + // Row 4: Location: label + if (professional.location.isNotEmpty) ...[ + const Text( + 'Location:', + style: TextStyle( + fontFamily: 'Fractul', + fontSize: 14, + fontWeight: FontWeight.w700, + color: AppColors.primaryDark, + ), + ), + const SizedBox(height: 2), + // Pin icon + location text Row( children: [ - const Text( - 'Location:', - style: TextStyle( - fontFamily: 'Fractul', - fontSize: 14, - fontWeight: FontWeight.w700, - color: AppColors.primaryDark, - ), - ), - const SizedBox(width: 4), SvgPicture.asset( 'assets/icons/location_pin_icon.svg', width: 14, @@ -305,7 +433,7 @@ class TopProfessionalsSection extends ConsumerWidget { const SizedBox(width: 4), Expanded( child: Text( - agent.location, + professional.location, style: const TextStyle( fontFamily: 'SourceSerif4', fontSize: 10, @@ -317,52 +445,35 @@ class TopProfessionalsSection extends ConsumerWidget { ), ], ), - const SizedBox(height: 8), + const SizedBox(height: 8), + ], - // Expertise - if (agent.agentType != null) - Row( - children: [ - const Text( - 'Expertise:', - style: TextStyle( - fontFamily: 'Fractul', - fontSize: 14, - fontWeight: FontWeight.w700, - color: AppColors.primaryDark, - ), - ), - const SizedBox(width: 8), - Expanded( - child: Text( - '(${agent.agentType!.name})', - style: const TextStyle( - fontFamily: 'SourceSerif4', - fontSize: 10, - fontWeight: FontWeight.w400, - color: AppColors.primaryDark, - ), - overflow: TextOverflow.ellipsis, - ), - ), - ], + // Row 5: Expertise: label + tags + if (professional.expertise.isNotEmpty) ...[ + const Text( + 'Expertise:', + style: TextStyle( + fontFamily: 'Fractul', + fontSize: 14, + fontWeight: FontWeight.w700, + color: AppColors.primaryDark, + ), ), - const SizedBox(height: 12), - - // Tags from specializations - if (agent.specializations.isNotEmpty) + const SizedBox(height: 6), + // Tags as bordered pills Wrap( spacing: 8, - runSpacing: 8, - children: agent.specializations + runSpacing: 6, + children: professional.expertise .take(6) .map((tag) => _buildTag(tag)) .toList(), ), + ], const Spacer(), - // Experience - if (agent.experienceText.isNotEmpty) + // Experience text + if (professional.experience.isNotEmpty) RichText( text: TextSpan( children: [ @@ -376,7 +487,7 @@ class TopProfessionalsSection extends ConsumerWidget { ), ), TextSpan( - text: agent.experienceText, + text: professional.experience, style: const TextStyle( fontFamily: 'SourceSerif4', fontSize: 14, @@ -386,7 +497,30 @@ class TopProfessionalsSection extends ConsumerWidget { ), ], ), + maxLines: 2, + overflow: TextOverflow.ellipsis, ), + const SizedBox(height: 8), + + // 5 star rating icons row at bottom + Row( + children: List.generate( + 5, + (index) => 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, + ), + ), + ), + ), + ), ], ), ), @@ -427,9 +561,11 @@ class TopProfessionalsSection extends ConsumerWidget { Widget _buildProgressIndicator(int totalCards) { return LayoutBuilder( builder: (context, constraints) { - final indicatorWidth = totalCards > 0 - ? constraints.maxWidth / totalCards - : 103.0; + final indicatorWidth = + totalCards > 0 ? constraints.maxWidth / totalCards : 103.0; + final offset = totalCards > 0 + ? (_currentPage * constraints.maxWidth / totalCards) + : 0.0; return Stack( children: [ Container( @@ -440,12 +576,16 @@ class TopProfessionalsSection extends ConsumerWidget { border: Border.all(color: AppColors.primaryDark, width: 0.1), ), ), - Container( - height: 5, - width: indicatorWidth.clamp(40.0, 150.0), - decoration: BoxDecoration( - color: AppColors.primaryDark, - borderRadius: BorderRadius.circular(15), + 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), + ), ), ), ], @@ -454,7 +594,7 @@ class TopProfessionalsSection extends ConsumerWidget { ); } - Widget _buildErrorState(WidgetRef ref, String error) { + Widget _buildErrorState(String error) { return Padding( padding: const EdgeInsets.symmetric(vertical: 40), child: Column( @@ -476,8 +616,7 @@ class TopProfessionalsSection extends ConsumerWidget { ), const SizedBox(height: 16), TextButton( - onPressed: () => - ref.read(topProfessionalsProvider.notifier).refresh(), + onPressed: () => ref.read(homeProvider.notifier).loadContent(), child: const Text( 'Retry', style: TextStyle( @@ -493,19 +632,21 @@ class TopProfessionalsSection extends ConsumerWidget { ); } - Widget _buildEmptyState(String activeTab) { + Widget _buildEmptyState() { return Padding( padding: const EdgeInsets.symmetric(vertical: 40), child: Column( children: [ Icon( - activeTab == 'agents' ? Icons.people_outline : Icons.account_balance_outlined, + _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', + 'No ${_activeTab == 'agents' ? 'agents' : 'lenders'} found', style: const TextStyle( fontFamily: 'Fractul', fontSize: 14, @@ -518,7 +659,7 @@ class TopProfessionalsSection extends ConsumerWidget { ); } - Widget _buildSeeAllAgentsCta() { + Widget _buildSeeAllAgentsCta(TopProfessionalsContent? content) { return Container( width: double.infinity, padding: const EdgeInsets.all(24), @@ -541,20 +682,21 @@ class TopProfessionalsSection extends ConsumerWidget { child: Column( children: [ Image.asset( - 'assets/icons/network_icon.svg', + 'assets/icons/network_icon.png', width: 30, height: 23, - errorBuilder: (_, __, ___) => const Icon( + errorBuilder: (_, e, s) => const Icon( Icons.hub, color: AppColors.primaryDark, size: 30, ), ), const SizedBox(height: 12), - const Text( - 'Discover 5,000+ Top Real Estate Agents in Our Network', + Text( + content?.ctaText ?? + 'Discover 5,000+ Top Real Estate Agents in Our Network.', textAlign: TextAlign.center, - style: TextStyle( + style: const TextStyle( fontFamily: 'SourceSerif4', fontSize: 14, fontWeight: FontWeight.w400, @@ -574,9 +716,9 @@ class TopProfessionalsSection extends ConsumerWidget { borderRadius: BorderRadius.circular(10), ), ), - child: const Text( - 'See All Agents', - style: TextStyle( + child: Text( + content?.ctaButtonText ?? 'See All Agents', + style: const TextStyle( fontFamily: 'Fractul', fontSize: 16, fontWeight: FontWeight.w700,