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

@@ -1,3 +1,4 @@
<svg width="24" height="24" viewBox="0 0 24 24" fill="none" xmlns="http://www.w3.org/2000/svg">
<path d="M19 4H18V2H16V4H8V2H6V4H5C3.89 4 3 4.9 3 6V20C3 21.1 3.89 22 5 22H19C20.1 22 21 21.1 21 20V6C21 4.9 20.1 4 19 4ZM19 20H5V10H19V20ZM19 8H5V6H19V8Z" fill="currentColor"/>
<svg width="23" height="21" viewBox="0 0 23 21" fill="none" xmlns="http://www.w3.org/2000/svg">
<path d="M6.46581 0.75C6.46581 0.551088 6.37692 0.360322 6.21868 0.21967C6.06045 0.0790177 5.84584 0 5.62206 0C5.39829 0 5.18367 0.0790177 5.02544 0.21967C4.86721 0.360322 4.77831 0.551088 4.77831 0.75V2.33C3.15831 2.445 2.09631 2.727 1.31556 3.422C0.533687 4.116 0.216437 5.061 0.0859375 6.5H22.4082C22.2777 5.06 21.9604 4.116 21.1786 3.422C20.3978 2.727 19.3347 2.445 17.7158 2.329V0.75C17.7158 0.551088 17.6269 0.360322 17.4687 0.21967C17.3104 0.0790177 17.0958 0 16.8721 0C16.6483 0 16.4337 0.0790177 16.2754 0.21967C16.1172 0.360322 16.0283 0.551088 16.0283 0.75V2.263C15.2802 2.25 14.4409 2.25 13.4971 2.25H8.99706C8.05319 2.25 7.21394 2.25 6.46581 2.263V0.75Z" fill="#00293D"/>
<path fill-rule="evenodd" clip-rule="evenodd" d="M0 10.25C0 9.411 1.25729e-08 8.665 0.014625 8H22.4854C22.5 8.665 22.5 9.411 22.5 10.25V12.25C22.5 16.021 22.5 17.907 21.1815 19.078C19.863 20.249 17.7424 20.25 13.5 20.25H9C4.75763 20.25 2.63587 20.25 1.3185 19.078C0.00112492 17.906 0 16.021 0 12.25V10.25ZM16.875 12.25C17.1734 12.25 17.4595 12.1446 17.6705 11.9571C17.8815 11.7696 18 11.5152 18 11.25C18 10.9848 17.8815 10.7304 17.6705 10.5429C17.4595 10.3554 17.1734 10.25 16.875 10.25C16.5766 10.25 16.2905 10.3554 16.0795 10.5429C15.8685 10.7304 15.75 10.9848 15.75 11.25C15.75 11.5152 15.8685 11.7696 16.0795 11.9571C16.2905 12.1446 16.5766 12.25 16.875 12.25ZM16.875 16.25C17.1734 16.25 17.4595 16.1446 17.6705 15.9571C17.8815 15.7696 18 15.5152 18 15.25C18 14.9848 17.8815 14.7304 17.6705 14.5429C17.4595 14.3554 17.1734 14.25 16.875 14.25C16.5766 14.25 16.2905 14.3554 16.0795 14.5429C15.8685 14.7304 15.75 14.9848 15.75 15.25C15.75 15.5152 15.8685 15.7696 16.0795 15.9571C16.2905 16.1446 16.5766 16.25 16.875 16.25ZM12.375 11.25C12.375 11.5152 12.2565 11.7696 12.0455 11.9571C11.8345 12.1446 11.5484 12.25 11.25 12.25C10.9516 12.25 10.6655 12.1446 10.4545 11.9571C10.2435 11.7696 10.125 11.5152 10.125 11.25C10.125 10.9848 10.2435 10.7304 10.4545 10.5429C10.6655 10.3554 10.9516 10.25 11.25 10.25C11.5484 10.25 11.8345 10.3554 12.0455 10.5429C12.2565 10.7304 12.375 10.9848 12.375 11.25ZM12.375 15.25C12.375 15.5152 12.2565 15.7696 12.0455 15.9571C11.8345 16.1446 11.5484 16.25 11.25 16.25C10.9516 16.25 10.6655 16.1446 10.4545 15.9571C10.2435 15.7696 10.125 15.5152 10.125 15.25C10.125 14.9848 10.2435 14.7304 10.4545 14.5429C10.6655 14.3554 10.9516 14.25 11.25 14.25C11.5484 14.25 11.8345 14.3554 12.0455 14.5429C12.2565 14.7304 12.375 14.9848 12.375 15.25ZM5.625 12.25C5.92337 12.25 6.20952 12.1446 6.4205 11.9571C6.63147 11.7696 6.75 11.5152 6.75 11.25C6.75 10.9848 6.63147 10.7304 6.4205 10.5429C6.20952 10.3554 5.92337 10.25 5.625 10.25C5.32663 10.25 5.04048 10.3554 4.8295 10.5429C4.61853 10.7304 4.5 10.9848 4.5 11.25C4.5 11.5152 4.61853 11.7696 4.8295 11.9571C5.04048 12.1446 5.32663 12.25 5.625 12.25ZM5.625 16.25C5.92337 16.25 6.20952 16.1446 6.4205 15.9571C6.63147 15.7696 6.75 15.5152 6.75 15.25C6.75 14.9848 6.63147 14.7304 6.4205 14.5429C6.20952 14.3554 5.92337 14.25 5.625 14.25C5.32663 14.25 5.04048 14.3554 4.8295 14.5429C4.61853 14.7304 4.5 14.9848 4.5 15.25C4.5 15.5152 4.61853 15.7696 4.8295 15.9571C5.04048 16.1446 5.32663 16.25 5.625 16.25Z" fill="#00293D"/>
</svg>

Before

Width:  |  Height:  |  Size: 283 B

After

Width:  |  Height:  |  Size: 3.2 KiB

View File

@@ -1,3 +1,3 @@
<svg preserveAspectRatio="none" width="100%" height="100%" overflow="visible" style="display: block;" viewBox="0 0 14.3648 20" fill="none" xmlns="http://www.w3.org/2000/svg">
<path id="Vector" d="M7.18241 9.5C6.50209 9.5 5.84964 9.23661 5.36858 8.76777C4.88752 8.29893 4.61726 7.66304 4.61726 7C4.61726 6.33696 4.88752 5.70107 5.36858 5.23223C5.84964 4.76339 6.50209 4.5 7.18241 4.5C7.86273 4.5 8.51518 4.76339 8.99624 5.23223C9.4773 5.70107 9.74756 6.33696 9.74756 7C9.74756 7.3283 9.68121 7.65339 9.5523 7.95671C9.42339 8.26002 9.23444 8.53562 8.99624 8.76777C8.75805 8.99991 8.47527 9.18406 8.16405 9.3097C7.85283 9.43534 7.51927 9.5 7.18241 9.5ZM7.18241 0C5.27752 0 3.45064 0.737498 2.10368 2.05025C0.756716 3.36301 0 5.14348 0 7C0 12.25 7.18241 20 7.18241 20C7.18241 20 14.3648 12.25 14.3648 7C14.3648 5.14348 13.6081 3.36301 12.2611 2.05025C10.9142 0.737498 9.0873 0 7.18241 0Z" fill="#E58625"/>
<svg width="21" height="19" viewBox="0 0 21 19" fill="none" xmlns="http://www.w3.org/2000/svg">
<path d="M19.8539 0.00561027L19.9634 0L20.0978 0.00748022L20.1908 0.0224409L20.3179 0.0551673L20.4285 0.0981791L20.5318 0.151476L20.6249 0.214124L20.7096 0.284252L20.7633 0.339419L20.8481 0.447884L20.9018 0.537648C20.95 0.631151 20.9811 0.729642 20.9948 0.83312L21 0.932234C21 1.00267 20.9917 1.07124 20.9752 1.13794L20.939 1.25295L14.1884 18.1557C14.0605 18.4077 13.8552 18.6212 13.5971 18.7709C13.339 18.9206 13.0389 19.0001 12.7323 19C12.4562 19.0005 12.1847 18.9365 11.9441 18.8141C11.7034 18.6917 11.5018 18.5152 11.3588 18.3015L11.2916 18.1828L7.82746 11.9161L0.934252 8.79685C0.679194 8.69131 0.459233 8.52703 0.296369 8.32044C0.133505 8.11385 0.0334469 7.87219 0.00620083 7.61963L0 7.48031C0 6.95669 0.311073 6.47795 0.869144 6.19931L1.01383 6.13386L19.6338 0.050492L19.7433 0.0224409L19.8539 0.00561027Z" fill="#00293D"/>
</svg>

Before

Width:  |  Height:  |  Size: 909 B

After

Width:  |  Height:  |  Size: 934 B

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,86 +200,56 @@ 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,
),
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,
child: Container(
height: 50,
decoration: BoxDecoration(
borderRadius: BorderRadius.circular(15),
border: Border.all(
color: AppColors.primaryDark.withValues(alpha: 0.15),
color: AppColors.primaryDark.withValues(alpha: 0.1),
width: 1,
),
),
child: Row(
children: [
const SizedBox(width: 20),
const SizedBox(width: 16),
// Status dot
Container(
width: 14,
width: 12,
height: 12,
decoration: BoxDecoration(
shape: BoxShape.circle,
color: active
? (isAvailable ? const Color(0xFF4CAF50) : Colors.transparent)
: Colors.transparent,
color: isAvailable
? const Color(0xFF22C55E)
: Colors.white,
border: Border.all(
color: active
? (isAvailable
? const Color(0xFF4CAF50)
: AppColors.primaryDark.withValues(alpha: 0.3))
: AppColors.primaryDark.withValues(alpha: 0.15),
width: 2,
color: isAvailable
? const Color(0xFF22C55E)
: AppColors.primaryDark.withValues(alpha: 0.3),
width: 1.5,
),
),
),
const SizedBox(width: 12),
const SizedBox(width: 8),
Text(
label,
style: TextStyle(
isAvailable ? 'Available.' : 'Unavailable.',
style: const TextStyle(
fontFamily: 'Fractul',
fontSize: 12,
fontSize: 14,
fontWeight: FontWeight.w700,
color: active
? AppColors.primaryDark
: AppColors.primaryDark.withValues(alpha: 0.4),
color: AppColors.primaryDark,
),
),
const Spacer(),
// Connect button
if (active) _buildConnectButton(connState, isAvailable, onConnect),
// 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,20 +326,33 @@ 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(
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,
24, 24, 24, 24 + MediaQuery.of(ctx).padding.bottom,
),
child: Column(
mainAxisSize: MainAxisSize.min,
@@ -379,9 +361,9 @@ class _AgentDetailScreenState extends ConsumerState<AgentDetailScreen> {
Row(
mainAxisAlignment: MainAxisAlignment.spaceBetween,
children: [
Text(
'Connect with ${state.agent?.fullName ?? ''}',
style: const TextStyle(
const Text(
'Service Areas',
style: TextStyle(
fontFamily: 'Fractul',
fontSize: 18,
fontWeight: FontWeight.w700,
@@ -389,75 +371,40 @@ class _AgentDetailScreenState extends ConsumerState<AgentDetailScreen> {
),
),
GestureDetector(
onTap: () => Navigator.pop(ctx),
child: const Icon(
onTap: () => Navigator.of(ctx).pop(),
child: Icon(
Icons.close,
color: AppColors.primaryDark,
color: AppColors.primaryDark.withValues(alpha: 0.5),
),
),
],
),
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(
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),
borderSide: BorderSide(
color: AppColors.primaryDark.withValues(alpha: 0.15),
),
),
),
),
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,
),
),
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,35 +560,60 @@ class _AgentDetailScreenState extends ConsumerState<AgentDetailScreen> {
),
],
const SizedBox(height: 8),
// Location + Member Since
Row(
// 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 (agent.location.isNotEmpty) ...[
Icon(
Icons.location_on,
size: 16,
color: AppColors.primaryDark.withValues(alpha: 0.7),
if (parts.isNotEmpty) ...[
SvgPicture.asset(
'assets/icons/location_pin_icon.svg',
width: 18,
height: 18,
),
const SizedBox(width: 4),
Text(
agent.location,
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,
),
const SizedBox(width: 20),
),
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),
],
Icon(
Icons.calendar_today,
size: 14,
color: AppColors.primaryDark.withValues(alpha: 0.7),
SvgPicture.asset(
'assets/icons/calendar_icon.svg',
width: 18,
height: 18,
),
const SizedBox(width: 4),
Text(
Flexible(
child: Text(
agent.memberSinceText,
style: const TextStyle(
fontFamily: 'SourceSerif4',
@@ -651,22 +621,82 @@ class _AgentDetailScreenState extends ConsumerState<AgentDetailScreen> {
fontWeight: FontWeight.w500,
color: AppColors.primaryDark,
),
overflow: TextOverflow.ellipsis,
maxLines: 1,
),
),
],
);
}),
// 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,
),
// Bio
if (agent.description.isNotEmpty) ...[
),
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.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(
agent.description,
_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,13 +732,11 @@ class _AgentDetailScreenState extends ConsumerState<AgentDetailScreen> {
),
),
const SizedBox(height: 22),
// Years
if (state.yearsInExperience != '-')
// Years (always show, matching web)
_buildExperienceItem('Years in Experience', [
state.yearsInExperience,
]),
// Contracts
if (state.contractsClosed != '-')
// Contracts (always show, matching web)
_buildExperienceItem('Number of contracts closed', [
state.contractsClosed,
]),
@@ -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,
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),

View File

@@ -79,8 +79,9 @@ class _FeaturedProfessionalsSectionState
height: 192,
decoration: const BoxDecoration(
color: Colors.white,
borderRadius:
BorderRadius.vertical(top: Radius.circular(15)),
borderRadius: BorderRadius.vertical(
top: Radius.circular(15),
),
),
),
Padding(
@@ -147,7 +148,7 @@ class _FeaturedProfessionalsSectionState
left: index == 0 ? 0 : 8,
right: index == professionals.length - 1 ? 0 : 8,
),
child: _buildFeaturedCard(professionals[index], index),
child: Container(),
),
);
},
@@ -180,8 +181,7 @@ class _FeaturedProfessionalsSectionState
children: [
// Image
ClipRRect(
borderRadius:
const BorderRadius.vertical(top: Radius.circular(15)),
borderRadius: const BorderRadius.vertical(top: Radius.circular(15)),
child: _buildCardImage(agent, index),
),
@@ -233,9 +233,9 @@ class _FeaturedProfessionalsSectionState
size: 16,
),
),
const SizedBox(width: 6),
const SizedBox(width: 4),
const Text(
'\u201CVerified local agent\u201D',
'Verified Agent',
style: TextStyle(
fontFamily: 'SourceSerif4',
fontSize: 14,
@@ -243,8 +243,9 @@ class _FeaturedProfessionalsSectionState
color: Color(0xFF638559),
),
),
const SizedBox(width: 16),
],
if (agent.isVerified && ratingText.isNotEmpty)
const SizedBox(width: 16),
if (ratingText.isNotEmpty) ...[
SvgPicture.asset(
'assets/icons/star_rating_icon.svg',
@@ -276,14 +277,9 @@ class _FeaturedProfessionalsSectionState
Row(
children: [
SvgPicture.asset(
'assets/icons/location_filled_icon.svg',
width: 19,
height: 19,
placeholderBuilder: (_) => const Icon(
Icons.location_on,
color: AppColors.accentOrange,
size: 19,
),
'assets/icons/location_pin_icon.svg',
width: 16,
height: 16,
),
const SizedBox(width: 6),
Expanded(

View File

@@ -129,7 +129,6 @@ class _ChangePasswordTabState extends ConsumerState<ChangePasswordTab> {
TextField(
controller: controller,
obscureText: obscure,
textAlignVertical: TextAlignVertical.center,
style: const TextStyle(
fontFamily: 'SourceSerif4',
fontSize: 14,
@@ -137,9 +136,8 @@ class _ChangePasswordTabState extends ConsumerState<ChangePasswordTab> {
color: AppColors.primaryDark,
),
decoration: InputDecoration(
isDense: true,
contentPadding:
const EdgeInsets.fromLTRB(12, 12, 8, 12),
const EdgeInsets.symmetric(horizontal: 12, vertical: 14),
border: border,
enabledBorder: border,
focusedBorder: border,

View File

@@ -134,7 +134,7 @@ class _ProfileSettingsTabState extends ConsumerState<ProfileSettingsTab> {
color: AppColors.primaryDark,
),
),
const SizedBox(height: 39),
const SizedBox(height: 20),
// Avatar
Center(
@@ -231,7 +231,7 @@ class _ProfileSettingsTabState extends ConsumerState<ProfileSettingsTab> {
],
),
),
const SizedBox(height: 49),
const SizedBox(height: 24),
ProfileFormField(label: 'Full Name', controller: _fullNameController),
const SizedBox(height: 24),
@@ -244,12 +244,14 @@ class _ProfileSettingsTabState extends ConsumerState<ProfileSettingsTab> {
ProfileFormField(
label: 'Email Address',
controller: _emailController,
enabled: false),
enabled: false,
textCapitalization: TextCapitalization.none),
const SizedBox(height: 24),
ProfileFormField(
label: 'Phone Number',
controller: _phoneController,
keyboardType: TextInputType.phone),
keyboardType: TextInputType.phone,
textCapitalization: TextCapitalization.none),
const SizedBox(height: 24),
ProfileFormField(
label: isAgent ? 'Service Areas' : 'Location',

View File

@@ -7,6 +7,9 @@ class ProfileFormField extends StatelessWidget {
final bool enabled;
final TextInputType? keyboardType;
final IconData? prefixIcon;
final TextCapitalization textCapitalization;
final bool obscureText;
final Widget? suffixIcon;
const ProfileFormField({
super.key,
@@ -15,6 +18,9 @@ class ProfileFormField extends StatelessWidget {
this.enabled = true,
this.keyboardType,
this.prefixIcon,
this.textCapitalization = TextCapitalization.words,
this.obscureText = false,
this.suffixIcon,
});
@override
@@ -45,6 +51,9 @@ class ProfileFormField extends StatelessWidget {
controller: controller,
enabled: enabled,
keyboardType: keyboardType,
textCapitalization: textCapitalization,
obscureText: obscureText,
textAlignVertical: TextAlignVertical.center,
style: const TextStyle(
fontFamily: 'SourceSerif4',
fontSize: 14,
@@ -52,8 +61,9 @@ class ProfileFormField extends StatelessWidget {
color: AppColors.primaryDark,
),
decoration: InputDecoration(
isDense: true,
contentPadding:
const EdgeInsets.symmetric(horizontal: 26, vertical: 8),
const EdgeInsets.symmetric(horizontal: 12, vertical: 10),
border: border,
enabledBorder: border,
focusedBorder: border,
@@ -64,6 +74,7 @@ class ProfileFormField extends StatelessWidget {
prefixIconConstraints: prefixIcon != null
? const BoxConstraints(minWidth: 40)
: null,
suffixIcon: suffixIcon,
),
),
),