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:
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