feat: Implement messaging functionality with conversation and chat screens, and enhance agent detail views.

This commit is contained in:
pradeepkumar
2026-03-08 00:41:44 +05:30
parent bdb7b04fbf
commit bba9cd3936
23 changed files with 4402 additions and 3 deletions

View File

@@ -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);
});

File diff suppressed because it is too large Load Diff

View File

@@ -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(