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.
This commit is contained in:
@@ -1,5 +1,3 @@
|
||||
<svg width="22" height="22" viewBox="0 0 24 24" fill="none" xmlns="http://www.w3.org/2000/svg">
|
||||
<circle cx="12" cy="12" r="11" stroke="#E58625" stroke-width="1.5"/>
|
||||
<circle cx="12" cy="9" r="3.5" stroke="#E58625" stroke-width="1.5"/>
|
||||
<path d="M5.5 19.5C5.5 16.5 8.5 14 12 14C15.5 14 18.5 16.5 18.5 19.5" stroke="#E58625" stroke-width="1.5" stroke-linecap="round"/>
|
||||
<svg width="30" height="31" viewBox="0 0 30 31" fill="none" xmlns="http://www.w3.org/2000/svg">
|
||||
<path fill-rule="evenodd" clip-rule="evenodd" d="M15 27.2635C17.1396 27.2666 19.2419 26.7023 21.0938 25.6277V21.6228C21.0938 20.5007 20.6493 19.4247 19.8581 18.6313C19.0669 17.8379 17.9939 17.3922 16.875 17.3922H13.125C12.0061 17.3922 10.9331 17.8379 10.1419 18.6313C9.35072 19.4247 8.90625 20.5007 8.90625 21.6228V25.6277C10.7581 26.7023 12.8604 27.2666 15 27.2635ZM23.9062 21.6228V23.3845C25.5276 21.644 26.6064 19.4664 27.0101 17.1193C27.4139 14.7722 27.1249 12.3579 26.1787 10.1733C25.2326 7.98864 23.6705 6.12885 21.6845 4.82252C19.6985 3.51619 17.3751 2.82025 15 2.82025C12.6249 2.82025 10.3015 3.51619 8.31549 4.82252C6.32949 6.12885 4.7674 7.98864 3.82125 10.1733C2.8751 12.3579 2.58613 14.7722 2.98986 17.1193C3.39359 19.4664 4.47243 21.644 6.09375 23.3845V21.6228C6.09319 20.169 6.54077 18.7506 7.3752 17.5617C8.20962 16.3729 9.39011 15.4718 10.755 14.9817C10.047 14.1651 9.58815 13.1614 9.43325 12.0905C9.27835 11.0196 9.43392 9.92655 9.88139 8.94183C10.3289 7.9571 11.0494 7.12224 11.9569 6.53681C12.8645 5.95138 13.9209 5.64011 15 5.64011C16.0791 5.64011 17.1355 5.95138 18.0431 6.53681C18.9506 7.12224 19.6711 7.9571 20.1186 8.94183C20.5661 9.92655 20.7216 11.0196 20.5667 12.0905C20.4118 13.1614 19.953 14.1651 19.245 14.9817C20.6099 15.4718 21.7904 16.3729 22.6248 17.5617C23.4592 18.7506 23.9068 20.169 23.9062 21.6228ZM15 30.0838C18.9782 30.0838 22.7936 28.4991 25.6066 25.6782C28.4196 22.8573 30 19.0313 30 15.0419C30 11.0526 28.4196 7.22658 25.6066 4.40567C22.7936 1.58477 18.9782 0 15 0C11.0218 0 7.20644 1.58477 4.3934 4.40567C1.58035 7.22658 0 11.0526 0 15.0419C0 19.0313 1.58035 22.8573 4.3934 25.6782C7.20644 28.4991 11.0218 30.0838 15 30.0838ZM17.8125 11.2814C17.8125 12.0294 17.5162 12.7468 16.9887 13.2757C16.4613 13.8047 15.7459 14.1018 15 14.1018C14.2541 14.1018 13.5387 13.8047 13.0113 13.2757C12.4838 12.7468 12.1875 12.0294 12.1875 11.2814C12.1875 10.5334 12.4838 9.81606 13.0113 9.28714C13.5387 8.75822 14.2541 8.46108 15 8.46108C15.7459 8.46108 16.4613 8.75822 16.9887 9.28714C17.5162 9.81606 17.8125 10.5334 17.8125 11.2814Z" fill="#E58625"/>
|
||||
</svg>
|
||||
|
||||
|
Before Width: | Height: | Size: 372 B After Width: | Height: | Size: 2.1 KiB |
@@ -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';
|
||||
}
|
||||
|
||||
@@ -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<AgentProfile> data;
|
||||
@@ -49,6 +52,7 @@ class AgentsRepository {
|
||||
int limit = 10,
|
||||
String? sortBy,
|
||||
String? sortOrder,
|
||||
Map<String, List<String>>? filters,
|
||||
}) async {
|
||||
try {
|
||||
final queryParams = <String, dynamic>{
|
||||
@@ -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<List<FilterableField>> getFilterableFields() async {
|
||||
try {
|
||||
final response = await _dio.get(ApiConstants.filterableFields);
|
||||
final data = response.data['data'] as List<dynamic>;
|
||||
return data
|
||||
.map((e) => FilterableField.fromJson(e as Map<String, dynamic>))
|
||||
.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<List<AgentType>> getAgentTypes() async {
|
||||
|
||||
39
lib/features/agents/data/models/filterable_field.dart
Normal file
39
lib/features/agents/data/models/filterable_field.dart
Normal file
@@ -0,0 +1,39 @@
|
||||
class FilterOption {
|
||||
final String value;
|
||||
final String label;
|
||||
|
||||
const FilterOption({required this.value, required this.label});
|
||||
|
||||
factory FilterOption.fromJson(Map<String, dynamic> 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<FilterOption> options;
|
||||
|
||||
const FilterableField({
|
||||
required this.slug,
|
||||
required this.name,
|
||||
required this.fieldType,
|
||||
this.options = const [],
|
||||
});
|
||||
|
||||
factory FilterableField.fromJson(Map<String, dynamic> json) {
|
||||
return FilterableField(
|
||||
slug: json['slug'] as String? ?? '',
|
||||
name: json['name'] as String? ?? '',
|
||||
fieldType: json['fieldType'] as String? ?? '',
|
||||
options: (json['options'] as List<dynamic>?)
|
||||
?.map((e) => FilterOption.fromJson(e as Map<String, dynamic>))
|
||||
.toList() ??
|
||||
const [],
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -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<AgentProfile> agents;
|
||||
final List<AgentType> agentTypes;
|
||||
final List<FilterableField> 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<String, List<String>> 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<AgentProfile>? agents,
|
||||
List<AgentType>? agentTypes,
|
||||
List<FilterableField>? filterFields,
|
||||
bool? isLoading,
|
||||
bool? isLoadingMore,
|
||||
String? error,
|
||||
@@ -40,10 +46,12 @@ class SearchAgentsState {
|
||||
bool clearAgentTypeId = false,
|
||||
int? currentPage,
|
||||
int? totalPages,
|
||||
Map<String, List<String>>? 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<SearchAgentsState> {
|
||||
|
||||
Future<void> _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<AgentType>,
|
||||
filterFields: results[1] as List<FilterableField>,
|
||||
);
|
||||
await _fetchAgents(page: 1);
|
||||
} catch (e) {
|
||||
state = state.copyWith(isLoading: false, error: e.toString());
|
||||
@@ -82,6 +97,7 @@ class SearchAgentsNotifier extends StateNotifier<SearchAgentsState> {
|
||||
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<SearchAgentsState> {
|
||||
_fetchAgents(page: 1);
|
||||
}
|
||||
|
||||
void applyFilters(Map<String, List<String>> 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);
|
||||
|
||||
@@ -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<AgentSearchScreen> {
|
||||
}
|
||||
}
|
||||
|
||||
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<AgentSearchScreen> {
|
||||
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,
|
||||
),
|
||||
),
|
||||
),
|
||||
|
||||
423
lib/features/agents/presentation/widgets/agent_filter_sheet.dart
Normal file
423
lib/features/agents/presentation/widgets/agent_filter_sheet.dart
Normal file
@@ -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<FilterableField> filterFields;
|
||||
final List<AgentType> agentTypes;
|
||||
final Map<String, List<String>> currentFilters;
|
||||
final String? selectedAgentTypeId;
|
||||
final void Function(Map<String, List<String>> 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<AgentFilterSheet> createState() => _AgentFilterSheetState();
|
||||
}
|
||||
|
||||
class _AgentFilterSheetState extends State<AgentFilterSheet> {
|
||||
late Map<String, List<String>> _filters;
|
||||
final Map<String, bool> _expandedSections = {};
|
||||
|
||||
@override
|
||||
void initState() {
|
||||
super.initState();
|
||||
// Deep copy current filters
|
||||
_filters = {};
|
||||
for (final entry in widget.currentFilters.entries) {
|
||||
_filters[entry.key] = List<String>.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 = <String, List<String>>{};
|
||||
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<Widget> _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,
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user