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

452 lines
14 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 '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';
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 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 {
final val = _getFieldValue('email') ??
_getFieldValue('email_address') ??
agent?.email ??
agent?.user?.email;
return val?.toString();
}
String? get contactPhone {
final val = _getFieldValue('phone_number') ??
_getFieldValue('phone') ??
_getFieldValue('cell_number') ??
_getFieldValue('office_number') ??
agent?.phone;
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)}';
}
// ── 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;
AgentDetailNotifier(this._repo, this.agentId)
: super(const AgentDetailState()) {
_load();
}
Future<void> _load() async {
try {
final results = await Future.wait([
_repo.getAgentById(agentId),
_repo.getFieldValues(agentId),
_repo.getTestimonials(agentId),
]);
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) {
state = state.copyWith(isLoading: false, error: e.toString());
}
}
Future<void> refresh() async {
try {
final results = await Future.wait([
_repo.getAgentById(agentId),
_repo.getFieldValues(agentId),
_repo.getTestimonials(agentId),
]);
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);
state = state.copyWith(connectionStatus: status);
}
Future<bool> connect({String? message}) async {
final success =
await _repo.createConnectionRequest(agentId, message: message);
if (success) {
state = state.copyWith(
connectionStatus: {'status': 'PENDING'},
);
}
return success;
}
Future<bool> cancelConnection() async {
final requestId = state.connectionRequestId;
if (requestId == null) return false;
final success = await _repo.cancelConnectionRequest(requestId);
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);
});