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