feat: Implement messaging functionality with conversation and chat screens, and enhance agent detail views.
This commit is contained in:
@@ -19,6 +19,16 @@ class ApiConstants {
|
|||||||
// Profile fields
|
// Profile fields
|
||||||
static const String filterableFields = '/profile-fields/filterable';
|
static const String filterableFields = '/profile-fields/filterable';
|
||||||
|
|
||||||
|
// Testimonials
|
||||||
|
static const String testimonials = '/testimonials';
|
||||||
|
|
||||||
|
// Connection requests
|
||||||
|
static const String connectionRequests = '/connection-requests';
|
||||||
|
|
||||||
// CMS
|
// CMS
|
||||||
static const String cmsPageLanding = '/cms/page/landing';
|
static const String cmsPageLanding = '/cms/page/landing';
|
||||||
|
|
||||||
|
// Messages
|
||||||
|
static const String conversations = '/messages/conversations';
|
||||||
|
static const String unreadCount = '/messages/unread-count';
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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
|
/// Get agent types: GET /agent-types
|
||||||
/// Response: { success, data: [...] }
|
/// Response: { success, data: [...] }
|
||||||
Future<List<AgentType>> getAgentTypes() async {
|
Future<List<AgentType>> getAgentTypes() async {
|
||||||
|
|||||||
@@ -19,6 +19,8 @@ class AgentProfile {
|
|||||||
final bool isVerified;
|
final bool isVerified;
|
||||||
final bool isFeatured;
|
final bool isFeatured;
|
||||||
final bool isActive;
|
final bool isActive;
|
||||||
|
final bool isAvailable;
|
||||||
|
final String? email;
|
||||||
final AgentType? agentType;
|
final AgentType? agentType;
|
||||||
final AgentUser? user;
|
final AgentUser? user;
|
||||||
final List<AgentFieldValue> fieldValues;
|
final List<AgentFieldValue> fieldValues;
|
||||||
@@ -46,6 +48,8 @@ class AgentProfile {
|
|||||||
this.isVerified = false,
|
this.isVerified = false,
|
||||||
this.isFeatured = false,
|
this.isFeatured = false,
|
||||||
this.isActive = true,
|
this.isActive = true,
|
||||||
|
this.isAvailable = false,
|
||||||
|
this.email,
|
||||||
this.agentType,
|
this.agentType,
|
||||||
this.user,
|
this.user,
|
||||||
this.fieldValues = const [],
|
this.fieldValues = const [],
|
||||||
@@ -212,6 +216,8 @@ class AgentProfile {
|
|||||||
isVerified: json['isVerified'] as bool? ?? false,
|
isVerified: json['isVerified'] as bool? ?? false,
|
||||||
isFeatured: json['isFeatured'] as bool? ?? false,
|
isFeatured: json['isFeatured'] as bool? ?? false,
|
||||||
isActive: json['isActive'] as bool? ?? true,
|
isActive: json['isActive'] as bool? ?? true,
|
||||||
|
isAvailable: json['isAvailable'] as bool? ?? false,
|
||||||
|
email: json['email'] as String?,
|
||||||
agentType: json['agentType'] != null
|
agentType: json['agentType'] != null
|
||||||
? AgentType.fromJson(json['agentType'] as Map<String, dynamic>)
|
? AgentType.fromJson(json['agentType'] as Map<String, dynamic>)
|
||||||
: null,
|
: null,
|
||||||
@@ -264,6 +270,8 @@ class AgentFieldValue {
|
|||||||
final String fieldSlug;
|
final String fieldSlug;
|
||||||
final String fieldName;
|
final String fieldName;
|
||||||
final String fieldType;
|
final String fieldType;
|
||||||
|
final String? sectionSlug;
|
||||||
|
final String? sectionName;
|
||||||
final String? textValue;
|
final String? textValue;
|
||||||
final num? numberValue;
|
final num? numberValue;
|
||||||
final bool? booleanValue;
|
final bool? booleanValue;
|
||||||
@@ -275,6 +283,8 @@ class AgentFieldValue {
|
|||||||
required this.fieldSlug,
|
required this.fieldSlug,
|
||||||
required this.fieldName,
|
required this.fieldName,
|
||||||
required this.fieldType,
|
required this.fieldType,
|
||||||
|
this.sectionSlug,
|
||||||
|
this.sectionName,
|
||||||
this.textValue,
|
this.textValue,
|
||||||
this.numberValue,
|
this.numberValue,
|
||||||
this.booleanValue,
|
this.booleanValue,
|
||||||
@@ -282,6 +292,39 @@ class AgentFieldValue {
|
|||||||
this.dateValue,
|
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) {
|
factory AgentFieldValue.fromJson(Map<String, dynamic> json) {
|
||||||
// API returns field info nested: { field: { slug, name, fieldType } }
|
// API returns field info nested: { field: { slug, name, fieldType } }
|
||||||
// Fallback to top-level fieldSlug/fieldName/fieldType for compatibility
|
// 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(
|
return Padding(
|
||||||
padding: const EdgeInsets.only(bottom: 16),
|
padding: const EdgeInsets.only(bottom: 16),
|
||||||
|
child: GestureDetector(
|
||||||
|
onTap: () => context.push('/agents/detail/${state.agents[index].id}'),
|
||||||
child: _AgentCard(agent: state.agents[index]),
|
child: _AgentCard(agent: state.agents[index]),
|
||||||
|
),
|
||||||
);
|
);
|
||||||
},
|
},
|
||||||
);
|
);
|
||||||
@@ -397,6 +400,7 @@ class _AgentSearchScreenState extends ConsumerState<AgentSearchScreen> {
|
|||||||
context.push('/login');
|
context.push('/login');
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
context.go('/messages');
|
||||||
},
|
},
|
||||||
),
|
),
|
||||||
_buildNavItem(
|
_buildNavItem(
|
||||||
|
|||||||
@@ -150,7 +150,16 @@ class _HomeScreenState extends ConsumerState<HomeScreen> {
|
|||||||
context.push('/agents/search');
|
context.push('/agents/search');
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
if (index == 2 || index == 3) {
|
if (index == 2) {
|
||||||
|
final authState = ref.read(authProvider);
|
||||||
|
if (authState.status != AuthStatus.authenticated) {
|
||||||
|
context.push('/login');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
context.push('/messages');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (index == 3) {
|
||||||
final authState = ref.read(authProvider);
|
final authState = ref.read(authProvider);
|
||||||
if (authState.status != AuthStatus.authenticated) {
|
if (authState.status != AuthStatus.authenticated) {
|
||||||
context.push('/login');
|
context.push('/login');
|
||||||
|
|||||||
149
lib/features/messaging/data/messaging_repository.dart
Normal file
149
lib/features/messaging/data/messaging_repository.dart
Normal file
@@ -0,0 +1,149 @@
|
|||||||
|
import 'package:dio/dio.dart';
|
||||||
|
import 'package:flutter_riverpod/flutter_riverpod.dart';
|
||||||
|
import 'package:logger/logger.dart';
|
||||||
|
import 'package:real_estate_mobile/core/constants/api_constants.dart';
|
||||||
|
import 'package:real_estate_mobile/core/network/api_client.dart';
|
||||||
|
import 'package:real_estate_mobile/core/network/api_exceptions.dart';
|
||||||
|
import 'package:real_estate_mobile/features/messaging/data/models/messaging_models.dart';
|
||||||
|
|
||||||
|
final _log = Logger(printer: PrettyPrinter(methodCount: 0));
|
||||||
|
|
||||||
|
class MessagingRepository {
|
||||||
|
final Dio _dio = ApiClient.instance.dio;
|
||||||
|
|
||||||
|
/// GET /messages/conversations
|
||||||
|
Future<List<Conversation>> getConversations() async {
|
||||||
|
try {
|
||||||
|
final response = await _dio.get(ApiConstants.conversations);
|
||||||
|
final data = response.data['data'] as List<dynamic>;
|
||||||
|
return data
|
||||||
|
.map((e) => Conversation.fromJson(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 conversations',
|
||||||
|
statusCode: e.response?.statusCode,
|
||||||
|
);
|
||||||
|
} catch (e, stack) {
|
||||||
|
_log.e('Conversation parsing error', error: e, stackTrace: stack);
|
||||||
|
throw ApiException(message: 'Failed to parse conversations: $e');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// POST /messages/conversations
|
||||||
|
Future<Conversation> startConversation(String agentProfileId) async {
|
||||||
|
try {
|
||||||
|
final response = await _dio.post(
|
||||||
|
ApiConstants.conversations,
|
||||||
|
data: {'agentProfileId': agentProfileId},
|
||||||
|
);
|
||||||
|
return Conversation.fromJson(
|
||||||
|
response.data['data'] as Map<String, dynamic>);
|
||||||
|
} on DioException catch (e) {
|
||||||
|
if (e.error is ApiException) throw e.error as ApiException;
|
||||||
|
throw ApiException(
|
||||||
|
message: e.message ?? 'Failed to start conversation',
|
||||||
|
statusCode: e.response?.statusCode,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// GET /messages/conversations/:id
|
||||||
|
Future<Conversation> getConversation(String id) async {
|
||||||
|
try {
|
||||||
|
final response = await _dio.get('${ApiConstants.conversations}/$id');
|
||||||
|
return Conversation.fromJson(
|
||||||
|
response.data['data'] as Map<String, dynamic>);
|
||||||
|
} on DioException catch (e) {
|
||||||
|
if (e.error is ApiException) throw e.error as ApiException;
|
||||||
|
throw ApiException(
|
||||||
|
message: e.message ?? 'Failed to fetch conversation',
|
||||||
|
statusCode: e.response?.statusCode,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// GET /messages/conversations/:id/messages?page=&limit=
|
||||||
|
Future<({List<ChatMessage> messages, PaginationInfo pagination})> getMessages(
|
||||||
|
String conversationId, {
|
||||||
|
int page = 1,
|
||||||
|
int limit = 50,
|
||||||
|
}) async {
|
||||||
|
try {
|
||||||
|
final response = await _dio.get(
|
||||||
|
'${ApiConstants.conversations}/$conversationId/messages',
|
||||||
|
queryParameters: {'page': page, 'limit': limit},
|
||||||
|
);
|
||||||
|
final data = response.data['data'] as Map<String, dynamic>;
|
||||||
|
final messages = (data['messages'] as List<dynamic>)
|
||||||
|
.map((e) => ChatMessage.fromJson(e as Map<String, dynamic>))
|
||||||
|
.toList();
|
||||||
|
final pagination = PaginationInfo.fromJson(
|
||||||
|
data['pagination'] as Map<String, dynamic>);
|
||||||
|
return (messages: messages, pagination: pagination);
|
||||||
|
} on DioException catch (e) {
|
||||||
|
if (e.error is ApiException) throw e.error as ApiException;
|
||||||
|
throw ApiException(
|
||||||
|
message: e.message ?? 'Failed to fetch messages',
|
||||||
|
statusCode: e.response?.statusCode,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// POST /messages/conversations/:id/messages
|
||||||
|
Future<ChatMessage> sendMessage(
|
||||||
|
String conversationId, {
|
||||||
|
required String content,
|
||||||
|
MessageType type = MessageType.text,
|
||||||
|
}) async {
|
||||||
|
try {
|
||||||
|
final response = await _dio.post(
|
||||||
|
'${ApiConstants.conversations}/$conversationId/messages',
|
||||||
|
data: {
|
||||||
|
'content': content,
|
||||||
|
'messageType': type.toApiString(),
|
||||||
|
},
|
||||||
|
);
|
||||||
|
return ChatMessage.fromJson(
|
||||||
|
response.data['data'] as Map<String, dynamic>);
|
||||||
|
} on DioException catch (e) {
|
||||||
|
if (e.error is ApiException) throw e.error as ApiException;
|
||||||
|
throw ApiException(
|
||||||
|
message: e.message ?? 'Failed to send message',
|
||||||
|
statusCode: e.response?.statusCode,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// PATCH /messages/conversations/:id/read
|
||||||
|
Future<void> markAsRead(String conversationId) async {
|
||||||
|
try {
|
||||||
|
await _dio.patch('${ApiConstants.conversations}/$conversationId/read');
|
||||||
|
} on DioException catch (e) {
|
||||||
|
if (e.error is ApiException) throw e.error as ApiException;
|
||||||
|
throw ApiException(
|
||||||
|
message: e.message ?? 'Failed to mark as read',
|
||||||
|
statusCode: e.response?.statusCode,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// GET /messages/unread-count
|
||||||
|
Future<int> getUnreadCount() async {
|
||||||
|
try {
|
||||||
|
final response = await _dio.get(ApiConstants.unreadCount);
|
||||||
|
return response.data['data']['unreadCount'] as int? ?? 0;
|
||||||
|
} on DioException catch (e) {
|
||||||
|
if (e.error is ApiException) throw e.error as ApiException;
|
||||||
|
throw ApiException(
|
||||||
|
message: e.message ?? 'Failed to fetch unread count',
|
||||||
|
statusCode: e.response?.statusCode,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
final messagingRepositoryProvider = Provider<MessagingRepository>((ref) {
|
||||||
|
return MessagingRepository();
|
||||||
|
});
|
||||||
3
lib/features/messaging/data/models/conversation.dart
Normal file
3
lib/features/messaging/data/models/conversation.dart
Normal file
@@ -0,0 +1,3 @@
|
|||||||
|
// Re-export from the combined messaging models file.
|
||||||
|
export 'package:real_estate_mobile/features/messaging/data/models/messaging_models.dart'
|
||||||
|
show OtherParty, Conversation;
|
||||||
3
lib/features/messaging/data/models/message.dart
Normal file
3
lib/features/messaging/data/models/message.dart
Normal file
@@ -0,0 +1,3 @@
|
|||||||
|
// Re-export from the combined messaging models file.
|
||||||
|
export 'package:real_estate_mobile/features/messaging/data/models/messaging_models.dart'
|
||||||
|
show MessageType, MessageStatus, MessageSender, SenderProfile, ChatMessage, PaginationInfo;
|
||||||
281
lib/features/messaging/data/models/messaging_models.dart
Normal file
281
lib/features/messaging/data/models/messaging_models.dart
Normal file
@@ -0,0 +1,281 @@
|
|||||||
|
enum MessageType {
|
||||||
|
text,
|
||||||
|
file,
|
||||||
|
image,
|
||||||
|
system;
|
||||||
|
|
||||||
|
static MessageType fromString(String? value) {
|
||||||
|
switch (value?.toUpperCase()) {
|
||||||
|
case 'FILE':
|
||||||
|
return MessageType.file;
|
||||||
|
case 'IMAGE':
|
||||||
|
return MessageType.image;
|
||||||
|
case 'SYSTEM':
|
||||||
|
return MessageType.system;
|
||||||
|
default:
|
||||||
|
return MessageType.text;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
String toApiString() => name.toUpperCase();
|
||||||
|
}
|
||||||
|
|
||||||
|
enum MessageStatus {
|
||||||
|
sent,
|
||||||
|
delivered,
|
||||||
|
read;
|
||||||
|
|
||||||
|
static MessageStatus fromString(String? value) {
|
||||||
|
switch (value?.toUpperCase()) {
|
||||||
|
case 'DELIVERED':
|
||||||
|
return MessageStatus.delivered;
|
||||||
|
case 'READ':
|
||||||
|
return MessageStatus.read;
|
||||||
|
default:
|
||||||
|
return MessageStatus.sent;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
class OtherParty {
|
||||||
|
final String id;
|
||||||
|
final String? userId;
|
||||||
|
final String name;
|
||||||
|
final String? avatar;
|
||||||
|
final String? headline;
|
||||||
|
final bool isOnline;
|
||||||
|
final String? lastSeenAt;
|
||||||
|
|
||||||
|
const OtherParty({
|
||||||
|
required this.id,
|
||||||
|
this.userId,
|
||||||
|
required this.name,
|
||||||
|
this.avatar,
|
||||||
|
this.headline,
|
||||||
|
this.isOnline = false,
|
||||||
|
this.lastSeenAt,
|
||||||
|
});
|
||||||
|
|
||||||
|
factory OtherParty.fromJson(Map<String, dynamic> json) {
|
||||||
|
return OtherParty(
|
||||||
|
id: json['id'] as String,
|
||||||
|
userId: json['userId'] as String?,
|
||||||
|
name: json['name'] as String? ?? '',
|
||||||
|
avatar: json['avatar'] as String?,
|
||||||
|
headline: json['headline'] as String?,
|
||||||
|
isOnline: json['isOnline'] as bool? ?? false,
|
||||||
|
lastSeenAt: json['lastSeenAt'] as String?,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
class Conversation {
|
||||||
|
final String id;
|
||||||
|
final String userId;
|
||||||
|
final String agentProfileId;
|
||||||
|
final String? lastMessageAt;
|
||||||
|
final String? lastMessageText;
|
||||||
|
final int userUnreadCount;
|
||||||
|
final int agentUnreadCount;
|
||||||
|
final int unreadCount;
|
||||||
|
final String createdAt;
|
||||||
|
final String updatedAt;
|
||||||
|
final OtherParty otherParty;
|
||||||
|
final List<ChatMessage>? messages;
|
||||||
|
|
||||||
|
const Conversation({
|
||||||
|
required this.id,
|
||||||
|
required this.userId,
|
||||||
|
required this.agentProfileId,
|
||||||
|
this.lastMessageAt,
|
||||||
|
this.lastMessageText,
|
||||||
|
this.userUnreadCount = 0,
|
||||||
|
this.agentUnreadCount = 0,
|
||||||
|
this.unreadCount = 0,
|
||||||
|
required this.createdAt,
|
||||||
|
required this.updatedAt,
|
||||||
|
required this.otherParty,
|
||||||
|
this.messages,
|
||||||
|
});
|
||||||
|
|
||||||
|
factory Conversation.fromJson(Map<String, dynamic> json) {
|
||||||
|
return Conversation(
|
||||||
|
id: json['id'] as String,
|
||||||
|
userId: json['userId'] as String,
|
||||||
|
agentProfileId: json['agentProfileId'] as String,
|
||||||
|
lastMessageAt: json['lastMessageAt'] as String?,
|
||||||
|
lastMessageText: json['lastMessageText'] as String?,
|
||||||
|
userUnreadCount: json['userUnreadCount'] as int? ?? 0,
|
||||||
|
agentUnreadCount: json['agentUnreadCount'] as int? ?? 0,
|
||||||
|
unreadCount: json['unreadCount'] as int? ?? 0,
|
||||||
|
createdAt: json['createdAt'] as String,
|
||||||
|
updatedAt: json['updatedAt'] as String,
|
||||||
|
otherParty:
|
||||||
|
OtherParty.fromJson(json['otherParty'] as Map<String, dynamic>),
|
||||||
|
messages: (json['messages'] as List<dynamic>?)
|
||||||
|
?.map((e) => ChatMessage.fromJson(e as Map<String, dynamic>))
|
||||||
|
.toList(),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
Conversation copyWith({
|
||||||
|
int? unreadCount,
|
||||||
|
String? lastMessageAt,
|
||||||
|
String? lastMessageText,
|
||||||
|
OtherParty? otherParty,
|
||||||
|
List<ChatMessage>? messages,
|
||||||
|
}) {
|
||||||
|
return Conversation(
|
||||||
|
id: id,
|
||||||
|
userId: userId,
|
||||||
|
agentProfileId: agentProfileId,
|
||||||
|
lastMessageAt: lastMessageAt ?? this.lastMessageAt,
|
||||||
|
lastMessageText: lastMessageText ?? this.lastMessageText,
|
||||||
|
userUnreadCount: userUnreadCount,
|
||||||
|
agentUnreadCount: agentUnreadCount,
|
||||||
|
unreadCount: unreadCount ?? this.unreadCount,
|
||||||
|
createdAt: createdAt,
|
||||||
|
updatedAt: updatedAt,
|
||||||
|
otherParty: otherParty ?? this.otherParty,
|
||||||
|
messages: messages ?? this.messages,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
class SenderProfile {
|
||||||
|
final String? firstName;
|
||||||
|
final String? lastName;
|
||||||
|
final String? avatar;
|
||||||
|
|
||||||
|
const SenderProfile({this.firstName, this.lastName, this.avatar});
|
||||||
|
|
||||||
|
factory SenderProfile.fromJson(Map<String, dynamic> json) {
|
||||||
|
return SenderProfile(
|
||||||
|
firstName: json['firstName'] as String?,
|
||||||
|
lastName: json['lastName'] as String?,
|
||||||
|
avatar: json['avatar'] as String?,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
class MessageSender {
|
||||||
|
final String id;
|
||||||
|
final String role;
|
||||||
|
final SenderProfile? userProfile;
|
||||||
|
final SenderProfile? agentProfile;
|
||||||
|
|
||||||
|
const MessageSender({
|
||||||
|
required this.id,
|
||||||
|
required this.role,
|
||||||
|
this.userProfile,
|
||||||
|
this.agentProfile,
|
||||||
|
});
|
||||||
|
|
||||||
|
String get displayName {
|
||||||
|
final profile = userProfile ?? agentProfile;
|
||||||
|
if (profile == null) return 'Unknown';
|
||||||
|
return '${profile.firstName ?? ''} ${profile.lastName ?? ''}'.trim();
|
||||||
|
}
|
||||||
|
|
||||||
|
String? get avatar => userProfile?.avatar ?? agentProfile?.avatar;
|
||||||
|
|
||||||
|
factory MessageSender.fromJson(Map<String, dynamic> json) {
|
||||||
|
return MessageSender(
|
||||||
|
id: json['id'] as String,
|
||||||
|
role: json['role'] as String? ?? '',
|
||||||
|
userProfile: json['userProfile'] != null
|
||||||
|
? SenderProfile.fromJson(
|
||||||
|
json['userProfile'] as Map<String, dynamic>)
|
||||||
|
: null,
|
||||||
|
agentProfile: json['agentProfile'] != null
|
||||||
|
? SenderProfile.fromJson(
|
||||||
|
json['agentProfile'] as Map<String, dynamic>)
|
||||||
|
: null,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
class ChatMessage {
|
||||||
|
final String id;
|
||||||
|
final String conversationId;
|
||||||
|
final String senderId;
|
||||||
|
final String content;
|
||||||
|
final MessageType messageType;
|
||||||
|
final String? fileUrl;
|
||||||
|
final String? fileName;
|
||||||
|
final int? fileSize;
|
||||||
|
final String? mimeType;
|
||||||
|
final MessageStatus status;
|
||||||
|
final String? deliveredAt;
|
||||||
|
final String? readAt;
|
||||||
|
final String createdAt;
|
||||||
|
final String updatedAt;
|
||||||
|
final MessageSender sender;
|
||||||
|
|
||||||
|
const ChatMessage({
|
||||||
|
required this.id,
|
||||||
|
required this.conversationId,
|
||||||
|
required this.senderId,
|
||||||
|
required this.content,
|
||||||
|
this.messageType = MessageType.text,
|
||||||
|
this.fileUrl,
|
||||||
|
this.fileName,
|
||||||
|
this.fileSize,
|
||||||
|
this.mimeType,
|
||||||
|
this.status = MessageStatus.sent,
|
||||||
|
this.deliveredAt,
|
||||||
|
this.readAt,
|
||||||
|
required this.createdAt,
|
||||||
|
required this.updatedAt,
|
||||||
|
required this.sender,
|
||||||
|
});
|
||||||
|
|
||||||
|
bool isMine(String currentUserId) => senderId == currentUserId;
|
||||||
|
|
||||||
|
factory ChatMessage.fromJson(Map<String, dynamic> json) {
|
||||||
|
return ChatMessage(
|
||||||
|
id: json['id'] as String,
|
||||||
|
conversationId: json['conversationId'] as String,
|
||||||
|
senderId: json['senderId'] as String,
|
||||||
|
content: json['content'] as String? ?? '',
|
||||||
|
messageType: MessageType.fromString(json['messageType'] as String?),
|
||||||
|
fileUrl: json['fileUrl'] as String?,
|
||||||
|
fileName: json['fileName'] as String?,
|
||||||
|
fileSize: json['fileSize'] as int?,
|
||||||
|
mimeType: json['mimeType'] as String?,
|
||||||
|
status: MessageStatus.fromString(json['status'] as String?),
|
||||||
|
deliveredAt: json['deliveredAt'] as String?,
|
||||||
|
readAt: json['readAt'] as String?,
|
||||||
|
createdAt: json['createdAt'] as String,
|
||||||
|
updatedAt: json['updatedAt'] as String,
|
||||||
|
sender: json['sender'] != null
|
||||||
|
? MessageSender.fromJson(json['sender'] as Map<String, dynamic>)
|
||||||
|
: MessageSender(id: json['senderId'] as String, role: ''),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
class PaginationInfo {
|
||||||
|
final int page;
|
||||||
|
final int limit;
|
||||||
|
final int total;
|
||||||
|
final int pages;
|
||||||
|
|
||||||
|
const PaginationInfo({
|
||||||
|
required this.page,
|
||||||
|
required this.limit,
|
||||||
|
required this.total,
|
||||||
|
required this.pages,
|
||||||
|
});
|
||||||
|
|
||||||
|
bool get hasMore => page < pages;
|
||||||
|
|
||||||
|
factory PaginationInfo.fromJson(Map<String, dynamic> json) {
|
||||||
|
return PaginationInfo(
|
||||||
|
page: json['page'] as int? ?? 1,
|
||||||
|
limit: json['limit'] as int? ?? 50,
|
||||||
|
total: json['total'] as int? ?? 0,
|
||||||
|
pages: json['pages'] as int? ?? 1,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
204
lib/features/messaging/data/socket_service.dart
Normal file
204
lib/features/messaging/data/socket_service.dart
Normal file
@@ -0,0 +1,204 @@
|
|||||||
|
import 'dart:async';
|
||||||
|
import 'package:logger/logger.dart';
|
||||||
|
import 'package:real_estate_mobile/config/app_config.dart';
|
||||||
|
import 'package:real_estate_mobile/core/storage/secure_storage.dart';
|
||||||
|
import 'package:real_estate_mobile/features/messaging/data/models/message.dart';
|
||||||
|
|
||||||
|
// We'll use a simple WebSocket approach for now since socket_io_client needs to be added
|
||||||
|
// This will be a placeholder that uses REST polling until socket_io_client is added
|
||||||
|
import 'package:socket_io_client/socket_io_client.dart' as io;
|
||||||
|
|
||||||
|
final _log = Logger(printer: PrettyPrinter(methodCount: 0));
|
||||||
|
|
||||||
|
class SocketService {
|
||||||
|
static final SocketService _instance = SocketService._();
|
||||||
|
factory SocketService() => _instance;
|
||||||
|
SocketService._();
|
||||||
|
|
||||||
|
io.Socket? _socket;
|
||||||
|
bool _isConnected = false;
|
||||||
|
String? _currentToken;
|
||||||
|
|
||||||
|
// Stream controllers for events
|
||||||
|
final _messageController = StreamController<ChatMessage>.broadcast();
|
||||||
|
final _typingStartController = StreamController<Map<String, dynamic>>.broadcast();
|
||||||
|
final _typingStopController = StreamController<Map<String, dynamic>>.broadcast();
|
||||||
|
final _statusController = StreamController<Map<String, dynamic>>.broadcast();
|
||||||
|
final _readController = StreamController<Map<String, dynamic>>.broadcast();
|
||||||
|
final _connectionController = StreamController<bool>.broadcast();
|
||||||
|
|
||||||
|
Stream<ChatMessage> get onNewMessage => _messageController.stream;
|
||||||
|
Stream<Map<String, dynamic>> get onTypingStart => _typingStartController.stream;
|
||||||
|
Stream<Map<String, dynamic>> get onTypingStop => _typingStopController.stream;
|
||||||
|
Stream<Map<String, dynamic>> get onStatusChange => _statusController.stream;
|
||||||
|
Stream<Map<String, dynamic>> get onMessagesRead => _readController.stream;
|
||||||
|
Stream<bool> get onConnectionChange => _connectionController.stream;
|
||||||
|
bool get isConnected => _isConnected;
|
||||||
|
|
||||||
|
Future<void> connect() async {
|
||||||
|
final token = await SecureStorage.getAccessToken();
|
||||||
|
if (token == null) {
|
||||||
|
_log.w('No access token for socket connection');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (_socket?.connected == true && _currentToken == token) return;
|
||||||
|
|
||||||
|
_currentToken = token;
|
||||||
|
|
||||||
|
// Strip /api/v1 from base URL for socket connection
|
||||||
|
final apiUrl = AppConfig.apiBaseUrl;
|
||||||
|
final baseUrl = apiUrl.replaceAll(RegExp(r'/api/v1/?$'), '');
|
||||||
|
|
||||||
|
_socket?.disconnect();
|
||||||
|
_socket?.dispose();
|
||||||
|
|
||||||
|
_socket = io.io(baseUrl, io.OptionBuilder()
|
||||||
|
.setTransports(['websocket', 'polling'])
|
||||||
|
.setAuth({'token': token})
|
||||||
|
.disableAutoConnect()
|
||||||
|
.enableReconnection()
|
||||||
|
.setReconnectionAttempts(5)
|
||||||
|
.setReconnectionDelay(1000)
|
||||||
|
.setReconnectionDelayMax(5000)
|
||||||
|
.build(),
|
||||||
|
);
|
||||||
|
|
||||||
|
_socket!.onConnect((_) {
|
||||||
|
_log.i('Socket connected: ${_socket!.id}');
|
||||||
|
_isConnected = true;
|
||||||
|
_connectionController.add(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
_socket!.onDisconnect((reason) {
|
||||||
|
_log.w('Socket disconnected: $reason');
|
||||||
|
_isConnected = false;
|
||||||
|
_connectionController.add(false);
|
||||||
|
|
||||||
|
if (reason == 'io server disconnect') {
|
||||||
|
_log.w('Server rejected connection - token likely expired');
|
||||||
|
_reconnectWithFreshToken();
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
_socket!.onConnectError((error) {
|
||||||
|
_log.e('Socket connect error: $error');
|
||||||
|
_isConnected = false;
|
||||||
|
_connectionController.add(false);
|
||||||
|
});
|
||||||
|
|
||||||
|
// Listen for events
|
||||||
|
_socket!.on('new_message', (data) {
|
||||||
|
try {
|
||||||
|
final message = ChatMessage.fromJson(data as Map<String, dynamic>);
|
||||||
|
_messageController.add(message);
|
||||||
|
} catch (e) {
|
||||||
|
_log.e('Error parsing new_message: $e');
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
_socket!.on('typing_start', (data) {
|
||||||
|
_typingStartController.add(Map<String, dynamic>.from(data as Map));
|
||||||
|
});
|
||||||
|
|
||||||
|
_socket!.on('typing_stop', (data) {
|
||||||
|
_typingStopController.add(Map<String, dynamic>.from(data as Map));
|
||||||
|
});
|
||||||
|
|
||||||
|
_socket!.on('user_status_change', (data) {
|
||||||
|
_statusController.add(Map<String, dynamic>.from(data as Map));
|
||||||
|
});
|
||||||
|
|
||||||
|
_socket!.on('messages_read', (data) {
|
||||||
|
_readController.add(Map<String, dynamic>.from(data as Map));
|
||||||
|
});
|
||||||
|
|
||||||
|
_socket!.connect();
|
||||||
|
}
|
||||||
|
|
||||||
|
Future<void> _reconnectWithFreshToken() async {
|
||||||
|
final token = await SecureStorage.getAccessToken();
|
||||||
|
if (token != null && token != _currentToken) {
|
||||||
|
_currentToken = token;
|
||||||
|
_socket?.io.options?['auth'] = {'token': token};
|
||||||
|
_socket?.connect();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
void disconnect() {
|
||||||
|
_socket?.disconnect();
|
||||||
|
_socket?.dispose();
|
||||||
|
_socket = null;
|
||||||
|
_isConnected = false;
|
||||||
|
_currentToken = null;
|
||||||
|
}
|
||||||
|
|
||||||
|
Future<void> joinConversation(String conversationId) async {
|
||||||
|
if (_socket == null || !_isConnected) return;
|
||||||
|
final completer = Completer<void>();
|
||||||
|
_socket!.emitWithAck('join_conversation', {'conversationId': conversationId},
|
||||||
|
ack: (data) => completer.complete(),
|
||||||
|
);
|
||||||
|
await completer.future.timeout(
|
||||||
|
const Duration(seconds: 10),
|
||||||
|
onTimeout: () => _log.w('join_conversation timeout'),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
void leaveConversation(String conversationId) {
|
||||||
|
_socket?.emit('leave_conversation', {'conversationId': conversationId});
|
||||||
|
}
|
||||||
|
|
||||||
|
Future<ChatMessage?> sendMessage(
|
||||||
|
String conversationId,
|
||||||
|
Map<String, dynamic> messageData,
|
||||||
|
) async {
|
||||||
|
if (_socket == null || !_isConnected) return null;
|
||||||
|
|
||||||
|
final completer = Completer<ChatMessage?>();
|
||||||
|
_socket!.emitWithAck('send_message', {
|
||||||
|
'conversationId': conversationId,
|
||||||
|
'message': messageData,
|
||||||
|
}, ack: (data) {
|
||||||
|
try {
|
||||||
|
final response = data as Map<String, dynamic>;
|
||||||
|
if (response['success'] == true && response['message'] != null) {
|
||||||
|
completer.complete(
|
||||||
|
ChatMessage.fromJson(response['message'] as Map<String, dynamic>),
|
||||||
|
);
|
||||||
|
} else {
|
||||||
|
completer.complete(null);
|
||||||
|
}
|
||||||
|
} catch (e) {
|
||||||
|
completer.complete(null);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
return completer.future.timeout(
|
||||||
|
const Duration(seconds: 10),
|
||||||
|
onTimeout: () => null,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
void startTyping(String conversationId) {
|
||||||
|
_socket?.emit('typing_start', {'conversationId': conversationId});
|
||||||
|
}
|
||||||
|
|
||||||
|
void stopTyping(String conversationId) {
|
||||||
|
_socket?.emit('typing_stop', {'conversationId': conversationId});
|
||||||
|
}
|
||||||
|
|
||||||
|
void markAsRead(String conversationId) {
|
||||||
|
_socket?.emit('mark_read', {'conversationId': conversationId});
|
||||||
|
}
|
||||||
|
|
||||||
|
void dispose() {
|
||||||
|
disconnect();
|
||||||
|
_messageController.close();
|
||||||
|
_typingStartController.close();
|
||||||
|
_typingStopController.close();
|
||||||
|
_statusController.close();
|
||||||
|
_readController.close();
|
||||||
|
_connectionController.close();
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,299 @@
|
|||||||
|
import 'package:flutter_riverpod/flutter_riverpod.dart';
|
||||||
|
import 'package:logger/logger.dart';
|
||||||
|
import 'package:real_estate_mobile/features/auth/presentation/providers/auth_provider.dart';
|
||||||
|
import 'package:real_estate_mobile/features/messaging/data/models/messaging_models.dart';
|
||||||
|
import 'package:real_estate_mobile/features/messaging/data/messaging_repository.dart';
|
||||||
|
|
||||||
|
final _log = Logger(printer: PrettyPrinter(methodCount: 0));
|
||||||
|
|
||||||
|
class MessagingState {
|
||||||
|
final List<Conversation> conversations;
|
||||||
|
final Map<String, List<ChatMessage>> messages;
|
||||||
|
final Map<String, PaginationInfo> pagination;
|
||||||
|
final Set<String> typingUsers;
|
||||||
|
final bool isLoadingConversations;
|
||||||
|
final bool isLoadingMessages;
|
||||||
|
final bool isSending;
|
||||||
|
final String? error;
|
||||||
|
final String? activeConversationId;
|
||||||
|
final int unreadCount;
|
||||||
|
|
||||||
|
const MessagingState({
|
||||||
|
this.conversations = const [],
|
||||||
|
this.messages = const {},
|
||||||
|
this.pagination = const {},
|
||||||
|
this.typingUsers = const {},
|
||||||
|
this.isLoadingConversations = false,
|
||||||
|
this.isLoadingMessages = false,
|
||||||
|
this.isSending = false,
|
||||||
|
this.error,
|
||||||
|
this.activeConversationId,
|
||||||
|
this.unreadCount = 0,
|
||||||
|
});
|
||||||
|
|
||||||
|
MessagingState copyWith({
|
||||||
|
List<Conversation>? conversations,
|
||||||
|
Map<String, List<ChatMessage>>? messages,
|
||||||
|
Map<String, PaginationInfo>? pagination,
|
||||||
|
Set<String>? typingUsers,
|
||||||
|
bool? isLoadingConversations,
|
||||||
|
bool? isLoadingMessages,
|
||||||
|
bool? isSending,
|
||||||
|
String? error,
|
||||||
|
String? activeConversationId,
|
||||||
|
int? unreadCount,
|
||||||
|
bool clearError = false,
|
||||||
|
bool clearActiveConversation = false,
|
||||||
|
}) {
|
||||||
|
return MessagingState(
|
||||||
|
conversations: conversations ?? this.conversations,
|
||||||
|
messages: messages ?? this.messages,
|
||||||
|
pagination: pagination ?? this.pagination,
|
||||||
|
typingUsers: typingUsers ?? this.typingUsers,
|
||||||
|
isLoadingConversations:
|
||||||
|
isLoadingConversations ?? this.isLoadingConversations,
|
||||||
|
isLoadingMessages: isLoadingMessages ?? this.isLoadingMessages,
|
||||||
|
isSending: isSending ?? this.isSending,
|
||||||
|
error: clearError ? null : (error ?? this.error),
|
||||||
|
activeConversationId: clearActiveConversation
|
||||||
|
? null
|
||||||
|
: (activeConversationId ?? this.activeConversationId),
|
||||||
|
unreadCount: unreadCount ?? this.unreadCount,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
class MessagingNotifier extends StateNotifier<MessagingState> {
|
||||||
|
final MessagingRepository _repository;
|
||||||
|
final Ref _ref;
|
||||||
|
|
||||||
|
MessagingNotifier(this._repository, this._ref)
|
||||||
|
: super(const MessagingState());
|
||||||
|
|
||||||
|
String? get _currentUserId => _ref.read(authProvider).user?.id;
|
||||||
|
|
||||||
|
Future<void> loadConversations() async {
|
||||||
|
state = state.copyWith(isLoadingConversations: true, clearError: true);
|
||||||
|
try {
|
||||||
|
final result = await _repository.getConversations();
|
||||||
|
state = state.copyWith(
|
||||||
|
conversations: result,
|
||||||
|
isLoadingConversations: false,
|
||||||
|
);
|
||||||
|
} catch (e, stack) {
|
||||||
|
_log.e('Failed to load conversations', error: e, stackTrace: stack);
|
||||||
|
state = state.copyWith(
|
||||||
|
isLoadingConversations: false,
|
||||||
|
error: 'Failed to load conversations',
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
Future<void> loadMessages(
|
||||||
|
String conversationId, {
|
||||||
|
bool loadMore = false,
|
||||||
|
}) async {
|
||||||
|
if (loadMore) {
|
||||||
|
final existing = state.pagination[conversationId];
|
||||||
|
if (existing != null && !existing.hasMore) return;
|
||||||
|
}
|
||||||
|
|
||||||
|
state = state.copyWith(isLoadingMessages: true, clearError: true);
|
||||||
|
|
||||||
|
try {
|
||||||
|
final nextPage =
|
||||||
|
loadMore ? ((state.pagination[conversationId]?.page ?? 0) + 1) : 1;
|
||||||
|
|
||||||
|
final result = await _repository.getMessages(
|
||||||
|
conversationId,
|
||||||
|
page: nextPage,
|
||||||
|
);
|
||||||
|
|
||||||
|
final newMessages = result.messages;
|
||||||
|
final paginationInfo = result.pagination;
|
||||||
|
|
||||||
|
final existingMessages =
|
||||||
|
loadMore ? (state.messages[conversationId] ?? []) : <ChatMessage>[];
|
||||||
|
final mergedMessages =
|
||||||
|
loadMore ? [...existingMessages, ...newMessages] : newMessages;
|
||||||
|
|
||||||
|
final updatedMessages =
|
||||||
|
Map<String, List<ChatMessage>>.from(state.messages)
|
||||||
|
..[conversationId] = mergedMessages;
|
||||||
|
|
||||||
|
final updatedPagination =
|
||||||
|
Map<String, PaginationInfo>.from(state.pagination)
|
||||||
|
..[conversationId] = paginationInfo;
|
||||||
|
|
||||||
|
state = state.copyWith(
|
||||||
|
messages: updatedMessages,
|
||||||
|
pagination: updatedPagination,
|
||||||
|
isLoadingMessages: false,
|
||||||
|
);
|
||||||
|
} catch (_) {
|
||||||
|
state = state.copyWith(
|
||||||
|
isLoadingMessages: false,
|
||||||
|
error: 'Failed to load messages',
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
Future<void> sendMessage(String conversationId, String content) async {
|
||||||
|
final currentUserId = _currentUserId;
|
||||||
|
if (currentUserId == null) return;
|
||||||
|
|
||||||
|
final tempId = 'temp_${DateTime.now().millisecondsSinceEpoch}';
|
||||||
|
final now = DateTime.now().toIso8601String();
|
||||||
|
|
||||||
|
final tempMessage = ChatMessage(
|
||||||
|
id: tempId,
|
||||||
|
conversationId: conversationId,
|
||||||
|
senderId: currentUserId,
|
||||||
|
content: content,
|
||||||
|
messageType: MessageType.text,
|
||||||
|
status: MessageStatus.sent,
|
||||||
|
createdAt: now,
|
||||||
|
updatedAt: now,
|
||||||
|
sender: MessageSender(
|
||||||
|
id: currentUserId,
|
||||||
|
role: 'user',
|
||||||
|
),
|
||||||
|
);
|
||||||
|
|
||||||
|
// Add optimistic message at the beginning (newest-first)
|
||||||
|
final currentMessages =
|
||||||
|
List<ChatMessage>.from(state.messages[conversationId] ?? []);
|
||||||
|
currentMessages.insert(0, tempMessage);
|
||||||
|
|
||||||
|
final updatedMessages =
|
||||||
|
Map<String, List<ChatMessage>>.from(state.messages)
|
||||||
|
..[conversationId] = currentMessages;
|
||||||
|
|
||||||
|
state = state.copyWith(
|
||||||
|
messages: updatedMessages,
|
||||||
|
isSending: true,
|
||||||
|
clearError: true,
|
||||||
|
);
|
||||||
|
|
||||||
|
try {
|
||||||
|
final realMessage = await _repository.sendMessage(
|
||||||
|
conversationId,
|
||||||
|
content: content,
|
||||||
|
);
|
||||||
|
|
||||||
|
final messagesAfterSend =
|
||||||
|
List<ChatMessage>.from(state.messages[conversationId] ?? []);
|
||||||
|
final tempIndex = messagesAfterSend.indexWhere((m) => m.id == tempId);
|
||||||
|
if (tempIndex != -1) {
|
||||||
|
messagesAfterSend[tempIndex] = realMessage;
|
||||||
|
}
|
||||||
|
|
||||||
|
final finalMessages =
|
||||||
|
Map<String, List<ChatMessage>>.from(state.messages)
|
||||||
|
..[conversationId] = messagesAfterSend;
|
||||||
|
|
||||||
|
// Update conversation's last message
|
||||||
|
final updatedConversations = state.conversations.map((c) {
|
||||||
|
if (c.id == conversationId) {
|
||||||
|
return c.copyWith(
|
||||||
|
lastMessageText: realMessage.content,
|
||||||
|
lastMessageAt: realMessage.createdAt,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
return c;
|
||||||
|
}).toList();
|
||||||
|
|
||||||
|
state = state.copyWith(
|
||||||
|
messages: finalMessages,
|
||||||
|
conversations: updatedConversations,
|
||||||
|
isSending: false,
|
||||||
|
);
|
||||||
|
} catch (_) {
|
||||||
|
final messagesAfterError =
|
||||||
|
List<ChatMessage>.from(state.messages[conversationId] ?? []);
|
||||||
|
messagesAfterError.removeWhere((m) => m.id == tempId);
|
||||||
|
|
||||||
|
final errorMessages =
|
||||||
|
Map<String, List<ChatMessage>>.from(state.messages)
|
||||||
|
..[conversationId] = messagesAfterError;
|
||||||
|
|
||||||
|
state = state.copyWith(
|
||||||
|
messages: errorMessages,
|
||||||
|
isSending: false,
|
||||||
|
error: 'Failed to send message',
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
Future<void> markAsRead(String conversationId) async {
|
||||||
|
try {
|
||||||
|
await _repository.markAsRead(conversationId);
|
||||||
|
|
||||||
|
final updatedConversations = state.conversations.map((c) {
|
||||||
|
if (c.id == conversationId) {
|
||||||
|
return c.copyWith(unreadCount: 0);
|
||||||
|
}
|
||||||
|
return c;
|
||||||
|
}).toList();
|
||||||
|
|
||||||
|
final totalUnread = updatedConversations.fold<int>(
|
||||||
|
0,
|
||||||
|
(sum, c) => sum + c.unreadCount,
|
||||||
|
);
|
||||||
|
|
||||||
|
state = state.copyWith(
|
||||||
|
conversations: updatedConversations,
|
||||||
|
unreadCount: totalUnread,
|
||||||
|
);
|
||||||
|
} catch (_) {}
|
||||||
|
}
|
||||||
|
|
||||||
|
void setActiveConversation(String? conversationId) {
|
||||||
|
if (conversationId == null) {
|
||||||
|
state = state.copyWith(clearActiveConversation: true);
|
||||||
|
} else {
|
||||||
|
state = state.copyWith(activeConversationId: conversationId);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
Future<void> loadUnreadCount() async {
|
||||||
|
try {
|
||||||
|
final count = await _repository.getUnreadCount();
|
||||||
|
state = state.copyWith(unreadCount: count);
|
||||||
|
} catch (_) {}
|
||||||
|
}
|
||||||
|
|
||||||
|
Future<String?> startConversation(String agentProfileId) async {
|
||||||
|
state = state.copyWith(isLoadingConversations: true, clearError: true);
|
||||||
|
try {
|
||||||
|
final conversation =
|
||||||
|
await _repository.startConversation(agentProfileId);
|
||||||
|
|
||||||
|
final exists = state.conversations.any((c) => c.id == conversation.id);
|
||||||
|
final updatedConversations = exists
|
||||||
|
? state.conversations
|
||||||
|
: [conversation, ...state.conversations];
|
||||||
|
|
||||||
|
state = state.copyWith(
|
||||||
|
conversations: updatedConversations,
|
||||||
|
isLoadingConversations: false,
|
||||||
|
);
|
||||||
|
return conversation.id;
|
||||||
|
} catch (_) {
|
||||||
|
state = state.copyWith(
|
||||||
|
isLoadingConversations: false,
|
||||||
|
error: 'Failed to start conversation',
|
||||||
|
);
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
final messagingProvider =
|
||||||
|
StateNotifierProvider<MessagingNotifier, MessagingState>((ref) {
|
||||||
|
return MessagingNotifier(ref.watch(messagingRepositoryProvider), ref);
|
||||||
|
});
|
||||||
|
|
||||||
|
final currentUserIdProvider = Provider<String?>((ref) {
|
||||||
|
return ref.watch(authProvider).user?.id;
|
||||||
|
});
|
||||||
907
lib/features/messaging/presentation/screens/chat_screen.dart
Normal file
907
lib/features/messaging/presentation/screens/chat_screen.dart
Normal file
@@ -0,0 +1,907 @@
|
|||||||
|
import 'package:cached_network_image/cached_network_image.dart';
|
||||||
|
import 'package:flutter/material.dart';
|
||||||
|
import 'package:flutter_riverpod/flutter_riverpod.dart';
|
||||||
|
import 'package:real_estate_mobile/config/app_config.dart';
|
||||||
|
import 'package:real_estate_mobile/core/constants/app_colors.dart';
|
||||||
|
import 'package:real_estate_mobile/features/auth/presentation/providers/auth_provider.dart';
|
||||||
|
import 'package:real_estate_mobile/features/messaging/data/models/messaging_models.dart';
|
||||||
|
import 'package:real_estate_mobile/features/messaging/presentation/providers/messaging_provider.dart';
|
||||||
|
|
||||||
|
class ChatScreen extends ConsumerStatefulWidget {
|
||||||
|
final String conversationId;
|
||||||
|
|
||||||
|
const ChatScreen({super.key, required this.conversationId});
|
||||||
|
|
||||||
|
@override
|
||||||
|
ConsumerState<ChatScreen> createState() => _ChatScreenState();
|
||||||
|
}
|
||||||
|
|
||||||
|
class _ChatScreenState extends ConsumerState<ChatScreen> {
|
||||||
|
final TextEditingController _messageController = TextEditingController();
|
||||||
|
final ScrollController _scrollController = ScrollController();
|
||||||
|
final FocusNode _messageFocusNode = FocusNode();
|
||||||
|
bool _isComposing = false;
|
||||||
|
int _previousMessageCount = 0;
|
||||||
|
|
||||||
|
@override
|
||||||
|
void initState() {
|
||||||
|
super.initState();
|
||||||
|
WidgetsBinding.instance.addPostFrameCallback((_) {
|
||||||
|
final notifier = ref.read(messagingProvider.notifier);
|
||||||
|
notifier.setActiveConversation(widget.conversationId);
|
||||||
|
notifier.loadMessages(widget.conversationId);
|
||||||
|
notifier.markAsRead(widget.conversationId);
|
||||||
|
});
|
||||||
|
_scrollController.addListener(_onScroll);
|
||||||
|
_messageController.addListener(_onTextChanged);
|
||||||
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
void dispose() {
|
||||||
|
ref.read(messagingProvider.notifier).setActiveConversation(null);
|
||||||
|
_messageController.dispose();
|
||||||
|
_scrollController.dispose();
|
||||||
|
_messageFocusNode.dispose();
|
||||||
|
super.dispose();
|
||||||
|
}
|
||||||
|
|
||||||
|
void _onScroll() {
|
||||||
|
if (_scrollController.position.pixels >=
|
||||||
|
_scrollController.position.maxScrollExtent - 50) {
|
||||||
|
ref
|
||||||
|
.read(messagingProvider.notifier)
|
||||||
|
.loadMessages(widget.conversationId, loadMore: true);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
void _onTextChanged() {
|
||||||
|
final composing = _messageController.text.trim().isNotEmpty;
|
||||||
|
if (composing != _isComposing) {
|
||||||
|
setState(() => _isComposing = composing);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
void _scrollToBottom({bool animated = true}) {
|
||||||
|
if (!_scrollController.hasClients) return;
|
||||||
|
if (animated) {
|
||||||
|
_scrollController.animateTo(
|
||||||
|
0.0,
|
||||||
|
duration: const Duration(milliseconds: 300),
|
||||||
|
curve: Curves.easeOut,
|
||||||
|
);
|
||||||
|
} else {
|
||||||
|
_scrollController.jumpTo(0.0);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
void _sendMessage() {
|
||||||
|
final text = _messageController.text.trim();
|
||||||
|
if (text.isEmpty) return;
|
||||||
|
|
||||||
|
ref
|
||||||
|
.read(messagingProvider.notifier)
|
||||||
|
.sendMessage(widget.conversationId, text);
|
||||||
|
_messageController.clear();
|
||||||
|
setState(() => _isComposing = false);
|
||||||
|
|
||||||
|
Future.delayed(const Duration(milliseconds: 100), () {
|
||||||
|
_scrollToBottom();
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
void _handleBack() {
|
||||||
|
ref.read(messagingProvider.notifier).setActiveConversation(null);
|
||||||
|
Navigator.pop(context);
|
||||||
|
}
|
||||||
|
|
||||||
|
// ============================================================
|
||||||
|
// DATE / TIME FORMATTING
|
||||||
|
// ============================================================
|
||||||
|
|
||||||
|
String _formatTime(String isoTimestamp) {
|
||||||
|
try {
|
||||||
|
final dt = DateTime.parse(isoTimestamp).toLocal();
|
||||||
|
final hour =
|
||||||
|
dt.hour > 12 ? dt.hour - 12 : (dt.hour == 0 ? 12 : dt.hour);
|
||||||
|
final minute = dt.minute.toString().padLeft(2, '0');
|
||||||
|
return '$hour.$minute';
|
||||||
|
} catch (_) {
|
||||||
|
return '';
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
String _formatDateSeparator(DateTime date) {
|
||||||
|
final now = DateTime.now();
|
||||||
|
final today = DateTime(now.year, now.month, now.day);
|
||||||
|
final messageDate = DateTime(date.year, date.month, date.day);
|
||||||
|
final diff = today.difference(messageDate).inDays;
|
||||||
|
|
||||||
|
if (diff == 0) return 'Today';
|
||||||
|
if (diff == 1) return 'Yesterday';
|
||||||
|
|
||||||
|
const months = [
|
||||||
|
'January', 'February', 'March', 'April', 'May', 'June',
|
||||||
|
'July', 'August', 'September', 'October', 'November', 'December',
|
||||||
|
];
|
||||||
|
return '${months[date.month - 1]} ${date.day}, ${date.year}';
|
||||||
|
}
|
||||||
|
|
||||||
|
bool _shouldShowDateSeparator(List<ChatMessage> messages, int index) {
|
||||||
|
if (index == messages.length - 1) return true;
|
||||||
|
|
||||||
|
final current = DateTime.parse(messages[index].createdAt).toLocal();
|
||||||
|
final older = DateTime.parse(messages[index + 1].createdAt).toLocal();
|
||||||
|
|
||||||
|
return DateTime(current.year, current.month, current.day) !=
|
||||||
|
DateTime(older.year, older.month, older.day);
|
||||||
|
}
|
||||||
|
|
||||||
|
String _getInitials(String name) {
|
||||||
|
final parts = name.trim().split(RegExp(r'\s+'));
|
||||||
|
if (parts.isEmpty) return '';
|
||||||
|
if (parts.length == 1) return parts[0][0].toUpperCase();
|
||||||
|
return '${parts[0][0]}${parts[1][0]}'.toUpperCase();
|
||||||
|
}
|
||||||
|
|
||||||
|
String _resolveAvatarUrl(String? avatarKey) {
|
||||||
|
if (avatarKey == null || avatarKey.isEmpty) return '';
|
||||||
|
if (avatarKey.startsWith('http://') || avatarKey.startsWith('https://')) {
|
||||||
|
return avatarKey;
|
||||||
|
}
|
||||||
|
final base = AppConfig.storageBaseUrl;
|
||||||
|
if (avatarKey.startsWith('/uploads')) {
|
||||||
|
return '$base$avatarKey';
|
||||||
|
}
|
||||||
|
return '$base/$avatarKey';
|
||||||
|
}
|
||||||
|
|
||||||
|
// ============================================================
|
||||||
|
// BUILD
|
||||||
|
// ============================================================
|
||||||
|
|
||||||
|
@override
|
||||||
|
Widget build(BuildContext context) {
|
||||||
|
final messagingState = ref.watch(messagingProvider);
|
||||||
|
final currentUserId = ref.read(authProvider).user?.id;
|
||||||
|
final messages = messagingState.messages[widget.conversationId] ?? [];
|
||||||
|
final isTyping = messagingState.typingUsers.isNotEmpty;
|
||||||
|
|
||||||
|
final conversation = messagingState.conversations
|
||||||
|
.cast<Conversation?>()
|
||||||
|
.firstWhere(
|
||||||
|
(c) => c!.id == widget.conversationId,
|
||||||
|
orElse: () => null,
|
||||||
|
);
|
||||||
|
|
||||||
|
if (messages.length > _previousMessageCount && _previousMessageCount > 0) {
|
||||||
|
WidgetsBinding.instance.addPostFrameCallback((_) {
|
||||||
|
_scrollToBottom(animated: true);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
_previousMessageCount = messages.length;
|
||||||
|
|
||||||
|
final otherPartyName = conversation?.otherParty.name ?? 'Chat';
|
||||||
|
final otherPartyAvatar = conversation?.otherParty.avatar;
|
||||||
|
final isOnline = conversation?.otherParty.isOnline ?? false;
|
||||||
|
return Scaffold(
|
||||||
|
backgroundColor: Colors.white,
|
||||||
|
body: SafeArea(
|
||||||
|
child: Column(
|
||||||
|
children: [
|
||||||
|
_buildHeader(
|
||||||
|
name: otherPartyName,
|
||||||
|
isOnline: isOnline,
|
||||||
|
),
|
||||||
|
Expanded(
|
||||||
|
child: messagingState.isLoadingMessages && messages.isEmpty
|
||||||
|
? const Center(
|
||||||
|
child: CircularProgressIndicator(
|
||||||
|
color: AppColors.accentOrange,
|
||||||
|
strokeWidth: 2,
|
||||||
|
),
|
||||||
|
)
|
||||||
|
: messages.isEmpty
|
||||||
|
? Center(
|
||||||
|
child: Text(
|
||||||
|
'No messages yet.\nSay hello!',
|
||||||
|
textAlign: TextAlign.center,
|
||||||
|
style: TextStyle(
|
||||||
|
fontFamily: 'SourceSerif4',
|
||||||
|
fontSize: 14,
|
||||||
|
fontWeight: FontWeight.w400,
|
||||||
|
color: AppColors.hintText,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
)
|
||||||
|
: _buildMessagesList(
|
||||||
|
messages: messages,
|
||||||
|
currentUserId: currentUserId ?? '',
|
||||||
|
currentUserName: 'You',
|
||||||
|
otherPartyName: otherPartyName,
|
||||||
|
otherPartyAvatar: otherPartyAvatar,
|
||||||
|
isTyping: isTyping,
|
||||||
|
isLoadingMore: messagingState.isLoadingMessages &&
|
||||||
|
messages.isNotEmpty,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
_buildInputArea(),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// ============================================================
|
||||||
|
// HEADER - matches Figma: rounded border, back arrow, name, status, icons
|
||||||
|
// ============================================================
|
||||||
|
|
||||||
|
Widget _buildHeader({
|
||||||
|
required String name,
|
||||||
|
required bool isOnline,
|
||||||
|
}) {
|
||||||
|
return Padding(
|
||||||
|
padding: const EdgeInsets.symmetric(horizontal: 12, vertical: 8),
|
||||||
|
child: Container(
|
||||||
|
height: 55,
|
||||||
|
decoration: BoxDecoration(
|
||||||
|
border: Border.all(
|
||||||
|
color: AppColors.primaryDark,
|
||||||
|
width: 0.5,
|
||||||
|
),
|
||||||
|
borderRadius: BorderRadius.circular(7),
|
||||||
|
),
|
||||||
|
child: Row(
|
||||||
|
children: [
|
||||||
|
// Back arrow
|
||||||
|
GestureDetector(
|
||||||
|
onTap: _handleBack,
|
||||||
|
behavior: HitTestBehavior.opaque,
|
||||||
|
child: const Padding(
|
||||||
|
padding: EdgeInsets.symmetric(horizontal: 14),
|
||||||
|
child: Icon(
|
||||||
|
Icons.arrow_back,
|
||||||
|
size: 23,
|
||||||
|
color: AppColors.primaryDark,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
// Name and active status
|
||||||
|
Expanded(
|
||||||
|
child: Column(
|
||||||
|
mainAxisAlignment: MainAxisAlignment.center,
|
||||||
|
crossAxisAlignment: CrossAxisAlignment.start,
|
||||||
|
children: [
|
||||||
|
Text(
|
||||||
|
name,
|
||||||
|
style: const TextStyle(
|
||||||
|
fontFamily: 'Fractul',
|
||||||
|
fontSize: 14,
|
||||||
|
fontWeight: FontWeight.w700,
|
||||||
|
color: AppColors.primaryDark,
|
||||||
|
),
|
||||||
|
maxLines: 1,
|
||||||
|
overflow: TextOverflow.ellipsis,
|
||||||
|
),
|
||||||
|
const SizedBox(height: 2),
|
||||||
|
Row(
|
||||||
|
children: [
|
||||||
|
if (isOnline) ...[
|
||||||
|
Container(
|
||||||
|
width: 8,
|
||||||
|
height: 8,
|
||||||
|
decoration: const BoxDecoration(
|
||||||
|
color: Color(0xFF4CAF50),
|
||||||
|
shape: BoxShape.circle,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
const SizedBox(width: 5),
|
||||||
|
],
|
||||||
|
Text(
|
||||||
|
isOnline ? 'Active Now' : 'Offline',
|
||||||
|
style: const TextStyle(
|
||||||
|
fontFamily: 'Fractul',
|
||||||
|
fontSize: 10,
|
||||||
|
fontWeight: FontWeight.w400,
|
||||||
|
color: AppColors.primaryDark,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
),
|
||||||
|
// Three dots (vertical)
|
||||||
|
GestureDetector(
|
||||||
|
onTap: () {},
|
||||||
|
behavior: HitTestBehavior.opaque,
|
||||||
|
child: const Padding(
|
||||||
|
padding: EdgeInsets.symmetric(horizontal: 10),
|
||||||
|
child: Icon(
|
||||||
|
Icons.more_vert,
|
||||||
|
size: 20,
|
||||||
|
color: AppColors.primaryDark,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
// Star icon
|
||||||
|
GestureDetector(
|
||||||
|
onTap: () {},
|
||||||
|
behavior: HitTestBehavior.opaque,
|
||||||
|
child: Padding(
|
||||||
|
padding: const EdgeInsets.only(right: 14),
|
||||||
|
child: Icon(
|
||||||
|
Icons.star_rounded,
|
||||||
|
size: 24,
|
||||||
|
color: AppColors.accentOrange,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// ============================================================
|
||||||
|
// MESSAGES LIST
|
||||||
|
// ============================================================
|
||||||
|
|
||||||
|
Widget _buildMessagesList({
|
||||||
|
required List<ChatMessage> messages,
|
||||||
|
required String currentUserId,
|
||||||
|
required String currentUserName,
|
||||||
|
required String otherPartyName,
|
||||||
|
String? otherPartyAvatar,
|
||||||
|
required bool isTyping,
|
||||||
|
required bool isLoadingMore,
|
||||||
|
}) {
|
||||||
|
final itemCount =
|
||||||
|
messages.length + (isLoadingMore ? 1 : 0) + (isTyping ? 1 : 0);
|
||||||
|
|
||||||
|
return ListView.builder(
|
||||||
|
controller: _scrollController,
|
||||||
|
reverse: true,
|
||||||
|
padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 12),
|
||||||
|
itemCount: itemCount,
|
||||||
|
itemBuilder: (context, index) {
|
||||||
|
if (isTyping && index == 0) {
|
||||||
|
return _buildTypingIndicator();
|
||||||
|
}
|
||||||
|
|
||||||
|
final adjustedIndex = isTyping ? index - 1 : index;
|
||||||
|
|
||||||
|
if (isLoadingMore && adjustedIndex == messages.length) {
|
||||||
|
return const Padding(
|
||||||
|
padding: EdgeInsets.symmetric(vertical: 16),
|
||||||
|
child: Center(
|
||||||
|
child: SizedBox(
|
||||||
|
width: 20,
|
||||||
|
height: 20,
|
||||||
|
child: CircularProgressIndicator(
|
||||||
|
color: AppColors.accentOrange,
|
||||||
|
strokeWidth: 2,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (adjustedIndex < 0 || adjustedIndex >= messages.length) {
|
||||||
|
return const SizedBox.shrink();
|
||||||
|
}
|
||||||
|
|
||||||
|
final message = messages[adjustedIndex];
|
||||||
|
final isMine = message.isMine(currentUserId);
|
||||||
|
|
||||||
|
final showDateSeparator =
|
||||||
|
_shouldShowDateSeparator(messages, adjustedIndex);
|
||||||
|
|
||||||
|
// Get sender info
|
||||||
|
final senderName = isMine
|
||||||
|
? (message.sender.displayName.isNotEmpty
|
||||||
|
? message.sender.displayName
|
||||||
|
: currentUserName)
|
||||||
|
: otherPartyName;
|
||||||
|
final senderAvatar = isMine ? null : otherPartyAvatar;
|
||||||
|
|
||||||
|
return Column(
|
||||||
|
children: [
|
||||||
|
if (showDateSeparator)
|
||||||
|
_buildDateSeparator(
|
||||||
|
DateTime.parse(message.createdAt).toLocal(),
|
||||||
|
),
|
||||||
|
Padding(
|
||||||
|
padding: const EdgeInsets.only(bottom: 16),
|
||||||
|
child: isMine
|
||||||
|
? _buildSentMessage(message, senderName)
|
||||||
|
: _buildReceivedMessage(message, senderName, senderAvatar),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
);
|
||||||
|
},
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// -- Date Separator --
|
||||||
|
Widget _buildDateSeparator(DateTime date) {
|
||||||
|
return Padding(
|
||||||
|
padding: const EdgeInsets.symmetric(vertical: 16),
|
||||||
|
child: Row(
|
||||||
|
children: [
|
||||||
|
Expanded(
|
||||||
|
child: Divider(
|
||||||
|
color: AppColors.primaryDark.withValues(alpha: 0.1),
|
||||||
|
thickness: 0.5,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
Padding(
|
||||||
|
padding: const EdgeInsets.symmetric(horizontal: 12),
|
||||||
|
child: Text(
|
||||||
|
_formatDateSeparator(date),
|
||||||
|
style: const TextStyle(
|
||||||
|
fontFamily: 'SourceSerif4',
|
||||||
|
fontSize: 12,
|
||||||
|
fontWeight: FontWeight.w400,
|
||||||
|
color: AppColors.subtleText,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
Expanded(
|
||||||
|
child: Divider(
|
||||||
|
color: AppColors.primaryDark.withValues(alpha: 0.1),
|
||||||
|
thickness: 0.5,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// -- Sent Message (right-aligned, with avatar on right) --
|
||||||
|
Widget _buildSentMessage(ChatMessage message, String senderName) {
|
||||||
|
final timestamp = _formatTime(message.createdAt);
|
||||||
|
|
||||||
|
return Row(
|
||||||
|
mainAxisAlignment: MainAxisAlignment.end,
|
||||||
|
crossAxisAlignment: CrossAxisAlignment.start,
|
||||||
|
children: [
|
||||||
|
const Spacer(),
|
||||||
|
Flexible(
|
||||||
|
flex: 3,
|
||||||
|
child: Column(
|
||||||
|
crossAxisAlignment: CrossAxisAlignment.end,
|
||||||
|
children: [
|
||||||
|
// Name row with timestamp
|
||||||
|
Row(
|
||||||
|
mainAxisSize: MainAxisSize.min,
|
||||||
|
children: [
|
||||||
|
Text(
|
||||||
|
timestamp,
|
||||||
|
style: const TextStyle(
|
||||||
|
fontFamily: 'SourceSerif4',
|
||||||
|
fontSize: 10,
|
||||||
|
fontWeight: FontWeight.w200,
|
||||||
|
color: AppColors.primaryDark,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
const SizedBox(width: 6),
|
||||||
|
Text(
|
||||||
|
senderName,
|
||||||
|
style: const TextStyle(
|
||||||
|
fontFamily: 'Fractul',
|
||||||
|
fontSize: 14,
|
||||||
|
fontWeight: FontWeight.w700,
|
||||||
|
color: AppColors.primaryDark,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
const SizedBox(width: 6),
|
||||||
|
Container(
|
||||||
|
width: 8,
|
||||||
|
height: 8,
|
||||||
|
decoration: const BoxDecoration(
|
||||||
|
color: AppColors.accentOrange,
|
||||||
|
shape: BoxShape.circle,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
const SizedBox(height: 6),
|
||||||
|
// Message text
|
||||||
|
Text(
|
||||||
|
message.content,
|
||||||
|
style: const TextStyle(
|
||||||
|
fontFamily: 'Fractul',
|
||||||
|
fontSize: 10,
|
||||||
|
fontWeight: FontWeight.w400,
|
||||||
|
color: AppColors.primaryDark,
|
||||||
|
),
|
||||||
|
textAlign: TextAlign.right,
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
),
|
||||||
|
const SizedBox(width: 10),
|
||||||
|
// Avatar
|
||||||
|
_buildMessageAvatar(null, senderName, 45),
|
||||||
|
],
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// -- Received Message (left-aligned, with avatar on left) --
|
||||||
|
Widget _buildReceivedMessage(
|
||||||
|
ChatMessage message, String senderName, String? avatar) {
|
||||||
|
final timestamp = _formatTime(message.createdAt);
|
||||||
|
|
||||||
|
return Row(
|
||||||
|
mainAxisAlignment: MainAxisAlignment.start,
|
||||||
|
crossAxisAlignment: CrossAxisAlignment.start,
|
||||||
|
children: [
|
||||||
|
// Avatar
|
||||||
|
_buildMessageAvatar(avatar, senderName, 45),
|
||||||
|
const SizedBox(width: 10),
|
||||||
|
Flexible(
|
||||||
|
flex: 3,
|
||||||
|
child: Column(
|
||||||
|
crossAxisAlignment: CrossAxisAlignment.start,
|
||||||
|
children: [
|
||||||
|
// Name row with timestamp
|
||||||
|
Row(
|
||||||
|
mainAxisSize: MainAxisSize.min,
|
||||||
|
children: [
|
||||||
|
Text(
|
||||||
|
senderName,
|
||||||
|
style: const TextStyle(
|
||||||
|
fontFamily: 'Fractul',
|
||||||
|
fontSize: 14,
|
||||||
|
fontWeight: FontWeight.w700,
|
||||||
|
color: AppColors.primaryDark,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
const SizedBox(width: 6),
|
||||||
|
Container(
|
||||||
|
width: 8,
|
||||||
|
height: 8,
|
||||||
|
decoration: const BoxDecoration(
|
||||||
|
color: AppColors.accentOrange,
|
||||||
|
shape: BoxShape.circle,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
const SizedBox(width: 6),
|
||||||
|
Text(
|
||||||
|
timestamp,
|
||||||
|
style: const TextStyle(
|
||||||
|
fontFamily: 'SourceSerif4',
|
||||||
|
fontSize: 10,
|
||||||
|
fontWeight: FontWeight.w200,
|
||||||
|
color: AppColors.primaryDark,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
const SizedBox(height: 6),
|
||||||
|
// Message text
|
||||||
|
Text(
|
||||||
|
message.content,
|
||||||
|
style: const TextStyle(
|
||||||
|
fontFamily: 'Fractul',
|
||||||
|
fontSize: 10,
|
||||||
|
fontWeight: FontWeight.w400,
|
||||||
|
color: AppColors.primaryDark,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
),
|
||||||
|
const Spacer(),
|
||||||
|
],
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// -- Message Avatar --
|
||||||
|
Widget _buildMessageAvatar(String? avatarKey, String name, double size) {
|
||||||
|
final avatarUrl = _resolveAvatarUrl(avatarKey);
|
||||||
|
final initials = _getInitials(name);
|
||||||
|
|
||||||
|
return ClipOval(
|
||||||
|
child: avatarUrl.isNotEmpty
|
||||||
|
? CachedNetworkImage(
|
||||||
|
imageUrl: avatarUrl,
|
||||||
|
width: size,
|
||||||
|
height: size,
|
||||||
|
fit: BoxFit.cover,
|
||||||
|
errorWidget: (_, __, ___) =>
|
||||||
|
_AvatarFallback(letter: initials, size: size),
|
||||||
|
)
|
||||||
|
: _AvatarFallback(letter: initials, size: size),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// -- Typing Indicator --
|
||||||
|
Widget _buildTypingIndicator() {
|
||||||
|
return Align(
|
||||||
|
alignment: Alignment.centerLeft,
|
||||||
|
child: Padding(
|
||||||
|
padding: const EdgeInsets.only(bottom: 8),
|
||||||
|
child: Row(
|
||||||
|
mainAxisSize: MainAxisSize.min,
|
||||||
|
children: [
|
||||||
|
_buildDot(0),
|
||||||
|
const SizedBox(width: 4),
|
||||||
|
_buildDot(1),
|
||||||
|
const SizedBox(width: 4),
|
||||||
|
_buildDot(2),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
Widget _buildDot(int index) {
|
||||||
|
return TweenAnimationBuilder<double>(
|
||||||
|
tween: Tween(begin: 0.3, end: 1.0),
|
||||||
|
duration: Duration(milliseconds: 600 + (index * 200)),
|
||||||
|
builder: (context, value, child) {
|
||||||
|
return Opacity(
|
||||||
|
opacity: value,
|
||||||
|
child: Container(
|
||||||
|
width: 6,
|
||||||
|
height: 6,
|
||||||
|
decoration: const BoxDecoration(
|
||||||
|
color: AppColors.subtleText,
|
||||||
|
shape: BoxShape.circle,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
},
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// ============================================================
|
||||||
|
// INPUT AREA - matches Figma: + circle, text input, send, mic circle
|
||||||
|
// ============================================================
|
||||||
|
|
||||||
|
Widget _buildInputArea() {
|
||||||
|
return Container(
|
||||||
|
padding: const EdgeInsets.symmetric(horizontal: 14, vertical: 14),
|
||||||
|
color: Colors.white,
|
||||||
|
child: Row(
|
||||||
|
crossAxisAlignment: CrossAxisAlignment.center,
|
||||||
|
children: [
|
||||||
|
// Plus icon in circle border
|
||||||
|
GestureDetector(
|
||||||
|
onTap: _showAttachmentOptions,
|
||||||
|
child: Container(
|
||||||
|
width: 36,
|
||||||
|
height: 36,
|
||||||
|
decoration: BoxDecoration(
|
||||||
|
color: Colors.white,
|
||||||
|
shape: BoxShape.circle,
|
||||||
|
border: Border.all(
|
||||||
|
color: AppColors.primaryDark,
|
||||||
|
width: 0.5,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
child: const Center(
|
||||||
|
child: Icon(
|
||||||
|
Icons.add,
|
||||||
|
size: 24,
|
||||||
|
color: AppColors.primaryDark,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
const SizedBox(width: 10),
|
||||||
|
|
||||||
|
// Text input
|
||||||
|
Expanded(
|
||||||
|
child: Container(
|
||||||
|
height: 38,
|
||||||
|
decoration: BoxDecoration(
|
||||||
|
color: Colors.white,
|
||||||
|
borderRadius: BorderRadius.circular(20),
|
||||||
|
border: Border.all(
|
||||||
|
color: AppColors.primaryDark,
|
||||||
|
width: 0.5,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
child: Row(
|
||||||
|
children: [
|
||||||
|
Expanded(
|
||||||
|
child: TextField(
|
||||||
|
controller: _messageController,
|
||||||
|
focusNode: _messageFocusNode,
|
||||||
|
textInputAction: TextInputAction.send,
|
||||||
|
onSubmitted: (_) => _sendMessage(),
|
||||||
|
style: const TextStyle(
|
||||||
|
fontFamily: 'Fractul',
|
||||||
|
fontSize: 14,
|
||||||
|
fontWeight: FontWeight.w500,
|
||||||
|
color: AppColors.primaryDark,
|
||||||
|
),
|
||||||
|
decoration: const InputDecoration(
|
||||||
|
hintText: 'Write a Message',
|
||||||
|
hintStyle: TextStyle(
|
||||||
|
fontFamily: 'Fractul',
|
||||||
|
fontSize: 14,
|
||||||
|
fontWeight: FontWeight.w500,
|
||||||
|
color: AppColors.primaryDark,
|
||||||
|
),
|
||||||
|
contentPadding: EdgeInsets.symmetric(
|
||||||
|
horizontal: 16,
|
||||||
|
vertical: 8,
|
||||||
|
),
|
||||||
|
border: InputBorder.none,
|
||||||
|
enabledBorder: InputBorder.none,
|
||||||
|
focusedBorder: InputBorder.none,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
// Send icon inside the input field
|
||||||
|
GestureDetector(
|
||||||
|
onTap: _sendMessage,
|
||||||
|
behavior: HitTestBehavior.opaque,
|
||||||
|
child: Padding(
|
||||||
|
padding: const EdgeInsets.only(right: 12),
|
||||||
|
child: Icon(
|
||||||
|
Icons.send,
|
||||||
|
size: 20,
|
||||||
|
color: AppColors.accentOrange,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
const SizedBox(width: 10),
|
||||||
|
|
||||||
|
// Mic icon in circle border
|
||||||
|
GestureDetector(
|
||||||
|
onTap: () => _showComingSoon('Speech to text'),
|
||||||
|
child: Container(
|
||||||
|
width: 36,
|
||||||
|
height: 36,
|
||||||
|
decoration: BoxDecoration(
|
||||||
|
color: Colors.white,
|
||||||
|
shape: BoxShape.circle,
|
||||||
|
border: Border.all(
|
||||||
|
color: AppColors.primaryDark,
|
||||||
|
width: 0.5,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
child: const Center(
|
||||||
|
child: Icon(
|
||||||
|
Icons.mic_none,
|
||||||
|
size: 20,
|
||||||
|
color: AppColors.primaryDark,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// ============================================================
|
||||||
|
// HELPERS
|
||||||
|
// ============================================================
|
||||||
|
|
||||||
|
void _showComingSoon(String feature) {
|
||||||
|
ScaffoldMessenger.of(context).showSnackBar(
|
||||||
|
SnackBar(
|
||||||
|
content: Text('$feature coming soon'),
|
||||||
|
duration: const Duration(seconds: 1),
|
||||||
|
backgroundColor: AppColors.primaryDark,
|
||||||
|
behavior: SnackBarBehavior.floating,
|
||||||
|
shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(8)),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
void _showAttachmentOptions() {
|
||||||
|
showModalBottomSheet(
|
||||||
|
context: context,
|
||||||
|
backgroundColor: Colors.white,
|
||||||
|
shape: const RoundedRectangleBorder(
|
||||||
|
borderRadius: BorderRadius.vertical(top: Radius.circular(20)),
|
||||||
|
),
|
||||||
|
builder: (ctx) {
|
||||||
|
return Padding(
|
||||||
|
padding: const EdgeInsets.all(20),
|
||||||
|
child: Column(
|
||||||
|
mainAxisSize: MainAxisSize.min,
|
||||||
|
children: [
|
||||||
|
Container(
|
||||||
|
width: 40,
|
||||||
|
height: 4,
|
||||||
|
decoration: BoxDecoration(
|
||||||
|
color: AppColors.divider,
|
||||||
|
borderRadius: BorderRadius.circular(2),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
const SizedBox(height: 20),
|
||||||
|
_buildAttachmentOption(
|
||||||
|
icon: Icons.image_outlined,
|
||||||
|
label: 'Photo',
|
||||||
|
onTap: () {
|
||||||
|
Navigator.pop(ctx);
|
||||||
|
_showComingSoon('Photo attachment');
|
||||||
|
},
|
||||||
|
),
|
||||||
|
_buildAttachmentOption(
|
||||||
|
icon: Icons.attach_file,
|
||||||
|
label: 'Document',
|
||||||
|
onTap: () {
|
||||||
|
Navigator.pop(ctx);
|
||||||
|
_showComingSoon('Document attachment');
|
||||||
|
},
|
||||||
|
),
|
||||||
|
_buildAttachmentOption(
|
||||||
|
icon: Icons.location_on_outlined,
|
||||||
|
label: 'Location',
|
||||||
|
onTap: () {
|
||||||
|
Navigator.pop(ctx);
|
||||||
|
_showComingSoon('Location sharing');
|
||||||
|
},
|
||||||
|
),
|
||||||
|
const SizedBox(height: 10),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
);
|
||||||
|
},
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
Widget _buildAttachmentOption({
|
||||||
|
required IconData icon,
|
||||||
|
required String label,
|
||||||
|
required VoidCallback onTap,
|
||||||
|
}) {
|
||||||
|
return ListTile(
|
||||||
|
leading: Icon(icon, color: AppColors.primaryDark),
|
||||||
|
title: Text(
|
||||||
|
label,
|
||||||
|
style: const TextStyle(
|
||||||
|
fontFamily: 'Fractul',
|
||||||
|
fontSize: 14,
|
||||||
|
fontWeight: FontWeight.w500,
|
||||||
|
color: AppColors.primaryDark,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
onTap: onTap,
|
||||||
|
shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(8)),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// -- Avatar Fallback --
|
||||||
|
|
||||||
|
class _AvatarFallback extends StatelessWidget {
|
||||||
|
final String letter;
|
||||||
|
final double size;
|
||||||
|
|
||||||
|
const _AvatarFallback({required this.letter, this.size = 45});
|
||||||
|
|
||||||
|
@override
|
||||||
|
Widget build(BuildContext context) {
|
||||||
|
return Container(
|
||||||
|
width: size,
|
||||||
|
height: size,
|
||||||
|
decoration: const BoxDecoration(
|
||||||
|
color: Color(0xFFE8E8E8),
|
||||||
|
shape: BoxShape.circle,
|
||||||
|
),
|
||||||
|
alignment: Alignment.center,
|
||||||
|
child: Text(
|
||||||
|
letter,
|
||||||
|
style: TextStyle(
|
||||||
|
fontFamily: 'Fractul',
|
||||||
|
fontSize: size * 0.38,
|
||||||
|
fontWeight: FontWeight.w700,
|
||||||
|
color: AppColors.primaryDark,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,527 @@
|
|||||||
|
import 'package:cached_network_image/cached_network_image.dart';
|
||||||
|
import 'package:flutter/material.dart';
|
||||||
|
import 'package:flutter_riverpod/flutter_riverpod.dart';
|
||||||
|
import 'package:go_router/go_router.dart';
|
||||||
|
import 'package:shimmer/shimmer.dart';
|
||||||
|
import 'package:real_estate_mobile/config/app_config.dart';
|
||||||
|
import 'package:real_estate_mobile/core/constants/app_colors.dart';
|
||||||
|
import 'package:real_estate_mobile/features/messaging/data/models/messaging_models.dart';
|
||||||
|
import 'package:real_estate_mobile/features/messaging/presentation/providers/messaging_provider.dart';
|
||||||
|
|
||||||
|
class ConversationsScreen extends ConsumerStatefulWidget {
|
||||||
|
const ConversationsScreen({super.key});
|
||||||
|
|
||||||
|
@override
|
||||||
|
ConsumerState<ConversationsScreen> createState() =>
|
||||||
|
_ConversationsScreenState();
|
||||||
|
}
|
||||||
|
|
||||||
|
class _ConversationsScreenState extends ConsumerState<ConversationsScreen> {
|
||||||
|
final TextEditingController _searchController = TextEditingController();
|
||||||
|
String _searchQuery = '';
|
||||||
|
bool _isSearchActive = false;
|
||||||
|
|
||||||
|
@override
|
||||||
|
void initState() {
|
||||||
|
super.initState();
|
||||||
|
WidgetsBinding.instance.addPostFrameCallback((_) {
|
||||||
|
ref.read(messagingProvider.notifier).loadConversations();
|
||||||
|
});
|
||||||
|
_searchController.addListener(() {
|
||||||
|
setState(() {
|
||||||
|
_searchQuery = _searchController.text.trim().toLowerCase();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
void dispose() {
|
||||||
|
_searchController.dispose();
|
||||||
|
super.dispose();
|
||||||
|
}
|
||||||
|
|
||||||
|
List<Conversation> _filteredConversations(List<Conversation> conversations) {
|
||||||
|
if (_searchQuery.isEmpty) return conversations;
|
||||||
|
return conversations
|
||||||
|
.where((c) => c.otherParty.name.toLowerCase().contains(_searchQuery))
|
||||||
|
.toList();
|
||||||
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
Widget build(BuildContext context) {
|
||||||
|
final state = ref.watch(messagingProvider);
|
||||||
|
final filtered = _filteredConversations(state.conversations);
|
||||||
|
|
||||||
|
return Scaffold(
|
||||||
|
backgroundColor: Colors.white,
|
||||||
|
body: SafeArea(
|
||||||
|
child: Column(
|
||||||
|
children: [
|
||||||
|
_buildHeader(),
|
||||||
|
Expanded(child: _buildConversationList(state, filtered)),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// -- Header with back arrow, search, icons --
|
||||||
|
Widget _buildHeader() {
|
||||||
|
return Container(
|
||||||
|
padding: const EdgeInsets.symmetric(horizontal: 12, vertical: 12),
|
||||||
|
child: Container(
|
||||||
|
height: 51,
|
||||||
|
decoration: BoxDecoration(
|
||||||
|
border: Border.all(
|
||||||
|
color: AppColors.primaryDark,
|
||||||
|
width: 0.5,
|
||||||
|
),
|
||||||
|
borderRadius: BorderRadius.circular(7),
|
||||||
|
),
|
||||||
|
child: Row(
|
||||||
|
children: [
|
||||||
|
// Back arrow
|
||||||
|
GestureDetector(
|
||||||
|
onTap: () {
|
||||||
|
if (_isSearchActive) {
|
||||||
|
setState(() {
|
||||||
|
_isSearchActive = false;
|
||||||
|
_searchController.clear();
|
||||||
|
});
|
||||||
|
} else {
|
||||||
|
context.go('/home');
|
||||||
|
}
|
||||||
|
},
|
||||||
|
behavior: HitTestBehavior.opaque,
|
||||||
|
child: Padding(
|
||||||
|
padding: const EdgeInsets.symmetric(horizontal: 12),
|
||||||
|
child: Icon(
|
||||||
|
Icons.arrow_back_ios_new,
|
||||||
|
size: 17,
|
||||||
|
color: AppColors.primaryDark,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
// Search text or input
|
||||||
|
Expanded(
|
||||||
|
child: _isSearchActive
|
||||||
|
? TextField(
|
||||||
|
controller: _searchController,
|
||||||
|
autofocus: true,
|
||||||
|
style: const TextStyle(
|
||||||
|
fontFamily: 'Fractul',
|
||||||
|
fontSize: 15,
|
||||||
|
fontWeight: FontWeight.w700,
|
||||||
|
color: AppColors.primaryDark,
|
||||||
|
),
|
||||||
|
decoration: const InputDecoration(
|
||||||
|
hintText: 'Search Messages',
|
||||||
|
hintStyle: TextStyle(
|
||||||
|
fontFamily: 'Fractul',
|
||||||
|
fontSize: 15,
|
||||||
|
fontWeight: FontWeight.w700,
|
||||||
|
color: AppColors.hintText,
|
||||||
|
),
|
||||||
|
border: InputBorder.none,
|
||||||
|
contentPadding: EdgeInsets.zero,
|
||||||
|
isDense: true,
|
||||||
|
),
|
||||||
|
)
|
||||||
|
: GestureDetector(
|
||||||
|
onTap: () => setState(() => _isSearchActive = true),
|
||||||
|
child: const Text(
|
||||||
|
'Search Messages',
|
||||||
|
style: TextStyle(
|
||||||
|
fontFamily: 'Fractul',
|
||||||
|
fontSize: 15,
|
||||||
|
fontWeight: FontWeight.w700,
|
||||||
|
color: AppColors.primaryDark,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
// Search icon
|
||||||
|
GestureDetector(
|
||||||
|
onTap: () => setState(() => _isSearchActive = true),
|
||||||
|
behavior: HitTestBehavior.opaque,
|
||||||
|
child: Padding(
|
||||||
|
padding: const EdgeInsets.symmetric(horizontal: 8),
|
||||||
|
child: Icon(
|
||||||
|
Icons.search,
|
||||||
|
size: 20,
|
||||||
|
color: AppColors.primaryDark,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
// Three dots menu
|
||||||
|
GestureDetector(
|
||||||
|
onTap: () {},
|
||||||
|
behavior: HitTestBehavior.opaque,
|
||||||
|
child: Padding(
|
||||||
|
padding: const EdgeInsets.only(right: 12, left: 4),
|
||||||
|
child: Icon(
|
||||||
|
Icons.more_horiz,
|
||||||
|
size: 20,
|
||||||
|
color: AppColors.primaryDark,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// -- Shimmer Loading State --
|
||||||
|
Widget _buildShimmerLoading() {
|
||||||
|
return Shimmer.fromColors(
|
||||||
|
baseColor: const Color(0xFFE8E8E8),
|
||||||
|
highlightColor: const Color(0xFFF5F5F5),
|
||||||
|
child: ListView.builder(
|
||||||
|
padding: const EdgeInsets.symmetric(horizontal: 12),
|
||||||
|
physics: const NeverScrollableScrollPhysics(),
|
||||||
|
itemCount: 6,
|
||||||
|
itemBuilder: (_, index) => Padding(
|
||||||
|
padding: const EdgeInsets.symmetric(vertical: 14),
|
||||||
|
child: Row(
|
||||||
|
children: [
|
||||||
|
Container(
|
||||||
|
width: 52,
|
||||||
|
height: 52,
|
||||||
|
decoration: const BoxDecoration(
|
||||||
|
color: Colors.white,
|
||||||
|
shape: BoxShape.circle,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
const SizedBox(width: 10),
|
||||||
|
Expanded(
|
||||||
|
child: Column(
|
||||||
|
crossAxisAlignment: CrossAxisAlignment.start,
|
||||||
|
children: [
|
||||||
|
Container(
|
||||||
|
width: 130,
|
||||||
|
height: 14,
|
||||||
|
decoration: BoxDecoration(
|
||||||
|
color: Colors.white,
|
||||||
|
borderRadius: BorderRadius.circular(4),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
const SizedBox(height: 8),
|
||||||
|
Container(
|
||||||
|
width: double.infinity,
|
||||||
|
height: 12,
|
||||||
|
decoration: BoxDecoration(
|
||||||
|
color: Colors.white,
|
||||||
|
borderRadius: BorderRadius.circular(4),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
),
|
||||||
|
const SizedBox(width: 8),
|
||||||
|
Container(
|
||||||
|
width: 45,
|
||||||
|
height: 12,
|
||||||
|
decoration: BoxDecoration(
|
||||||
|
color: Colors.white,
|
||||||
|
borderRadius: BorderRadius.circular(4),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// -- Conversation List --
|
||||||
|
Widget _buildConversationList(
|
||||||
|
MessagingState state, List<Conversation> filtered) {
|
||||||
|
if (state.isLoadingConversations) {
|
||||||
|
return _buildShimmerLoading();
|
||||||
|
}
|
||||||
|
|
||||||
|
if (filtered.isEmpty) {
|
||||||
|
return Center(
|
||||||
|
child: Column(
|
||||||
|
mainAxisAlignment: MainAxisAlignment.center,
|
||||||
|
children: [
|
||||||
|
Icon(
|
||||||
|
Icons.chat_bubble_outline_rounded,
|
||||||
|
color: AppColors.hintText,
|
||||||
|
size: 56,
|
||||||
|
),
|
||||||
|
const SizedBox(height: 16),
|
||||||
|
Text(
|
||||||
|
_searchQuery.isNotEmpty
|
||||||
|
? 'No conversations found'
|
||||||
|
: 'No messages yet',
|
||||||
|
style: const TextStyle(
|
||||||
|
fontFamily: 'Fractul',
|
||||||
|
fontSize: 16,
|
||||||
|
fontWeight: FontWeight.w600,
|
||||||
|
color: AppColors.primaryDark,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
const SizedBox(height: 8),
|
||||||
|
Text(
|
||||||
|
_searchQuery.isNotEmpty
|
||||||
|
? 'Try a different search term'
|
||||||
|
: 'Start a conversation with a professional',
|
||||||
|
style: const TextStyle(
|
||||||
|
fontFamily: 'SourceSerif4',
|
||||||
|
fontSize: 14,
|
||||||
|
fontWeight: FontWeight.w400,
|
||||||
|
color: AppColors.subtleText,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
if (state.error != null) ...[
|
||||||
|
const SizedBox(height: 20),
|
||||||
|
TextButton.icon(
|
||||||
|
onPressed: () =>
|
||||||
|
ref.read(messagingProvider.notifier).loadConversations(),
|
||||||
|
icon: const Icon(
|
||||||
|
Icons.refresh,
|
||||||
|
color: AppColors.accentOrange,
|
||||||
|
size: 18,
|
||||||
|
),
|
||||||
|
label: const Text(
|
||||||
|
'Retry',
|
||||||
|
style: TextStyle(
|
||||||
|
fontFamily: 'Fractul',
|
||||||
|
fontSize: 14,
|
||||||
|
fontWeight: FontWeight.w600,
|
||||||
|
color: AppColors.accentOrange,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
],
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
return RefreshIndicator(
|
||||||
|
color: AppColors.accentOrange,
|
||||||
|
onRefresh: () =>
|
||||||
|
ref.read(messagingProvider.notifier).loadConversations(),
|
||||||
|
child: ListView.separated(
|
||||||
|
padding: EdgeInsets.zero,
|
||||||
|
itemCount: filtered.length,
|
||||||
|
separatorBuilder: (_, __) => Divider(
|
||||||
|
color: AppColors.primaryDark.withValues(alpha: 0.1),
|
||||||
|
height: 0.5,
|
||||||
|
thickness: 0.5,
|
||||||
|
),
|
||||||
|
itemBuilder: (context, index) {
|
||||||
|
return _ConversationTile(
|
||||||
|
conversation: filtered[index],
|
||||||
|
onTap: () =>
|
||||||
|
context.push('/messages/chat/${filtered[index].id}'),
|
||||||
|
);
|
||||||
|
},
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// -- Conversation Tile Widget --
|
||||||
|
|
||||||
|
class _ConversationTile extends StatelessWidget {
|
||||||
|
final Conversation conversation;
|
||||||
|
final VoidCallback onTap;
|
||||||
|
|
||||||
|
const _ConversationTile({
|
||||||
|
required this.conversation,
|
||||||
|
required this.onTap,
|
||||||
|
});
|
||||||
|
|
||||||
|
String _resolveAvatarUrl(String? avatarKey) {
|
||||||
|
if (avatarKey == null || avatarKey.isEmpty) return '';
|
||||||
|
if (avatarKey.startsWith('http://') || avatarKey.startsWith('https://')) {
|
||||||
|
return avatarKey;
|
||||||
|
}
|
||||||
|
final base = AppConfig.storageBaseUrl;
|
||||||
|
if (avatarKey.startsWith('/uploads')) {
|
||||||
|
return '$base$avatarKey';
|
||||||
|
}
|
||||||
|
return '$base/$avatarKey';
|
||||||
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
Widget build(BuildContext context) {
|
||||||
|
final otherParty = conversation.otherParty;
|
||||||
|
final avatarUrl = _resolveAvatarUrl(otherParty.avatar);
|
||||||
|
final lastMessage = conversation.lastMessageText ?? '';
|
||||||
|
final timestamp = _formatTimestamp(conversation.lastMessageAt);
|
||||||
|
final firstLetter = otherParty.name.isNotEmpty
|
||||||
|
? otherParty.name[0].toUpperCase()
|
||||||
|
: '?';
|
||||||
|
|
||||||
|
return InkWell(
|
||||||
|
onTap: onTap,
|
||||||
|
child: Padding(
|
||||||
|
padding: const EdgeInsets.symmetric(horizontal: 12, vertical: 12),
|
||||||
|
child: Row(
|
||||||
|
children: [
|
||||||
|
// Avatar with online indicator
|
||||||
|
Stack(
|
||||||
|
children: [
|
||||||
|
ClipOval(
|
||||||
|
child: avatarUrl.isNotEmpty
|
||||||
|
? CachedNetworkImage(
|
||||||
|
imageUrl: avatarUrl,
|
||||||
|
width: 52,
|
||||||
|
height: 52,
|
||||||
|
fit: BoxFit.cover,
|
||||||
|
placeholder: (_, url) => _AvatarFallback(
|
||||||
|
letter: firstLetter,
|
||||||
|
),
|
||||||
|
errorWidget: (_, url, err) => _AvatarFallback(
|
||||||
|
letter: firstLetter,
|
||||||
|
),
|
||||||
|
)
|
||||||
|
: _AvatarFallback(letter: firstLetter),
|
||||||
|
),
|
||||||
|
if (otherParty.isOnline)
|
||||||
|
Positioned(
|
||||||
|
right: 0,
|
||||||
|
bottom: 0,
|
||||||
|
child: Container(
|
||||||
|
width: 14,
|
||||||
|
height: 14,
|
||||||
|
decoration: BoxDecoration(
|
||||||
|
color: const Color(0xFF4CAF50),
|
||||||
|
shape: BoxShape.circle,
|
||||||
|
border: Border.all(
|
||||||
|
color: Colors.white,
|
||||||
|
width: 2.5,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
const SizedBox(width: 10),
|
||||||
|
// Name and last message
|
||||||
|
Expanded(
|
||||||
|
child: Column(
|
||||||
|
crossAxisAlignment: CrossAxisAlignment.start,
|
||||||
|
children: [
|
||||||
|
Text(
|
||||||
|
otherParty.name,
|
||||||
|
style: const TextStyle(
|
||||||
|
fontFamily: 'Fractul',
|
||||||
|
fontSize: 14,
|
||||||
|
fontWeight: FontWeight.w700,
|
||||||
|
color: AppColors.primaryDark,
|
||||||
|
),
|
||||||
|
maxLines: 1,
|
||||||
|
overflow: TextOverflow.ellipsis,
|
||||||
|
),
|
||||||
|
const SizedBox(height: 4),
|
||||||
|
Text(
|
||||||
|
lastMessage,
|
||||||
|
style: const TextStyle(
|
||||||
|
fontFamily: 'SourceSerif4',
|
||||||
|
fontSize: 14,
|
||||||
|
fontWeight: FontWeight.w400,
|
||||||
|
color: AppColors.primaryDark,
|
||||||
|
),
|
||||||
|
maxLines: 1,
|
||||||
|
overflow: TextOverflow.ellipsis,
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
),
|
||||||
|
const SizedBox(width: 8),
|
||||||
|
// Timestamp
|
||||||
|
Text(
|
||||||
|
timestamp,
|
||||||
|
style: const TextStyle(
|
||||||
|
fontFamily: 'SourceSerif4',
|
||||||
|
fontSize: 14,
|
||||||
|
fontWeight: FontWeight.w400,
|
||||||
|
color: AppColors.primaryDark,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// -- Avatar Fallback --
|
||||||
|
|
||||||
|
class _AvatarFallback extends StatelessWidget {
|
||||||
|
final String letter;
|
||||||
|
|
||||||
|
const _AvatarFallback({required this.letter});
|
||||||
|
|
||||||
|
@override
|
||||||
|
Widget build(BuildContext context) {
|
||||||
|
return Container(
|
||||||
|
width: 52,
|
||||||
|
height: 52,
|
||||||
|
decoration: const BoxDecoration(
|
||||||
|
color: Color(0xFFE8E8E8),
|
||||||
|
shape: BoxShape.circle,
|
||||||
|
),
|
||||||
|
alignment: Alignment.center,
|
||||||
|
child: Text(
|
||||||
|
letter,
|
||||||
|
style: const TextStyle(
|
||||||
|
fontFamily: 'Fractul',
|
||||||
|
fontSize: 20,
|
||||||
|
fontWeight: FontWeight.w700,
|
||||||
|
color: AppColors.primaryDark,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// -- Timestamp Formatting --
|
||||||
|
|
||||||
|
String _formatTimestamp(String? isoTimestamp) {
|
||||||
|
if (isoTimestamp == null || isoTimestamp.isEmpty) return '';
|
||||||
|
|
||||||
|
final dateTime = DateTime.tryParse(isoTimestamp);
|
||||||
|
if (dateTime == null) return '';
|
||||||
|
|
||||||
|
final now = DateTime.now();
|
||||||
|
final today = DateTime(now.year, now.month, now.day);
|
||||||
|
final messageDate = DateTime(dateTime.year, dateTime.month, dateTime.day);
|
||||||
|
final localTime = dateTime.toLocal();
|
||||||
|
final diff = now.difference(dateTime);
|
||||||
|
|
||||||
|
// "Now" for messages within the last 2 minutes
|
||||||
|
if (diff.inMinutes < 2) {
|
||||||
|
return 'Now';
|
||||||
|
}
|
||||||
|
|
||||||
|
// Same day: show time like "2 hours Ago"
|
||||||
|
if (messageDate == today) {
|
||||||
|
if (diff.inHours >= 1) {
|
||||||
|
return '${diff.inHours} hour${diff.inHours > 1 ? 's' : ''} Ago';
|
||||||
|
}
|
||||||
|
return '${diff.inMinutes} min Ago';
|
||||||
|
}
|
||||||
|
|
||||||
|
// Within last 30 days: show "X Days Ago"
|
||||||
|
final daysDiff = today.difference(messageDate).inDays;
|
||||||
|
if (daysDiff == 1) {
|
||||||
|
return 'Yesterday';
|
||||||
|
}
|
||||||
|
if (daysDiff <= 30) {
|
||||||
|
return '$daysDiff Days Ago';
|
||||||
|
}
|
||||||
|
|
||||||
|
// Older: show month and day
|
||||||
|
const months = [
|
||||||
|
'Jan', 'Feb', 'Mar', 'Apr', 'May', 'Jun',
|
||||||
|
'Jul', 'Aug', 'Sep', 'Oct', 'Nov', 'Dec',
|
||||||
|
];
|
||||||
|
return '${months[localTime.month - 1]} ${localTime.day}';
|
||||||
|
}
|
||||||
@@ -4,9 +4,12 @@ import 'package:go_router/go_router.dart';
|
|||||||
import 'package:real_estate_mobile/features/auth/presentation/providers/auth_provider.dart';
|
import 'package:real_estate_mobile/features/auth/presentation/providers/auth_provider.dart';
|
||||||
import 'package:real_estate_mobile/features/auth/presentation/screens/login_screen.dart';
|
import 'package:real_estate_mobile/features/auth/presentation/screens/login_screen.dart';
|
||||||
import 'package:real_estate_mobile/features/auth/presentation/screens/signup_screen.dart';
|
import 'package:real_estate_mobile/features/auth/presentation/screens/signup_screen.dart';
|
||||||
|
import 'package:real_estate_mobile/features/agents/presentation/screens/agent_detail_screen.dart';
|
||||||
import 'package:real_estate_mobile/features/agents/presentation/screens/agent_search_screen.dart';
|
import 'package:real_estate_mobile/features/agents/presentation/screens/agent_search_screen.dart';
|
||||||
import 'package:real_estate_mobile/features/faq/presentation/screens/faq_screen.dart';
|
import 'package:real_estate_mobile/features/faq/presentation/screens/faq_screen.dart';
|
||||||
import 'package:real_estate_mobile/features/home/presentation/screens/home_screen.dart';
|
import 'package:real_estate_mobile/features/home/presentation/screens/home_screen.dart';
|
||||||
|
import 'package:real_estate_mobile/features/messaging/presentation/screens/conversations_screen.dart';
|
||||||
|
import 'package:real_estate_mobile/features/messaging/presentation/screens/chat_screen.dart';
|
||||||
|
|
||||||
final routerProvider = Provider<GoRouter>((ref) {
|
final routerProvider = Provider<GoRouter>((ref) {
|
||||||
final authRefreshNotifier = _AuthRefreshNotifier();
|
final authRefreshNotifier = _AuthRefreshNotifier();
|
||||||
@@ -20,7 +23,7 @@ final routerProvider = Provider<GoRouter>((ref) {
|
|||||||
});
|
});
|
||||||
|
|
||||||
// Public routes accessible without authentication (matching web middleware)
|
// Public routes accessible without authentication (matching web middleware)
|
||||||
const publicRoutes = ['/home', '/agents/search', '/faq'];
|
const publicRoutes = ['/home', '/agents/search', '/agents/detail', '/faq'];
|
||||||
const authRoutes = ['/login', '/signup'];
|
const authRoutes = ['/login', '/signup'];
|
||||||
|
|
||||||
return GoRouter(
|
return GoRouter(
|
||||||
@@ -71,6 +74,24 @@ final routerProvider = Provider<GoRouter>((ref) {
|
|||||||
return AgentSearchScreen(initialQuery: query);
|
return AgentSearchScreen(initialQuery: query);
|
||||||
},
|
},
|
||||||
),
|
),
|
||||||
|
GoRoute(
|
||||||
|
path: '/agents/detail/:id',
|
||||||
|
builder: (context, state) {
|
||||||
|
final id = state.pathParameters['id']!;
|
||||||
|
return AgentDetailScreen(agentId: id);
|
||||||
|
},
|
||||||
|
),
|
||||||
|
GoRoute(
|
||||||
|
path: '/messages',
|
||||||
|
builder: (context, state) => const ConversationsScreen(),
|
||||||
|
),
|
||||||
|
GoRoute(
|
||||||
|
path: '/messages/chat/:id',
|
||||||
|
builder: (context, state) {
|
||||||
|
final id = state.pathParameters['id']!;
|
||||||
|
return ChatScreen(conversationId: id);
|
||||||
|
},
|
||||||
|
),
|
||||||
GoRoute(
|
GoRoute(
|
||||||
path: '/faq',
|
path: '/faq',
|
||||||
builder: (context, state) => const FaqScreen(),
|
builder: (context, state) => const FaqScreen(),
|
||||||
|
|||||||
@@ -6,9 +6,13 @@
|
|||||||
|
|
||||||
#include "generated_plugin_registrant.h"
|
#include "generated_plugin_registrant.h"
|
||||||
|
|
||||||
|
#include <emoji_picker_flutter/emoji_picker_flutter_plugin.h>
|
||||||
#include <flutter_secure_storage_linux/flutter_secure_storage_linux_plugin.h>
|
#include <flutter_secure_storage_linux/flutter_secure_storage_linux_plugin.h>
|
||||||
|
|
||||||
void fl_register_plugins(FlPluginRegistry* registry) {
|
void fl_register_plugins(FlPluginRegistry* registry) {
|
||||||
|
g_autoptr(FlPluginRegistrar) emoji_picker_flutter_registrar =
|
||||||
|
fl_plugin_registry_get_registrar_for_plugin(registry, "EmojiPickerFlutterPlugin");
|
||||||
|
emoji_picker_flutter_plugin_register_with_registrar(emoji_picker_flutter_registrar);
|
||||||
g_autoptr(FlPluginRegistrar) flutter_secure_storage_linux_registrar =
|
g_autoptr(FlPluginRegistrar) flutter_secure_storage_linux_registrar =
|
||||||
fl_plugin_registry_get_registrar_for_plugin(registry, "FlutterSecureStorageLinuxPlugin");
|
fl_plugin_registry_get_registrar_for_plugin(registry, "FlutterSecureStorageLinuxPlugin");
|
||||||
flutter_secure_storage_linux_plugin_register_with_registrar(flutter_secure_storage_linux_registrar);
|
flutter_secure_storage_linux_plugin_register_with_registrar(flutter_secure_storage_linux_registrar);
|
||||||
|
|||||||
@@ -3,6 +3,7 @@
|
|||||||
#
|
#
|
||||||
|
|
||||||
list(APPEND FLUTTER_PLUGIN_LIST
|
list(APPEND FLUTTER_PLUGIN_LIST
|
||||||
|
emoji_picker_flutter
|
||||||
flutter_secure_storage_linux
|
flutter_secure_storage_linux
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|||||||
@@ -5,12 +5,14 @@
|
|||||||
import FlutterMacOS
|
import FlutterMacOS
|
||||||
import Foundation
|
import Foundation
|
||||||
|
|
||||||
|
import emoji_picker_flutter
|
||||||
import flutter_secure_storage_macos
|
import flutter_secure_storage_macos
|
||||||
import path_provider_foundation
|
import path_provider_foundation
|
||||||
import shared_preferences_foundation
|
import shared_preferences_foundation
|
||||||
import sqflite_darwin
|
import sqflite_darwin
|
||||||
|
|
||||||
func RegisterGeneratedPlugins(registry: FlutterPluginRegistry) {
|
func RegisterGeneratedPlugins(registry: FlutterPluginRegistry) {
|
||||||
|
EmojiPickerFlutterPlugin.register(with: registry.registrar(forPlugin: "EmojiPickerFlutterPlugin"))
|
||||||
FlutterSecureStoragePlugin.register(with: registry.registrar(forPlugin: "FlutterSecureStoragePlugin"))
|
FlutterSecureStoragePlugin.register(with: registry.registrar(forPlugin: "FlutterSecureStoragePlugin"))
|
||||||
PathProviderPlugin.register(with: registry.registrar(forPlugin: "PathProviderPlugin"))
|
PathProviderPlugin.register(with: registry.registrar(forPlugin: "PathProviderPlugin"))
|
||||||
SharedPreferencesPlugin.register(with: registry.registrar(forPlugin: "SharedPreferencesPlugin"))
|
SharedPreferencesPlugin.register(with: registry.registrar(forPlugin: "SharedPreferencesPlugin"))
|
||||||
|
|||||||
24
pubspec.lock
24
pubspec.lock
@@ -249,6 +249,14 @@ packages:
|
|||||||
url: "https://pub.dev"
|
url: "https://pub.dev"
|
||||||
source: hosted
|
source: hosted
|
||||||
version: "2.1.1"
|
version: "2.1.1"
|
||||||
|
emoji_picker_flutter:
|
||||||
|
dependency: "direct main"
|
||||||
|
description:
|
||||||
|
name: emoji_picker_flutter
|
||||||
|
sha256: "984d3e9b9cf3175df9a868ce4a2d9611491e80e5d3b8e2b1e8991a4998972885"
|
||||||
|
url: "https://pub.dev"
|
||||||
|
source: hosted
|
||||||
|
version: "4.4.0"
|
||||||
fake_async:
|
fake_async:
|
||||||
dependency: transitive
|
dependency: transitive
|
||||||
description:
|
description:
|
||||||
@@ -861,6 +869,22 @@ packages:
|
|||||||
description: flutter
|
description: flutter
|
||||||
source: sdk
|
source: sdk
|
||||||
version: "0.0.0"
|
version: "0.0.0"
|
||||||
|
socket_io_client:
|
||||||
|
dependency: "direct main"
|
||||||
|
description:
|
||||||
|
name: socket_io_client
|
||||||
|
sha256: ef6c989e5eee8d04baf18482ec3d7699b91bc41e279794a99d8e3bef897b074a
|
||||||
|
url: "https://pub.dev"
|
||||||
|
source: hosted
|
||||||
|
version: "3.1.4"
|
||||||
|
socket_io_common:
|
||||||
|
dependency: transitive
|
||||||
|
description:
|
||||||
|
name: socket_io_common
|
||||||
|
sha256: "162fbaecbf4bf9a9372a62a341b3550b51dcef2f02f3e5830a297fd48203d45b"
|
||||||
|
url: "https://pub.dev"
|
||||||
|
source: hosted
|
||||||
|
version: "3.1.1"
|
||||||
source_gen:
|
source_gen:
|
||||||
dependency: transitive
|
dependency: transitive
|
||||||
description:
|
description:
|
||||||
|
|||||||
@@ -36,6 +36,10 @@ dependencies:
|
|||||||
cached_network_image: ^3.4.1
|
cached_network_image: ^3.4.1
|
||||||
shimmer: ^3.0.0
|
shimmer: ^3.0.0
|
||||||
|
|
||||||
|
# Real-time messaging
|
||||||
|
socket_io_client: ^3.0.2
|
||||||
|
emoji_picker_flutter: ^4.3.0
|
||||||
|
|
||||||
# Pin to avoid objective_c crash on iOS 26 simulator
|
# Pin to avoid objective_c crash on iOS 26 simulator
|
||||||
path_provider_foundation: 2.4.0
|
path_provider_foundation: 2.4.0
|
||||||
|
|
||||||
|
|||||||
@@ -6,9 +6,12 @@
|
|||||||
|
|
||||||
#include "generated_plugin_registrant.h"
|
#include "generated_plugin_registrant.h"
|
||||||
|
|
||||||
|
#include <emoji_picker_flutter/emoji_picker_flutter_plugin_c_api.h>
|
||||||
#include <flutter_secure_storage_windows/flutter_secure_storage_windows_plugin.h>
|
#include <flutter_secure_storage_windows/flutter_secure_storage_windows_plugin.h>
|
||||||
|
|
||||||
void RegisterPlugins(flutter::PluginRegistry* registry) {
|
void RegisterPlugins(flutter::PluginRegistry* registry) {
|
||||||
|
EmojiPickerFlutterPluginCApiRegisterWithRegistrar(
|
||||||
|
registry->GetRegistrarForPlugin("EmojiPickerFlutterPluginCApi"));
|
||||||
FlutterSecureStorageWindowsPluginRegisterWithRegistrar(
|
FlutterSecureStorageWindowsPluginRegisterWithRegistrar(
|
||||||
registry->GetRegistrarForPlugin("FlutterSecureStorageWindowsPlugin"));
|
registry->GetRegistrarForPlugin("FlutterSecureStorageWindowsPlugin"));
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -3,6 +3,7 @@
|
|||||||
#
|
#
|
||||||
|
|
||||||
list(APPEND FLUTTER_PLUGIN_LIST
|
list(APPEND FLUTTER_PLUGIN_LIST
|
||||||
|
emoji_picker_flutter
|
||||||
flutter_secure_storage_windows
|
flutter_secure_storage_windows
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user