From ef0fe593abc7c9052a7713499f842a84a9757817 Mon Sep 17 00:00:00 2001 From: pradeepkumar Date: Sat, 7 Mar 2026 22:33:09 +0530 Subject: [PATCH] feat: Implement agent filtering by integrating a new API endpoint for filterable fields, introducing filter data models, and adding a dedicated filter sheet UI, alongside an updated profile icon. --- assets/icons/nav_profile_icon.svg | 6 +- lib/core/constants/api_constants.dart | 3 + .../agents/data/agents_repository.dart | 27 ++ .../agents/data/models/filterable_field.dart | 39 ++ .../providers/search_agents_provider.dart | 38 +- .../screens/agent_search_screen.dart | 51 ++- .../widgets/agent_filter_sheet.dart | 423 ++++++++++++++++++ 7 files changed, 568 insertions(+), 19 deletions(-) create mode 100644 lib/features/agents/data/models/filterable_field.dart create mode 100644 lib/features/agents/presentation/widgets/agent_filter_sheet.dart diff --git a/assets/icons/nav_profile_icon.svg b/assets/icons/nav_profile_icon.svg index aa3a060..5358ae9 100644 --- a/assets/icons/nav_profile_icon.svg +++ b/assets/icons/nav_profile_icon.svg @@ -1,5 +1,3 @@ - - - - + + diff --git a/lib/core/constants/api_constants.dart b/lib/core/constants/api_constants.dart index b5d9819..bec6e93 100644 --- a/lib/core/constants/api_constants.dart +++ b/lib/core/constants/api_constants.dart @@ -16,6 +16,9 @@ class ApiConstants { static const String agents = '/agents'; static const String agentTypes = '/agent-types'; + // Profile fields + static const String filterableFields = '/profile-fields/filterable'; + // CMS static const String cmsPageLanding = '/cms/page/landing'; } diff --git a/lib/features/agents/data/agents_repository.dart b/lib/features/agents/data/agents_repository.dart index a009ec8..8b4879f 100644 --- a/lib/features/agents/data/agents_repository.dart +++ b/lib/features/agents/data/agents_repository.dart @@ -1,9 +1,12 @@ +import 'dart:convert'; + import 'package:dio/dio.dart'; import 'package:real_estate_mobile/core/constants/api_constants.dart'; import 'package:real_estate_mobile/core/network/api_client.dart'; import 'package:real_estate_mobile/core/network/api_exceptions.dart'; import 'package:real_estate_mobile/features/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/data/models/filterable_field.dart'; class SearchAgentsResponse { final List data; @@ -49,6 +52,7 @@ class AgentsRepository { int limit = 10, String? sortBy, String? sortOrder, + Map>? filters, }) async { try { final queryParams = { @@ -60,6 +64,9 @@ class AgentsRepository { if (search != null && search.isNotEmpty) queryParams['search'] = search; if (sortBy != null) queryParams['sortBy'] = sortBy; if (sortOrder != null) queryParams['sortOrder'] = sortOrder; + if (filters != null && filters.isNotEmpty) { + queryParams['filters'] = jsonEncode(filters); + } final response = await _dio.get( ApiConstants.agents, @@ -81,6 +88,26 @@ class AgentsRepository { } } + /// Get filterable profile fields: GET /profile-fields/filterable + /// Response: { success, data: [...] } + Future> getFilterableFields() async { + try { + final response = await _dio.get(ApiConstants.filterableFields); + final data = response.data['data'] as List; + return data + .map((e) => FilterableField.fromJson(e as Map)) + .toList(); + } on DioException catch (e) { + if (e.error is ApiException) { + throw e.error as ApiException; + } + throw ApiException( + message: e.message ?? 'Failed to fetch filterable fields', + statusCode: e.response?.statusCode, + ); + } + } + /// Get agent types: GET /agent-types /// Response: { success, data: [...] } Future> getAgentTypes() async { diff --git a/lib/features/agents/data/models/filterable_field.dart b/lib/features/agents/data/models/filterable_field.dart new file mode 100644 index 0000000..661c94f --- /dev/null +++ b/lib/features/agents/data/models/filterable_field.dart @@ -0,0 +1,39 @@ +class FilterOption { + final String value; + final String label; + + const FilterOption({required this.value, required this.label}); + + factory FilterOption.fromJson(Map json) { + return FilterOption( + value: json['value'] as String? ?? '', + label: json['label'] as String? ?? '', + ); + } +} + +class FilterableField { + final String slug; + final String name; + final String fieldType; + final List options; + + const FilterableField({ + required this.slug, + required this.name, + required this.fieldType, + this.options = const [], + }); + + factory FilterableField.fromJson(Map json) { + return FilterableField( + slug: json['slug'] as String? ?? '', + name: json['name'] as String? ?? '', + fieldType: json['fieldType'] as String? ?? '', + options: (json['options'] as List?) + ?.map((e) => FilterOption.fromJson(e as Map)) + .toList() ?? + const [], + ); + } +} diff --git a/lib/features/agents/presentation/providers/search_agents_provider.dart b/lib/features/agents/presentation/providers/search_agents_provider.dart index 745d3f7..2771f14 100644 --- a/lib/features/agents/presentation/providers/search_agents_provider.dart +++ b/lib/features/agents/presentation/providers/search_agents_provider.dart @@ -2,11 +2,13 @@ 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/data/models/filterable_field.dart'; import 'package:real_estate_mobile/features/agents/presentation/providers/agents_provider.dart'; class SearchAgentsState { final List agents; final List agentTypes; + final List filterFields; final bool isLoading; final bool isLoadingMore; final String? error; @@ -14,10 +16,12 @@ class SearchAgentsState { final String? selectedAgentTypeId; final int currentPage; final int totalPages; + final Map> activeFilters; const SearchAgentsState({ this.agents = const [], this.agentTypes = const [], + this.filterFields = const [], this.isLoading = false, this.isLoadingMore = false, this.error, @@ -25,6 +29,7 @@ class SearchAgentsState { this.selectedAgentTypeId, this.currentPage = 1, this.totalPages = 1, + this.activeFilters = const {}, }); bool get hasMore => currentPage < totalPages; @@ -32,6 +37,7 @@ class SearchAgentsState { SearchAgentsState copyWith({ List? agents, List? agentTypes, + List? filterFields, bool? isLoading, bool? isLoadingMore, String? error, @@ -40,10 +46,12 @@ class SearchAgentsState { bool clearAgentTypeId = false, int? currentPage, int? totalPages, + Map>? activeFilters, }) { return SearchAgentsState( agents: agents ?? this.agents, agentTypes: agentTypes ?? this.agentTypes, + filterFields: filterFields ?? this.filterFields, isLoading: isLoading ?? this.isLoading, isLoadingMore: isLoadingMore ?? this.isLoadingMore, error: error, @@ -53,6 +61,7 @@ class SearchAgentsState { : (selectedAgentTypeId ?? this.selectedAgentTypeId), currentPage: currentPage ?? this.currentPage, totalPages: totalPages ?? this.totalPages, + activeFilters: activeFilters ?? this.activeFilters, ); } } @@ -67,8 +76,14 @@ class SearchAgentsNotifier extends StateNotifier { Future _init() async { try { - final agentTypes = await _repository.getAgentTypes(); - state = state.copyWith(agentTypes: agentTypes); + final results = await Future.wait([ + _repository.getAgentTypes(), + _repository.getFilterableFields(), + ]); + state = state.copyWith( + agentTypes: results[0] as List, + filterFields: results[1] as List, + ); await _fetchAgents(page: 1); } catch (e) { state = state.copyWith(isLoading: false, error: e.toString()); @@ -82,6 +97,7 @@ class SearchAgentsNotifier extends StateNotifier { agentTypeId: state.selectedAgentTypeId, page: page, limit: 10, + filters: state.activeFilters.isNotEmpty ? state.activeFilters : null, ); state = state.copyWith( @@ -119,6 +135,24 @@ class SearchAgentsNotifier extends StateNotifier { _fetchAgents(page: 1); } + void applyFilters(Map> filters) { + state = state.copyWith( + activeFilters: filters, + isLoading: true, + currentPage: 1, + ); + _fetchAgents(page: 1); + } + + void clearFilters() { + state = state.copyWith( + activeFilters: const {}, + isLoading: true, + currentPage: 1, + ); + _fetchAgents(page: 1); + } + void loadMore() { if (state.isLoadingMore || !state.hasMore) return; state = state.copyWith(isLoadingMore: true); diff --git a/lib/features/agents/presentation/screens/agent_search_screen.dart b/lib/features/agents/presentation/screens/agent_search_screen.dart index 4cedfcf..1615c26 100644 --- a/lib/features/agents/presentation/screens/agent_search_screen.dart +++ b/lib/features/agents/presentation/screens/agent_search_screen.dart @@ -7,6 +7,7 @@ import 'package:real_estate_mobile/core/widgets/s3_image.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/agents/presentation/widgets/agent_filter_sheet.dart'; import 'package:real_estate_mobile/features/home/presentation/widgets/home_header.dart'; class AgentSearchScreen extends ConsumerStatefulWidget { @@ -52,6 +53,27 @@ class _AgentSearchScreenState extends ConsumerState { } } + void _openFilterSheet() { + final state = ref.read(searchAgentsProvider); + showModalBottomSheet( + context: context, + isScrollControlled: true, + backgroundColor: Colors.transparent, + builder: (_) => AgentFilterSheet( + filterFields: state.filterFields, + agentTypes: state.agentTypes, + currentFilters: state.activeFilters, + selectedAgentTypeId: state.selectedAgentTypeId, + onApply: (filters) { + ref.read(searchAgentsProvider.notifier).applyFilters(filters); + }, + onClearAll: () { + ref.read(searchAgentsProvider.notifier).clearFilters(); + }, + ), + ); + } + @override Widget build(BuildContext context) { final state = ref.watch(searchAgentsProvider); @@ -222,20 +244,23 @@ class _AgentSearchScreenState extends ConsumerState { 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: GestureDetector( + onTap: () => _openFilterSheet(), + 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, ), - ), - child: const Icon( - Icons.tune, - color: AppColors.primaryDark, - size: 20, ), ), ), diff --git a/lib/features/agents/presentation/widgets/agent_filter_sheet.dart b/lib/features/agents/presentation/widgets/agent_filter_sheet.dart new file mode 100644 index 0000000..4028528 --- /dev/null +++ b/lib/features/agents/presentation/widgets/agent_filter_sheet.dart @@ -0,0 +1,423 @@ +import 'package:flutter/material.dart'; +import 'package:real_estate_mobile/core/constants/app_colors.dart'; +import 'package:real_estate_mobile/features/agents/data/models/agent_type.dart'; +import 'package:real_estate_mobile/features/agents/data/models/filterable_field.dart'; + +class AgentFilterSheet extends StatefulWidget { + final List filterFields; + final List agentTypes; + final Map> currentFilters; + final String? selectedAgentTypeId; + final void Function(Map> filters) onApply; + final VoidCallback onClearAll; + + const AgentFilterSheet({ + super.key, + required this.filterFields, + required this.agentTypes, + required this.currentFilters, + this.selectedAgentTypeId, + required this.onApply, + required this.onClearAll, + }); + + @override + State createState() => _AgentFilterSheetState(); +} + +class _AgentFilterSheetState extends State { + late Map> _filters; + final Map _expandedSections = {}; + + @override + void initState() { + super.initState(); + // Deep copy current filters + _filters = {}; + for (final entry in widget.currentFilters.entries) { + _filters[entry.key] = List.from(entry.value); + } + // Expand all sections by default + for (final field in widget.filterFields) { + _expandedSections[field.slug] = true; + } + } + + void _toggleOption(String fieldSlug, String optionValue) { + setState(() { + final current = _filters[fieldSlug] ?? []; + if (current.contains(optionValue)) { + current.remove(optionValue); + } else { + current.add(optionValue); + } + _filters[fieldSlug] = current; + }); + } + + bool _isSelected(String fieldSlug, String optionValue) { + return _filters[fieldSlug]?.contains(optionValue) ?? false; + } + + void _clearAll() { + setState(() { + _filters.clear(); + }); + } + + @override + Widget build(BuildContext context) { + return Container( + height: MediaQuery.of(context).size.height * 0.85, + decoration: const BoxDecoration( + color: Colors.white, + borderRadius: BorderRadius.vertical(top: Radius.circular(20)), + ), + child: Column( + children: [ + // Handle bar + Center( + child: Container( + margin: const EdgeInsets.only(top: 12), + width: 40, + height: 4, + decoration: BoxDecoration( + color: Colors.grey[300], + borderRadius: BorderRadius.circular(2), + ), + ), + ), + + // Header + Padding( + padding: const EdgeInsets.fromLTRB(27, 16, 27, 12), + child: Row( + children: [ + const Text( + 'Filters', + style: TextStyle( + fontFamily: 'Fractul', + fontSize: 20, + fontWeight: FontWeight.w700, + color: AppColors.primaryDark, + ), + ), + const Spacer(), + GestureDetector( + onTap: () => Navigator.of(context).pop(), + child: const Icon( + Icons.close, + color: AppColors.primaryDark, + size: 24, + ), + ), + ], + ), + ), + + const Divider(height: 1, color: Color(0xFFE8E8E8)), + + // Listing Type row + Padding( + padding: const EdgeInsets.fromLTRB(27, 16, 27, 12), + child: Row( + children: [ + const Text( + 'Listing Type', + style: TextStyle( + fontFamily: 'Fractul', + fontSize: 20, + fontWeight: FontWeight.w600, + color: AppColors.primaryDark, + ), + ), + const Spacer(), + ..._buildListingTypeChips(), + ], + ), + ), + + // Scrollable filter sections + Expanded( + child: ListView.builder( + padding: const EdgeInsets.symmetric(horizontal: 27), + itemCount: widget.filterFields.length, + itemBuilder: (context, index) { + return _buildFilterSection(widget.filterFields[index]); + }, + ), + ), + + // Footer: divider + Clear All / Apply buttons + const Divider(height: 1, color: Color(0xFFE8E8E8)), + Padding( + padding: const EdgeInsets.fromLTRB(27, 16, 27, 16), + child: SafeArea( + top: false, + child: Row( + children: [ + // Clear All + Expanded( + child: GestureDetector( + onTap: () { + _clearAll(); + widget.onClearAll(); + Navigator.of(context).pop(); + }, + child: Container( + height: 29, + decoration: BoxDecoration( + borderRadius: BorderRadius.circular(15), + border: Border.all( + color: AppColors.primaryDark, + width: 0.1, + ), + ), + child: const Center( + child: Text( + 'Clear All', + style: TextStyle( + fontFamily: 'Fractul', + fontSize: 14, + fontWeight: FontWeight.w700, + color: AppColors.primaryDark, + ), + ), + ), + ), + ), + ), + const SizedBox(width: 16), + // Apply + Expanded( + child: GestureDetector( + onTap: () { + // Remove empty filter entries + final cleanFilters = >{}; + for (final entry in _filters.entries) { + if (entry.value.isNotEmpty) { + cleanFilters[entry.key] = entry.value; + } + } + widget.onApply(cleanFilters); + Navigator.of(context).pop(); + }, + child: Container( + height: 29, + decoration: BoxDecoration( + color: AppColors.accentOrange, + borderRadius: BorderRadius.circular(15), + ), + child: const Center( + child: Text( + 'Apply', + style: TextStyle( + fontFamily: 'Fractul', + fontSize: 14, + fontWeight: FontWeight.w700, + color: AppColors.primaryDark, + ), + ), + ), + ), + ), + ), + ], + ), + ), + ), + ], + ), + ); + } + + List _buildListingTypeChips() { + // Find "Professional" and "Lender" agent types + final types = <_ListingChip>[]; + for (final at in widget.agentTypes) { + final name = at.name.toLowerCase(); + if (name == 'professional' || name == 'agent') { + types.add(_ListingChip(label: 'Agents', id: at.id)); + } else if (name == 'lender') { + types.add(_ListingChip(label: 'Lenders', id: at.id)); + } + } + if (types.isEmpty) { + types.add(const _ListingChip(label: 'Agents', id: null)); + types.add(const _ListingChip(label: 'Lenders', id: null)); + } + + return types.map((chip) { + final isSelected = chip.id == widget.selectedAgentTypeId; + return Padding( + padding: const EdgeInsets.only(left: 12), + child: Text( + chip.label, + style: TextStyle( + fontFamily: 'SourceSerif4', + fontSize: 18, + fontWeight: isSelected ? FontWeight.w500 : FontWeight.w400, + color: AppColors.primaryDark, + ), + ), + ); + }).toList(); + } + + Widget _buildFilterSection(FilterableField field) { + final isExpanded = _expandedSections[field.slug] ?? true; + final options = field.options; + if (options.isEmpty) return const SizedBox.shrink(); + + return Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + const SizedBox(height: 8), + // Section header with expand/collapse + GestureDetector( + onTap: () { + setState(() { + _expandedSections[field.slug] = !isExpanded; + }); + }, + behavior: HitTestBehavior.opaque, + child: Padding( + padding: const EdgeInsets.symmetric(vertical: 8), + child: Row( + children: [ + Expanded( + child: Text( + field.name, + style: const TextStyle( + fontFamily: 'Fractul', + fontSize: 18, + fontWeight: FontWeight.w600, + color: AppColors.primaryDark, + ), + ), + ), + Icon( + isExpanded + ? Icons.keyboard_arrow_up + : Icons.keyboard_arrow_down, + color: AppColors.primaryDark, + size: 20, + ), + ], + ), + ), + ), + + // Options list + if (isExpanded) + _FilterOptionsList( + field: field, + isSelected: (value) => _isSelected(field.slug, value), + onToggle: (value) => _toggleOption(field.slug, value), + ), + + const SizedBox(height: 4), + ], + ); + } +} + +class _ListingChip { + final String label; + final String? id; + const _ListingChip({required this.label, required this.id}); +} + +class _FilterOptionsList extends StatefulWidget { + final FilterableField field; + final bool Function(String value) isSelected; + final void Function(String value) onToggle; + + const _FilterOptionsList({ + required this.field, + required this.isSelected, + required this.onToggle, + }); + + @override + State<_FilterOptionsList> createState() => _FilterOptionsListState(); +} + +class _FilterOptionsListState extends State<_FilterOptionsList> { + bool _showAll = false; + static const _initialCount = 6; + + @override + Widget build(BuildContext context) { + final options = widget.field.options; + final displayOptions = + _showAll ? options : options.take(_initialCount).toList(); + + return Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + ...displayOptions.map((option) => _buildCheckboxRow(option)), + if (options.length > _initialCount) + GestureDetector( + onTap: () => setState(() => _showAll = !_showAll), + child: Padding( + padding: const EdgeInsets.only(left: 27, top: 8), + child: Text( + _showAll ? 'Show Less' : 'Show More', + style: const TextStyle( + fontFamily: 'Fractul', + fontSize: 15, + fontWeight: FontWeight.w700, + color: AppColors.primaryDark, + ), + ), + ), + ), + ], + ); + } + + Widget _buildCheckboxRow(FilterOption option) { + final selected = widget.isSelected(option.value); + return GestureDetector( + onTap: () => widget.onToggle(option.value), + behavior: HitTestBehavior.opaque, + child: Padding( + padding: const EdgeInsets.symmetric(vertical: 6), + child: Row( + children: [ + Container( + width: 17, + height: 17, + decoration: BoxDecoration( + color: selected ? AppColors.accentOrange : Colors.transparent, + borderRadius: BorderRadius.circular(5), + border: Border.all( + color: selected + ? AppColors.accentOrange + : AppColors.primaryDark.withValues(alpha: 0.2), + width: 0.2, + ), + ), + child: selected + ? const Icon(Icons.check, size: 13, color: Colors.white) + : null, + ), + const SizedBox(width: 10), + Expanded( + child: Text( + option.label, + style: const TextStyle( + fontFamily: 'SourceSerif4', + fontSize: 15, + fontWeight: FontWeight.w400, + color: AppColors.primaryDark, + ), + ), + ), + ], + ), + ), + ); + } +}