diff --git a/assets/icons/calendar_icon.svg b/assets/icons/calendar_icon.svg index 3b01be3..17e180e 100644 --- a/assets/icons/calendar_icon.svg +++ b/assets/icons/calendar_icon.svg @@ -1,3 +1,4 @@ - - + + + diff --git a/assets/icons/location_pin_icon.svg b/assets/icons/location_pin_icon.svg index 8fa58d1..1501495 100644 --- a/assets/icons/location_pin_icon.svg +++ b/assets/icons/location_pin_icon.svg @@ -1,3 +1,3 @@ - - + + diff --git a/lib/features/agents/presentation/providers/agent_detail_provider.dart b/lib/features/agents/presentation/providers/agent_detail_provider.dart index a484e74..b777b9e 100644 --- a/lib/features/agents/presentation/providers/agent_detail_provider.dart +++ b/lib/features/agents/presentation/providers/agent_detail_provider.dart @@ -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 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 get locationParts { + final parts = []; + + // 1. Extract from detail fieldValues (full arrays, not just first item) + List? fvCities; + List? 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? _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 { diff --git a/lib/features/agents/presentation/screens/agent_detail_screen.dart b/lib/features/agents/presentation/screens/agent_detail_screen.dart index d64f9b8..68c975e 100644 --- a/lib/features/agents/presentation/screens/agent_detail_screen.dart +++ b/lib/features/agents/presentation/screens/agent_detail_screen.dart @@ -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 { bool _emailVisible = false; bool _phoneVisible = false; + bool _bioExpanded = false; @override void initState() { @@ -190,7 +192,7 @@ class _AgentDetailScreenState extends ConsumerState { ); } - // ── 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 { 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 { 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 { style: TextStyle( fontFamily: 'Fractul', fontSize: 14, - fontWeight: FontWeight.w400, + fontWeight: FontWeight.w600, color: textColor, ), ), @@ -357,105 +326,83 @@ class _AgentDetailScreenState extends ConsumerState { 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 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 { 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 { ), ), 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 { ), ), 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 { ), ], 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 { ), ), 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 { ), 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 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 _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, + ), + ), + ), + ), + ), + ], + ), + ], + ); + } +} diff --git a/lib/features/agents/presentation/screens/agent_search_screen.dart b/lib/features/agents/presentation/screens/agent_search_screen.dart index bb3e7a8..8538cb2 100644 --- a/lib/features/agents/presentation/screens/agent_search_screen.dart +++ b/lib/features/agents/presentation/screens/agent_search_screen.dart @@ -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 = []; + // 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 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), diff --git a/lib/features/home/presentation/widgets/featured_professionals_section.dart b/lib/features/home/presentation/widgets/featured_professionals_section.dart index 4f637ee..59929e0 100644 --- a/lib/features/home/presentation/widgets/featured_professionals_section.dart +++ b/lib/features/home/presentation/widgets/featured_professionals_section.dart @@ -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( diff --git a/lib/features/profile/presentation/widgets/change_password_tab.dart b/lib/features/profile/presentation/widgets/change_password_tab.dart index 73bfeb3..09977bc 100644 --- a/lib/features/profile/presentation/widgets/change_password_tab.dart +++ b/lib/features/profile/presentation/widgets/change_password_tab.dart @@ -129,7 +129,6 @@ class _ChangePasswordTabState extends ConsumerState { TextField( controller: controller, obscureText: obscure, - textAlignVertical: TextAlignVertical.center, style: const TextStyle( fontFamily: 'SourceSerif4', fontSize: 14, @@ -137,9 +136,8 @@ class _ChangePasswordTabState extends ConsumerState { 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, diff --git a/lib/features/profile/presentation/widgets/profile_settings_tab.dart b/lib/features/profile/presentation/widgets/profile_settings_tab.dart index fa91016..c0b26e6 100644 --- a/lib/features/profile/presentation/widgets/profile_settings_tab.dart +++ b/lib/features/profile/presentation/widgets/profile_settings_tab.dart @@ -134,7 +134,7 @@ class _ProfileSettingsTabState extends ConsumerState { color: AppColors.primaryDark, ), ), - const SizedBox(height: 39), + const SizedBox(height: 20), // Avatar Center( @@ -231,7 +231,7 @@ class _ProfileSettingsTabState extends ConsumerState { ], ), ), - 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 { 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', diff --git a/lib/features/profile/presentation/widgets/profile_shared_widgets.dart b/lib/features/profile/presentation/widgets/profile_shared_widgets.dart index b1d636b..7d1c72c 100644 --- a/lib/features/profile/presentation/widgets/profile_shared_widgets.dart +++ b/lib/features/profile/presentation/widgets/profile_shared_widgets.dart @@ -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, ), ), ),