diff --git a/ios/Podfile.lock b/ios/Podfile.lock index 0acd3e5..cc29b4a 100644 --- a/ios/Podfile.lock +++ b/ios/Podfile.lock @@ -72,6 +72,8 @@ PODS: - nanopb/encode (= 3.30910.0) - nanopb/decode (3.30910.0) - nanopb/encode (3.30910.0) + - package_info_plus (0.4.5): + - Flutter - path_provider_foundation (0.0.1): - Flutter - FlutterMacOS @@ -96,6 +98,7 @@ DEPENDENCIES: - flutter_local_notifications (from `.symlinks/plugins/flutter_local_notifications/ios`) - flutter_secure_storage (from `.symlinks/plugins/flutter_secure_storage/ios`) - image_picker_ios (from `.symlinks/plugins/image_picker_ios/ios`) + - package_info_plus (from `.symlinks/plugins/package_info_plus/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`) @@ -129,6 +132,8 @@ EXTERNAL SOURCES: :path: ".symlinks/plugins/flutter_secure_storage/ios" image_picker_ios: :path: ".symlinks/plugins/image_picker_ios/ios" + package_info_plus: + :path: ".symlinks/plugins/package_info_plus/ios" path_provider_foundation: :path: ".symlinks/plugins/path_provider_foundation/darwin" shared_preferences_foundation: @@ -156,6 +161,7 @@ SPEC CHECKSUMS: GoogleUtilities: 00c88b9a86066ef77f0da2fab05f65d7768ed8e1 image_picker_ios: 4f2f91b01abdb52842a8e277617df877e40f905b nanopb: fad817b59e0457d11a5dfbde799381cd727c1275 + package_info_plus: c0502532a26c7662a62a356cebe2692ec5fe4ec4 path_provider_foundation: 2b6b4c569c0fb62ec74538f866245ac84301af46 PromisesObjC: f5707f49cb48b9636751c5b2e7d227e43fba9f47 shared_preferences_foundation: 5086985c1d43c5ba4d5e69a4e8083a389e2909e6 diff --git a/lib/core/widgets/s3_image.dart b/lib/core/widgets/s3_image.dart index abf7b7f..c39a9b5 100644 --- a/lib/core/widgets/s3_image.dart +++ b/lib/core/widgets/s3_image.dart @@ -1,5 +1,6 @@ import 'package:cached_network_image/cached_network_image.dart'; import 'package:flutter/material.dart'; +import 'package:shimmer/shimmer.dart'; import 'package:real_estate_mobile/core/utils/image_url_resolver.dart'; /// Displays an image from an S3 key or full URL. @@ -9,6 +10,7 @@ class S3Image extends StatefulWidget { final double? width; final double? height; final BoxFit fit; + final Alignment alignment; final Widget Function(BuildContext context)? placeholder; final Widget Function(BuildContext context)? errorWidget; @@ -18,6 +20,7 @@ class S3Image extends StatefulWidget { this.width, this.height, this.fit = BoxFit.cover, + this.alignment = Alignment.center, this.placeholder, this.errorWidget, }); @@ -67,15 +70,22 @@ class _S3ImageState extends State { } } - Widget _buildPlaceholder(BuildContext context) { - return widget.placeholder?.call(context) ?? Container( - width: widget.width, - height: widget.height, - color: const Color(0xFFC4D9D4), - child: const Center(child: CircularProgressIndicator(strokeWidth: 2)), + Widget _buildShimmerPlaceholder() { + return Shimmer.fromColors( + baseColor: const Color(0xFFE0E0E0), + highlightColor: const Color(0xFFF5F5F5), + child: Container( + width: widget.width, + height: widget.height, + color: Colors.white, + ), ); } + Widget _buildPlaceholder(BuildContext context) { + return widget.placeholder?.call(context) ?? _buildShimmerPlaceholder(); + } + Widget _buildError(BuildContext context) { return widget.errorWidget?.call(context) ?? Container( width: widget.width, @@ -95,6 +105,7 @@ class _S3ImageState extends State { width: widget.width, height: widget.height, fit: widget.fit, + alignment: widget.alignment, placeholder: (ctx, _) => _buildPlaceholder(ctx), errorWidget: (ctx, _, __) => _buildError(ctx), ); diff --git a/lib/features/agents/presentation/providers/search_agents_provider.dart b/lib/features/agents/presentation/providers/search_agents_provider.dart index 2771f14..2dd385d 100644 --- a/lib/features/agents/presentation/providers/search_agents_provider.dart +++ b/lib/features/agents/presentation/providers/search_agents_provider.dart @@ -80,12 +80,14 @@ class SearchAgentsNotifier extends StateNotifier { _repository.getAgentTypes(), _repository.getFilterableFields(), ]); + if (!mounted) return; state = state.copyWith( agentTypes: results[0] as List, filterFields: results[1] as List, ); await _fetchAgents(page: 1); } catch (e) { + if (!mounted) return; state = state.copyWith(isLoading: false, error: e.toString()); } } @@ -99,7 +101,7 @@ class SearchAgentsNotifier extends StateNotifier { limit: 10, filters: state.activeFilters.isNotEmpty ? state.activeFilters : null, ); - + if (!mounted) return; state = state.copyWith( agents: append ? [...state.agents, ...response.data] : response.data, currentPage: response.page, @@ -108,6 +110,7 @@ class SearchAgentsNotifier extends StateNotifier { isLoadingMore: false, ); } catch (e) { + if (!mounted) return; state = state.copyWith( isLoading: false, isLoadingMore: false, diff --git a/lib/features/agents/presentation/screens/agent_search_screen.dart b/lib/features/agents/presentation/screens/agent_search_screen.dart index aa94f83..bb3e7a8 100644 --- a/lib/features/agents/presentation/screens/agent_search_screen.dart +++ b/lib/features/agents/presentation/screens/agent_search_screen.dart @@ -11,8 +11,17 @@ import 'package:real_estate_mobile/features/agents/presentation/widgets/agent_fi class AgentSearchScreen extends ConsumerStatefulWidget { final String? initialQuery; + final String? initialAgentTypeId; + final String? initialLocation; + final String? initialCategory; - const AgentSearchScreen({super.key, this.initialQuery}); + const AgentSearchScreen({ + super.key, + this.initialQuery, + this.initialAgentTypeId, + this.initialLocation, + this.initialCategory, + }); @override ConsumerState createState() => _AgentSearchScreenState(); @@ -28,14 +37,31 @@ class _AgentSearchScreenState extends ConsumerState { _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!); - }); - } + WidgetsBinding.instance.addPostFrameCallback((_) { + final notifier = ref.read(searchAgentsProvider.notifier); + + // Apply initial agent type filter if provided + if (widget.initialAgentTypeId != null) { + notifier.filterByAgentType(widget.initialAgentTypeId); + } + + // Apply location and category filters if provided + final initialFilters = >{}; + if (widget.initialLocation != null) { + initialFilters['state'] = [widget.initialLocation!]; + } + if (widget.initialCategory != null) { + initialFilters['specialization'] = [widget.initialCategory!]; + } + if (initialFilters.isNotEmpty) { + notifier.applyFilters(initialFilters); + } + + // Trigger initial search if query provided + if (widget.initialQuery != null && widget.initialQuery!.isNotEmpty) { + notifier.search(widget.initialQuery!); + } + }); } @override @@ -397,6 +423,7 @@ class _AgentCardState extends State<_AgentCard> { imageUrl: agent.avatarUrl, width: double.infinity, height: 265, + alignment: Alignment.topCenter, errorWidget: (_) => Container( width: double.infinity, height: 265, diff --git a/lib/features/home/presentation/screens/home_screen.dart b/lib/features/home/presentation/screens/home_screen.dart index 0af0421..bd2f05e 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_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/presentation/widgets/featured_professionals_section.dart'; import 'package:real_estate_mobile/features/home/presentation/widgets/features_section.dart'; @@ -41,13 +42,20 @@ class HomeScreen extends ConsumerWidget { const SizedBox(height: 40), // Footer - _buildFooter(), + _buildFooter(context), ], ), ); } - Widget _buildFooter() { + static const _footerRoutes = { + 'Home': '/home', + 'About': '/about', + 'Contact': '/contact', + 'FAQ': '/faq', + }; + + Widget _buildFooter(BuildContext context) { return Container( width: double.infinity, padding: const EdgeInsets.symmetric(horizontal: 24, vertical: 24), @@ -64,13 +72,13 @@ class HomeScreen extends ConsumerWidget { Row( mainAxisAlignment: MainAxisAlignment.center, children: [ - _buildFooterLink('Home'), + _buildFooterLink(context, 'Home'), _buildFooterDot(), - _buildFooterLink('About'), + _buildFooterLink(context, 'About'), _buildFooterDot(), - _buildFooterLink('Contact'), + _buildFooterLink(context, 'Contact'), _buildFooterDot(), - _buildFooterLink('FAQ'), + _buildFooterLink(context, 'FAQ'), ], ), const SizedBox(height: 16), @@ -88,14 +96,20 @@ class HomeScreen extends ConsumerWidget { ); } - Widget _buildFooterLink(String text) { - return Text( - text, - style: const TextStyle( - fontFamily: 'SourceSerif4', - fontSize: 14, - fontWeight: FontWeight.w400, - color: Colors.white, + Widget _buildFooterLink(BuildContext context, String text) { + return GestureDetector( + onTap: () { + final route = _footerRoutes[text]; + if (route != null) context.go(route); + }, + child: Text( + text, + style: const TextStyle( + fontFamily: 'SourceSerif4', + fontSize: 14, + fontWeight: FontWeight.w400, + color: Colors.white, + ), ), ); } diff --git a/lib/features/home/presentation/widgets/featured_professionals_section.dart b/lib/features/home/presentation/widgets/featured_professionals_section.dart index 20c19d6..4f637ee 100644 --- a/lib/features/home/presentation/widgets/featured_professionals_section.dart +++ b/lib/features/home/presentation/widgets/featured_professionals_section.dart @@ -1,6 +1,7 @@ import 'package:flutter/material.dart'; import 'package:flutter_riverpod/flutter_riverpod.dart'; import 'package:flutter_svg/flutter_svg.dart'; +import 'package:shimmer/shimmer.dart'; import 'package:real_estate_mobile/core/constants/app_colors.dart'; import 'package:real_estate_mobile/core/widgets/s3_image.dart'; import 'package:real_estate_mobile/features/agents/data/models/agent_profile.dart'; @@ -60,10 +61,63 @@ class _FeaturedProfessionalsSectionState final professionals = topProState.agents; if (topProState.isLoading) { - return const Padding( - padding: EdgeInsets.symmetric(vertical: 40), - child: Center( - child: CircularProgressIndicator(color: AppColors.accentOrange), + return Padding( + padding: const EdgeInsets.symmetric(horizontal: 40), + child: Shimmer.fromColors( + baseColor: const Color(0xFFE0E0E0), + highlightColor: const Color(0xFFF5F5F5), + child: Container( + height: 370, + decoration: BoxDecoration( + color: Colors.white, + borderRadius: BorderRadius.circular(15), + ), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Container( + height: 192, + decoration: const BoxDecoration( + color: Colors.white, + borderRadius: + BorderRadius.vertical(top: Radius.circular(15)), + ), + ), + Padding( + padding: const EdgeInsets.fromLTRB(16, 12, 16, 12), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Container(height: 16, width: 160, color: Colors.white), + const SizedBox(height: 8), + Container(height: 12, width: 100, color: Colors.white), + const SizedBox(height: 12), + Container(height: 14, width: 180, color: Colors.white), + const SizedBox(height: 12), + Container(height: 12, width: 140, color: Colors.white), + const SizedBox(height: 12), + Row( + children: List.generate( + 5, + (_) => Padding( + padding: const EdgeInsets.only(right: 4), + child: Container( + height: 18, + width: 18, + decoration: const BoxDecoration( + color: Colors.white, + shape: BoxShape.circle, + ), + ), + ), + ), + ), + ], + ), + ), + ], + ), + ), ), ); } @@ -291,6 +345,7 @@ class _FeaturedProfessionalsSectionState imageUrl: agent.avatarUrl, width: double.infinity, height: 192, + alignment: Alignment.topCenter, placeholder: (_) => _buildImagePlaceholder(index), errorWidget: (_) => _buildImagePlaceholder(index), ); diff --git a/lib/features/home/presentation/widgets/hero_section.dart b/lib/features/home/presentation/widgets/hero_section.dart index 5a3723b..8204ff4 100644 --- a/lib/features/home/presentation/widgets/hero_section.dart +++ b/lib/features/home/presentation/widgets/hero_section.dart @@ -2,13 +2,15 @@ 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/agents/data/models/filterable_field.dart'; +import 'package:real_estate_mobile/features/agents/presentation/providers/agents_provider.dart'; +import 'package:real_estate_mobile/features/agents/presentation/providers/search_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'; /// Default hero content — matches web's hardcoded defaultHeroContent fallback. const _defaultHero = HeroContent( - headline: - 'Discover verified, top-rated real estate professionals to guide your buying, selling, or investing journey.', + headline: 'Discover verified, top-rated real estate professionals.', description: 'Discover verified, top-rated real estate professionals', ctaButtonText: 'See All Agents', helperText: @@ -24,6 +26,15 @@ class HeroSection extends ConsumerStatefulWidget { class _HeroSectionState extends ConsumerState { final _searchController = TextEditingController(); + String? _selectedTypeId; + String _selectedTypeName = 'Types'; + String? _selectedLocation; + String _selectedLocationName = 'Location'; + String? _selectedCategory; + String _selectedCategoryName = 'Categories'; + + /// Cached filter fields fetched on-demand for Location/Categories pickers. + List? _cachedFilterFields; @override void dispose() { @@ -33,11 +44,105 @@ class _HeroSectionState extends ConsumerState { void _navigateToSearch() { final query = _searchController.text.trim(); - if (query.isNotEmpty) { - context.push('/agents/search?q=${Uri.encodeComponent(query)}'); - } else { + final params = {}; + if (query.isNotEmpty) params['q'] = Uri.encodeComponent(query); + if (_selectedTypeId != null) params['type'] = _selectedTypeId!; + if (_selectedLocation != null) params['location'] = Uri.encodeComponent(_selectedLocation!); + if (_selectedCategory != null) params['category'] = Uri.encodeComponent(_selectedCategory!); + + final queryString = + params.entries.map((e) => '${e.key}=${e.value}').join('&'); + context.push('/agents/search${queryString.isNotEmpty ? '?$queryString' : ''}'); + } + + void _showTypePicker() { + final topProState = ref.read(topProfessionalsProvider); + final agentTypes = topProState.agentTypes; + + if (agentTypes.isEmpty) { + // If types not loaded yet, just navigate to search context.push('/agents/search'); + return; } + + showModalBottomSheet( + context: context, + backgroundColor: Colors.white, + shape: const RoundedRectangleBorder( + borderRadius: BorderRadius.vertical(top: Radius.circular(15)), + ), + builder: (ctx) { + return SafeArea( + child: Column( + mainAxisSize: MainAxisSize.min, + children: [ + const SizedBox(height: 12), + Container( + width: 40, + height: 4, + decoration: BoxDecoration( + color: AppColors.divider, + borderRadius: BorderRadius.circular(2), + ), + ), + const SizedBox(height: 16), + const Text( + 'Select Agent Type', + style: TextStyle( + fontFamily: 'Fractul', + fontSize: 18, + fontWeight: FontWeight.w700, + color: AppColors.primaryDark, + ), + ), + const SizedBox(height: 8), + // "All Types" option + ListTile( + title: const Text( + 'All Types', + style: TextStyle( + fontFamily: 'SourceSerif4', + fontSize: 16, + color: AppColors.primaryDark, + ), + ), + trailing: _selectedTypeId == null + ? const Icon(Icons.check, color: AppColors.accentOrange) + : null, + onTap: () { + setState(() { + _selectedTypeId = null; + _selectedTypeName = 'Types'; + }); + Navigator.pop(ctx); + }, + ), + ...agentTypes.map((type) => ListTile( + title: Text( + type.name, + style: const TextStyle( + fontFamily: 'SourceSerif4', + fontSize: 16, + color: AppColors.primaryDark, + ), + ), + trailing: _selectedTypeId == type.id + ? const Icon(Icons.check, color: AppColors.accentOrange) + : null, + onTap: () { + setState(() { + _selectedTypeId = type.id; + _selectedTypeName = type.name; + }); + Navigator.pop(ctx); + }, + )), + const SizedBox(height: 16), + ], + ), + ); + }, + ); } @override @@ -54,33 +159,60 @@ class _HeroSectionState extends ConsumerState { return Stack( children: [ - ClipRRect( - borderRadius: BorderRadius.circular(0), - child: Image.asset( - 'assets/images/hero_bg.png', - width: double.infinity, - height: 580, - fit: BoxFit.cover, - ), + Image.asset( + 'assets/images/hero_bg.png', + width: double.infinity, + height: 760, + fit: BoxFit.cover, ), Padding( - padding: const EdgeInsets.symmetric(horizontal: 32), + padding: const EdgeInsets.symmetric(horizontal: 40), child: Column( children: [ - const SizedBox(height: 40), - Text( - headline, - textAlign: TextAlign.center, - style: const TextStyle( - fontFamily: 'Fractul', - fontSize: 30, - fontWeight: FontWeight.w700, - color: AppColors.primaryDark, - height: 1.1, + const SizedBox(height: 42), + SizedBox( + width: 303, + child: Text( + headline, + textAlign: TextAlign.center, + style: const TextStyle( + fontFamily: 'Fractul', + fontSize: 30, + fontWeight: FontWeight.w700, + color: AppColors.primaryDark, + height: 0.9, + ), ), ), - const SizedBox(height: 30), + const SizedBox(height: 40), _buildSearchCard(ctaButtonText), + const SizedBox(height: 32), + // "See All Agents" pill button below the card + SizedBox( + width: 248, + height: 50, + child: ElevatedButton( + onPressed: () => context.push('/agents/search'), + style: ElevatedButton.styleFrom( + backgroundColor: AppColors.accentOrange, + foregroundColor: AppColors.primaryDark, + padding: EdgeInsets.zero, + elevation: 0, + shape: RoundedRectangleBorder( + borderRadius: BorderRadius.circular(25), + ), + ), + child: Text( + ctaButtonText, + style: const TextStyle( + fontFamily: 'Fractul', + fontSize: 16, + fontWeight: FontWeight.w700, + ), + ), + ), + ), + const SizedBox(height: 32), ], ), ), @@ -90,7 +222,7 @@ class _HeroSectionState extends ConsumerState { Widget _buildSearchCard(String ctaButtonText) { return Container( - padding: const EdgeInsets.all(20), + padding: const EdgeInsets.fromLTRB(36, 32, 36, 40), decoration: BoxDecoration( color: AppColors.accentOrange, borderRadius: BorderRadius.circular(15), @@ -109,63 +241,186 @@ class _HeroSectionState extends ConsumerState { ), ), ), - const SizedBox(height: 16), - _buildSearchField('Types'), - const SizedBox(height: 12), + const SizedBox(height: 22), + // Types — opens picker + GestureDetector( + onTap: _showTypePicker, + child: Container( + height: 42, + padding: const EdgeInsets.symmetric(horizontal: 20), + decoration: BoxDecoration( + color: Colors.white, + borderRadius: BorderRadius.circular(10), + ), + child: Row( + children: [ + Expanded( + child: Text( + _selectedTypeName, + style: const TextStyle( + fontFamily: 'SourceSerif4', + fontSize: 14, + fontWeight: FontWeight.w400, + color: AppColors.primaryDark, + ), + ), + ), + const Icon( + Icons.keyboard_arrow_down, + color: AppColors.primaryDark, + size: 20, + ), + ], + ), + ), + ), + const SizedBox(height: 18), // Name or Keyword — actual text input Container( + height: 42, 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, + child: Theme( + data: Theme.of(context).copyWith( + inputDecorationTheme: const InputDecorationTheme( + filled: false, + border: InputBorder.none, + ), ), - decoration: const InputDecoration( - hintText: 'Name or Keyword', - hintStyle: TextStyle( + child: TextField( + controller: _searchController, + style: const TextStyle( fontFamily: 'SourceSerif4', fontSize: 14, fontWeight: FontWeight.w400, color: AppColors.primaryDark, ), - border: InputBorder.none, - contentPadding: - EdgeInsets.symmetric(horizontal: 20, vertical: 12), + decoration: const InputDecoration( + hintText: 'Name or Keyword', + hintStyle: TextStyle( + fontFamily: 'SourceSerif4', + fontSize: 14, + fontWeight: FontWeight.w400, + color: AppColors.primaryDark, + ), + border: InputBorder.none, + enabledBorder: InputBorder.none, + focusedBorder: InputBorder.none, + contentPadding: + EdgeInsets.symmetric(horizontal: 20, vertical: 12), + ), + onSubmitted: (_) => _navigateToSearch(), ), - onSubmitted: (_) => _navigateToSearch(), ), ), - const SizedBox(height: 12), - _buildSearchField('Location'), - const SizedBox(height: 12), - _buildSearchField('Categories'), - const SizedBox(height: 12), + const SizedBox(height: 18), + // Location — opens picker (no dropdown arrow per Figma) + GestureDetector( + onTap: () => _showFilterPicker( + title: 'Select Location', + fieldSlug: 'state', + currentValue: _selectedLocation, + onSelect: (value, label) { + setState(() { + _selectedLocation = value; + _selectedLocationName = label ?? 'Location'; + }); + }, + onClear: () { + setState(() { + _selectedLocation = null; + _selectedLocationName = 'Location'; + }); + }, + ), + child: Container( + height: 42, + padding: const EdgeInsets.symmetric(horizontal: 20), + decoration: BoxDecoration( + color: Colors.white, + borderRadius: BorderRadius.circular(10), + ), + child: Align( + alignment: Alignment.centerLeft, + child: Text( + _selectedLocationName, + style: const TextStyle( + fontFamily: 'SourceSerif4', + fontSize: 14, + fontWeight: FontWeight.w400, + color: AppColors.primaryDark, + ), + ), + ), + ), + ), + const SizedBox(height: 18), + // Categories — opens picker + GestureDetector( + onTap: () => _showFilterPicker( + title: 'Select Category', + fieldSlug: 'specialization', + currentValue: _selectedCategory, + onSelect: (value, label) { + setState(() { + _selectedCategory = value; + _selectedCategoryName = label ?? 'Categories'; + }); + }, + onClear: () { + setState(() { + _selectedCategory = null; + _selectedCategoryName = 'Categories'; + }); + }, + ), + child: Container( + height: 42, + padding: const EdgeInsets.symmetric(horizontal: 20), + decoration: BoxDecoration( + color: Colors.white, + borderRadius: BorderRadius.circular(10), + ), + child: Row( + children: [ + Expanded( + child: Text( + _selectedCategoryName, + style: const TextStyle( + fontFamily: 'SourceSerif4', + fontSize: 14, + fontWeight: FontWeight.w400, + color: AppColors.primaryDark, + ), + ), + ), + const Icon( + Icons.keyboard_arrow_down, + color: AppColors.primaryDark, + size: 20, + ), + ], + ), + ), + ), + const SizedBox(height: 18), + // Arrow button — navigates to search with filters SizedBox( width: double.infinity, + height: 42, child: ElevatedButton( onPressed: _navigateToSearch, style: ElevatedButton.styleFrom( backgroundColor: AppColors.primaryDark, foregroundColor: Colors.white, - padding: const EdgeInsets.symmetric(vertical: 14), + padding: EdgeInsets.zero, shape: RoundedRectangleBorder( borderRadius: BorderRadius.circular(10), ), ), - child: Text( - ctaButtonText, - style: const TextStyle( - fontFamily: 'Fractul', - fontSize: 16, - fontWeight: FontWeight.w700, - ), - ), + child: const Icon(Icons.arrow_forward, size: 20), ), ), ], @@ -173,42 +428,134 @@ class _HeroSectionState extends ConsumerState { ); } - Widget _buildSearchField(String hint) { - 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, - ), - ], - ), + Future> _getFilterFields() async { + if (_cachedFilterFields != null) return _cachedFilterFields!; + + // Try reading from the search provider first (if already loaded) + final searchState = ref.read(searchAgentsProvider); + if (searchState.filterFields.isNotEmpty) { + _cachedFilterFields = searchState.filterFields; + return _cachedFilterFields!; + } + + // Fetch directly from the repository + final repo = ref.read(agentsRepositoryProvider); + _cachedFilterFields = await repo.getFilterableFields(); + return _cachedFilterFields!; + } + + FilterableField? _findField(List fields, String slug) { + for (final f in fields) { + if (f.slug == slug || + f.slug.toLowerCase().contains(slug.toLowerCase()) || + f.name.toLowerCase().contains(slug.toLowerCase())) { + return f; + } + } + return null; + } + + Future _showFilterPicker({ + required String title, + required String fieldSlug, + required String? currentValue, + required void Function(String value, String? label) onSelect, + required VoidCallback onClear, + }) async { + // Show bottom sheet immediately with a loading state, then populate + final filterFields = await _getFilterFields(); + if (!mounted) return; + + final field = _findField(filterFields, fieldSlug); + if (field == null || field.options.isEmpty) return; + + showModalBottomSheet( + context: context, + backgroundColor: Colors.white, + isScrollControlled: true, + shape: const RoundedRectangleBorder( + borderRadius: BorderRadius.vertical(top: Radius.circular(15)), ), + builder: (ctx) { + return DraggableScrollableSheet( + initialChildSize: 0.5, + minChildSize: 0.3, + maxChildSize: 0.8, + expand: false, + builder: (context, scrollController) { + return SafeArea( + child: Column( + children: [ + const SizedBox(height: 12), + Container( + width: 40, + height: 4, + decoration: BoxDecoration( + color: AppColors.divider, + borderRadius: BorderRadius.circular(2), + ), + ), + const SizedBox(height: 16), + Text( + title, + style: const TextStyle( + fontFamily: 'Fractul', + fontSize: 18, + fontWeight: FontWeight.w700, + color: AppColors.primaryDark, + ), + ), + const SizedBox(height: 8), + Expanded( + child: ListView( + controller: scrollController, + children: [ + // "All" option + ListTile( + title: Text( + 'All ${field.name}', + style: const TextStyle( + fontFamily: 'SourceSerif4', + fontSize: 16, + color: AppColors.primaryDark, + ), + ), + trailing: currentValue == null + ? const Icon(Icons.check, + color: AppColors.accentOrange) + : null, + onTap: () { + onClear(); + Navigator.pop(ctx); + }, + ), + ...field.options.map((option) => ListTile( + title: Text( + option.label, + style: const TextStyle( + fontFamily: 'SourceSerif4', + fontSize: 16, + color: AppColors.primaryDark, + ), + ), + trailing: currentValue == option.value + ? const Icon(Icons.check, + color: AppColors.accentOrange) + : null, + onTap: () { + onSelect(option.value, option.label); + Navigator.pop(ctx); + }, + )), + ], + ), + ), + ], + ), + ); + }, + ); + }, ); } } diff --git a/lib/features/home/presentation/widgets/top_professionals_section.dart b/lib/features/home/presentation/widgets/top_professionals_section.dart index 380817d..713bb92 100644 --- a/lib/features/home/presentation/widgets/top_professionals_section.dart +++ b/lib/features/home/presentation/widgets/top_professionals_section.dart @@ -2,6 +2,7 @@ import 'package:flutter/material.dart'; import 'package:flutter_riverpod/flutter_riverpod.dart'; import 'package:flutter_svg/flutter_svg.dart'; import 'package:go_router/go_router.dart'; +import 'package:shimmer/shimmer.dart'; import 'package:real_estate_mobile/core/constants/app_colors.dart'; import 'package:real_estate_mobile/core/widgets/s3_image.dart'; import 'package:real_estate_mobile/features/agents/data/models/agent_profile.dart'; @@ -96,12 +97,7 @@ class _TopProfessionalsSectionState // Content if (topProState.isLoading) - const Padding( - padding: EdgeInsets.symmetric(vertical: 40), - child: CircularProgressIndicator( - color: AppColors.accentOrange, - ), - ) + _buildShimmerCards() else if (topProState.error != null) _buildErrorState(topProState.error!) else if (activeList.isEmpty) @@ -164,7 +160,7 @@ class _TopProfessionalsSectionState } }, child: Container( - padding: const EdgeInsets.symmetric(vertical: 12), + alignment: Alignment.center, decoration: BoxDecoration( color: activeTab == 'agents' ? AppColors.accentOrange @@ -172,7 +168,7 @@ class _TopProfessionalsSectionState borderRadius: BorderRadius.circular(15), ), child: Row( - mainAxisAlignment: MainAxisAlignment.center, + mainAxisSize: MainAxisSize.min, children: [ SvgPicture.asset( 'assets/icons/agents_tab_icon.svg', @@ -220,7 +216,7 @@ class _TopProfessionalsSectionState } }, child: Container( - padding: const EdgeInsets.symmetric(vertical: 12), + alignment: Alignment.center, decoration: BoxDecoration( color: activeTab == 'lenders' ? AppColors.accentOrange @@ -228,7 +224,7 @@ class _TopProfessionalsSectionState borderRadius: BorderRadius.circular(15), ), child: Row( - mainAxisAlignment: MainAxisAlignment.center, + mainAxisSize: MainAxisSize.min, children: [ SvgPicture.asset( 'assets/icons/lenders_tab_icon.svg', @@ -270,23 +266,12 @@ class _TopProfessionalsSectionState ); } - /// Builds the card image from AgentProfile avatar using presigned URL. - Widget _buildCardImage(AgentProfile agent, int index) { - return S3Image( - imageUrl: agent.avatarUrl, - width: double.infinity, - height: 192, - placeholder: (_) => _buildImagePlaceholder(index), - errorWidget: (_) => _buildImagePlaceholder(index), - ); - } - Widget _buildImagePlaceholder(int index) { final assetPath = _fallbackImages[index % _fallbackImages.length]; return Image.asset( assetPath, width: double.infinity, - height: 192, + height: double.infinity, fit: BoxFit.cover, errorBuilder: (context, error, stackTrace) => _buildAvatarPlaceholder(), ); @@ -305,15 +290,25 @@ class _TopProfessionalsSectionState borderRadius: BorderRadius.circular(15), border: Border.all(color: AppColors.primaryDark, width: 0.1), ), + clipBehavior: Clip.antiAlias, child: Column( crossAxisAlignment: CrossAxisAlignment.start, - mainAxisSize: MainAxisSize.min, children: [ - // Image - ClipRRect( - borderRadius: - const BorderRadius.vertical(top: Radius.circular(15)), - child: _buildCardImage(agent, index), + // Image – expands to fill available space above the content + Expanded( + child: ClipRRect( + borderRadius: + const BorderRadius.vertical(top: Radius.circular(15)), + child: S3Image( + imageUrl: agent.avatarUrl, + width: double.infinity, + height: double.infinity, + fit: BoxFit.cover, + alignment: Alignment.topCenter, + placeholder: (_) => _buildImagePlaceholder(index), + errorWidget: (_) => _buildImagePlaceholder(index), + ), + ), ), // Content @@ -484,6 +479,84 @@ class _TopProfessionalsSectionState ); } + Widget _buildShimmerCards() { + return SizedBox( + height: 480, + child: Shimmer.fromColors( + baseColor: const Color(0xFFE0E0E0), + highlightColor: const Color(0xFFF5F5F5), + child: Container( + decoration: BoxDecoration( + color: Colors.white, + borderRadius: BorderRadius.circular(15), + ), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + // Image placeholder + Container( + height: 192, + decoration: const BoxDecoration( + color: Colors.white, + borderRadius: BorderRadius.vertical(top: Radius.circular(15)), + ), + ), + Padding( + padding: const EdgeInsets.fromLTRB(16, 12, 16, 12), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Container(height: 16, width: 160, color: Colors.white), + const SizedBox(height: 8), + Container(height: 12, width: 100, color: Colors.white), + const SizedBox(height: 12), + Container(height: 14, width: 180, color: Colors.white), + const SizedBox(height: 12), + Container(height: 12, width: 140, color: Colors.white), + const SizedBox(height: 12), + Row( + children: List.generate( + 3, + (_) => Container( + height: 28, + width: 70, + margin: const EdgeInsets.only(right: 8), + decoration: BoxDecoration( + color: Colors.white, + borderRadius: BorderRadius.circular(15), + ), + ), + ), + ), + const SizedBox(height: 12), + Container(height: 14, width: 200, color: Colors.white), + const SizedBox(height: 12), + Row( + children: List.generate( + 5, + (_) => Padding( + padding: const EdgeInsets.only(right: 4), + child: Container( + height: 18, + width: 18, + decoration: const BoxDecoration( + color: Colors.white, + shape: BoxShape.circle, + ), + ), + ), + ), + ), + ], + ), + ), + ], + ), + ), + ), + ); + } + Widget _buildStarRating(double rating) { final fullStars = rating.floor(); final hasHalfStar = (rating - fullStars) >= 0.3; diff --git a/lib/routing/app_router.dart b/lib/routing/app_router.dart index fbdef74..9971988 100644 --- a/lib/routing/app_router.dart +++ b/lib/routing/app_router.dart @@ -115,9 +115,17 @@ final routerProvider = Provider((ref) { path: '/agents/search', pageBuilder: (context, state) { final query = state.uri.queryParameters['q']; + final typeId = state.uri.queryParameters['type']; + final location = state.uri.queryParameters['location']; + final category = state.uri.queryParameters['category']; return CustomTransitionPage( key: state.pageKey, - child: AgentSearchScreen(initialQuery: query), + child: AgentSearchScreen( + initialQuery: query, + initialAgentTypeId: typeId, + initialLocation: location, + initialCategory: category, + ), transitionsBuilder: _fadeTransition, ); },