2026-03-26 09:46:40 +05:30
|
|
|
|
import 'dart:async';
|
|
|
|
|
|
|
2026-03-08 00:41:44 +05:30
|
|
|
|
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';
|
2026-03-26 09:46:40 +05:30
|
|
|
|
import 'package:real_estate_mobile/features/messaging/data/socket_service.dart';
|
2026-03-08 00:41:44 +05:30
|
|
|
|
|
|
|
|
|
|
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 '-';
|
2026-03-14 20:59:47 +05:30
|
|
|
|
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;
|
2026-03-08 00:41:44 +05:30
|
|
|
|
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();
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2026-03-14 20:59:47 +05:30
|
|
|
|
// ── 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).
|
2026-04-11 22:52:13 +05:30
|
|
|
|
/// Sources: detail fieldValues state/city arrays → agent model → serviceAreas
|
|
|
|
|
|
/// Order: state first, then city.
|
2026-03-14 20:59:47 +05:30
|
|
|
|
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);
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
2026-04-15 15:54:43 +05:30
|
|
|
|
if (fvStates != null) parts.addAll(fvStates.map(_normalizeStateCode));
|
2026-04-11 22:52:13 +05:30
|
|
|
|
if (fvCities != null) {
|
|
|
|
|
|
for (final c in fvCities) {
|
2026-04-18 11:22:27 +05:30
|
|
|
|
if (!parts.any((p) => p.toLowerCase() == c.toLowerCase())) {
|
|
|
|
|
|
parts.add(c);
|
2026-03-14 20:59:47 +05:30
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2026-04-11 22:52:13 +05:30
|
|
|
|
// 2. Fallback: agent model direct state/city
|
2026-03-14 20:59:47 +05:30
|
|
|
|
if (parts.isEmpty && agent != null) {
|
|
|
|
|
|
if (agent!.state != null && agent!.state!.isNotEmpty) {
|
2026-04-15 15:54:43 +05:30
|
|
|
|
parts.add(_normalizeStateCode(agent!.state!));
|
2026-03-14 20:59:47 +05:30
|
|
|
|
}
|
2026-04-11 22:52:13 +05:30
|
|
|
|
if (agent!.city != null && agent!.city!.isNotEmpty) {
|
2026-04-18 11:22:27 +05:30
|
|
|
|
parts.add(agent!.city!);
|
2026-04-11 22:52:13 +05:30
|
|
|
|
}
|
2026-03-14 20:59:47 +05:30
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
// 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;
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2026-04-15 15:54:43 +05:30
|
|
|
|
/// 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;
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2026-03-14 20:59:47 +05:30
|
|
|
|
/// 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.
|
2026-04-18 11:22:27 +05:30
|
|
|
|
/// For city fields, strip the 2-letter state prefix before title-casing
|
|
|
|
|
|
/// (matches web's `stripCityPrefix`: "ne_adams" → "Adams").
|
2026-03-14 20:59:47 +05:30
|
|
|
|
static List<String>? _extractAllValuesAsList(AgentFieldValue fv) {
|
2026-04-18 11:22:27 +05:30
|
|
|
|
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);
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2026-03-14 20:59:47 +05:30
|
|
|
|
if (fv.jsonValue is List && (fv.jsonValue as List).isNotEmpty) {
|
|
|
|
|
|
return (fv.jsonValue as List)
|
2026-04-18 11:22:27 +05:30
|
|
|
|
.map((v) => format(v.toString()))
|
2026-03-14 20:59:47 +05:30
|
|
|
|
.toList();
|
|
|
|
|
|
}
|
|
|
|
|
|
if (fv.textValue != null && fv.textValue!.isNotEmpty) {
|
2026-04-18 11:22:27 +05:30
|
|
|
|
return [format(fv.textValue!)];
|
2026-03-14 20:59:47 +05:30
|
|
|
|
}
|
|
|
|
|
|
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;
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2026-03-08 00:41:44 +05:30
|
|
|
|
// ── Contact info (masked) ──
|
|
|
|
|
|
|
2026-04-09 22:08:24 +05:30
|
|
|
|
/// Contact email — matches web priority: agentProfile.email first,
|
|
|
|
|
|
/// then user login email, then dynamic field values as fallback.
|
2026-03-08 00:41:44 +05:30
|
|
|
|
String? get contactEmail {
|
2026-04-09 22:08:24 +05:30
|
|
|
|
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');
|
2026-03-08 00:41:44 +05:30
|
|
|
|
return val?.toString();
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2026-04-09 22:08:24 +05:30
|
|
|
|
/// Contact phone — matches web priority: agentProfile.phone first,
|
|
|
|
|
|
/// then dynamic field values as fallback.
|
2026-03-08 00:41:44 +05:30
|
|
|
|
String? get contactPhone {
|
2026-04-09 22:08:24 +05:30
|
|
|
|
final profilePhone = agent?.phone;
|
|
|
|
|
|
if (profilePhone != null && profilePhone.isNotEmpty) return profilePhone;
|
|
|
|
|
|
|
2026-03-08 00:41:44 +05:30
|
|
|
|
final val = _getFieldValue('phone_number') ??
|
|
|
|
|
|
_getFieldValue('phone') ??
|
|
|
|
|
|
_getFieldValue('cell_number') ??
|
2026-04-09 22:08:24 +05:30
|
|
|
|
_getFieldValue('office_number');
|
2026-03-08 00:41:44 +05:30
|
|
|
|
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)}';
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2026-04-15 15:57:52 +05:30
|
|
|
|
// ── 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');
|
|
|
|
|
|
|
2026-04-15 16:50:35 +05:30
|
|
|
|
// 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');
|
|
|
|
|
|
|
2026-03-08 00:41:44 +05:30
|
|
|
|
// ── 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;
|
2026-03-26 09:46:40 +05:30
|
|
|
|
StreamSubscription? _connectionResponseSub;
|
2026-03-08 00:41:44 +05:30
|
|
|
|
|
|
|
|
|
|
AgentDetailNotifier(this._repo, this.agentId)
|
|
|
|
|
|
: super(const AgentDetailState()) {
|
2026-04-18 16:35:32 +05:30
|
|
|
|
// 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);
|
2026-03-26 09:46:40 +05:30
|
|
|
|
// Listen for real-time connection response (accepted/rejected)
|
|
|
|
|
|
_connectionResponseSub =
|
|
|
|
|
|
SocketService().onConnectionResponse.listen((_) {
|
|
|
|
|
|
if (mounted) loadConnectionStatus();
|
|
|
|
|
|
});
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
@override
|
|
|
|
|
|
void dispose() {
|
|
|
|
|
|
_connectionResponseSub?.cancel();
|
|
|
|
|
|
super.dispose();
|
2026-03-08 00:41:44 +05:30
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
Future<void> _load() async {
|
|
|
|
|
|
try {
|
|
|
|
|
|
final results = await Future.wait([
|
|
|
|
|
|
_repo.getAgentById(agentId),
|
|
|
|
|
|
_repo.getFieldValues(agentId),
|
|
|
|
|
|
_repo.getTestimonials(agentId),
|
|
|
|
|
|
]);
|
2026-03-15 17:07:18 +05:30
|
|
|
|
if (!mounted) return;
|
2026-03-08 00:41:44 +05:30
|
|
|
|
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) {
|
2026-03-15 17:07:18 +05:30
|
|
|
|
if (!mounted) return;
|
2026-03-30 15:35:35 +05:30
|
|
|
|
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);
|
|
|
|
|
|
}
|
2026-03-08 00:41:44 +05:30
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2026-03-14 23:52:45 +05:30
|
|
|
|
Future<void> refresh() async {
|
|
|
|
|
|
try {
|
|
|
|
|
|
final results = await Future.wait([
|
|
|
|
|
|
_repo.getAgentById(agentId),
|
|
|
|
|
|
_repo.getFieldValues(agentId),
|
|
|
|
|
|
_repo.getTestimonials(agentId),
|
|
|
|
|
|
]);
|
2026-03-15 17:07:18 +05:30
|
|
|
|
if (!mounted) return;
|
2026-03-14 23:52:45 +05:30
|
|
|
|
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 (_) {}
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2026-03-08 00:41:44 +05:30
|
|
|
|
Future<void> loadConnectionStatus() async {
|
|
|
|
|
|
final status = await _repo.getConnectionStatus(agentId);
|
2026-03-15 17:07:18 +05:30
|
|
|
|
if (!mounted) return;
|
2026-03-08 00:41:44 +05:30
|
|
|
|
state = state.copyWith(connectionStatus: status);
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
Future<bool> connect({String? message}) async {
|
|
|
|
|
|
final success =
|
|
|
|
|
|
await _repo.createConnectionRequest(agentId, message: message);
|
2026-03-15 17:07:18 +05:30
|
|
|
|
if (!mounted) return success;
|
2026-03-08 00:41:44 +05:30
|
|
|
|
if (success) {
|
|
|
|
|
|
state = state.copyWith(
|
|
|
|
|
|
connectionStatus: {'status': 'PENDING'},
|
|
|
|
|
|
);
|
|
|
|
|
|
}
|
|
|
|
|
|
return success;
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2026-03-15 00:35:39 +05:30
|
|
|
|
void updateAvailability(bool isAvailable) {
|
|
|
|
|
|
if (state.agent == null) return;
|
|
|
|
|
|
state = state.copyWith(
|
|
|
|
|
|
agent: state.agent!.copyWith(isAvailable: isAvailable),
|
|
|
|
|
|
);
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2026-03-08 00:41:44 +05:30
|
|
|
|
Future<bool> cancelConnection() async {
|
|
|
|
|
|
final requestId = state.connectionRequestId;
|
|
|
|
|
|
if (requestId == null) return false;
|
|
|
|
|
|
final success = await _repo.cancelConnectionRequest(requestId);
|
2026-03-15 17:07:18 +05:30
|
|
|
|
if (!mounted) return success;
|
2026-03-08 00:41:44 +05:30
|
|
|
|
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);
|
|
|
|
|
|
});
|