import 'dart:async'; import 'package:flutter_riverpod/flutter_riverpod.dart'; import 'package:real_estate_mobile/features/agents/data/agents_repository.dart'; import 'package:real_estate_mobile/features/agents/data/models/agent_profile.dart'; import 'package:real_estate_mobile/features/agents/presentation/providers/agents_provider.dart'; import 'package:real_estate_mobile/features/messaging/data/socket_service.dart'; class AgentDetailState { final AgentProfile? agent; final List fieldValues; final List> testimonials; final Map? connectionStatus; final bool isLoading; final String? error; const AgentDetailState({ this.agent, this.fieldValues = const [], this.testimonials = const [], this.connectionStatus, this.isLoading = true, this.error, }); AgentDetailState copyWith({ AgentProfile? agent, List? fieldValues, List>? testimonials, Map? connectionStatus, bool? isLoading, String? error, bool clearError = false, bool clearConnection = false, }) { return AgentDetailState( agent: agent ?? this.agent, fieldValues: fieldValues ?? this.fieldValues, testimonials: testimonials ?? this.testimonials, connectionStatus: clearConnection ? null : (connectionStatus ?? this.connectionStatus), isLoading: isLoading ?? this.isLoading, error: clearError ? null : (error ?? this.error), ); } // ── Mapped data helpers (matching web profileDataMapper.ts) ── /// Get field value by slug dynamic _getFieldValue(String slug) { for (final fv in fieldValues) { if (fv.fieldSlug == slug) { if (fv.jsonValue != null) return fv.jsonValue; if (fv.textValue != null) return fv.textValue; if (fv.numberValue != null) return fv.numberValue; return null; } } return null; } // ── Experience data ── String get yearsInExperience { final val = _getFieldValue('years_in_business'); if (val == null) return '-'; const mapping = { '<2': 'Less than 2 years', '2+': '2+ years', '5+': '5+ years', '10+': '10+ years', }; return mapping[val.toString()] ?? val.toString(); } String get contractsClosed { final val = _getFieldValue('contracts_completed'); if (val == null) return '-'; 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'; if (num >= 20) return '20+ Contracts'; if (num >= 10) return '10+ Contracts'; if (num >= 5) return '5+ Contracts'; return '$num Contract${num != 1 ? 's' : ''}'; } List get licensingAreas { final val = _getFieldValue('licensed_areas'); if (val is List) return val.map((e) => titleCase(e.toString())).toList(); return []; } List> get expertiseYears { final val = _getFieldValue('expertise_areas'); if (val is! List) return []; return val.map((item) { final str = item.toString(); final match = RegExp(r'^(.+?)\s*[-–]\s*(\d+)\s*(yrs?|years?)$', caseSensitive: false) .firstMatch(str); if (match != null) { return {'area': match.group(1)!.trim(), 'years': '${match.group(2)} yrs'}; } return {'area': str.trim(), 'years': ''}; }).toList(); } List> get certifications { final val = _getFieldValue('certification_entries'); if (val is! List) return []; return val .where((e) => e is Map && e['certification_name'] != null) .map((e) => { 'name': (e['certification_name'] ?? '').toString(), 'org': (e['issuing_organization'] ?? '').toString(), }) .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 state/city arrays → agent model → serviceAreas /// Order: state first, then city. 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 (fvStates != null) parts.addAll(fvStates.map(_normalizeStateCode)); if (fvCities != null) { for (final c in fvCities) { if (!parts.any((p) => p.toLowerCase() == c.toLowerCase())) { parts.add(c); } } } // 2. Fallback: agent model direct state/city if (parts.isEmpty && agent != null) { if (agent!.state != null && agent!.state!.isNotEmpty) { parts.add(_normalizeStateCode(agent!.state!)); } if (agent!.city != null && agent!.city!.isNotEmpty) { parts.add(agent!.city!); } } // 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; } /// Uppercase 2-letter US state codes (e.g. "co" → "CO"); leave full state names alone. String _normalizeStateCode(String raw) { final trimmed = raw.trim(); if (trimmed.length == 2) return trimmed.toUpperCase(); return trimmed; } /// 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. /// For city fields, strip the 2-letter state prefix before title-casing /// (matches web's `stripCityPrefix`: "ne_adams" → "Adams"). static List? _extractAllValuesAsList(AgentFieldValue fv) { final isCity = (fv.fieldSlug.toLowerCase() == 'city' || fv.fieldSlug.toLowerCase() == 'city_name'); String format(String raw) { var s = raw; if (isCity) { // Strip 2-letter state prefix: "ne_adams" → "adams", "co_aetna_estates" → "aetna_estates" s = s.replaceFirst(RegExp(r'^[a-z]{2}_', caseSensitive: false), ''); } return titleCase(s); } if (fv.jsonValue is List && (fv.jsonValue as List).isNotEmpty) { return (fv.jsonValue as List) .map((v) => format(v.toString())) .toList(); } if (fv.textValue != null && fv.textValue!.isNotEmpty) { return [format(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) ── /// Contact email — matches web priority: agentProfile.email first, /// then user login email, then dynamic field values as fallback. String? get contactEmail { final profileEmail = agent?.email; if (profileEmail != null && profileEmail.isNotEmpty) return profileEmail; final userEmail = agent?.user?.email; if (userEmail != null && userEmail.isNotEmpty) return userEmail; final val = _getFieldValue('email') ?? _getFieldValue('email_address'); return val?.toString(); } /// Contact phone — matches web priority: agentProfile.phone first, /// then dynamic field values as fallback. String? get contactPhone { final profilePhone = agent?.phone; if (profilePhone != null && profilePhone.isNotEmpty) return profilePhone; final val = _getFieldValue('phone_number') ?? _getFieldValue('phone') ?? _getFieldValue('cell_number') ?? _getFieldValue('office_number'); return val?.toString(); } String maskEmail(String email) { final parts = email.split('@'); if (parts.length != 2) return email; final prefix = parts[0].length > 2 ? parts[0].substring(0, 2) : parts[0]; return '$prefix********@${parts[1]}'; } String maskPhone(String phone) { final digits = phone.replaceAll(RegExp(r'[^\d]'), ''); if (digits.length <= 4) return '****$digits'; return '${'*' * (digits.length - 4)}${digits.substring(digits.length - 4)}'; } // ── Dynamic labels (fieldName from backend, with fallback) ── String _labelForSlug(String slug, String fallback) { for (final fv in fieldValues) { if (fv.fieldSlug == slug && fv.fieldName.isNotEmpty) { return fv.fieldName; } } return fallback; } String get availabilityLabel => _labelForSlug('availability', 'Availability'); String get workEnvironmentLabel => _labelForSlug('preferred_work_environment', 'Preferred Work Environment'); String get bestExperienceLabel => _labelForSlug('best_experience', 'Best Experience'); // Experience section labels (matches web mapFieldValuesToExperience) String get yearsInExperienceLabel => _labelForSlug('years_in_business', 'Years in Experience'); String get contractsClosedLabel => _labelForSlug('contracts_completed', 'Number of contracts closed'); String get licensingAreasLabel => _labelForSlug('licensed_areas', 'Licensing & Areas'); String get expertiseYearsLabel => _labelForSlug('expertise_areas', 'Areas in expertise & Years'); String get certificationsLabel => _labelForSlug('certification_entries', 'Certifications'); // ── Availability ── String get availabilityType { final val = _getFieldValue('availability'); if (val is! List || val.isEmpty) return ''; final values = val.map((e) => e.toString()).toList(); if (values.contains('full_time') || values.contains('mf_9_5') || values.contains('mf_8_6')) return 'Full-time'; if (values.contains('part_time')) return 'Part-time'; if (values.contains('flexible')) return 'Flexible'; return 'Available'; } List get availabilitySchedule { final val = _getFieldValue('availability'); if (val is! List) return []; const labels = { 'mf_9_5': 'Monday - Friday, 9 AM - 5 PM', 'mf_8_6': 'Monday - Friday, 8 AM - 6 PM', 'weekends': 'Weekends', 'evenings': 'Evenings', 'flexible': 'Flexible Schedule', 'full_time': 'Full-time', 'part_time': 'Part-time', '24_7': '24/7 Available', 'by_appointment': 'By Appointment Only', 'monday': 'Monday', 'tuesday': 'Tuesday', 'wednesday': 'Wednesday', 'thursday': 'Thursday', 'friday': 'Friday', 'saturday': 'Saturday', 'sunday': 'Sunday', }; return val.map((v) => labels[v.toString()] ?? titleCase(v.toString())).toList(); } // ── Specialization fields ── List get specializationCards { final cards = []; for (final fv in fieldValues) { final section = (fv.sectionSlug ?? '').toLowerCase(); if (!section.contains('specialization')) continue; if (fv.fieldSlug.isEmpty || fv.fieldName.isEmpty) continue; final values = []; if (fv.jsonValue is List) { for (final v in (fv.jsonValue as List)) { final s = v.toString().trim(); if (s.isNotEmpty) values.add(s); } } else if (fv.textValue != null && fv.textValue!.trim().isNotEmpty) { values.add(fv.textValue!); } if (values.isEmpty) continue; cards.add(SpecializationCard( slug: fv.fieldSlug, name: fv.fieldName, values: values, )); } return cards; } // ── Connection status helpers ── String get connectionState { if (connectionStatus == null) return 'none'; final status = connectionStatus!['status']?.toString().toUpperCase(); if (status == 'PENDING') return 'pending'; if (status == 'ACCEPTED') return 'accepted'; return 'none'; } String? get connectionRequestId => connectionStatus?['id']?.toString(); static String titleCase(String s) { return s .split('_') .map((w) => w.isNotEmpty ? '${w[0].toUpperCase()}${w.substring(1).toLowerCase()}' : '') .join(' '); } } class SpecializationCard { final String slug; final String name; final List values; const SpecializationCard({ required this.slug, required this.name, required this.values, }); } class AgentDetailNotifier extends StateNotifier { final AgentsRepository _repo; final String agentId; StreamSubscription? _connectionResponseSub; AgentDetailNotifier(this._repo, this.agentId) : super(const AgentDetailState()) { // Defer the network burst to the next microtask so the agent detail // screen's first frame (loading placeholder) paints before the three // parallel HTTP requests kick off — smoother slide-in transition. Future.microtask(_load); // Listen for real-time connection response (accepted/rejected) _connectionResponseSub = SocketService().onConnectionResponse.listen((_) { if (mounted) loadConnectionStatus(); }); } @override void dispose() { _connectionResponseSub?.cancel(); super.dispose(); } Future _load() async { try { final results = await Future.wait([ _repo.getAgentById(agentId), _repo.getFieldValues(agentId), _repo.getTestimonials(agentId), ]); if (!mounted) return; state = state.copyWith( agent: results[0] as AgentProfile, fieldValues: results[1] as List, testimonials: results[2] as List>, isLoading: false, ); } catch (e) { if (!mounted) return; final errStr = e.toString(); if (errStr.contains('403') || errStr.contains('Forbidden') || errStr.contains('permission') || errStr.contains('only visible')) { state = state.copyWith(isLoading: false, error: 'PERMISSION_DENIED'); } else { state = state.copyWith(isLoading: false, error: errStr); } } } Future refresh() async { try { final results = await Future.wait([ _repo.getAgentById(agentId), _repo.getFieldValues(agentId), _repo.getTestimonials(agentId), ]); if (!mounted) return; state = state.copyWith( agent: results[0] as AgentProfile, fieldValues: results[1] as List, testimonials: results[2] as List>, isLoading: false, ); // Also refresh connection status await loadConnectionStatus(); } catch (_) {} } Future loadConnectionStatus() async { final status = await _repo.getConnectionStatus(agentId); if (!mounted) return; state = state.copyWith(connectionStatus: status); } Future connect({String? message}) async { final success = await _repo.createConnectionRequest(agentId, message: message); if (!mounted) return success; if (success) { state = state.copyWith( connectionStatus: {'status': 'PENDING'}, ); } return success; } void updateAvailability(bool isAvailable) { if (state.agent == null) return; state = state.copyWith( agent: state.agent!.copyWith(isAvailable: isAvailable), ); } Future cancelConnection() async { final requestId = state.connectionRequestId; if (requestId == null) return false; final success = await _repo.cancelConnectionRequest(requestId); if (!mounted) return success; if (success) { state = state.copyWith(clearConnection: true); } return success; } } final agentDetailProvider = StateNotifierProvider.autoDispose .family((ref, agentId) { final repo = ref.read(agentsRepositoryProvider); return AgentDetailNotifier(repo, agentId); });