feat: Implement messaging functionality with conversation and chat screens, and enhance agent detail views.
This commit is contained in:
@@ -108,6 +108,93 @@ class AgentsRepository {
|
||||
}
|
||||
}
|
||||
|
||||
/// Get agent by ID: GET /agents/{id}
|
||||
/// Response: { success, data: AgentProfile }
|
||||
Future<AgentProfile> getAgentById(String id) async {
|
||||
try {
|
||||
final response = await _dio.get('${ApiConstants.agents}/$id');
|
||||
final data = response.data['data'] as Map<String, dynamic>;
|
||||
return AgentProfile.fromJson(data);
|
||||
} on DioException catch (e) {
|
||||
if (e.error is ApiException) throw e.error as ApiException;
|
||||
throw ApiException(
|
||||
message: e.message ?? 'Failed to fetch agent',
|
||||
statusCode: e.response?.statusCode,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/// Get field values for agent: GET /agents/{id}/field-values
|
||||
/// Response: { success, data: { agentProfileId, fieldValues: [...] } }
|
||||
Future<List<AgentFieldValue>> getFieldValues(String agentId) async {
|
||||
try {
|
||||
final response =
|
||||
await _dio.get('${ApiConstants.agents}/$agentId/field-values');
|
||||
final data = response.data['data'] as Map<String, dynamic>;
|
||||
final fieldValues = data['fieldValues'] as List<dynamic>? ?? [];
|
||||
return fieldValues
|
||||
.map((e) => AgentFieldValue.fromFieldValueResponse(
|
||||
e as Map<String, dynamic>))
|
||||
.toList();
|
||||
} on DioException catch (e) {
|
||||
if (e.error is ApiException) throw e.error as ApiException;
|
||||
throw ApiException(
|
||||
message: e.message ?? 'Failed to fetch field values',
|
||||
statusCode: e.response?.statusCode,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/// Get testimonials for agent: GET /testimonials/agent/{agentProfileId}
|
||||
Future<List<Map<String, dynamic>>> getTestimonials(String agentId,
|
||||
{int limit = 10}) async {
|
||||
try {
|
||||
final response = await _dio.get(
|
||||
'${ApiConstants.testimonials}/agent/$agentId',
|
||||
queryParameters: {'limit': limit},
|
||||
);
|
||||
final data = response.data['data'] as List<dynamic>? ?? [];
|
||||
return data.cast<Map<String, dynamic>>();
|
||||
} on DioException catch (_) {
|
||||
return [];
|
||||
}
|
||||
}
|
||||
|
||||
/// Get connection status: GET /connection-requests/status/{agentProfileId}
|
||||
Future<Map<String, dynamic>?> getConnectionStatus(String agentId) async {
|
||||
try {
|
||||
final response = await _dio
|
||||
.get('${ApiConstants.connectionRequests}/status/$agentId');
|
||||
return response.data['data'] as Map<String, dynamic>?;
|
||||
} on DioException catch (_) {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
/// Create connection request: POST /connection-requests
|
||||
Future<bool> createConnectionRequest(String agentId,
|
||||
{String? message}) async {
|
||||
try {
|
||||
await _dio.post(ApiConstants.connectionRequests, data: {
|
||||
'agentProfileId': agentId,
|
||||
if (message != null && message.isNotEmpty) 'message': message,
|
||||
});
|
||||
return true;
|
||||
} on DioException catch (_) {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
/// Cancel connection request: DELETE /connection-requests/{requestId}
|
||||
Future<bool> cancelConnectionRequest(String requestId) async {
|
||||
try {
|
||||
await _dio.delete('${ApiConstants.connectionRequests}/$requestId');
|
||||
return true;
|
||||
} on DioException catch (_) {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
/// Get agent types: GET /agent-types
|
||||
/// Response: { success, data: [...] }
|
||||
Future<List<AgentType>> getAgentTypes() async {
|
||||
|
||||
@@ -19,6 +19,8 @@ class AgentProfile {
|
||||
final bool isVerified;
|
||||
final bool isFeatured;
|
||||
final bool isActive;
|
||||
final bool isAvailable;
|
||||
final String? email;
|
||||
final AgentType? agentType;
|
||||
final AgentUser? user;
|
||||
final List<AgentFieldValue> fieldValues;
|
||||
@@ -46,6 +48,8 @@ class AgentProfile {
|
||||
this.isVerified = false,
|
||||
this.isFeatured = false,
|
||||
this.isActive = true,
|
||||
this.isAvailable = false,
|
||||
this.email,
|
||||
this.agentType,
|
||||
this.user,
|
||||
this.fieldValues = const [],
|
||||
@@ -212,6 +216,8 @@ class AgentProfile {
|
||||
isVerified: json['isVerified'] as bool? ?? false,
|
||||
isFeatured: json['isFeatured'] as bool? ?? false,
|
||||
isActive: json['isActive'] as bool? ?? true,
|
||||
isAvailable: json['isAvailable'] as bool? ?? false,
|
||||
email: json['email'] as String?,
|
||||
agentType: json['agentType'] != null
|
||||
? AgentType.fromJson(json['agentType'] as Map<String, dynamic>)
|
||||
: null,
|
||||
@@ -264,6 +270,8 @@ class AgentFieldValue {
|
||||
final String fieldSlug;
|
||||
final String fieldName;
|
||||
final String fieldType;
|
||||
final String? sectionSlug;
|
||||
final String? sectionName;
|
||||
final String? textValue;
|
||||
final num? numberValue;
|
||||
final bool? booleanValue;
|
||||
@@ -275,6 +283,8 @@ class AgentFieldValue {
|
||||
required this.fieldSlug,
|
||||
required this.fieldName,
|
||||
required this.fieldType,
|
||||
this.sectionSlug,
|
||||
this.sectionName,
|
||||
this.textValue,
|
||||
this.numberValue,
|
||||
this.booleanValue,
|
||||
@@ -282,6 +292,39 @@ class AgentFieldValue {
|
||||
this.dateValue,
|
||||
});
|
||||
|
||||
/// Parse from the /agents/{id}/field-values endpoint format
|
||||
/// which has { fieldSlug, fieldName, fieldType, sectionSlug, value }
|
||||
factory AgentFieldValue.fromFieldValueResponse(Map<String, dynamic> json) {
|
||||
final value = json['value'];
|
||||
String? textValue;
|
||||
num? numberValue;
|
||||
bool? booleanValue;
|
||||
dynamic jsonValue;
|
||||
|
||||
if (value is String) {
|
||||
textValue = value;
|
||||
} else if (value is num) {
|
||||
numberValue = value;
|
||||
} else if (value is bool) {
|
||||
booleanValue = value;
|
||||
} else if (value is List || value is Map) {
|
||||
jsonValue = value;
|
||||
}
|
||||
|
||||
return AgentFieldValue(
|
||||
id: '',
|
||||
fieldSlug: json['fieldSlug'] as String? ?? '',
|
||||
fieldName: json['fieldName'] as String? ?? '',
|
||||
fieldType: json['fieldType'] as String? ?? '',
|
||||
sectionSlug: json['sectionSlug'] as String?,
|
||||
sectionName: json['sectionName'] as String?,
|
||||
textValue: textValue,
|
||||
numberValue: numberValue,
|
||||
booleanValue: booleanValue,
|
||||
jsonValue: jsonValue,
|
||||
);
|
||||
}
|
||||
|
||||
factory AgentFieldValue.fromJson(Map<String, dynamic> json) {
|
||||
// API returns field info nested: { field: { slug, name, fieldType } }
|
||||
// Fallback to top-level fieldSlug/fieldName/fieldType for compatibility
|
||||
|
||||
@@ -0,0 +1,308 @@
|
||||
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 num = val is int ? val : int.tryParse(val.toString()) ?? -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();
|
||||
}
|
||||
|
||||
// ── 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> 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);
|
||||
});
|
||||
1505
lib/features/agents/presentation/screens/agent_detail_screen.dart
Normal file
1505
lib/features/agents/presentation/screens/agent_detail_screen.dart
Normal file
File diff suppressed because it is too large
Load Diff
@@ -350,7 +350,10 @@ class _AgentSearchScreenState extends ConsumerState<AgentSearchScreen> {
|
||||
}
|
||||
return Padding(
|
||||
padding: const EdgeInsets.only(bottom: 16),
|
||||
child: _AgentCard(agent: state.agents[index]),
|
||||
child: GestureDetector(
|
||||
onTap: () => context.push('/agents/detail/${state.agents[index].id}'),
|
||||
child: _AgentCard(agent: state.agents[index]),
|
||||
),
|
||||
);
|
||||
},
|
||||
);
|
||||
@@ -397,6 +400,7 @@ class _AgentSearchScreenState extends ConsumerState<AgentSearchScreen> {
|
||||
context.push('/login');
|
||||
return;
|
||||
}
|
||||
context.go('/messages');
|
||||
},
|
||||
),
|
||||
_buildNavItem(
|
||||
|
||||
Reference in New Issue
Block a user