976 lines
36 KiB
Dart
976 lines
36 KiB
Dart
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,
|
||
),
|
||
),
|
||
);
|
||
}
|
||
}
|