Files
mobile-app/lib/features/agents/presentation/providers/agent_detail_provider.dart

555 lines
17 KiB
Dart
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
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<AgentFieldValue> fieldValues;
final List<Map<String, dynamic>> testimonials;
final Map<String, dynamic>? 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<AgentFieldValue>? fieldValues,
List<Map<String, dynamic>>? testimonials,
Map<String, dynamic>? 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<String> get licensingAreas {
final val = _getFieldValue('licensed_areas');
if (val is List) return val.map((e) => titleCase(e.toString())).toList();
return [];
}
List<Map<String, String>> 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<Map<String, String>> 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<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 state/city arrays → agent model → serviceAreas
/// Order: state first, then city.
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 (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<String>? _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<String> 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<SpecializationCard> get specializationCards {
final cards = <SpecializationCard>[];
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 = <String>[];
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<String> values;
const SpecializationCard({
required this.slug,
required this.name,
required this.values,
});
}
class AgentDetailNotifier extends StateNotifier<AgentDetailState> {
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<void> _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<AgentFieldValue>,
testimonials: results[2] as List<Map<String, dynamic>>,
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<void> 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<AgentFieldValue>,
testimonials: results[2] as List<Map<String, dynamic>>,
isLoading: false,
);
// Also refresh connection status
await loadConnectionStatus();
} catch (_) {}
}
Future<void> loadConnectionStatus() async {
final status = await _repo.getConnectionStatus(agentId);
if (!mounted) return;
state = state.copyWith(connectionStatus: status);
}
Future<bool> 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<bool> 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<AgentDetailNotifier, AgentDetailState, String>((ref, agentId) {
final repo = ref.read(agentsRepositoryProvider);
return AgentDetailNotifier(repo, agentId);
});