diff --git a/assets/icons/calendar_icon.svg b/assets/icons/calendar_icon.svg
new file mode 100644
index 0000000..3b01be3
--- /dev/null
+++ b/assets/icons/calendar_icon.svg
@@ -0,0 +1,3 @@
+
diff --git a/assets/icons/cities_icon.svg b/assets/icons/cities_icon.svg
new file mode 100644
index 0000000..39d9007
--- /dev/null
+++ b/assets/icons/cities_icon.svg
@@ -0,0 +1,3 @@
+
diff --git a/assets/icons/properties_icon.svg b/assets/icons/properties_icon.svg
new file mode 100644
index 0000000..244b275
--- /dev/null
+++ b/assets/icons/properties_icon.svg
@@ -0,0 +1,3 @@
+
diff --git a/devtools_options.yaml b/devtools_options.yaml
new file mode 100644
index 0000000..fa0b357
--- /dev/null
+++ b/devtools_options.yaml
@@ -0,0 +1,3 @@
+description: This file stores settings for Dart & Flutter DevTools.
+documentation: https://docs.flutter.dev/tools/devtools/extensions#configure-extension-enablement-states
+extensions:
diff --git a/ios/Podfile.lock b/ios/Podfile.lock
index 3a24621..7a40d93 100644
--- a/ios/Podfile.lock
+++ b/ios/Podfile.lock
@@ -2,6 +2,9 @@ PODS:
- Flutter (1.0.0)
- flutter_secure_storage (6.0.0):
- Flutter
+ - path_provider_foundation (0.0.1):
+ - Flutter
+ - FlutterMacOS
- shared_preferences_foundation (0.0.1):
- Flutter
- FlutterMacOS
@@ -12,6 +15,7 @@ PODS:
DEPENDENCIES:
- Flutter (from `Flutter`)
- flutter_secure_storage (from `.symlinks/plugins/flutter_secure_storage/ios`)
+ - path_provider_foundation (from `.symlinks/plugins/path_provider_foundation/darwin`)
- shared_preferences_foundation (from `.symlinks/plugins/shared_preferences_foundation/darwin`)
- sqflite_darwin (from `.symlinks/plugins/sqflite_darwin/darwin`)
@@ -20,6 +24,8 @@ EXTERNAL SOURCES:
:path: Flutter
flutter_secure_storage:
:path: ".symlinks/plugins/flutter_secure_storage/ios"
+ path_provider_foundation:
+ :path: ".symlinks/plugins/path_provider_foundation/darwin"
shared_preferences_foundation:
:path: ".symlinks/plugins/shared_preferences_foundation/darwin"
sqflite_darwin:
@@ -28,6 +34,7 @@ EXTERNAL SOURCES:
SPEC CHECKSUMS:
Flutter: cabc95a1d2626b1b06e7179b784ebcf0c0cde467
flutter_secure_storage: d33dac7ae2ea08509be337e775f6b59f1ff45f12
+ path_provider_foundation: 2b6b4c569c0fb62ec74538f866245ac84301af46
shared_preferences_foundation: 5086985c1d43c5ba4d5e69a4e8083a389e2909e6
sqflite_darwin: 5a7236e3b501866c1c9befc6771dfd73ffb8702d
diff --git a/lib/features/agents/data/agents_repository.dart b/lib/features/agents/data/agents_repository.dart
index 3b73730..a009ec8 100644
--- a/lib/features/agents/data/agents_repository.dart
+++ b/lib/features/agents/data/agents_repository.dart
@@ -44,6 +44,7 @@ class AgentsRepository {
/// Response: { success, data: { data: [...], meta: {...} } }
Future searchAgents({
String? agentTypeId,
+ String? search,
int page = 1,
int limit = 10,
String? sortBy,
@@ -56,6 +57,7 @@ class AgentsRepository {
};
if (agentTypeId != null) queryParams['agentTypeId'] = agentTypeId;
+ if (search != null && search.isNotEmpty) queryParams['search'] = search;
if (sortBy != null) queryParams['sortBy'] = sortBy;
if (sortOrder != null) queryParams['sortOrder'] = sortOrder;
diff --git a/lib/features/agents/data/models/agent_profile.dart b/lib/features/agents/data/models/agent_profile.dart
index e98cb72..d422256 100644
--- a/lib/features/agents/data/models/agent_profile.dart
+++ b/lib/features/agents/data/models/agent_profile.dart
@@ -53,12 +53,45 @@ class AgentProfile {
String? get avatarUrl => avatar ?? user?.avatar;
String get location {
+ // Direct fields first
if (city != null && state != null) return '$city, $state';
if (city != null) return city!;
if (state != null) return state!;
+
+ // Fallback: extract from fieldValues (matches web getLocationString)
+ String? fvCity;
+ String? fvState;
+ for (final fv in fieldValues) {
+ final slug = fv.fieldSlug.toLowerCase();
+ if (slug == 'city' || slug == 'city_name') {
+ fvCity = _extractFieldString(fv);
+ } else if (slug == 'state' || slug == 'state_name') {
+ fvState = _extractFieldString(fv);
+ }
+ }
+ final parts = [fvCity, fvState].where((s) => s != null && s.isNotEmpty);
+ if (parts.isNotEmpty) return parts.join(', ');
+
return '';
}
+ /// Extract a display string from a field value (handles jsonValue arrays).
+ static String? _extractFieldString(AgentFieldValue fv) {
+ if (fv.jsonValue is List && (fv.jsonValue as List).isNotEmpty) {
+ final val = (fv.jsonValue as List).first;
+ if (val is String && val.isNotEmpty) {
+ return val
+ .split('_')
+ .map((w) => w.isNotEmpty
+ ? '${w[0].toUpperCase()}${w.substring(1).toLowerCase()}'
+ : '')
+ .join(' ');
+ }
+ }
+ if (fv.textValue != null && fv.textValue!.isNotEmpty) return fv.textValue;
+ return null;
+ }
+
/// Get experience from field values
String get experienceText {
for (final fv in fieldValues) {
@@ -66,29 +99,50 @@ class AgentProfile {
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 '${fv.numberValue!.toInt()}+ years in real estate.';
}
}
}
return '';
}
- /// Get specializations from field values
+ /// Get specializations / expertise from field values.
+ /// Matches web's getExpertiseTags slugs.
List get specializations {
+ final tags = [];
+ const expertiseSlugs = [
+ 'about_me_expertise',
+ 'expertise_areas',
+ 'specializations',
+ 'areas_of_expertise',
+ 'expertise',
+ 'specialization',
+ 'property_type',
+ 'loan_type',
+ ];
+
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();
+ if (!expertiseSlugs.any((s) => slug.contains(s))) continue;
+
+ if (fv.jsonValue is List) {
+ for (final v in (fv.jsonValue as List)) {
+ final s = v.toString().trim();
+ if (s.isEmpty) continue;
+ final display = s
+ .split('_')
+ .map((w) => w.isNotEmpty
+ ? '${w[0].toUpperCase()}${w.substring(1).toLowerCase()}'
+ : '')
+ .join(' ');
+ if (!tags.contains(display)) tags.add(display);
}
+ } else if (fv.textValue != null && fv.textValue!.trim().isNotEmpty) {
+ final display = fv.textValue!.trim();
+ if (!tags.contains(display)) tags.add(display);
}
}
- return [];
+ return tags;
}
factory AgentProfile.fromJson(Map json) {
diff --git a/lib/features/agents/presentation/providers/search_agents_provider.dart b/lib/features/agents/presentation/providers/search_agents_provider.dart
new file mode 100644
index 0000000..745d3f7
--- /dev/null
+++ b/lib/features/agents/presentation/providers/search_agents_provider.dart
@@ -0,0 +1,139 @@
+import 'package:flutter_riverpod/flutter_riverpod.dart';
+import 'package:real_estate_mobile/features/agents/data/agents_repository.dart';
+import 'package:real_estate_mobile/features/agents/data/models/agent_profile.dart';
+import 'package:real_estate_mobile/features/agents/data/models/agent_type.dart';
+import 'package:real_estate_mobile/features/agents/presentation/providers/agents_provider.dart';
+
+class SearchAgentsState {
+ final List agents;
+ final List agentTypes;
+ final bool isLoading;
+ final bool isLoadingMore;
+ final String? error;
+ final String searchQuery;
+ final String? selectedAgentTypeId;
+ final int currentPage;
+ final int totalPages;
+
+ const SearchAgentsState({
+ this.agents = const [],
+ this.agentTypes = const [],
+ this.isLoading = false,
+ this.isLoadingMore = false,
+ this.error,
+ this.searchQuery = '',
+ this.selectedAgentTypeId,
+ this.currentPage = 1,
+ this.totalPages = 1,
+ });
+
+ bool get hasMore => currentPage < totalPages;
+
+ SearchAgentsState copyWith({
+ List? agents,
+ List? agentTypes,
+ bool? isLoading,
+ bool? isLoadingMore,
+ String? error,
+ String? searchQuery,
+ String? selectedAgentTypeId,
+ bool clearAgentTypeId = false,
+ int? currentPage,
+ int? totalPages,
+ }) {
+ return SearchAgentsState(
+ agents: agents ?? this.agents,
+ agentTypes: agentTypes ?? this.agentTypes,
+ isLoading: isLoading ?? this.isLoading,
+ isLoadingMore: isLoadingMore ?? this.isLoadingMore,
+ error: error,
+ searchQuery: searchQuery ?? this.searchQuery,
+ selectedAgentTypeId: clearAgentTypeId
+ ? null
+ : (selectedAgentTypeId ?? this.selectedAgentTypeId),
+ currentPage: currentPage ?? this.currentPage,
+ totalPages: totalPages ?? this.totalPages,
+ );
+ }
+}
+
+class SearchAgentsNotifier extends StateNotifier {
+ final AgentsRepository _repository;
+
+ SearchAgentsNotifier(this._repository)
+ : super(const SearchAgentsState(isLoading: true)) {
+ _init();
+ }
+
+ Future _init() async {
+ try {
+ final agentTypes = await _repository.getAgentTypes();
+ state = state.copyWith(agentTypes: agentTypes);
+ await _fetchAgents(page: 1);
+ } catch (e) {
+ state = state.copyWith(isLoading: false, error: e.toString());
+ }
+ }
+
+ Future _fetchAgents({required int page, bool append = false}) async {
+ try {
+ final response = await _repository.searchAgents(
+ search: state.searchQuery.isNotEmpty ? state.searchQuery : null,
+ agentTypeId: state.selectedAgentTypeId,
+ page: page,
+ limit: 10,
+ );
+
+ state = state.copyWith(
+ agents: append ? [...state.agents, ...response.data] : response.data,
+ currentPage: response.page,
+ totalPages: response.totalPages,
+ isLoading: false,
+ isLoadingMore: false,
+ );
+ } catch (e) {
+ state = state.copyWith(
+ isLoading: false,
+ isLoadingMore: false,
+ error: e.toString(),
+ );
+ }
+ }
+
+ void search(String query) {
+ state = state.copyWith(
+ searchQuery: query,
+ isLoading: true,
+ currentPage: 1,
+ );
+ _fetchAgents(page: 1);
+ }
+
+ void filterByAgentType(String? agentTypeId) {
+ state = state.copyWith(
+ selectedAgentTypeId: agentTypeId,
+ clearAgentTypeId: agentTypeId == null,
+ isLoading: true,
+ currentPage: 1,
+ );
+ _fetchAgents(page: 1);
+ }
+
+ void loadMore() {
+ if (state.isLoadingMore || !state.hasMore) return;
+ state = state.copyWith(isLoadingMore: true);
+ _fetchAgents(page: state.currentPage + 1, append: true);
+ }
+
+ Future refresh() async {
+ state = state.copyWith(isLoading: true, currentPage: 1);
+ await _fetchAgents(page: 1);
+ }
+}
+
+final searchAgentsProvider =
+ StateNotifierProvider.autoDispose(
+ (ref) {
+ final repository = ref.watch(agentsRepositoryProvider);
+ return SearchAgentsNotifier(repository);
+});
diff --git a/lib/features/agents/presentation/screens/agent_search_screen.dart b/lib/features/agents/presentation/screens/agent_search_screen.dart
new file mode 100644
index 0000000..dc93348
--- /dev/null
+++ b/lib/features/agents/presentation/screens/agent_search_screen.dart
@@ -0,0 +1,699 @@
+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:go_router/go_router.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/data/models/agent_type.dart';
+import 'package:real_estate_mobile/features/agents/presentation/providers/search_agents_provider.dart';
+import 'package:real_estate_mobile/features/home/presentation/widgets/home_header.dart';
+
+class AgentSearchScreen extends ConsumerStatefulWidget {
+ final String? initialQuery;
+
+ const AgentSearchScreen({super.key, this.initialQuery});
+
+ @override
+ ConsumerState createState() => _AgentSearchScreenState();
+}
+
+class _AgentSearchScreenState extends ConsumerState {
+ late TextEditingController _searchController;
+ final ScrollController _scrollController = ScrollController();
+
+ @override
+ void initState() {
+ super.initState();
+ _searchController = TextEditingController(text: widget.initialQuery ?? '');
+ _scrollController.addListener(_onScroll);
+
+ // Trigger initial search if query provided
+ if (widget.initialQuery != null && widget.initialQuery!.isNotEmpty) {
+ WidgetsBinding.instance.addPostFrameCallback((_) {
+ ref
+ .read(searchAgentsProvider.notifier)
+ .search(widget.initialQuery!);
+ });
+ }
+ }
+
+ @override
+ void dispose() {
+ _searchController.dispose();
+ _scrollController.dispose();
+ super.dispose();
+ }
+
+ void _onScroll() {
+ if (_scrollController.position.pixels >=
+ _scrollController.position.maxScrollExtent - 200) {
+ ref.read(searchAgentsProvider.notifier).loadMore();
+ }
+ }
+
+ @override
+ Widget build(BuildContext context) {
+ final state = ref.watch(searchAgentsProvider);
+
+ return Scaffold(
+ backgroundColor: Colors.white,
+ body: Column(
+ children: [
+ // Shared header
+ SafeArea(
+ bottom: false,
+ child: const HomeHeader(),
+ ),
+ _buildSearchBar(),
+ const SizedBox(height: 12),
+ _buildFilterChips(state.agentTypes, state.selectedAgentTypeId),
+ const SizedBox(height: 12),
+ Expanded(child: _buildAgentList(state)),
+ ],
+ ),
+ bottomNavigationBar: _buildBottomNavBar(),
+ );
+ }
+
+ Widget _buildSearchBar() {
+ return Padding(
+ padding: const EdgeInsets.fromLTRB(24, 16, 24, 0),
+ child: SizedBox(
+ height: 42,
+ child: Row(
+ children: [
+ Expanded(
+ child: Container(
+ decoration: BoxDecoration(
+ border: Border.all(
+ color: AppColors.primaryDark,
+ width: 0.1,
+ ),
+ borderRadius: BorderRadius.circular(7),
+ ),
+ child: TextField(
+ controller: _searchController,
+ style: const TextStyle(
+ fontFamily: 'Fractul',
+ fontSize: 14,
+ fontWeight: FontWeight.w500,
+ color: AppColors.primaryDark,
+ ),
+ decoration: InputDecoration(
+ hintText: 'Search Agents',
+ hintStyle: TextStyle(
+ fontFamily: 'Fractul',
+ fontSize: 14,
+ fontWeight: FontWeight.w500,
+ color: Colors.black.withValues(alpha: 0.5),
+ ),
+ border: InputBorder.none,
+ contentPadding: const EdgeInsets.symmetric(
+ horizontal: 14,
+ vertical: 10,
+ ),
+ ),
+ onSubmitted: (value) {
+ ref.read(searchAgentsProvider.notifier).search(value);
+ },
+ ),
+ ),
+ ),
+ const SizedBox(width: 0),
+ GestureDetector(
+ onTap: () {
+ ref
+ .read(searchAgentsProvider.notifier)
+ .search(_searchController.text);
+ },
+ child: Container(
+ width: 62,
+ height: 42,
+ decoration: BoxDecoration(
+ color: AppColors.accentOrange,
+ border: Border.all(
+ color: AppColors.primaryDark,
+ width: 0.1,
+ ),
+ borderRadius: BorderRadius.circular(7),
+ ),
+ child: const Icon(Icons.search, color: Colors.white, size: 24),
+ ),
+ ),
+ ],
+ ),
+ ),
+ );
+ }
+
+ Widget _buildFilterChips(
+ List agentTypes, String? selectedTypeId) {
+ return SizedBox(
+ height: 32,
+ child: Padding(
+ padding: const EdgeInsets.only(left: 24),
+ child: Row(
+ children: [
+ Expanded(
+ child: ListView.separated(
+ scrollDirection: Axis.horizontal,
+ itemCount: agentTypes.length,
+ separatorBuilder: (_, __) => const SizedBox(width: 4),
+ itemBuilder: (context, index) {
+ final type = agentTypes[index];
+ final isSelected = type.id == selectedTypeId;
+ return GestureDetector(
+ onTap: () {
+ ref
+ .read(searchAgentsProvider.notifier)
+ .filterByAgentType(
+ isSelected ? null : type.id);
+ },
+ child: Container(
+ padding: const EdgeInsets.symmetric(
+ horizontal: 12, vertical: 4),
+ decoration: BoxDecoration(
+ color: isSelected
+ ? AppColors.accentOrange
+ : Colors.transparent,
+ borderRadius: BorderRadius.circular(15),
+ border: Border.all(
+ color: AppColors.primaryDark,
+ width: 0.1,
+ ),
+ ),
+ child: Center(
+ child: Text(
+ type.name,
+ style: TextStyle(
+ fontFamily: 'SourceSerif4',
+ fontSize: 14,
+ fontWeight: FontWeight.w400,
+ color: isSelected
+ ? Colors.white
+ : AppColors.primaryDark,
+ ),
+ ),
+ ),
+ ),
+ );
+ },
+ ),
+ ),
+ const SizedBox(width: 8),
+ Padding(
+ padding: const EdgeInsets.only(right: 24),
+ child: Container(
+ width: 39,
+ height: 27,
+ decoration: BoxDecoration(
+ borderRadius: BorderRadius.circular(15),
+ border: Border.all(
+ color: AppColors.primaryDark,
+ width: 0.1,
+ ),
+ ),
+ child: const Icon(
+ Icons.tune,
+ color: AppColors.primaryDark,
+ size: 20,
+ ),
+ ),
+ ),
+ ],
+ ),
+ ),
+ );
+ }
+
+ Widget _buildAgentList(SearchAgentsState state) {
+ if (state.isLoading) {
+ return const Center(
+ child: CircularProgressIndicator(color: AppColors.accentOrange),
+ );
+ }
+
+ if (state.error != null) {
+ return Center(
+ child: Column(
+ mainAxisAlignment: MainAxisAlignment.center,
+ children: [
+ const Icon(Icons.error_outline,
+ color: AppColors.accentOrange, size: 48),
+ const SizedBox(height: 12),
+ Text(
+ state.error!,
+ style: const TextStyle(
+ fontFamily: 'Fractul',
+ fontSize: 14,
+ color: AppColors.primaryDark,
+ ),
+ textAlign: TextAlign.center,
+ ),
+ const SizedBox(height: 16),
+ TextButton(
+ onPressed: () =>
+ ref.read(searchAgentsProvider.notifier).refresh(),
+ child: const Text(
+ 'Retry',
+ style: TextStyle(
+ fontFamily: 'Fractul',
+ fontSize: 14,
+ fontWeight: FontWeight.w600,
+ color: AppColors.accentOrange,
+ ),
+ ),
+ ),
+ ],
+ ),
+ );
+ }
+
+ if (state.agents.isEmpty) {
+ return const Center(
+ child: Column(
+ mainAxisAlignment: MainAxisAlignment.center,
+ children: [
+ Icon(Icons.people_outline, color: AppColors.hintText, size: 48),
+ SizedBox(height: 12),
+ Text(
+ 'No agents found',
+ style: TextStyle(
+ fontFamily: 'Fractul',
+ fontSize: 14,
+ color: AppColors.hintText,
+ ),
+ ),
+ ],
+ ),
+ );
+ }
+
+ return ListView.builder(
+ controller: _scrollController,
+ padding: const EdgeInsets.symmetric(horizontal: 24),
+ itemCount: state.agents.length + (state.isLoadingMore ? 1 : 0),
+ itemBuilder: (context, index) {
+ if (index == state.agents.length) {
+ return const Padding(
+ padding: EdgeInsets.symmetric(vertical: 20),
+ child: Center(
+ child:
+ CircularProgressIndicator(color: AppColors.accentOrange),
+ ),
+ );
+ }
+ return Padding(
+ padding: const EdgeInsets.only(bottom: 16),
+ child: _AgentCard(agent: state.agents[index]),
+ );
+ },
+ );
+ }
+
+ // ── Bottom Navigation Bar (shared style matching HomeScreen) ──
+ 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,
+ isSelected: false,
+ onTap: () => context.go('/home'),
+ ),
+ _buildNavItem(
+ index: 1,
+ svgPath: 'assets/icons/nav_list_icon.svg',
+ fallbackIcon: Icons.list,
+ isSelected: true,
+ onTap: () {},
+ ),
+ _buildNavItem(
+ index: 2,
+ svgPath: 'assets/icons/nav_messages_icon.svg',
+ fallbackIcon: Icons.chat_bubble,
+ isSelected: false,
+ onTap: () {},
+ ),
+ _buildNavItem(
+ index: 3,
+ svgPath: 'assets/icons/nav_profile_icon.svg',
+ fallbackIcon: Icons.person_outline,
+ isSelected: false,
+ onTap: () {},
+ ),
+ ],
+ ),
+ ),
+ ),
+ );
+ }
+
+ Widget _buildNavItem({
+ required int index,
+ required String svgPath,
+ required IconData fallbackIcon,
+ required bool isSelected,
+ required VoidCallback onTap,
+ }) {
+ return GestureDetector(
+ onTap: onTap,
+ 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,
+ ),
+ ),
+ ),
+ );
+ }
+}
+
+// --- Agent Card Widget matching Figma design ---
+
+class _AgentCard extends StatefulWidget {
+ final AgentProfile agent;
+
+ const _AgentCard({required this.agent});
+
+ @override
+ State<_AgentCard> createState() => _AgentCardState();
+}
+
+class _AgentCardState extends State<_AgentCard> {
+ bool _bioExpanded = false;
+
+ String? _resolveImageUrl(String? imageUrl) {
+ if (imageUrl == null || 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;
+ }
+
+ @override
+ Widget build(BuildContext context) {
+ final agent = widget.agent;
+ final specializations = agent.specializations;
+ final bio = agent.bio ?? '';
+ final matchPercent = agent.averageRating != null
+ ? '${(agent.averageRating! * 20).round()}%'
+ : null;
+
+ return Container(
+ decoration: BoxDecoration(
+ color: Colors.white,
+ borderRadius: BorderRadius.circular(20),
+ border: Border.all(color: AppColors.primaryDark.withValues(alpha: 0.1)),
+ ),
+ clipBehavior: Clip.antiAlias,
+ child: Column(
+ crossAxisAlignment: CrossAxisAlignment.start,
+ children: [
+ // Cover image with match badge
+ Stack(
+ children: [
+ _buildCoverImage(agent),
+ if (matchPercent != null)
+ Positioned(
+ top: 0,
+ right: 0,
+ child: Container(
+ padding:
+ const EdgeInsets.symmetric(horizontal: 10, vertical: 12),
+ decoration: const BoxDecoration(
+ color: Color(0xFF638559),
+ borderRadius: BorderRadius.only(
+ bottomLeft: Radius.circular(20),
+ topRight: Radius.circular(20),
+ ),
+ ),
+ child: Text(
+ matchPercent,
+ style: const TextStyle(
+ fontFamily: 'SourceSerif4',
+ fontSize: 14,
+ fontWeight: FontWeight.w400,
+ color: Color(0xFFF0F5FC),
+ ),
+ ),
+ ),
+ ),
+ ],
+ ),
+
+ // Card content
+ Padding(
+ padding: const EdgeInsets.fromLTRB(34, 16, 20, 20),
+ child: Column(
+ crossAxisAlignment: CrossAxisAlignment.start,
+ children: [
+ // Name row with verified badge
+ Row(
+ children: [
+ Flexible(
+ child: Text.rich(
+ TextSpan(
+ children: [
+ TextSpan(
+ text: agent.firstName,
+ style: const TextStyle(
+ fontFamily: 'Fractul',
+ fontSize: 15,
+ fontWeight: FontWeight.w700,
+ color: AppColors.primaryDark,
+ ),
+ ),
+ TextSpan(
+ text: ' ${agent.lastName}',
+ style: const TextStyle(
+ fontFamily: 'Fractul',
+ fontSize: 15,
+ fontWeight: FontWeight.w400,
+ color: AppColors.primaryDark,
+ ),
+ ),
+ ],
+ ),
+ overflow: TextOverflow.ellipsis,
+ ),
+ ),
+ if (agent.isVerified) ...[
+ const SizedBox(width: 6),
+ SvgPicture.asset(
+ 'assets/icons/verified_badge_icon.svg',
+ width: 16,
+ height: 16,
+ placeholderBuilder: (_) => const Icon(
+ Icons.verified,
+ color: Color(0xFF5D8B5A),
+ size: 16,
+ ),
+ ),
+ const SizedBox(width: 4),
+ const Text(
+ '(Verified Expert)',
+ style: TextStyle(
+ fontFamily: 'SourceSerif4',
+ fontSize: 14,
+ fontWeight: FontWeight.w500,
+ color: Color(0xFF5D8B5A),
+ ),
+ ),
+ ],
+ ],
+ ),
+ const SizedBox(height: 4),
+
+ // Headline / title
+ Text(
+ agent.headline ?? agent.agentType?.name ?? 'Licensed Real Estate Agent',
+ style: const TextStyle(
+ fontFamily: 'Fractul',
+ fontSize: 15,
+ fontWeight: FontWeight.w400,
+ color: AppColors.primaryDark,
+ ),
+ ),
+ const SizedBox(height: 10),
+
+ // Location + Member Since row
+ Row(
+ children: [
+ if (agent.location.isNotEmpty) ...[
+ 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),
+ Text(
+ agent.location,
+ style: const TextStyle(
+ fontFamily: 'SourceSerif4',
+ fontSize: 14,
+ fontWeight: FontWeight.w500,
+ color: AppColors.primaryDark,
+ ),
+ ),
+ const SizedBox(width: 16),
+ ],
+ SvgPicture.asset(
+ 'assets/icons/calendar_icon.svg',
+ width: 14,
+ height: 14,
+ placeholderBuilder: (_) => const Icon(
+ Icons.calendar_today,
+ color: AppColors.accentOrange,
+ size: 14,
+ ),
+ ),
+ const SizedBox(width: 4),
+ const Text(
+ 'Member Since 2024',
+ style: TextStyle(
+ fontFamily: 'SourceSerif4',
+ fontSize: 14,
+ fontWeight: FontWeight.w500,
+ color: AppColors.primaryDark,
+ ),
+ ),
+ ],
+ ),
+ const SizedBox(height: 12),
+
+ // Specialization tags
+ if (specializations.isNotEmpty) ...[
+ Wrap(
+ spacing: 8,
+ runSpacing: 8,
+ children: specializations
+ .take(8)
+ .map((tag) => _buildTag(tag))
+ .toList(),
+ ),
+ const SizedBox(height: 14),
+ ],
+
+ // Bio
+ if (bio.isNotEmpty) ...[
+ Text.rich(
+ TextSpan(
+ children: [
+ TextSpan(
+ text: _bioExpanded || bio.length <= 150
+ ? bio
+ : '${bio.substring(0, 150)}... ',
+ style: const TextStyle(
+ fontFamily: 'SourceSerif4',
+ fontSize: 10,
+ fontWeight: FontWeight.w400,
+ color: AppColors.primaryDark,
+ ),
+ ),
+ if (bio.length > 150)
+ WidgetSpan(
+ child: GestureDetector(
+ onTap: () =>
+ setState(() => _bioExpanded = !_bioExpanded),
+ child: Text(
+ _bioExpanded ? 'Show Less.' : 'Show More.',
+ style: const TextStyle(
+ fontFamily: 'Fractul',
+ fontSize: 10,
+ fontWeight: FontWeight.w600,
+ color: AppColors.primaryDark,
+ decoration: TextDecoration.underline,
+ ),
+ ),
+ ),
+ ),
+ ],
+ ),
+ ),
+ ],
+ ],
+ ),
+ ),
+ ],
+ ),
+ );
+ }
+
+ Widget _buildCoverImage(AgentProfile agent) {
+ final resolvedUrl = _resolveImageUrl(agent.avatarUrl);
+
+ if (resolvedUrl != null) {
+ return CachedNetworkImage(
+ imageUrl: resolvedUrl,
+ width: double.infinity,
+ height: 265,
+ fit: BoxFit.cover,
+ placeholder: (_, __) => _buildPlaceholder(),
+ errorWidget: (_, __, ___) => _buildPlaceholder(),
+ );
+ }
+ return _buildPlaceholder();
+ }
+
+ Widget _buildPlaceholder() {
+ return Container(
+ width: double.infinity,
+ height: 265,
+ color: AppColors.gradientStart,
+ child:
+ const Icon(Icons.person, size: 80, 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: 14,
+ fontWeight: FontWeight.w400,
+ color: AppColors.primaryDark,
+ ),
+ ),
+ );
+ }
+}
diff --git a/lib/features/home/presentation/screens/home_screen.dart b/lib/features/home/presentation/screens/home_screen.dart
index f634fea..475f419 100644
--- a/lib/features/home/presentation/screens/home_screen.dart
+++ b/lib/features/home/presentation/screens/home_screen.dart
@@ -1,5 +1,6 @@
import 'package:flutter/material.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/features/home/presentation/widgets/featured_professionals_section.dart';
import 'package:real_estate_mobile/features/home/presentation/widgets/features_section.dart';
@@ -143,6 +144,10 @@ class _HomeScreenState extends State {
return GestureDetector(
onTap: () {
+ if (index == 1) {
+ context.push('/agents/search');
+ return;
+ }
setState(() => _selectedIndex = index);
},
behavior: HitTestBehavior.opaque,
diff --git a/lib/features/home/presentation/widgets/featured_professionals_section.dart b/lib/features/home/presentation/widgets/featured_professionals_section.dart
index 11f6006..695818f 100644
--- a/lib/features/home/presentation/widgets/featured_professionals_section.dart
+++ b/lib/features/home/presentation/widgets/featured_professionals_section.dart
@@ -4,8 +4,8 @@ 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/home/data/models/landing_page_content.dart';
-import 'package:real_estate_mobile/features/home/presentation/providers/home_provider.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';
/// Local fallback images for professionals.
const _fallbackImages = [
@@ -14,39 +14,9 @@ const _fallbackImages = [
'assets/images/professional-3.jpg',
];
-/// Default agent data — matches web's hardcoded agentsData fallback.
-/// Used when CMS returns no data from admin.
-const _defaultAgents = [
- ProfessionalItem(
- name: 'Arjun Mehta',
- subtitle: 'Residential Property Expert',
- location: 'San Francisco, CA',
- experience: '10+ years in the real estate industry.',
- expertise: ['Residential', 'Rental', 'Commercial', 'Inspection', 'Land', 'Rental'],
- ),
- ProfessionalItem(
- name: 'Anderson',
- subtitle: 'Rental & Investment Consultant',
- location: 'New York',
- experience: '7+ years in the real estate industry.',
- expertise: ['Residential', 'Rental', 'Commercial'],
- ),
- ProfessionalItem(
- name: 'Sarah Johnson',
- subtitle: 'Luxury Real Estate Specialist',
- location: 'Los Angeles, CA',
- experience: '12+ years in the real estate industry.',
- expertise: ['Luxury', 'Residential', 'Investment'],
- ),
-];
-
/// Featured professionals carousel shown above the agents/lenders tab section.
-/// Matches Figma node 49:6248 — compact card with image, name, subtitle,
-/// verified+rating row, location, and experience.
-///
-/// Data flow (same as web pattern):
-/// CMS admin → GET /cms/page/landing → topProfessionals.agents
-/// Fallback → _defaultAgents (hardcoded, like web's agentsData)
+/// Now fetches REAL agent data from GET /agents API (like web does)
+/// instead of using CMS/hardcoded fallback data.
class FeaturedProfessionalsSection extends ConsumerStatefulWidget {
const FeaturedProfessionalsSection({super.key});
@@ -58,28 +28,50 @@ class FeaturedProfessionalsSection extends ConsumerStatefulWidget {
class _FeaturedProfessionalsSectionState
extends ConsumerState {
int _currentPage = 0;
- late PageController _pageController;
+ late ScrollController _scrollController;
@override
void initState() {
super.initState();
- _pageController = PageController();
+ _scrollController = ScrollController();
+ _scrollController.addListener(_onScroll);
}
@override
void dispose() {
- _pageController.dispose();
+ _scrollController.removeListener(_onScroll);
+ _scrollController.dispose();
super.dispose();
}
+ void _onScroll() {
+ if (!_scrollController.hasClients) return;
+ final cardWidth = MediaQuery.of(context).size.width - 80;
+ if (cardWidth <= 0) return;
+ final page = (_scrollController.offset / cardWidth).round();
+ if (page != _currentPage) {
+ setState(() => _currentPage = page);
+ }
+ }
+
@override
Widget build(BuildContext context) {
- final homeState = ref.watch(homeProvider);
- final topProfessionals = homeState.content?.topProfessionals;
+ // Real agent data from GET /agents API
+ final topProState = ref.watch(topProfessionalsProvider);
+ final professionals = topProState.agents;
- // Use CMS agents if available, otherwise fallback to defaults (same as web)
- final cmsAgents = topProfessionals?.agents ?? [];
- final professionals = cmsAgents.isNotEmpty ? cmsAgents : _defaultAgents;
+ if (topProState.isLoading) {
+ return const Padding(
+ padding: EdgeInsets.symmetric(vertical: 40),
+ child: Center(
+ child: CircularProgressIndicator(color: AppColors.accentOrange),
+ ),
+ );
+ }
+
+ if (professionals.isEmpty) {
+ return const SizedBox.shrink();
+ }
return Padding(
padding: const EdgeInsets.symmetric(horizontal: 40),
@@ -88,14 +80,23 @@ class _FeaturedProfessionalsSectionState
// Card carousel
SizedBox(
height: 370,
- child: PageView.builder(
- controller: _pageController,
+ child: ListView.builder(
+ controller: _scrollController,
+ scrollDirection: Axis.horizontal,
+ physics: const BouncingScrollPhysics(),
itemCount: professionals.length,
- onPageChanged: (index) {
- setState(() => _currentPage = index);
- },
itemBuilder: (context, index) {
- return _buildFeaturedCard(professionals[index], index);
+ final cardWidth = MediaQuery.of(context).size.width - 80;
+ return SizedBox(
+ width: cardWidth,
+ child: Padding(
+ padding: EdgeInsets.only(
+ left: index == 0 ? 0 : 8,
+ right: index == professionals.length - 1 ? 0 : 8,
+ ),
+ child: _buildFeaturedCard(professionals[index], index),
+ ),
+ );
},
),
),
@@ -108,7 +109,12 @@ class _FeaturedProfessionalsSectionState
);
}
- Widget _buildFeaturedCard(ProfessionalItem professional, int index) {
+ Widget _buildFeaturedCard(AgentProfile agent, int index) {
+ final rating = agent.averageRating ?? 0.0;
+ final ratingText = rating > 0 ? '${rating.toStringAsFixed(1)} Rating' : '';
+ final subtitle = agent.agentType?.name ?? agent.headline ?? '';
+ final experience = agent.experienceText;
+
return Container(
decoration: BoxDecoration(
color: Colors.white,
@@ -117,87 +123,90 @@ class _FeaturedProfessionalsSectionState
),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
+ mainAxisSize: MainAxisSize.min,
children: [
// Image
ClipRRect(
borderRadius:
const BorderRadius.vertical(top: Radius.circular(15)),
- child: _buildCardImage(professional, index),
+ child: _buildCardImage(agent, index),
),
// Content
- Expanded(
- child: Padding(
- padding: const EdgeInsets.fromLTRB(16, 12, 16, 14),
- child: Column(
- crossAxisAlignment: CrossAxisAlignment.start,
- children: [
- // Name (Fractul Bold 16px)
+ Padding(
+ padding: const EdgeInsets.fromLTRB(16, 12, 16, 14),
+ child: Column(
+ crossAxisAlignment: CrossAxisAlignment.start,
+ children: [
+ // Name
+ Text(
+ agent.fullName,
+ style: const TextStyle(
+ fontFamily: 'Fractul',
+ fontSize: 16,
+ fontWeight: FontWeight.w700,
+ color: AppColors.primaryDark,
+ ),
+ overflow: TextOverflow.ellipsis,
+ ),
+ const SizedBox(height: 2),
+
+ // Subtitle/headline
+ if (subtitle.isNotEmpty)
Text(
- professional.name,
+ subtitle,
style: const TextStyle(
fontFamily: 'Fractul',
- fontSize: 16,
- fontWeight: FontWeight.w700,
+ fontSize: 14,
+ fontWeight: FontWeight.w400,
color: AppColors.primaryDark,
),
+ maxLines: 1,
overflow: TextOverflow.ellipsis,
),
- const SizedBox(height: 2),
+ const SizedBox(height: 6),
- // Subtitle (e.g. "Rental & Investment Consultant")
- if (professional.subtitle.isNotEmpty)
- Text(
- professional.subtitle,
- style: const TextStyle(
- fontFamily: 'Fractul',
- fontSize: 14,
- fontWeight: FontWeight.w400,
- color: AppColors.primaryDark,
- ),
- maxLines: 1,
- overflow: TextOverflow.ellipsis,
- ),
- const SizedBox(height: 6),
-
- // Verified + Rating row
- Row(
- children: [
+ // Verified + Rating row
+ Row(
+ children: [
+ if (agent.isVerified) ...[
SvgPicture.asset(
'assets/icons/verified_badge_icon.svg',
- width: 19,
- height: 19,
+ width: 16,
+ height: 16,
placeholderBuilder: (_) => const Icon(
Icons.verified,
- color: Color(0xFF1DA1F2),
- size: 19,
+ color: Color(0xFF638559),
+ size: 16,
),
),
const SizedBox(width: 6),
const Text(
- 'Verified Agent',
+ '\u201CVerified local agent\u201D',
style: TextStyle(
fontFamily: 'SourceSerif4',
fontSize: 14,
fontWeight: FontWeight.w500,
- color: AppColors.primaryDark,
+ color: Color(0xFF638559),
),
),
const SizedBox(width: 16),
+ ],
+ if (ratingText.isNotEmpty) ...[
SvgPicture.asset(
'assets/icons/star_rating_icon.svg',
- width: 19,
- height: 19,
+ width: 16,
+ height: 16,
placeholderBuilder: (_) => const Icon(
Icons.star,
color: Color(0xFFFFDE21),
- size: 19,
+ size: 16,
),
),
const SizedBox(width: 4),
- const Text(
- '4.9 Rating',
- style: TextStyle(
+ Text(
+ ratingText,
+ style: const TextStyle(
fontFamily: 'SourceSerif4',
fontSize: 14,
fontWeight: FontWeight.w400,
@@ -205,70 +214,70 @@ class _FeaturedProfessionalsSectionState
),
),
],
- ),
- const SizedBox(height: 6),
+ ],
+ ),
+ const SizedBox(height: 6),
- // Location row with filled icon
- if (professional.location.isNotEmpty)
- Row(
+ // Location row
+ if (agent.location.isNotEmpty)
+ Row(
+ children: [
+ SvgPicture.asset(
+ 'assets/icons/location_filled_icon.svg',
+ width: 19,
+ height: 19,
+ placeholderBuilder: (_) => const Icon(
+ Icons.location_on,
+ color: AppColors.accentOrange,
+ size: 19,
+ ),
+ ),
+ const SizedBox(width: 6),
+ Expanded(
+ child: Text(
+ agent.location,
+ style: const TextStyle(
+ fontFamily: 'SourceSerif4',
+ fontSize: 14,
+ fontWeight: FontWeight.w500,
+ color: AppColors.primaryDark,
+ ),
+ overflow: TextOverflow.ellipsis,
+ ),
+ ),
+ ],
+ ),
+ const SizedBox(height: 8),
+
+ // Experience
+ if (experience.isNotEmpty)
+ RichText(
+ text: TextSpan(
children: [
- SvgPicture.asset(
- 'assets/icons/location_filled_icon.svg',
- width: 19,
- height: 19,
- placeholderBuilder: (_) => const Icon(
- Icons.location_on,
- color: AppColors.accentOrange,
- size: 19,
+ const TextSpan(
+ text: 'Experience: ',
+ style: TextStyle(
+ fontFamily: 'SourceSerif4',
+ fontSize: 14,
+ fontWeight: FontWeight.w700,
+ color: AppColors.primaryDark,
),
),
- const SizedBox(width: 6),
- Expanded(
- child: Text(
- professional.location,
- style: const TextStyle(
- fontFamily: 'SourceSerif4',
- fontSize: 14,
- fontWeight: FontWeight.w500,
- color: AppColors.primaryDark,
- ),
- overflow: TextOverflow.ellipsis,
+ TextSpan(
+ text: experience,
+ style: const TextStyle(
+ fontFamily: 'SourceSerif4',
+ fontSize: 14,
+ fontWeight: FontWeight.w400,
+ color: AppColors.primaryDark,
),
),
],
),
- const Spacer(),
-
- // Experience
- if (professional.experience.isNotEmpty)
- RichText(
- text: TextSpan(
- children: [
- const TextSpan(
- text: 'Experience: ',
- style: TextStyle(
- fontFamily: 'SourceSerif4',
- fontSize: 14,
- fontWeight: FontWeight.w700,
- color: AppColors.primaryDark,
- ),
- ),
- TextSpan(
- text: professional.experience,
- style: const TextStyle(
- fontFamily: 'SourceSerif4',
- fontSize: 14,
- fontWeight: FontWeight.w400,
- color: AppColors.primaryDark,
- ),
- ),
- ],
- ),
- maxLines: 2,
- overflow: TextOverflow.ellipsis,
- ),
- ],
- ),
+ maxLines: 2,
+ overflow: TextOverflow.ellipsis,
+ ),
+ ],
),
),
],
@@ -276,10 +285,10 @@ class _FeaturedProfessionalsSectionState
);
}
- // ── Image helpers ──
+ // -- Image helpers --
- Widget _buildCardImage(ProfessionalItem professional, int index) {
- final resolvedUrl = _resolveImageUrl(professional.imageUrl);
+ Widget _buildCardImage(AgentProfile agent, int index) {
+ final resolvedUrl = _resolveImageUrl(agent.avatarUrl);
if (resolvedUrl != null) {
return CachedNetworkImage(
imageUrl: resolvedUrl,
@@ -293,8 +302,8 @@ class _FeaturedProfessionalsSectionState
return _buildImagePlaceholder(index);
}
- String? _resolveImageUrl(String imageUrl) {
- if (imageUrl.isEmpty) return null;
+ String? _resolveImageUrl(String? imageUrl) {
+ if (imageUrl == null || imageUrl.isEmpty) return null;
if (imageUrl.startsWith('http://') || imageUrl.startsWith('https://')) {
return imageUrl;
}
@@ -319,7 +328,7 @@ class _FeaturedProfessionalsSectionState
);
}
- // ── Dot indicators ──
+ // -- Dot indicators --
Widget _buildDotIndicators(int count) {
return Row(
diff --git a/lib/features/home/presentation/widgets/hero_section.dart b/lib/features/home/presentation/widgets/hero_section.dart
index 78910f6..5a3723b 100644
--- a/lib/features/home/presentation/widgets/hero_section.dart
+++ b/lib/features/home/presentation/widgets/hero_section.dart
@@ -1,5 +1,6 @@
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/features/home/data/models/landing_page_content.dart';
import 'package:real_estate_mobile/features/home/presentation/providers/home_provider.dart';
@@ -14,15 +15,36 @@ const _defaultHero = HeroContent(
'Connect with trusted local agents to explore homes, compare options, and make smarter decisions.',
);
-class HeroSection extends ConsumerWidget {
+class HeroSection extends ConsumerStatefulWidget {
const HeroSection({super.key});
@override
- Widget build(BuildContext context, WidgetRef ref) {
+ ConsumerState createState() => _HeroSectionState();
+}
+
+class _HeroSectionState extends ConsumerState {
+ final _searchController = TextEditingController();
+
+ @override
+ void dispose() {
+ _searchController.dispose();
+ super.dispose();
+ }
+
+ void _navigateToSearch() {
+ final query = _searchController.text.trim();
+ if (query.isNotEmpty) {
+ context.push('/agents/search?q=${Uri.encodeComponent(query)}');
+ } else {
+ context.push('/agents/search');
+ }
+ }
+
+ @override
+ Widget build(BuildContext context) {
final homeState = ref.watch(homeProvider);
final hero = homeState.content?.hero ?? _defaultHero;
- // Use CMS headline if available, otherwise fallback
final headline = hero.headline.isNotEmpty
? hero.headline
: _defaultHero.headline;
@@ -32,7 +54,6 @@ class HeroSection extends ConsumerWidget {
return Stack(
children: [
- // Background image
ClipRRect(
borderRadius: BorderRadius.circular(0),
child: Image.asset(
@@ -42,7 +63,6 @@ class HeroSection extends ConsumerWidget {
fit: BoxFit.cover,
),
),
- // Content overlay
Padding(
padding: const EdgeInsets.symmetric(horizontal: 32),
child: Column(
@@ -60,7 +80,6 @@ class HeroSection extends ConsumerWidget {
),
),
const SizedBox(height: 30),
- // Search Agents Card
_buildSearchCard(ctaButtonText),
],
),
@@ -93,17 +112,44 @@ class HeroSection extends ConsumerWidget {
const SizedBox(height: 16),
_buildSearchField('Types'),
const SizedBox(height: 12),
- _buildSearchField('Name or Keyword'),
+ // Name or Keyword — actual text input
+ Container(
+ decoration: BoxDecoration(
+ color: Colors.white,
+ borderRadius: BorderRadius.circular(10),
+ ),
+ child: TextField(
+ controller: _searchController,
+ style: const TextStyle(
+ fontFamily: 'SourceSerif4',
+ fontSize: 14,
+ fontWeight: FontWeight.w400,
+ color: AppColors.primaryDark,
+ ),
+ decoration: const InputDecoration(
+ hintText: 'Name or Keyword',
+ hintStyle: TextStyle(
+ fontFamily: 'SourceSerif4',
+ fontSize: 14,
+ fontWeight: FontWeight.w400,
+ color: AppColors.primaryDark,
+ ),
+ border: InputBorder.none,
+ contentPadding:
+ EdgeInsets.symmetric(horizontal: 20, vertical: 12),
+ ),
+ onSubmitted: (_) => _navigateToSearch(),
+ ),
+ ),
const SizedBox(height: 12),
_buildSearchField('Location'),
const SizedBox(height: 12),
_buildSearchField('Categories'),
const SizedBox(height: 12),
- // CTA button
SizedBox(
width: double.infinity,
child: ElevatedButton(
- onPressed: () {},
+ onPressed: _navigateToSearch,
style: ElevatedButton.styleFrom(
backgroundColor: AppColors.primaryDark,
foregroundColor: Colors.white,
@@ -128,32 +174,40 @@ class HeroSection extends ConsumerWidget {
}
Widget _buildSearchField(String hint) {
- return Container(
- padding: const EdgeInsets.symmetric(horizontal: 20, vertical: 12),
- decoration: BoxDecoration(
- color: Colors.white,
- borderRadius: BorderRadius.circular(10),
- ),
- child: Row(
- children: [
- Expanded(
- child: Text(
- hint,
- style: const TextStyle(
- fontFamily: 'SourceSerif4',
- fontSize: 14,
- fontWeight: FontWeight.w400,
- color: AppColors.primaryDark,
+ return GestureDetector(
+ onTap: () {
+ // Tapping Types/Location/Categories navigates to search screen
+ if (hint == 'Types' || hint == 'Categories' || hint == 'Location') {
+ context.push('/agents/search');
+ }
+ },
+ child: Container(
+ padding: const EdgeInsets.symmetric(horizontal: 20, vertical: 12),
+ decoration: BoxDecoration(
+ color: Colors.white,
+ borderRadius: BorderRadius.circular(10),
+ ),
+ child: Row(
+ children: [
+ Expanded(
+ child: Text(
+ hint,
+ style: const TextStyle(
+ fontFamily: 'SourceSerif4',
+ fontSize: 14,
+ fontWeight: FontWeight.w400,
+ color: AppColors.primaryDark,
+ ),
),
),
- ),
- if (hint == 'Types' || hint == 'Categories')
- const Icon(
- Icons.keyboard_arrow_down,
- color: AppColors.primaryDark,
- size: 20,
- ),
- ],
+ if (hint == 'Types' || hint == 'Categories')
+ const Icon(
+ Icons.keyboard_arrow_down,
+ color: AppColors.primaryDark,
+ size: 20,
+ ),
+ ],
+ ),
),
);
}
diff --git a/lib/features/home/presentation/widgets/top_professionals_section.dart b/lib/features/home/presentation/widgets/top_professionals_section.dart
index 4ab2876..01882be 100644
--- a/lib/features/home/presentation/widgets/top_professionals_section.dart
+++ b/lib/features/home/presentation/widgets/top_professionals_section.dart
@@ -2,75 +2,28 @@ 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:go_router/go_router.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';
-/// Local fallback images for professionals when CMS returns no image.
+/// 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 agents data — matches web's hardcoded agentsData fallback.
-const _defaultAgents = [
- ProfessionalItem(
- name: 'Arjun Mehta',
- subtitle: 'Residential Property Expert',
- location: 'San Francisco, CA',
- experience: '10+ years in the real estate industry.',
- expertise: ['Residential', 'Rental', 'Commercial', 'Inspection', 'Land', 'Rental'],
- ),
- ProfessionalItem(
- name: 'Anderson',
- subtitle: 'Rental & Investment Consultant',
- location: 'New York',
- experience: '7+ years in the real estate industry.',
- expertise: ['Residential', 'Rental', 'Commercial'],
- ),
- ProfessionalItem(
- name: 'Sarah Johnson',
- subtitle: 'Luxury Real Estate Specialist',
- location: 'Los Angeles, CA',
- experience: '12+ years in the real estate industry.',
- expertise: ['Luxury', 'Residential', 'Investment'],
- ),
-];
-
-/// Default lenders data — matches web's hardcoded lendersData fallback.
-const _defaultLenders = [
- ProfessionalItem(
- name: 'Sarah Johnson',
- subtitle: 'Mortgage Specialist',
- location: 'Los Angeles, CA',
- experience: '10+ years in mortgage lending.',
- expertise: ['FHA Loans', 'Conventional', 'VA Loans', 'Refinancing', 'Jumbo'],
- ),
- ProfessionalItem(
- name: 'Michael Chen',
- subtitle: 'Senior Loan Officer',
- location: 'Seattle, WA',
- experience: '7+ years in lending.',
- expertise: ['Jumbo Loans', 'Refinancing', 'Investment', 'First-time', 'USDA'],
- ),
- ProfessionalItem(
- name: 'Emily Davis',
- subtitle: 'Home Loan Advisor',
- location: 'Austin, TX',
- experience: '5+ years in mortgage industry.',
- expertise: ['First-time Buyers', 'FHA', 'USDA Loans', 'VA Loans', 'Conventional'],
- ),
-];
-
-/// Default content — matches web's defaultContent pattern.
-const _defaultContent = TopProfessionalsContent(
+/// 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: _defaultAgents,
- lenders: _defaultLenders,
+ agents: [],
+ lenders: [],
);
class TopProfessionalsSection extends ConsumerStatefulWidget {
@@ -83,37 +36,51 @@ class TopProfessionalsSection extends ConsumerStatefulWidget {
class _TopProfessionalsSectionState
extends ConsumerState {
- String _activeTab = 'agents';
int _currentPage = 0;
- late PageController _pageController;
+ late ScrollController _scrollController;
@override
void initState() {
super.initState();
- _pageController = PageController();
+ _scrollController = ScrollController();
+ _scrollController.addListener(_onScroll);
}
@override
void dispose() {
- _pageController.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;
- // Use CMS data if available, otherwise fallback to defaults (same as web)
- final data = homeState.content?.topProfessionals ?? _defaultContent;
-
- final activeList = _activeTab == 'agents' ? data.agents : data.lenders;
+ // 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(
- data.title,
+ cmsContent.title.isNotEmpty
+ ? cmsContent.title
+ : _defaultCmsContent.title,
textAlign: TextAlign.center,
style: const TextStyle(
fontFamily: 'Fractul',
@@ -125,33 +92,42 @@ class _TopProfessionalsSectionState
const SizedBox(height: 24),
// Agents / Lenders Tab
- _buildCategoryTab(),
+ _buildCategoryTab(topProState.activeTab),
const SizedBox(height: 24),
// Content
- if (homeState.isLoading)
+ if (topProState.isLoading)
const Padding(
padding: EdgeInsets.symmetric(vertical: 40),
child: CircularProgressIndicator(
color: AppColors.accentOrange,
),
)
- else if (homeState.error != null)
- _buildErrorState(homeState.error!)
+ else if (topProState.error != null)
+ _buildErrorState(topProState.error!)
else if (activeList.isEmpty)
- _buildEmptyState()
+ _buildEmptyState(topProState.activeTab)
else ...[
// Professional Cards - horizontal scroll
SizedBox(
- height: 500,
- child: PageView.builder(
- controller: _pageController,
+ height: 480,
+ child: ListView.builder(
+ controller: _scrollController,
+ scrollDirection: Axis.horizontal,
+ physics: const BouncingScrollPhysics(),
itemCount: activeList.length,
- onPageChanged: (index) {
- setState(() => _currentPage = index);
- },
itemBuilder: (context, index) {
- return _buildProfessionalCard(activeList[index], index: 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),
+ ),
+ );
},
),
),
@@ -163,13 +139,13 @@ class _TopProfessionalsSectionState
const SizedBox(height: 32),
// See All Agents CTA
- _buildSeeAllAgentsCta(data),
+ _buildSeeAllAgentsCta(cmsContent),
],
),
);
}
- Widget _buildCategoryTab() {
+ Widget _buildCategoryTab(String activeTab) {
return Container(
height: 45,
decoration: BoxDecoration(
@@ -182,18 +158,16 @@ class _TopProfessionalsSectionState
Expanded(
child: GestureDetector(
onTap: () {
- setState(() {
- _activeTab = 'agents';
- _currentPage = 0;
- });
- if (_pageController.hasClients) {
- _pageController.jumpToPage(0);
+ ref.read(topProfessionalsProvider.notifier).setActiveTab('agents');
+ setState(() => _currentPage = 0);
+ if (_scrollController.hasClients) {
+ _scrollController.jumpTo(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),
@@ -206,14 +180,14 @@ class _TopProfessionalsSectionState
width: 24,
height: 24,
colorFilter: ColorFilter.mode(
- _activeTab == 'agents'
+ activeTab == 'agents'
? Colors.white
: AppColors.primaryDark,
BlendMode.srcIn,
),
placeholderBuilder: (_) => Icon(
Icons.people,
- color: _activeTab == 'agents'
+ color: activeTab == 'agents'
? Colors.white
: AppColors.primaryDark,
size: 24,
@@ -226,7 +200,7 @@ class _TopProfessionalsSectionState
fontFamily: 'SourceSerif4',
fontSize: 17,
fontWeight: FontWeight.w600,
- color: _activeTab == 'agents'
+ color: activeTab == 'agents'
? Colors.white
: AppColors.primaryDark,
),
@@ -240,18 +214,16 @@ class _TopProfessionalsSectionState
Expanded(
child: GestureDetector(
onTap: () {
- setState(() {
- _activeTab = 'lenders';
- _currentPage = 0;
- });
- if (_pageController.hasClients) {
- _pageController.jumpToPage(0);
+ ref.read(topProfessionalsProvider.notifier).setActiveTab('lenders');
+ setState(() => _currentPage = 0);
+ if (_scrollController.hasClients) {
+ _scrollController.jumpTo(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),
@@ -264,14 +236,14 @@ class _TopProfessionalsSectionState
width: 24,
height: 24,
colorFilter: ColorFilter.mode(
- _activeTab == 'lenders'
+ 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,
@@ -284,7 +256,7 @@ class _TopProfessionalsSectionState
fontFamily: 'SourceSerif4',
fontSize: 17,
fontWeight: FontWeight.w600,
- color: _activeTab == 'lenders'
+ color: activeTab == 'lenders'
? Colors.white
: AppColors.primaryDark,
),
@@ -299,9 +271,9 @@ class _TopProfessionalsSectionState
);
}
- /// Resolves an image URL: full URL → use as-is, relative → prepend base URL.
- String? _resolveImageUrl(String imageUrl) {
- if (imageUrl.isEmpty) return null;
+ /// Resolves an image URL: full URL as-is, relative prepend base URL.
+ String? _resolveImageUrl(String? imageUrl) {
+ if (imageUrl == null || imageUrl.isEmpty) return null;
if (imageUrl.startsWith('http://') || imageUrl.startsWith('https://')) {
return imageUrl;
}
@@ -312,9 +284,9 @@ class _TopProfessionalsSectionState
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);
+ /// Builds the card image from AgentProfile avatar.
+ Widget _buildCardImage(AgentProfile agent, int index) {
+ final resolvedUrl = _resolveImageUrl(agent.avatarUrl);
if (resolvedUrl != null) {
return CachedNetworkImage(
@@ -329,7 +301,6 @@ class _TopProfessionalsSectionState
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(
@@ -341,8 +312,13 @@ class _TopProfessionalsSectionState
);
}
- // ── Unified Professional Card (matches Figma design for both tabs) ──
- Widget _buildProfessionalCard(ProfessionalItem professional, {int index = 0}) {
+ // -- 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,
@@ -351,237 +327,176 @@ class _TopProfessionalsSectionState
),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
+ mainAxisSize: MainAxisSize.min,
children: [
// Image
ClipRRect(
borderRadius:
const BorderRadius.vertical(top: Radius.circular(15)),
- child: _buildCardImage(professional, index),
+ child: _buildCardImage(agent, index),
),
// Content
- Expanded(
- child: Padding(
- padding: const EdgeInsets.fromLTRB(16, 12, 16, 12),
- child: Column(
- crossAxisAlignment: CrossAxisAlignment.start,
- children: [
- // Row 1: Name + verified badge + star Rating
- Row(
- children: [
- Expanded(
- child: Row(
- children: [
- Flexible(
- child: Text(
- professional.name,
- style: const TextStyle(
- fontFamily: 'Fractul',
- fontSize: 14,
- fontWeight: FontWeight.w700,
- color: AppColors.primaryDark,
- ),
- overflow: TextOverflow.ellipsis,
- ),
- ),
- 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,
- ),
- ),
- ],
- ),
- ),
- 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),
- const Text(
- 'Rating',
- style: TextStyle(
- fontFamily: 'SourceSerif4',
- fontSize: 14,
- fontWeight: FontWeight.w400,
- color: AppColors.primaryDark,
- ),
- ),
- ],
+ 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,
),
- const SizedBox(height: 4),
+ overflow: TextOverflow.ellipsis,
+ ),
+ const SizedBox(height: 4),
- // Row 2: (Subtitle) in parentheses
- if (professional.subtitle.isNotEmpty)
- Text(
- '(${professional.subtitle})',
- style: const TextStyle(
- fontFamily: 'SourceSerif4',
- fontSize: 10,
- fontWeight: FontWeight.w400,
- color: AppColors.primaryDark,
- ),
- maxLines: 1,
- overflow: TextOverflow.ellipsis,
+ // Row 2: Subtitle/headline
+ if (subtitle.isNotEmpty)
+ Text(
+ '($subtitle)',
+ style: const TextStyle(
+ fontFamily: 'SourceSerif4',
+ fontSize: 10,
+ fontWeight: FontWeight.w400,
+ color: AppColors.primaryDark,
),
- const SizedBox(height: 4),
+ maxLines: 1,
+ overflow: TextOverflow.ellipsis,
+ ),
+ const SizedBox(height: 4),
- // Row 3: Verified badge + "Verified local agent" in green
+ // 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: 14,
- height: 14,
+ width: 16,
+ height: 16,
placeholderBuilder: (_) => const Icon(
Icons.verified,
color: Color(0xFF638559),
- size: 14,
+ size: 16,
),
),
- const SizedBox(width: 4),
+ const SizedBox(width: 6),
const Text(
- '"Verified local agent"',
+ '\u201CVerified local agent\u201D',
style: TextStyle(
fontFamily: 'SourceSerif4',
- fontSize: 10,
- fontWeight: FontWeight.w400,
+ fontSize: 14,
+ fontWeight: FontWeight.w500,
color: Color(0xFF638559),
),
),
],
),
- const SizedBox(height: 8),
+ const SizedBox(height: 8),
- // Row 4: Location: label
- if (professional.location.isNotEmpty) ...[
- const Text(
- 'Location:',
- style: TextStyle(
- fontFamily: 'Fractul',
- fontSize: 14,
- fontWeight: FontWeight.w700,
- color: AppColors.primaryDark,
- ),
+ // 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),
- // Pin icon + location text
- Row(
+ ),
+ 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: [
- SvgPicture.asset(
- 'assets/icons/location_pin_icon.svg',
- width: 14,
- height: 14,
- placeholderBuilder: (_) => const Icon(
- Icons.location_on,
- color: AppColors.accentOrange,
- size: 14,
+ const TextSpan(
+ text: 'Experience: ',
+ style: TextStyle(
+ fontFamily: 'Fractul',
+ fontSize: 14,
+ fontWeight: FontWeight.w700,
+ color: AppColors.primaryDark,
),
),
- const SizedBox(width: 4),
- Expanded(
- child: Text(
- professional.location,
- style: const TextStyle(
- fontFamily: 'SourceSerif4',
- fontSize: 10,
- fontWeight: FontWeight.w400,
- color: AppColors.primaryDark,
- ),
- overflow: TextOverflow.ellipsis,
+ TextSpan(
+ text: experience,
+ style: const TextStyle(
+ fontFamily: 'SourceSerif4',
+ fontSize: 14,
+ fontWeight: FontWeight.w400,
+ color: AppColors.primaryDark,
),
),
],
),
- const SizedBox(height: 8),
- ],
-
- // 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: 6),
- // Tags as bordered pills
- Wrap(
- spacing: 8,
- runSpacing: 6,
- children: professional.expertise
- .take(6)
- .map((tag) => _buildTag(tag))
- .toList(),
- ),
- ],
- const Spacer(),
-
- // Experience text
- if (professional.experience.isNotEmpty)
- RichText(
- text: TextSpan(
- children: [
- const TextSpan(
- text: 'Experience: ',
- style: TextStyle(
- fontFamily: 'Fractul',
- fontSize: 14,
- fontWeight: FontWeight.w700,
- color: AppColors.primaryDark,
- ),
- ),
- TextSpan(
- text: professional.experience,
- style: const TextStyle(
- fontFamily: 'SourceSerif4',
- fontSize: 14,
- fontWeight: FontWeight.w400,
- color: AppColors.primaryDark,
- ),
- ),
- ],
- ),
- 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,
- ),
- ),
- ),
- ),
+ maxLines: 2,
+ overflow: TextOverflow.ellipsis,
),
+ const SizedBox(height: 8),
],
- ),
+
+ // Star rating row at bottom
+ _buildStarRating(rating),
+ ],
),
),
],
@@ -589,6 +504,49 @@ class _TopProfessionalsSectionState
);
}
+ 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,
@@ -675,7 +633,8 @@ class _TopProfessionalsSectionState
),
const SizedBox(height: 16),
TextButton(
- onPressed: () => ref.read(homeProvider.notifier).loadContent(),
+ onPressed: () =>
+ ref.read(topProfessionalsProvider.notifier).refresh(),
child: const Text(
'Retry',
style: TextStyle(
@@ -691,13 +650,13 @@ class _TopProfessionalsSectionState
);
}
- Widget _buildEmptyState() {
+ Widget _buildEmptyState(String activeTab) {
return Padding(
padding: const EdgeInsets.symmetric(vertical: 40),
child: Column(
children: [
Icon(
- _activeTab == 'agents'
+ activeTab == 'agents'
? Icons.people_outline
: Icons.account_balance_outlined,
color: AppColors.hintText,
@@ -705,7 +664,7 @@ class _TopProfessionalsSectionState
),
const SizedBox(height: 12),
Text(
- 'No ${_activeTab == 'agents' ? 'agents' : 'lenders'} found',
+ 'No ${activeTab == 'agents' ? 'agents' : 'lenders'} found',
style: const TextStyle(
fontFamily: 'Fractul',
fontSize: 14,
@@ -719,6 +678,13 @@ class _TopProfessionalsSectionState
}
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),
@@ -752,7 +718,7 @@ class _TopProfessionalsSectionState
),
const SizedBox(height: 12),
Text(
- content.ctaText,
+ ctaText,
textAlign: TextAlign.center,
style: const TextStyle(
fontFamily: 'SourceSerif4',
@@ -765,17 +731,17 @@ class _TopProfessionalsSectionState
SizedBox(
width: double.infinity,
child: ElevatedButton(
- onPressed: () {},
+ onPressed: () => context.push('/agents/search'),
style: ElevatedButton.styleFrom(
- backgroundColor: AppColors.primaryDark,
- foregroundColor: Colors.white,
+ backgroundColor: AppColors.accentOrange,
+ foregroundColor: AppColors.primaryDark,
padding: const EdgeInsets.symmetric(vertical: 14),
shape: RoundedRectangleBorder(
borderRadius: BorderRadius.circular(10),
),
),
child: Text(
- content.ctaButtonText,
+ ctaButtonText,
style: const TextStyle(
fontFamily: 'Fractul',
fontSize: 16,
diff --git a/lib/features/home/presentation/widgets/trust_stats_section.dart b/lib/features/home/presentation/widgets/trust_stats_section.dart
index 843e4b3..dd33bb6 100644
--- a/lib/features/home/presentation/widgets/trust_stats_section.dart
+++ b/lib/features/home/presentation/widgets/trust_stats_section.dart
@@ -1,19 +1,69 @@
import 'package:flutter/material.dart';
+import 'package:flutter_riverpod/flutter_riverpod.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/data/models/landing_page_content.dart';
+import 'package:real_estate_mobile/features/home/presentation/providers/home_provider.dart';
-class TrustStatsSection extends StatelessWidget {
+/// Default content — matches web's hardcoded defaultContent fallback.
+const _defaultContent = TestimonialsContent(
+ title: "Our Clients' Trust Is Our Foundation",
+ subtitle:
+ 'We help buyers, sellers, and investors close successful real estate deals with confidence in every transaction.',
+ ratingInfo:
+ 'Clients rate our real estate services 4.9 out of 5 on average, based on recent client reviews.',
+ stats: [
+ StatItem(
+ iconPath: 'cities',
+ boldText: '25+ Cities &',
+ normalText: 'neighborhoods served',
+ ),
+ StatItem(
+ iconPath: 'properties',
+ boldText: '1,000+ Properties',
+ normalText: 'bought and sold for our clients.',
+ ),
+ ],
+ testimonials: [],
+);
+
+/// Map of stat icon keys to local SVG asset paths and Material fallback icons.
+const _statIconMap = {
+ 'cities': ('assets/icons/cities_icon.svg', Icons.location_city),
+ 'properties': ('assets/icons/properties_icon.svg', Icons.home_work),
+};
+
+class TrustStatsSection extends ConsumerWidget {
const TrustStatsSection({super.key});
@override
- Widget build(BuildContext context) {
+ Widget build(BuildContext context, WidgetRef ref) {
+ final homeState = ref.watch(homeProvider);
+ final cmsTestimonials = homeState.content?.testimonials;
+
+ // Use CMS data if available, otherwise fallback (same pattern as web)
+ final data = cmsTestimonials ?? _defaultContent;
+
+ final title = data.title.isNotEmpty ? data.title : _defaultContent.title;
+ final subtitle =
+ data.subtitle.isNotEmpty ? data.subtitle : _defaultContent.subtitle;
+ final ratingInfo = data.ratingInfo.isNotEmpty
+ ? data.ratingInfo
+ : _defaultContent.ratingInfo;
+ final stats =
+ data.stats.isNotEmpty ? data.stats : _defaultContent.stats;
+
+ // Extract rating number from ratingInfo string (e.g. "4.9" from "...4.9 out of 5...")
+ final ratingValue = _extractRating(ratingInfo);
+
return Padding(
padding: const EdgeInsets.symmetric(horizontal: 24),
child: Column(
children: [
- const Text(
- "Our Clients' Trust Is Our Foundation",
+ Text(
+ title,
textAlign: TextAlign.center,
- style: TextStyle(
+ style: const TextStyle(
fontFamily: 'Fractul',
fontSize: 25,
fontWeight: FontWeight.w700,
@@ -21,10 +71,10 @@ class TrustStatsSection extends StatelessWidget {
),
),
const SizedBox(height: 16),
- const Text(
- 'We help buyers, sellers, and investors close successful real estate deals with confidence in every transaction.',
+ Text(
+ subtitle,
textAlign: TextAlign.center,
- style: TextStyle(
+ style: const TextStyle(
fontFamily: 'Fractul',
fontSize: 14,
fontWeight: FontWeight.w500,
@@ -32,10 +82,10 @@ class TrustStatsSection extends StatelessWidget {
),
),
const SizedBox(height: 8),
- const Text(
- 'Clients rate our real estate services 4.9 out of 5 on average, based on recent client reviews.',
+ Text(
+ ratingInfo,
textAlign: TextAlign.center,
- style: TextStyle(
+ style: const TextStyle(
fontFamily: 'Fractul',
fontSize: 14,
fontWeight: FontWeight.w500,
@@ -49,92 +99,142 @@ class TrustStatsSection extends StatelessWidget {
Row(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
- // Cities stat
+ // Dynamic stats from CMS
Expanded(
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
- children: [
- RichText(
- text: const TextSpan(
- children: [
- TextSpan(
- text: '25+ Cities &\n',
- style: TextStyle(
- fontFamily: 'SourceSerif4',
- fontSize: 14,
- fontWeight: FontWeight.w700,
- color: AppColors.primaryDark,
- ),
- ),
- TextSpan(
- text: 'neighborhoods served',
- style: TextStyle(
- fontFamily: 'SourceSerif4',
- fontSize: 14,
- fontWeight: FontWeight.w400,
- color: AppColors.primaryDark,
- ),
- ),
- ],
- ),
- ),
- const SizedBox(height: 12),
- RichText(
- text: const TextSpan(
- children: [
- TextSpan(
- text: '1,000+ Properties\n',
- style: TextStyle(
- fontFamily: 'SourceSerif4',
- fontSize: 14,
- fontWeight: FontWeight.w700,
- color: AppColors.primaryDark,
- ),
- ),
- TextSpan(
- text: 'bought and sold for our clients.',
- style: TextStyle(
- fontFamily: 'SourceSerif4',
- fontSize: 14,
- fontWeight: FontWeight.w400,
- color: AppColors.primaryDark,
- ),
- ),
- ],
- ),
- ),
- ],
+ children: stats
+ .map((stat) => Padding(
+ padding: const EdgeInsets.only(bottom: 12),
+ child: _buildStatItem(stat),
+ ))
+ .toList(),
),
),
// Rating visual
const SizedBox(width: 16),
- Column(
- children: [
- const Text(
- '4.9',
- style: TextStyle(
- fontFamily: 'Fractul',
- fontSize: 32,
- fontWeight: FontWeight.w700,
- color: AppColors.primaryDark,
- ),
- ),
- Row(
- children: List.generate(
- 5,
- (index) => Icon(
- index < 4 ? Icons.star : Icons.star_half,
- color: AppColors.accentOrange,
- size: 18,
- ),
- ),
- ),
- ],
- ),
+ _buildRatingVisual(ratingValue),
],
),
],
),
);
}
+
+ Widget _buildStatItem(StatItem stat) {
+ final iconEntry = _statIconMap[stat.iconPath];
+ final svgPath = iconEntry?.$1;
+ final fallbackIcon = iconEntry?.$2 ?? Icons.info_outline;
+
+ return Row(
+ crossAxisAlignment: CrossAxisAlignment.start,
+ children: [
+ if (svgPath != null)
+ Padding(
+ padding: const EdgeInsets.only(right: 8, top: 2),
+ child: SvgPicture.asset(
+ svgPath,
+ width: 27,
+ height: 27,
+ placeholderBuilder: (_) => Icon(
+ fallbackIcon,
+ size: 27,
+ color: AppColors.accentOrange,
+ ),
+ ),
+ )
+ else
+ Padding(
+ padding: const EdgeInsets.only(right: 8, top: 2),
+ child: Icon(
+ fallbackIcon,
+ size: 27,
+ color: AppColors.accentOrange,
+ ),
+ ),
+ Expanded(
+ child: RichText(
+ text: TextSpan(
+ children: [
+ TextSpan(
+ text: '${stat.boldText}\n',
+ style: const TextStyle(
+ fontFamily: 'SourceSerif4',
+ fontSize: 14,
+ fontWeight: FontWeight.w700,
+ color: AppColors.primaryDark,
+ ),
+ ),
+ TextSpan(
+ text: stat.normalText,
+ style: const TextStyle(
+ fontFamily: 'SourceSerif4',
+ fontSize: 14,
+ fontWeight: FontWeight.w400,
+ color: AppColors.primaryDark,
+ ),
+ ),
+ ],
+ ),
+ ),
+ ),
+ ],
+ );
+ }
+
+ Widget _buildRatingVisual(double rating) {
+ final fullStars = rating.floor();
+ final hasHalfStar = (rating - fullStars) >= 0.3;
+ final totalStars = 5;
+
+ return Column(
+ children: [
+ Text(
+ rating.toStringAsFixed(1),
+ style: const TextStyle(
+ fontFamily: 'Fractul',
+ fontSize: 32,
+ fontWeight: FontWeight.w700,
+ color: AppColors.primaryDark,
+ ),
+ ),
+ Row(
+ children: List.generate(
+ totalStars,
+ (index) {
+ if (index < fullStars) {
+ return const Icon(
+ Icons.star,
+ color: AppColors.accentOrange,
+ size: 18,
+ );
+ } else if (index == fullStars && hasHalfStar) {
+ return const Icon(
+ Icons.star_half,
+ color: AppColors.accentOrange,
+ size: 18,
+ );
+ } else {
+ return const Icon(
+ Icons.star_border,
+ color: AppColors.accentOrange,
+ size: 18,
+ );
+ }
+ },
+ ),
+ ),
+ ],
+ );
+ }
+
+ /// Extract numeric rating from ratingInfo string.
+ /// e.g. "Clients rate our real estate services 4.9 out of 5..." → 4.9
+ double _extractRating(String ratingInfo) {
+ final match = RegExp(r'(\d+\.?\d*)\s*out\s*of\s*\d+').firstMatch(ratingInfo);
+ if (match != null) {
+ return double.tryParse(match.group(1)!) ?? 4.9;
+ }
+ return 4.9;
+ }
}
diff --git a/lib/routing/app_router.dart b/lib/routing/app_router.dart
index 74d15d6..2b12978 100644
--- a/lib/routing/app_router.dart
+++ b/lib/routing/app_router.dart
@@ -4,6 +4,7 @@ import 'package:go_router/go_router.dart';
import 'package:real_estate_mobile/features/auth/presentation/providers/auth_provider.dart';
import 'package:real_estate_mobile/features/auth/presentation/screens/login_screen.dart';
import 'package:real_estate_mobile/features/auth/presentation/screens/signup_screen.dart';
+import 'package:real_estate_mobile/features/agents/presentation/screens/agent_search_screen.dart';
import 'package:real_estate_mobile/features/home/presentation/screens/home_screen.dart';
final routerProvider = Provider((ref) {
@@ -52,6 +53,13 @@ final routerProvider = Provider((ref) {
path: '/home',
builder: (context, state) => const HomeScreen(),
),
+ GoRoute(
+ path: '/agents/search',
+ builder: (context, state) {
+ final query = state.uri.queryParameters['q'];
+ return AgentSearchScreen(initialQuery: query);
+ },
+ ),
],
);
});
diff --git a/macos/Flutter/GeneratedPluginRegistrant.swift b/macos/Flutter/GeneratedPluginRegistrant.swift
index baa8c0e..bbb5747 100644
--- a/macos/Flutter/GeneratedPluginRegistrant.swift
+++ b/macos/Flutter/GeneratedPluginRegistrant.swift
@@ -6,11 +6,13 @@ import FlutterMacOS
import Foundation
import flutter_secure_storage_macos
+import path_provider_foundation
import shared_preferences_foundation
import sqflite_darwin
func RegisterGeneratedPlugins(registry: FlutterPluginRegistry) {
FlutterSecureStoragePlugin.register(with: registry.registrar(forPlugin: "FlutterSecureStoragePlugin"))
+ PathProviderPlugin.register(with: registry.registrar(forPlugin: "PathProviderPlugin"))
SharedPreferencesPlugin.register(with: registry.registrar(forPlugin: "SharedPreferencesPlugin"))
SqflitePlugin.register(with: registry.registrar(forPlugin: "SqflitePlugin"))
}
diff --git a/pubspec.lock b/pubspec.lock
index 3d5ff37..b31cedb 100644
--- a/pubspec.lock
+++ b/pubspec.lock
@@ -169,14 +169,6 @@ packages:
url: "https://pub.dev"
source: hosted
version: "1.1.2"
- code_assets:
- dependency: transitive
- description:
- name: code_assets
- sha256: "83ccdaa064c980b5596c35dd64a8d3ecc68620174ab9b90b6343b753aa721687"
- url: "https://pub.dev"
- source: hosted
- version: "1.0.0"
code_builder:
dependency: transitive
description:
@@ -448,14 +440,6 @@ packages:
url: "https://pub.dev"
source: hosted
version: "2.3.2"
- hooks:
- dependency: transitive
- description:
- name: hooks
- sha256: "7a08a0d684cb3b8fb604b78455d5d352f502b68079f7b80b831c62220ab0a4f6"
- url: "https://pub.dev"
- source: hosted
- version: "1.0.1"
http:
dependency: transitive
description:
@@ -616,22 +600,6 @@ packages:
url: "https://pub.dev"
source: hosted
version: "2.0.0"
- native_toolchain_c:
- dependency: transitive
- description:
- name: native_toolchain_c
- sha256: "89e83885ba09da5fdf2cdacc8002a712ca238c28b7f717910b34bcd27b0d03ac"
- url: "https://pub.dev"
- source: hosted
- version: "0.17.4"
- objective_c:
- dependency: transitive
- description:
- name: objective_c
- sha256: "100a1c87616ab6ed41ec263b083c0ef3261ee6cd1dc3b0f35f8ddfa4f996fe52"
- url: "https://pub.dev"
- source: hosted
- version: "9.3.0"
octo_image:
dependency: transitive
description:
@@ -681,13 +649,13 @@ packages:
source: hosted
version: "2.2.22"
path_provider_foundation:
- dependency: transitive
+ dependency: "direct main"
description:
name: path_provider_foundation
- sha256: "2a376b7d6392d80cd3705782d2caa734ca4727776db0b6ec36ef3f1855197699"
+ sha256: f234384a3fdd67f989b4d54a5d73ca2a6c422fa55ae694381ae0f4375cd1ea16
url: "https://pub.dev"
source: hosted
- version: "2.6.0"
+ version: "2.4.0"
path_provider_linux:
dependency: transitive
description:
@@ -1158,5 +1126,5 @@ packages:
source: hosted
version: "3.1.3"
sdks:
- dart: ">=3.10.3 <4.0.0"
- flutter: ">=3.38.4"
+ dart: ">=3.10.1 <4.0.0"
+ flutter: ">=3.35.0"
diff --git a/pubspec.yaml b/pubspec.yaml
index 7df3010..38ca9a7 100644
--- a/pubspec.yaml
+++ b/pubspec.yaml
@@ -36,6 +36,9 @@ dependencies:
cached_network_image: ^3.4.1
shimmer: ^3.0.0
+ # Pin to avoid objective_c crash on iOS 26 simulator
+ path_provider_foundation: 2.4.0
+
# Utils
intl: ^0.20.2
logger: ^2.5.0