feat: enhance profile input fields with new styling and customization options, and update calendar and location icons

This commit is contained in:
pradeepkumar
2026-03-14 20:59:47 +05:30
parent da45b1ad42
commit ddcc04a446
9 changed files with 866 additions and 289 deletions

View File

@@ -73,7 +73,11 @@ class AgentDetailState {
String get contractsClosed {
final val = _getFieldValue('contracts_completed');
if (val == null) return '-';
final num = val is int ? val : int.tryParse(val.toString()) ?? -1;
final str = val.toString();
// Parse leading integer from value (handles "10-20", "50+", "<3", etc.)
final match = RegExp(r'\d+').firstMatch(str);
if (match == null) return '-';
final num = int.tryParse(match.group(0)!) ?? -1;
if (num < 0) return '-';
if (num >= 100) return '100+ Contracts';
if (num >= 50) return '50+ Contracts';
@@ -115,6 +119,127 @@ class AgentDetailState {
.toList();
}
// ── Expertise tags (matching web mapFieldValuesToProfileCard) ──
List<String> get expertiseTags {
const slugs = [
'about_me_expertise',
'expertise_areas',
'specializations',
'areas_of_expertise',
'expertise',
'specialization',
];
for (final slug in slugs) {
final val = _getFieldValue(slug);
if (val is List && val.isNotEmpty) {
return val.map((v) => titleCase(v.toString())).toList();
}
}
// Fallback to agent model specializations
if (agent != null) {
final tags = agent!.specializations;
if (tags.isNotEmpty) return tags;
}
return [];
}
// ── Description / Bio (from fieldValues first, then agent model) ──
String get descriptionText {
// Check detail fieldValues for 'description' slug
for (final fv in fieldValues) {
if (fv.fieldSlug == 'description' &&
fv.textValue != null &&
fv.textValue!.isNotEmpty) {
return fv.textValue!;
}
}
// Fallback to agent model bio
return agent?.bio ?? '';
}
// ── Location (from fieldValues, matching web profileDataMapper) ──
/// All location parts as individual items (for "+N more" display).
/// Sources: detail fieldValues city/state arrays → agent model → serviceAreas
List<String> get locationParts {
final parts = <String>[];
// 1. Extract from detail fieldValues (full arrays, not just first item)
List<String>? fvCities;
List<String>? fvStates;
for (final fv in fieldValues) {
final slug = fv.fieldSlug.toLowerCase();
if (slug == 'city' || slug == 'city_name') {
fvCities = _extractAllValuesAsList(fv);
} else if (slug == 'state' || slug == 'state_name') {
fvStates = _extractAllValuesAsList(fv);
}
}
if (fvCities != null) parts.addAll(fvCities);
if (fvStates != null) {
for (final s in fvStates) {
if (!parts.any((p) => p.toLowerCase() == s.toLowerCase())) {
parts.add(s);
}
}
}
// 2. Fallback: agent model direct city/state
if (parts.isEmpty && agent != null) {
if (agent!.city != null && agent!.city!.isNotEmpty) {
parts.add(agent!.city!);
}
if (agent!.state != null && agent!.state!.isNotEmpty) {
parts.add(agent!.state!);
}
}
// 3. Fallback: serviceAreas
if (parts.isEmpty && agent != null) {
for (final area in agent!.serviceAreas) {
final trimmed = area.trim();
if (trimmed.isNotEmpty) parts.add(trimmed);
}
}
return parts;
}
/// Combined location text for single-line display.
String get locationText {
final parts = locationParts;
if (parts.isEmpty) return '';
return parts.join(', ');
}
/// Extract all values from a field as a list of title-cased strings.
static List<String>? _extractAllValuesAsList(AgentFieldValue fv) {
if (fv.jsonValue is List && (fv.jsonValue as List).isNotEmpty) {
return (fv.jsonValue as List)
.map((v) => titleCase(v.toString()))
.toList();
}
if (fv.textValue != null && fv.textValue!.isNotEmpty) {
return [titleCase(fv.textValue!)];
}
return null;
}
/// Extract all values from a field (array or string), title-cased.
static String? _extractAllValues(AgentFieldValue fv) {
if (fv.jsonValue is List && (fv.jsonValue as List).isNotEmpty) {
return (fv.jsonValue as List)
.map((v) => titleCase(v.toString()))
.join(', ');
}
if (fv.textValue != null && fv.textValue!.isNotEmpty) {
return titleCase(fv.textValue!);
}
return null;
}
// ── Contact info (masked) ──
String? get contactEmail {

View File

@@ -1,5 +1,6 @@
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:real_estate_mobile/core/constants/app_colors.dart';
import 'package:real_estate_mobile/core/widgets/s3_image.dart';
@@ -19,6 +20,7 @@ class AgentDetailScreen extends ConsumerStatefulWidget {
class _AgentDetailScreenState extends ConsumerState<AgentDetailScreen> {
bool _emailVisible = false;
bool _phoneVisible = false;
bool _bioExpanded = false;
@override
void initState() {
@@ -190,7 +192,7 @@ class _AgentDetailScreenState extends ConsumerState<AgentDetailScreen> {
);
}
// ── Status + Connect ──
// ── Status + Connect (single row matching web public view) ──
Widget _buildStatusButtons(AgentDetailState state) {
final agent = state.agent!;
final isAvailable = agent.isAvailable;
@@ -198,85 +200,55 @@ class _AgentDetailScreenState extends ConsumerState<AgentDetailScreen> {
return Padding(
padding: const EdgeInsets.symmetric(horizontal: 38),
child: Column(
children: [
// Available row
_buildStatusRow(
isAvailable: true,
label: 'Available.',
active: isAvailable,
connState: connState,
onConnect: isAvailable ? () => _handleConnect(state) : null,
child: Container(
height: 50,
decoration: BoxDecoration(
borderRadius: BorderRadius.circular(15),
border: Border.all(
color: AppColors.primaryDark.withValues(alpha: 0.1),
width: 1,
),
const SizedBox(height: 10),
// Unavailable row
_buildStatusRow(
isAvailable: false,
label: 'Unavailable.',
active: !isAvailable,
connState: connState,
onConnect: !isAvailable ? () => _handleConnect(state) : null,
),
],
),
);
}
Widget _buildStatusRow({
required bool isAvailable,
required String label,
required bool active,
required String connState,
VoidCallback? onConnect,
}) {
return Container(
height: 55,
decoration: BoxDecoration(
borderRadius: BorderRadius.circular(15),
border: Border.all(
color: AppColors.primaryDark.withValues(alpha: 0.15),
width: 1,
),
),
child: Row(
children: [
const SizedBox(width: 20),
// Status dot
Container(
width: 14,
height: 12,
decoration: BoxDecoration(
shape: BoxShape.circle,
color: active
? (isAvailable ? const Color(0xFF4CAF50) : Colors.transparent)
: Colors.transparent,
border: Border.all(
color: active
? (isAvailable
? const Color(0xFF4CAF50)
: AppColors.primaryDark.withValues(alpha: 0.3))
: AppColors.primaryDark.withValues(alpha: 0.15),
width: 2,
child: Row(
children: [
const SizedBox(width: 16),
// Status dot
Container(
width: 12,
height: 12,
decoration: BoxDecoration(
shape: BoxShape.circle,
color: isAvailable
? const Color(0xFF22C55E)
: Colors.white,
border: Border.all(
color: isAvailable
? const Color(0xFF22C55E)
: AppColors.primaryDark.withValues(alpha: 0.3),
width: 1.5,
),
),
),
),
const SizedBox(width: 12),
Text(
label,
style: TextStyle(
fontFamily: 'Fractul',
fontSize: 12,
fontWeight: FontWeight.w700,
color: active
? AppColors.primaryDark
: AppColors.primaryDark.withValues(alpha: 0.4),
const SizedBox(width: 8),
Text(
isAvailable ? 'Available.' : 'Unavailable.',
style: const TextStyle(
fontFamily: 'Fractul',
fontSize: 14,
fontWeight: FontWeight.w700,
color: AppColors.primaryDark,
),
),
),
const Spacer(),
// Connect button
if (active) _buildConnectButton(connState, isAvailable, onConnect),
const SizedBox(width: 4),
],
const Spacer(),
// Connect/Pending/Unlink button
_buildConnectButton(
connState,
isAvailable,
() => _handleConnect(state),
),
const SizedBox(width: 4),
],
),
),
);
}
@@ -293,37 +265,34 @@ class _AgentDetailScreenState extends ConsumerState<AgentDetailScreen> {
switch (connState) {
case 'pending':
bgColor = const Color(0xFFFFF3CD);
textColor = const Color(0xFF856404);
bgColor = const Color(0xFFFEF3C7); // yellow-100
textColor = const Color(0xFF92600F);
label = 'Pending';
enabled = false;
break;
case 'accepted':
bgColor = AppColors.primaryDark;
textColor = const Color(0xFFF0F5FC);
bgColor = const Color(0xFFEF4444); // red-500
textColor = Colors.white;
label = 'Unlink';
enabled = true;
break;
default:
bgColor = isAvailable ? AppColors.accentOrange : AppColors.primaryDark;
textColor = isAvailable
? AppColors.primaryDark
: const Color(0xFFF0F5FC);
bgColor = isAvailable
? AppColors.accentOrange
: AppColors.primaryDark.withValues(alpha: 0.7);
textColor = Colors.white;
label = 'Connect';
enabled = true;
enabled = isAvailable;
}
return GestureDetector(
onTap: enabled ? onConnect : null,
child: Container(
height: 55,
width: 164,
width: 140,
decoration: BoxDecoration(
color: bgColor,
borderRadius: BorderRadius.circular(15),
border: isAvailable && connState == 'none'
? Border.all(color: AppColors.accentOrange)
: null,
),
alignment: Alignment.center,
child: Text(
@@ -331,7 +300,7 @@ class _AgentDetailScreenState extends ConsumerState<AgentDetailScreen> {
style: TextStyle(
fontFamily: 'Fractul',
fontSize: 14,
fontWeight: FontWeight.w400,
fontWeight: FontWeight.w600,
color: textColor,
),
),
@@ -357,105 +326,83 @@ class _AgentDetailScreenState extends ConsumerState<AgentDetailScreen> {
showModalBottomSheet(
context: context,
isScrollControlled: true,
useRootNavigator: true,
backgroundColor: Colors.transparent,
builder: (ctx) => Padding(
padding: EdgeInsets.only(bottom: MediaQuery.of(ctx).viewInsets.bottom),
child: Container(
decoration: const BoxDecoration(
color: Colors.white,
borderRadius: BorderRadius.vertical(top: Radius.circular(20)),
),
padding: EdgeInsets.fromLTRB(
24,
24,
24,
24 + MediaQuery.of(ctx).padding.bottom,
),
child: Column(
mainAxisSize: MainAxisSize.min,
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Row(
mainAxisAlignment: MainAxisAlignment.spaceBetween,
children: [
Text(
'Connect with ${state.agent?.fullName ?? ''}',
style: const TextStyle(
fontFamily: 'Fractul',
fontSize: 18,
fontWeight: FontWeight.w700,
color: AppColors.primaryDark,
),
),
GestureDetector(
onTap: () => Navigator.pop(ctx),
child: const Icon(
Icons.close,
color: AppColors.primaryDark,
),
),
],
),
const SizedBox(height: 8),
const Text(
'Send a connection request to this agent.',
style: TextStyle(
fontFamily: 'SourceSerif4',
fontSize: 14,
color: AppColors.primaryDark,
),
),
const SizedBox(height: 16),
TextField(
controller: controller,
maxLines: 4,
maxLength: 1000,
decoration: InputDecoration(
hintText: 'Introduce yourself... (optional)',
hintStyle: TextStyle(
fontFamily: 'SourceSerif4',
fontSize: 14,
color: AppColors.primaryDark.withValues(alpha: 0.4),
),
border: OutlineInputBorder(
borderRadius: BorderRadius.circular(10),
borderSide: BorderSide(
color: AppColors.primaryDark.withValues(alpha: 0.15),
),
builder: (ctx) => _ConnectModalContent(
agentName: state.agent?.fullName ?? '',
controller: controller,
onSend: (message) async {
final success = await ref
.read(agentDetailProvider(widget.agentId).notifier)
.connect(message: message);
return success;
},
),
);
}
// ── Location Modal (matching web "Service Areas" modal) ──
void _showLocationModal(List<String> locations) {
showModalBottomSheet(
context: context,
isScrollControlled: true,
backgroundColor: Colors.transparent,
builder: (ctx) => Container(
decoration: const BoxDecoration(
color: Colors.white,
borderRadius: BorderRadius.vertical(top: Radius.circular(20)),
),
padding: EdgeInsets.fromLTRB(
24, 24, 24, 24 + MediaQuery.of(ctx).padding.bottom,
),
child: Column(
mainAxisSize: MainAxisSize.min,
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Row(
mainAxisAlignment: MainAxisAlignment.spaceBetween,
children: [
const Text(
'Service Areas',
style: TextStyle(
fontFamily: 'Fractul',
fontSize: 18,
fontWeight: FontWeight.w700,
color: AppColors.primaryDark,
),
),
),
const SizedBox(height: 16),
SizedBox(
width: double.infinity,
height: 60,
child: ElevatedButton(
onPressed: () async {
Navigator.pop(ctx);
await ref
.read(agentDetailProvider(widget.agentId).notifier)
.connect(message: controller.text);
},
style: ElevatedButton.styleFrom(
backgroundColor: AppColors.accentOrange,
shape: RoundedRectangleBorder(
borderRadius: BorderRadius.circular(15),
),
),
child: const Text(
'Send Request',
style: TextStyle(
fontFamily: 'Fractul',
fontSize: 16,
fontWeight: FontWeight.w700,
color: Colors.white,
),
GestureDetector(
onTap: () => Navigator.of(ctx).pop(),
child: Icon(
Icons.close,
color: AppColors.primaryDark.withValues(alpha: 0.5),
),
),
),
],
),
],
),
const SizedBox(height: 16),
Wrap(
spacing: 8,
runSpacing: 8,
children: locations
.map((loc) => Container(
padding: const EdgeInsets.symmetric(
horizontal: 12, vertical: 6),
decoration: BoxDecoration(
color: const Color(0xFFE8E8E8),
borderRadius: BorderRadius.circular(10),
),
child: Text(
loc,
style: const TextStyle(
fontFamily: 'SourceSerif4',
fontSize: 13,
color: AppColors.primaryDark,
),
),
))
.toList(),
),
],
),
),
);
@@ -551,7 +498,7 @@ class _AgentDetailScreenState extends ConsumerState<AgentDetailScreen> {
child: Column(
crossAxisAlignment: CrossAxisAlignment.center,
children: [
// Name + Verified badge + (Verified Expert) on same line
// Name + Verified badge + (Verified Expert) — badge after last name
Row(
mainAxisAlignment: MainAxisAlignment.center,
crossAxisAlignment: CrossAxisAlignment.baseline,
@@ -567,17 +514,6 @@ class _AgentDetailScreenState extends ConsumerState<AgentDetailScreen> {
),
),
const SizedBox(width: 4),
if (agent.isVerified) ...[
const Padding(
padding: EdgeInsets.only(bottom: 1),
child: Icon(
Icons.verified,
size: 16,
color: Color(0xFF2196F3),
),
),
const SizedBox(width: 4),
],
Text(
agent.lastName,
style: const TextStyle(
@@ -588,6 +524,15 @@ class _AgentDetailScreenState extends ConsumerState<AgentDetailScreen> {
),
),
if (agent.isVerified) ...[
const SizedBox(width: 6),
const Padding(
padding: EdgeInsets.only(bottom: 1),
child: Icon(
Icons.verified,
size: 16,
color: Color(0xFF2196F3),
),
),
const SizedBox(width: 6),
const Text(
'(Verified Expert)',
@@ -615,57 +560,142 @@ class _AgentDetailScreenState extends ConsumerState<AgentDetailScreen> {
),
],
const SizedBox(height: 8),
// Location + Member Since
Row(
mainAxisAlignment: MainAxisAlignment.center,
children: [
if (agent.location.isNotEmpty) ...[
Icon(
Icons.location_on,
size: 16,
color: AppColors.primaryDark.withValues(alpha: 0.7),
// Location + Member Since (same line, matching web ProfileCard)
Builder(builder: (_) {
final parts = state.locationParts;
const maxVisible = 1;
final firstPart = parts.isNotEmpty ? parts.first : '';
final remaining = parts.length - maxVisible;
return Row(
mainAxisAlignment: MainAxisAlignment.center,
children: [
if (parts.isNotEmpty) ...[
SvgPicture.asset(
'assets/icons/location_pin_icon.svg',
width: 18,
height: 18,
),
const SizedBox(width: 4),
ConstrainedBox(
constraints: const BoxConstraints(maxWidth: 120),
child: Text(
firstPart,
style: const TextStyle(
fontFamily: 'SourceSerif4',
fontSize: 14,
fontWeight: FontWeight.w700,
color: AppColors.primaryDark,
),
overflow: TextOverflow.ellipsis,
maxLines: 1,
),
),
if (remaining > 0)
GestureDetector(
onTap: () => _showLocationModal(parts),
child: Text(
' +$remaining more',
style: const TextStyle(
fontFamily: 'SourceSerif4',
fontSize: 14,
fontWeight: FontWeight.w500,
color: AppColors.accentOrange,
),
),
),
const SizedBox(width: 12),
],
SvgPicture.asset(
'assets/icons/calendar_icon.svg',
width: 18,
height: 18,
),
const SizedBox(width: 4),
Text(
agent.location,
style: const TextStyle(
fontFamily: 'SourceSerif4',
fontSize: 14,
fontWeight: FontWeight.w700,
color: AppColors.primaryDark,
Flexible(
child: Text(
agent.memberSinceText,
style: const TextStyle(
fontFamily: 'SourceSerif4',
fontSize: 13,
fontWeight: FontWeight.w500,
color: AppColors.primaryDark,
),
overflow: TextOverflow.ellipsis,
maxLines: 1,
),
),
const SizedBox(width: 20),
],
Icon(
Icons.calendar_today,
size: 14,
color: AppColors.primaryDark.withValues(alpha: 0.7),
),
const SizedBox(width: 4),
Text(
agent.memberSinceText,
style: const TextStyle(
fontFamily: 'SourceSerif4',
fontSize: 13,
fontWeight: FontWeight.w500,
color: AppColors.primaryDark,
),
),
],
),
// Bio
if (agent.description.isNotEmpty) ...[
);
}),
// Expertise tags (matching web ProfileCard)
if (state.expertiseTags.isNotEmpty) ...[
const SizedBox(height: 14),
Wrap(
alignment: WrapAlignment.center,
spacing: 8,
runSpacing: 8,
children: state.expertiseTags
.map((tag) => Container(
padding: const EdgeInsets.symmetric(
horizontal: 12, vertical: 5),
decoration: BoxDecoration(
borderRadius: BorderRadius.circular(15),
border: Border.all(
color: AppColors.primaryDark,
width: 0.7,
),
),
child: Text(
tag,
style: const TextStyle(
fontFamily: 'SourceSerif4',
fontSize: 14,
fontWeight: FontWeight.w400,
color: AppColors.primaryDark,
),
),
))
.toList(),
),
],
// Bio with Show More / Show Less
if (state.descriptionText.isNotEmpty) ...[
const SizedBox(height: 16),
SizedBox(
width: 338,
child: Text(
agent.description,
style: const TextStyle(
fontFamily: 'SourceSerif4',
fontSize: 14,
color: AppColors.primaryDark,
height: 1.43,
child: Text.rich(
TextSpan(
children: [
TextSpan(
text: _bioExpanded || state.descriptionText.length <= 150
? state.descriptionText
: '${state.descriptionText.substring(0, 150)}... ',
),
if (state.descriptionText.length > 150)
WidgetSpan(
child: GestureDetector(
onTap: () =>
setState(() => _bioExpanded = !_bioExpanded),
child: Text(
_bioExpanded ? ' Show Less' : 'Show More',
style: const TextStyle(
fontFamily: 'Fractul',
fontSize: 14,
fontWeight: FontWeight.w700,
color: AppColors.primaryDark,
decoration: TextDecoration.underline,
),
),
),
),
],
style: const TextStyle(
fontFamily: 'SourceSerif4',
fontSize: 14,
color: AppColors.primaryDark,
height: 1.43,
),
),
textAlign: TextAlign.center,
),
@@ -702,16 +732,14 @@ class _AgentDetailScreenState extends ConsumerState<AgentDetailScreen> {
),
),
const SizedBox(height: 22),
// Years
if (state.yearsInExperience != '-')
_buildExperienceItem('Years in Experience', [
state.yearsInExperience,
]),
// Contracts
if (state.contractsClosed != '-')
_buildExperienceItem('Number of contracts closed', [
state.contractsClosed,
]),
// Years (always show, matching web)
_buildExperienceItem('Years in Experience', [
state.yearsInExperience,
]),
// Contracts (always show, matching web)
_buildExperienceItem('Number of contracts closed', [
state.contractsClosed,
]),
// Licensing Areas
if (state.licensingAreas.isNotEmpty)
_buildLicensingAreas(state.licensingAreas),
@@ -758,12 +786,14 @@ class _AgentDetailScreenState extends ConsumerState<AgentDetailScreen> {
),
Expanded(
child: Text(
item,
style: const TextStyle(
item == '-' ? 'Not specified' : item,
style: TextStyle(
fontFamily: 'SourceSerif4',
fontSize: 15,
fontWeight: FontWeight.w500,
color: AppColors.primaryDark,
color: item == '-'
? AppColors.primaryDark.withValues(alpha: 0.4)
: AppColors.primaryDark,
),
),
),
@@ -1586,3 +1616,278 @@ class _TrianglePainter extends CustomPainter {
@override
bool shouldRepaint(covariant CustomPainter oldDelegate) => false;
}
// ── Connect Modal Content (matches web ConnectRequestModal) ──
class _ConnectModalContent extends StatefulWidget {
final String agentName;
final TextEditingController controller;
final Future<bool> Function(String? message) onSend;
const _ConnectModalContent({
required this.agentName,
required this.controller,
required this.onSend,
});
@override
State<_ConnectModalContent> createState() => _ConnectModalContentState();
}
class _ConnectModalContentState extends State<_ConnectModalContent> {
bool _isSubmitting = false;
bool _success = false;
String? _error;
void _closeModal() {
if (mounted) {
Navigator.of(context, rootNavigator: false).pop();
}
}
Future<void> _handleSend() async {
setState(() {
_isSubmitting = true;
_error = null;
});
try {
final success = await widget.onSend(widget.controller.text.trim());
if (success) {
setState(() => _success = true);
// Auto-close after 2 seconds (matching web)
Future.delayed(const Duration(seconds: 2), () {
_closeModal();
});
} else {
setState(() => _error = 'Failed to send connection request');
}
} catch (e) {
setState(() => _error = e.toString());
} finally {
if (mounted) setState(() => _isSubmitting = false);
}
}
@override
Widget build(BuildContext context) {
return Padding(
padding: EdgeInsets.only(bottom: MediaQuery.of(context).viewInsets.bottom),
child: Container(
decoration: const BoxDecoration(
color: Colors.white,
borderRadius: BorderRadius.vertical(top: Radius.circular(20)),
),
padding: EdgeInsets.fromLTRB(
24,
24,
24,
24 + MediaQuery.of(context).padding.bottom,
),
child: _success ? _buildSuccessContent() : _buildFormContent(),
),
);
}
Widget _buildSuccessContent() {
return Column(
mainAxisSize: MainAxisSize.min,
children: [
const SizedBox(height: 16),
Container(
width: 64,
height: 64,
decoration: BoxDecoration(
color: const Color(0xFFDCFCE7), // green-100
shape: BoxShape.circle,
),
child: const Icon(
Icons.check,
size: 32,
color: Color(0xFF22C55E), // green-500
),
),
const SizedBox(height: 16),
const Text(
'Request Sent!',
style: TextStyle(
fontFamily: 'Fractul',
fontSize: 18,
fontWeight: FontWeight.w700,
color: AppColors.primaryDark,
),
),
const SizedBox(height: 8),
Text(
'Your connection request has been sent to ${widget.agentName}.',
style: TextStyle(
fontFamily: 'SourceSerif4',
fontSize: 14,
color: AppColors.primaryDark.withValues(alpha: 0.7),
),
textAlign: TextAlign.center,
),
const SizedBox(height: 24),
],
);
}
Widget _buildFormContent() {
return Column(
mainAxisSize: MainAxisSize.min,
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Row(
mainAxisAlignment: MainAxisAlignment.spaceBetween,
children: [
Expanded(
child: Text(
'Connect with ${widget.agentName}',
style: const TextStyle(
fontFamily: 'Fractul',
fontSize: 18,
fontWeight: FontWeight.w700,
color: AppColors.primaryDark,
),
),
),
GestureDetector(
onTap: _closeModal,
child: Icon(
Icons.close,
color: AppColors.primaryDark.withValues(alpha: 0.5),
),
),
],
),
const SizedBox(height: 4),
Text(
'Send a connection request to start communicating with this agent.',
style: TextStyle(
fontFamily: 'SourceSerif4',
fontSize: 14,
color: AppColors.primaryDark.withValues(alpha: 0.7),
),
),
const SizedBox(height: 16),
const Text(
'Message (Optional)',
style: TextStyle(
fontFamily: 'Fractul',
fontSize: 14,
fontWeight: FontWeight.w500,
color: AppColors.primaryDark,
),
),
const SizedBox(height: 8),
TextField(
controller: widget.controller,
maxLines: 4,
maxLength: 1000,
decoration: InputDecoration(
hintText:
'Introduce yourself and explain what you\'re looking for...',
hintStyle: TextStyle(
fontFamily: 'SourceSerif4',
fontSize: 14,
color: AppColors.primaryDark.withValues(alpha: 0.4),
),
border: OutlineInputBorder(
borderRadius: BorderRadius.circular(10),
borderSide: BorderSide(
color: AppColors.primaryDark.withValues(alpha: 0.2),
),
),
enabledBorder: OutlineInputBorder(
borderRadius: BorderRadius.circular(10),
borderSide: BorderSide(
color: AppColors.primaryDark.withValues(alpha: 0.2),
),
),
focusedBorder: OutlineInputBorder(
borderRadius: BorderRadius.circular(10),
borderSide: const BorderSide(color: AppColors.accentOrange),
),
),
),
if (_error != null) ...[
const SizedBox(height: 8),
Container(
width: double.infinity,
padding: const EdgeInsets.all(12),
decoration: BoxDecoration(
color: const Color(0xFFFEF2F2), // red-50
borderRadius: BorderRadius.circular(10),
border: Border.all(color: const Color(0xFFFECACA)), // red-200
),
child: Text(
_error!,
style: const TextStyle(
fontFamily: 'SourceSerif4',
fontSize: 14,
color: Color(0xFFDC2626), // red-600
),
),
),
],
const SizedBox(height: 16),
Row(
children: [
// Cancel button
Expanded(
child: SizedBox(
height: 48,
child: OutlinedButton(
onPressed: _closeModal,
style: OutlinedButton.styleFrom(
side: BorderSide(
color: AppColors.primaryDark.withValues(alpha: 0.2),
),
shape: RoundedRectangleBorder(
borderRadius: BorderRadius.circular(24),
),
),
child: const Text(
'Cancel',
style: TextStyle(
fontFamily: 'Fractul',
fontSize: 14,
fontWeight: FontWeight.w600,
color: AppColors.primaryDark,
),
),
),
),
),
const SizedBox(width: 12),
// Send Request button
Expanded(
child: SizedBox(
height: 48,
child: ElevatedButton(
onPressed: _isSubmitting ? null : _handleSend,
style: ElevatedButton.styleFrom(
backgroundColor: AppColors.accentOrange,
disabledBackgroundColor:
AppColors.accentOrange.withValues(alpha: 0.5),
shape: RoundedRectangleBorder(
borderRadius: BorderRadius.circular(24),
),
),
child: Text(
_isSubmitting ? 'Sending...' : 'Send Request',
style: const TextStyle(
fontFamily: 'Fractul',
fontSize: 14,
fontWeight: FontWeight.w600,
color: Colors.white,
),
),
),
),
),
],
),
],
);
}
}

View File

@@ -549,15 +549,90 @@ class _AgentCardState extends State<_AgentCard> {
),
),
const SizedBox(width: 4),
Text(
agent.location,
style: const TextStyle(
fontFamily: 'SourceSerif4',
fontSize: 14,
fontWeight: FontWeight.w500,
color: AppColors.primaryDark,
),
),
Builder(builder: (_) {
final allParts = <String>[];
// Extract all cities/states from fieldValues arrays
for (final fv in agent.fieldValues) {
final slug = fv.fieldSlug.toLowerCase();
if (slug == 'city' || slug == 'city_name' ||
slug == 'state' || slug == 'state_name') {
if (fv.jsonValue is List) {
for (final v in (fv.jsonValue as List)) {
final s = v.toString().trim();
if (s.isEmpty) continue;
final display = s
.split('_')
.map((w) => w.isNotEmpty
? '${w[0].toUpperCase()}${w.substring(1).toLowerCase()}'
: '')
.join(' ');
if (!allParts.any((p) =>
p.toLowerCase() == display.toLowerCase())) {
allParts.add(display);
}
}
} else if (fv.textValue != null &&
fv.textValue!.isNotEmpty) {
allParts.add(fv.textValue!);
}
}
}
// 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(
@@ -646,6 +721,70 @@ class _AgentCardState extends State<_AgentCard> {
}
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: 10, vertical: 4),