Files
mobile-app/lib/features/agents/presentation/screens/agent_search_screen.dart

976 lines
36 KiB
Dart
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
import 'dart:async';
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';
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';
class AgentSearchScreen extends ConsumerStatefulWidget {
final String? initialQuery;
final String? initialAgentTypeId;
final String? initialLocation;
final String? initialCategory;
const AgentSearchScreen({
super.key,
this.initialQuery,
this.initialAgentTypeId,
this.initialLocation,
this.initialCategory,
});
@override
ConsumerState<AgentSearchScreen> createState() => _AgentSearchScreenState();
}
class _AgentSearchScreenState extends ConsumerState<AgentSearchScreen> {
late TextEditingController _searchController;
final ScrollController _scrollController = ScrollController();
Timer? _searchDebounce;
@override
void initState() {
super.initState();
_searchController = TextEditingController(text: widget.initialQuery ?? '');
_scrollController.addListener(_onScroll);
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 = <String, List<String>>{};
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
void dispose() {
_searchDebounce?.cancel();
_searchController.dispose();
_scrollController.dispose();
super.dispose();
}
/// Debounced live search — fires search() while the user types so results
/// update incrementally. Empty value resets to the default listing.
void _onSearchChanged(String value) {
_searchDebounce?.cancel();
_searchDebounce = Timer(const Duration(milliseconds: 300), () {
if (!mounted) return;
ref.read(searchAgentsProvider.notifier).search(value.trim());
});
}
void _onScroll() {
if (_scrollController.position.pixels >=
_scrollController.position.maxScrollExtent - 200) {
ref.read(searchAgentsProvider.notifier).loadMore();
}
}
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();
},
onListingTypeChange: (agentTypeId) {
ref
.read(searchAgentsProvider.notifier)
.filterByAgentType(agentTypeId);
},
),
);
}
@override
Widget build(BuildContext context) {
final state = ref.watch(searchAgentsProvider);
return Column(
children: [
_buildSearchBar(),
const SizedBox(height: 12),
_buildFilterChips(state.agentTypes, state.selectedAgentTypeId),
const SizedBox(height: 12),
Expanded(child: _buildAgentList(state)),
],
);
}
Widget _buildSearchBar() {
return Padding(
padding: const EdgeInsets.fromLTRB(16, 16, 24, 0),
child: SizedBox(
height: 42,
child: Row(
children: [
GestureDetector(
onTap: () {
if (Navigator.of(context).canPop()) {
Navigator.of(context).pop();
} else {
context.go('/home');
}
},
child: const Padding(
padding: EdgeInsets.only(right: 8),
child: Icon(
Icons.arrow_back_ios_new_rounded,
color: AppColors.primaryDark,
size: 20,
),
),
),
Expanded(
child: Container(
decoration: BoxDecoration(
color: Colors.white,
border: Border.all(
color: AppColors.primaryDark,
width: 0.1,
),
borderRadius: BorderRadius.circular(7),
),
child: Theme(
data: Theme.of(context).copyWith(
inputDecorationTheme: const InputDecorationTheme(
filled: false,
border: InputBorder.none,
),
),
child: TextField(
controller: _searchController,
style: const TextStyle(
fontFamily: 'Fractul',
fontSize: 14,
fontWeight: FontWeight.w500,
color: AppColors.primaryDark,
),
decoration: InputDecoration(
hintText: 'Search Professionals',
hintStyle: TextStyle(
fontFamily: 'Fractul',
fontSize: 14,
fontWeight: FontWeight.w500,
color: Colors.black.withValues(alpha: 0.5),
),
filled: false,
border: InputBorder.none,
enabledBorder: InputBorder.none,
focusedBorder: InputBorder.none,
contentPadding: const EdgeInsets.symmetric(
horizontal: 14,
vertical: 10,
),
// Clear (×) button — resets the listing instantly.
suffixIcon: _searchController.text.isNotEmpty
? IconButton(
icon: const Icon(
Icons.close,
size: 18,
color: AppColors.hintText,
),
onPressed: () {
_searchDebounce?.cancel();
_searchController.clear();
setState(() {});
ref
.read(searchAgentsProvider.notifier)
.search('');
},
)
: null,
),
onChanged: (value) {
// Toggle suffix-icon visibility on each keystroke.
setState(() {});
_onSearchChanged(value);
},
onSubmitted: (value) {
_searchDebounce?.cancel();
ref
.read(searchAgentsProvider.notifier)
.search(value.trim());
},
),
),
),
),
const SizedBox(width: 0),
GestureDetector(
onTap: () {
final query = _searchController.text.trim();
if (query.isEmpty) {
// No input — don't trigger a refresh
ScaffoldMessenger.of(context).showSnackBar(
const SnackBar(
content: Text('Please enter a keyword'),
behavior: SnackBarBehavior.floating,
duration: Duration(seconds: 2),
),
);
return;
}
ref.read(searchAgentsProvider.notifier).search(query);
},
child: Container(
width: 62,
height: 42,
alignment: Alignment.center,
decoration: BoxDecoration(
color: AppColors.accentOrange,
border: Border.all(
color: AppColors.primaryDark,
width: 0.1,
),
borderRadius: BorderRadius.circular(7),
),
child: const Icon(
Icons.search,
color: Colors.white,
size: 24,
),
),
),
],
),
),
);
}
Widget _buildFilterChips(
List<AgentType> agentTypes, String? selectedTypeId) {
return SizedBox(
height: 32,
child: Padding(
padding: const EdgeInsets.only(left: 24),
child: Row(
children: [
Expanded(
child: ListView.separated(
scrollDirection: Axis.horizontal,
itemCount: agentTypes.length,
separatorBuilder: (_, __) => const SizedBox(width: 4),
itemBuilder: (context, index) {
final type = agentTypes[index];
final isSelected = type.id == selectedTypeId;
return GestureDetector(
onTap: () {
ref
.read(searchAgentsProvider.notifier)
.filterByAgentType(
isSelected ? null : type.id);
},
child: Container(
padding: const EdgeInsets.symmetric(
horizontal: 12, vertical: 4),
decoration: BoxDecoration(
color: isSelected
? AppColors.accentOrange
: Colors.transparent,
borderRadius: BorderRadius.circular(15),
border: Border.all(
color: AppColors.primaryDark,
width: 0.1,
),
),
child: Center(
child: Text(
type.name,
style: TextStyle(
fontFamily: 'SourceSerif4',
fontSize: 14,
fontWeight: FontWeight.w400,
color: isSelected
? Colors.white
: AppColors.primaryDark,
),
),
),
),
);
},
),
),
const SizedBox(width: 8),
Padding(
padding: const EdgeInsets.only(right: 24),
child: 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,
),
),
),
),
],
),
),
);
}
Widget _buildShimmerList() {
return Shimmer.fromColors(
baseColor: const Color(0xFFE8E8E8),
highlightColor: const Color(0xFFF5F5F5),
child: ListView.builder(
padding: const EdgeInsets.symmetric(horizontal: 24),
physics: const NeverScrollableScrollPhysics(),
itemCount: 3,
itemBuilder: (_, __) => Padding(
padding: const EdgeInsets.only(bottom: 16),
child: Container(
height: 300,
decoration: BoxDecoration(
color: Colors.white,
borderRadius: BorderRadius.circular(15),
),
),
),
),
);
}
Widget _buildAgentList(SearchAgentsState state) {
if (state.isLoading) {
return _buildShimmerList();
}
if (state.error != null) {
return Center(
child: Column(
mainAxisAlignment: MainAxisAlignment.center,
children: [
const Icon(Icons.error_outline,
color: AppColors.accentOrange, size: 48),
const SizedBox(height: 12),
Text(
state.error!,
style: const TextStyle(
fontFamily: 'Fractul',
fontSize: 14,
color: AppColors.primaryDark,
),
textAlign: TextAlign.center,
),
const SizedBox(height: 16),
TextButton(
onPressed: () =>
ref.read(searchAgentsProvider.notifier).refresh(),
child: const Text(
'Retry',
style: TextStyle(
fontFamily: 'Fractul',
fontSize: 14,
fontWeight: FontWeight.w600,
color: AppColors.accentOrange,
),
),
),
],
),
);
}
if (state.agents.isEmpty) {
return const Center(
child: Column(
mainAxisAlignment: MainAxisAlignment.center,
children: [
Icon(Icons.people_outline, color: AppColors.hintText, size: 48),
SizedBox(height: 12),
Text(
'No agents found',
style: TextStyle(
fontFamily: 'Fractul',
fontSize: 14,
color: AppColors.hintText,
),
),
],
),
);
}
return ListView.builder(
controller: _scrollController,
padding: const EdgeInsets.symmetric(horizontal: 24),
itemCount: state.agents.length + (state.isLoadingMore ? 1 : 0),
itemBuilder: (context, index) {
if (index == state.agents.length) {
return const Padding(
padding: EdgeInsets.symmetric(vertical: 20),
child: Center(
child:
CircularProgressIndicator(color: AppColors.accentOrange),
),
);
}
return Padding(
padding: const EdgeInsets.only(bottom: 16),
child: GestureDetector(
onTap: () => context.push('/agents/detail/${state.agents[index].id}'),
child: _AgentCard(agent: state.agents[index]),
),
);
},
);
}
}
// --- Agent Card Widget matching Figma design ---
class _AgentCard extends StatefulWidget {
final AgentProfile agent;
const _AgentCard({required this.agent});
@override
State<_AgentCard> createState() => _AgentCardState();
}
class _AgentCardState extends State<_AgentCard> {
bool _bioExpanded = false;
bool _tagsExpanded = false;
@override
Widget build(BuildContext context) {
final agent = widget.agent;
final specializations = agent.specializations;
final bio = agent.description;
final matchPercent = agent.matchPercentage != null
? '${agent.matchPercentage}%'
: null;
return Container(
decoration: BoxDecoration(
color: Colors.white,
borderRadius: BorderRadius.circular(20),
border: Border.all(color: AppColors.primaryDark.withValues(alpha: 0.1)),
boxShadow: [
BoxShadow(
color: const Color(0xFFD9D9D9).withValues(alpha: 0.5),
offset: const Offset(0, 10),
blurRadius: 20,
),
],
),
clipBehavior: Clip.antiAlias,
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
// Cover image with match badge
Stack(
children: [
S3Image(
imageUrl: agent.avatarUrl,
width: double.infinity,
height: 265,
alignment: Alignment.topCenter,
errorWidget: (_) => Container(
width: double.infinity,
height: 265,
color: AppColors.gradientStart,
child: const Icon(Icons.person, size: 80, color: AppColors.primaryDark),
),
),
if (matchPercent != null)
Positioned(
top: 0,
right: 0,
child: Container(
padding:
const EdgeInsets.symmetric(horizontal: 10, vertical: 12),
decoration: const BoxDecoration(
color: Color(0xFF638559),
borderRadius: BorderRadius.only(
bottomLeft: Radius.circular(20),
),
),
child: Text(
matchPercent,
style: const TextStyle(
fontFamily: 'SourceSerif4',
fontSize: 14,
fontWeight: FontWeight.w400,
color: Color(0xFFF0F5FC),
),
),
),
),
],
),
// Card content
Padding(
padding: const EdgeInsets.fromLTRB(34, 16, 20, 20),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
// Name
Text.rich(
TextSpan(
children: [
TextSpan(
text: agent.firstName,
style: const TextStyle(
fontFamily: 'Fractul',
fontSize: 15,
fontWeight: FontWeight.w700,
color: AppColors.primaryDark,
),
),
TextSpan(
text: ' ${agent.lastName}',
style: const TextStyle(
fontFamily: 'Fractul',
fontSize: 15,
fontWeight: FontWeight.w400,
color: AppColors.primaryDark,
),
),
],
),
maxLines: 1,
overflow: TextOverflow.ellipsis,
),
// Verified badge
if (agent.isVerified) ...[
const SizedBox(height: 4),
Row(
children: [
SvgPicture.asset(
'assets/icons/verified_badge_icon.svg',
width: 16,
height: 16,
placeholderBuilder: (_) => const Icon(
Icons.verified,
color: Color(0xFF5D8B5A),
size: 16,
),
),
const SizedBox(width: 4),
const Text(
'(Verified Expert)',
style: TextStyle(
fontFamily: 'SourceSerif4',
fontSize: 14,
fontWeight: FontWeight.w500,
color: Color(0xFF5D8B5A),
),
),
],
),
],
const SizedBox(height: 4),
// Headline / title
Text(
agent.agentType?.name ?? agent.headline ?? 'Professional',
style: const TextStyle(
fontFamily: 'Fractul',
fontSize: 15,
fontWeight: FontWeight.w400,
color: AppColors.primaryDark,
),
),
const SizedBox(height: 10),
// Location + Member Since row
Row(
children: [
if (agent.location.isNotEmpty) ...[
SvgPicture.asset(
'assets/icons/location_pin_icon.svg',
width: 14,
height: 14,
placeholderBuilder: (_) => const Icon(
Icons.location_on,
color: AppColors.accentOrange,
size: 14,
),
),
const SizedBox(width: 4),
Builder(builder: (_) {
final allParts = <String>[];
// Format raw slug/label. For cities, strip 2-letter
// state prefix ("nd_hurley" → "Hurley"). For states,
// uppercase 2-letter codes ("nd" → "ND"). Matches
// web's stripCityPrefix + extractAllValues.
String formatPart(String raw, {required bool isCity}) {
var s = raw.trim();
if (isCity) {
s = s.replaceFirst(
RegExp(r'^[a-z]{2}_', caseSensitive: false), '');
}
final titled = s
.split('_')
.map((w) => w.isNotEmpty
? '${w[0].toUpperCase()}${w.substring(1).toLowerCase()}'
: '')
.join(' ');
if (!isCity && titled.length == 2) {
return titled.toUpperCase();
}
return titled;
}
List<String> extractValues(dynamic fv,
{required bool isCity}) {
final out = <String>[];
if (fv.jsonValue is List) {
for (final v in (fv.jsonValue as List)) {
final s = v.toString().trim();
if (s.isEmpty) continue;
out.add(formatPart(s, isCity: isCity));
}
} else if (fv.textValue != null &&
(fv.textValue as String).isNotEmpty) {
out.add(formatPart(fv.textValue as String,
isCity: isCity));
}
return out;
}
void addUnique(String value) {
if (!allParts.any((p) =>
p.toLowerCase() == value.toLowerCase())) {
allParts.add(value);
}
}
// 1. States first
for (final fv in agent.fieldValues) {
final slug = fv.fieldSlug.toLowerCase();
if (slug == 'state' || slug == 'state_name') {
for (final v in extractValues(fv, isCity: false)) {
addUnique(v);
}
}
}
// 2. Cities next
for (final fv in agent.fieldValues) {
final slug = fv.fieldSlug.toLowerCase();
if (slug == 'city' || slug == 'city_name') {
for (final v in extractValues(fv, isCity: true)) {
addUnique(v);
}
}
}
// Fallback to agent.location + serviceAreas
if (allParts.isEmpty) {
final locParts = agent.location
.split(',')
.map((s) => s.trim())
.where((s) => s.isNotEmpty);
allParts.addAll(locParts);
for (final area in agent.serviceAreas) {
final trimmed = area.trim();
if (trimmed.isNotEmpty &&
!allParts.any((p) =>
p.toLowerCase() ==
trimmed.toLowerCase())) {
allParts.add(trimmed);
}
}
}
final firstPart = allParts.isNotEmpty
? allParts.first
: agent.location;
final remaining = allParts.length - 1;
return Row(
mainAxisSize: MainAxisSize.min,
children: [
ConstrainedBox(
constraints: const BoxConstraints(maxWidth: 90),
child: Text(
firstPart,
style: const TextStyle(
fontFamily: 'SourceSerif4',
fontSize: 14,
fontWeight: FontWeight.w500,
color: AppColors.primaryDark,
),
overflow: TextOverflow.ellipsis,
maxLines: 1,
),
),
if (remaining > 0)
GestureDetector(
onTap: () =>
_showLocationModal(context, allParts),
child: Text(
' +$remaining more',
style: const TextStyle(
fontFamily: 'SourceSerif4',
fontSize: 14,
fontWeight: FontWeight.w500,
color: AppColors.accentOrange,
),
),
),
],
);
}),
const SizedBox(width: 16),
],
SvgPicture.asset(
'assets/icons/calendar_icon.svg',
width: 14,
height: 14,
placeholderBuilder: (_) => const Icon(
Icons.calendar_today,
color: AppColors.accentOrange,
size: 14,
),
),
const SizedBox(width: 4),
Flexible(
child: Text(
agent.memberSinceText,
style: const TextStyle(
fontFamily: 'SourceSerif4',
fontSize: 14,
fontWeight: FontWeight.w500,
color: AppColors.primaryDark,
),
overflow: TextOverflow.ellipsis,
),
),
],
),
const SizedBox(height: 12),
// Expertise tags — full-width pills, all start at same left edge
if (specializations.isNotEmpty) ...[
Builder(builder: (_) {
final visible = _tagsExpanded
? specializations
: specializations.take(6).toList();
return Column(
crossAxisAlignment: CrossAxisAlignment.stretch,
children: [
for (int i = 0; i < visible.length; i++) ...[
if (i > 0) const SizedBox(height: 6),
_buildTag(visible[i]),
],
if (specializations.length > 6) ...[
const SizedBox(height: 8),
Align(
alignment: Alignment.center,
child: GestureDetector(
onTap: () => setState(
() => _tagsExpanded = !_tagsExpanded),
child: Container(
padding: const EdgeInsets.symmetric(
horizontal: 12, vertical: 5),
decoration: BoxDecoration(
borderRadius: BorderRadius.circular(20),
color: AppColors.accentOrange
.withValues(alpha: 0.1),
border: Border.all(
color: AppColors.accentOrange,
width: 0.7),
),
child: Text(
_tagsExpanded
? 'Show Less'
: '+${specializations.length - 6} more',
style: const TextStyle(
fontFamily: 'SourceSerif4',
fontSize: 13,
fontWeight: FontWeight.w600,
color: AppColors.accentOrange,
),
),
),
),
),
],
],
);
}),
const SizedBox(height: 14),
],
// Bio
if (bio.isNotEmpty) ...[
Text.rich(
TextSpan(
children: [
TextSpan(
text: _bioExpanded || bio.length <= 150
? bio
: '${bio.substring(0, 150)}... ',
style: const TextStyle(
fontFamily: 'SourceSerif4',
fontSize: 10,
fontWeight: FontWeight.w400,
color: AppColors.primaryDark,
),
),
if (bio.length > 150)
WidgetSpan(
child: GestureDetector(
onTap: () =>
setState(() => _bioExpanded = !_bioExpanded),
child: Text(
_bioExpanded ? 'Show Less.' : 'Show More.',
style: const TextStyle(
fontFamily: 'Fractul',
fontSize: 10,
fontWeight: FontWeight.w600,
color: AppColors.primaryDark,
decoration: TextDecoration.underline,
),
),
),
),
],
),
),
],
],
),
),
],
),
);
}
void _showLocationModal(BuildContext context, List<String> parts) {
showModalBottomSheet(
context: context,
shape: const RoundedRectangleBorder(
borderRadius: BorderRadius.vertical(top: Radius.circular(20)),
),
backgroundColor: Colors.white,
builder: (_) => Padding(
padding: const EdgeInsets.all(20),
child: Column(
mainAxisSize: MainAxisSize.min,
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Row(
mainAxisAlignment: MainAxisAlignment.spaceBetween,
children: [
const Text(
'All Locations',
style: TextStyle(
fontFamily: 'Fractul',
fontSize: 18,
fontWeight: FontWeight.w700,
color: AppColors.primaryDark,
),
),
GestureDetector(
onTap: () => Navigator.pop(context),
child: const Icon(Icons.close, size: 20),
),
],
),
const SizedBox(height: 16),
Wrap(
spacing: 8,
runSpacing: 8,
children: parts
.map((p) => Container(
padding: const EdgeInsets.symmetric(
horizontal: 12, vertical: 6),
decoration: BoxDecoration(
borderRadius: BorderRadius.circular(15),
border: Border.all(
color: AppColors.primaryDark.withValues(alpha: 0.2),
),
),
child: Text(
p,
style: const TextStyle(
fontFamily: 'SourceSerif4',
fontSize: 14,
fontWeight: FontWeight.w500,
color: AppColors.primaryDark,
),
),
))
.toList(),
),
const SizedBox(height: 20),
],
),
),
);
}
Widget _buildTag(String label) {
return Container(
padding: const EdgeInsets.symmetric(horizontal: 12, vertical: 5),
decoration: BoxDecoration(
borderRadius: BorderRadius.circular(20),
border: Border.all(color: AppColors.primaryDark, width: 0.7),
),
child: Text(
label,
style: const TextStyle(
fontFamily: 'SourceSerif4',
fontSize: 13,
fontWeight: FontWeight.w400,
color: AppColors.primaryDark,
),
),
);
}
}