diff --git a/lib/core/constants/api_constants.dart b/lib/core/constants/api_constants.dart index bec6e93..ebe61b4 100644 --- a/lib/core/constants/api_constants.dart +++ b/lib/core/constants/api_constants.dart @@ -19,6 +19,16 @@ class ApiConstants { // Profile fields static const String filterableFields = '/profile-fields/filterable'; + // Testimonials + static const String testimonials = '/testimonials'; + + // Connection requests + static const String connectionRequests = '/connection-requests'; + // CMS static const String cmsPageLanding = '/cms/page/landing'; + + // Messages + static const String conversations = '/messages/conversations'; + static const String unreadCount = '/messages/unread-count'; } diff --git a/lib/features/agents/data/agents_repository.dart b/lib/features/agents/data/agents_repository.dart index 8b4879f..a42242f 100644 --- a/lib/features/agents/data/agents_repository.dart +++ b/lib/features/agents/data/agents_repository.dart @@ -108,6 +108,93 @@ class AgentsRepository { } } + /// Get agent by ID: GET /agents/{id} + /// Response: { success, data: AgentProfile } + Future getAgentById(String id) async { + try { + final response = await _dio.get('${ApiConstants.agents}/$id'); + final data = response.data['data'] as Map; + 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> getFieldValues(String agentId) async { + try { + final response = + await _dio.get('${ApiConstants.agents}/$agentId/field-values'); + final data = response.data['data'] as Map; + final fieldValues = data['fieldValues'] as List? ?? []; + return fieldValues + .map((e) => AgentFieldValue.fromFieldValueResponse( + e as Map)) + .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>> 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? ?? []; + return data.cast>(); + } on DioException catch (_) { + return []; + } + } + + /// Get connection status: GET /connection-requests/status/{agentProfileId} + Future?> getConnectionStatus(String agentId) async { + try { + final response = await _dio + .get('${ApiConstants.connectionRequests}/status/$agentId'); + return response.data['data'] as Map?; + } on DioException catch (_) { + return null; + } + } + + /// Create connection request: POST /connection-requests + Future 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 cancelConnectionRequest(String requestId) async { + try { + await _dio.delete('${ApiConstants.connectionRequests}/$requestId'); + return true; + } on DioException catch (_) { + return false; + } + } + /// Get agent types: GET /agent-types /// Response: { success, data: [...] } Future> getAgentTypes() async { diff --git a/lib/features/agents/data/models/agent_profile.dart b/lib/features/agents/data/models/agent_profile.dart index 625e641..2d51022 100644 --- a/lib/features/agents/data/models/agent_profile.dart +++ b/lib/features/agents/data/models/agent_profile.dart @@ -19,6 +19,8 @@ class AgentProfile { final bool isVerified; final bool isFeatured; final bool isActive; + final bool isAvailable; + final String? email; final AgentType? agentType; final AgentUser? user; final List fieldValues; @@ -46,6 +48,8 @@ class AgentProfile { this.isVerified = false, this.isFeatured = false, this.isActive = true, + this.isAvailable = false, + this.email, this.agentType, this.user, this.fieldValues = const [], @@ -212,6 +216,8 @@ class AgentProfile { isVerified: json['isVerified'] as bool? ?? false, isFeatured: json['isFeatured'] as bool? ?? false, isActive: json['isActive'] as bool? ?? true, + isAvailable: json['isAvailable'] as bool? ?? false, + email: json['email'] as String?, agentType: json['agentType'] != null ? AgentType.fromJson(json['agentType'] as Map) : null, @@ -264,6 +270,8 @@ class AgentFieldValue { final String fieldSlug; final String fieldName; final String fieldType; + final String? sectionSlug; + final String? sectionName; final String? textValue; final num? numberValue; final bool? booleanValue; @@ -275,6 +283,8 @@ class AgentFieldValue { required this.fieldSlug, required this.fieldName, required this.fieldType, + this.sectionSlug, + this.sectionName, this.textValue, this.numberValue, this.booleanValue, @@ -282,6 +292,39 @@ class AgentFieldValue { this.dateValue, }); + /// Parse from the /agents/{id}/field-values endpoint format + /// which has { fieldSlug, fieldName, fieldType, sectionSlug, value } + factory AgentFieldValue.fromFieldValueResponse(Map 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 json) { // API returns field info nested: { field: { slug, name, fieldType } } // Fallback to top-level fieldSlug/fieldName/fieldType for compatibility diff --git a/lib/features/agents/presentation/providers/agent_detail_provider.dart b/lib/features/agents/presentation/providers/agent_detail_provider.dart new file mode 100644 index 0000000..a484e74 --- /dev/null +++ b/lib/features/agents/presentation/providers/agent_detail_provider.dart @@ -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 fieldValues; + final List> testimonials; + final Map? 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? fieldValues, + List>? testimonials, + Map? 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 get licensingAreas { + final val = _getFieldValue('licensed_areas'); + if (val is List) return val.map((e) => titleCase(e.toString())).toList(); + return []; + } + + List> 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> 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 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 get specializationCards { + final cards = []; + 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 = []; + 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 values; + + const SpecializationCard({ + required this.slug, + required this.name, + required this.values, + }); +} + +class AgentDetailNotifier extends StateNotifier { + final AgentsRepository _repo; + final String agentId; + + AgentDetailNotifier(this._repo, this.agentId) + : super(const AgentDetailState()) { + _load(); + } + + Future _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, + testimonials: results[2] as List>, + isLoading: false, + ); + } catch (e) { + state = state.copyWith(isLoading: false, error: e.toString()); + } + } + + Future loadConnectionStatus() async { + final status = await _repo.getConnectionStatus(agentId); + state = state.copyWith(connectionStatus: status); + } + + Future connect({String? message}) async { + final success = + await _repo.createConnectionRequest(agentId, message: message); + if (success) { + state = state.copyWith( + connectionStatus: {'status': 'PENDING'}, + ); + } + return success; + } + + Future 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((ref, agentId) { + final repo = ref.read(agentsRepositoryProvider); + return AgentDetailNotifier(repo, agentId); +}); diff --git a/lib/features/agents/presentation/screens/agent_detail_screen.dart b/lib/features/agents/presentation/screens/agent_detail_screen.dart new file mode 100644 index 0000000..ae2a933 --- /dev/null +++ b/lib/features/agents/presentation/screens/agent_detail_screen.dart @@ -0,0 +1,1505 @@ +import 'package:flutter/material.dart'; +import 'package:flutter_riverpod/flutter_riverpod.dart'; +import 'package:flutter_svg/flutter_svg.dart'; +import 'package:go_router/go_router.dart'; +import 'package:real_estate_mobile/core/constants/app_colors.dart'; +import 'package:real_estate_mobile/core/widgets/s3_image.dart'; +import 'package:real_estate_mobile/features/agents/data/models/agent_profile.dart'; +import 'package:real_estate_mobile/features/agents/presentation/providers/agent_detail_provider.dart'; +import 'package:real_estate_mobile/features/auth/presentation/providers/auth_provider.dart'; +import 'package:real_estate_mobile/features/home/presentation/widgets/home_header.dart'; + +class AgentDetailScreen extends ConsumerStatefulWidget { + final String agentId; + + const AgentDetailScreen({super.key, required this.agentId}); + + @override + ConsumerState createState() => _AgentDetailScreenState(); +} + +class _AgentDetailScreenState extends ConsumerState { + bool _emailVisible = false; + bool _phoneVisible = false; + + @override + void initState() { + super.initState(); + _loadConnectionIfAuth(); + } + + void _loadConnectionIfAuth() { + WidgetsBinding.instance.addPostFrameCallback((_) { + final authState = ref.read(authProvider); + if (authState.status == AuthStatus.authenticated) { + ref.read(agentDetailProvider(widget.agentId).notifier).loadConnectionStatus(); + } + }); + } + + @override + Widget build(BuildContext context) { + final state = ref.watch(agentDetailProvider(widget.agentId)); + + return Scaffold( + backgroundColor: Colors.white, + body: state.isLoading + ? const Center( + child: CircularProgressIndicator(color: AppColors.accentOrange)) + : state.error != null + ? _buildError(state.error!) + : _buildContent(state), + bottomNavigationBar: _buildBottomNavBar(), + ); + } + + Widget _buildError(String error) { + return Center( + child: Padding( + padding: const EdgeInsets.all(24), + child: Column( + mainAxisSize: MainAxisSize.min, + children: [ + const Icon(Icons.error_outline, + size: 48, color: AppColors.accentOrange), + const SizedBox(height: 16), + const Text('Unable to Load Profile', + style: TextStyle( + fontFamily: 'Fractul', + fontSize: 18, + fontWeight: FontWeight.w700, + color: AppColors.primaryDark)), + const SizedBox(height: 12), + TextButton( + onPressed: () => context.pop(), + child: const Text('Go Back', + style: TextStyle(color: AppColors.accentOrange)), + ), + ], + ), + ), + ); + } + + Widget _buildContent(AgentDetailState state) { + final agent = state.agent!; + return SingleChildScrollView( + child: Column( + children: [ + SafeArea(bottom: false, child: const HomeHeader()), + + // ── Avatar Section ── + const SizedBox(height: 16), + _buildAvatarSection(agent), + + // ── Status + Connect Buttons ── + const SizedBox(height: 16), + _buildStatusButtons(state), + + // ── Contact Info ── + const SizedBox(height: 12), + _buildContactInfo(state), + + // ── Profile Info ── + const SizedBox(height: 16), + _buildProfileInfo(agent, state), + + // ── Experience Section ── + if (_hasExperience(state)) ...[ + const SizedBox(height: 24), + _buildExperienceSection(state), + ], + + // ── Info Cards (Availability, Work Env, Best Experience) ── + const SizedBox(height: 24), + _buildInfoCards(state), + + // ── Specialization Section ── + if (state.specializationCards.isNotEmpty) ...[ + const SizedBox(height: 24), + _buildSpecializationSection(state), + ], + + // ── Testimonials Section ── + if (state.testimonials.isNotEmpty) ...[ + const SizedBox(height: 24), + _buildTestimonialsSection(state), + ], + + const SizedBox(height: 30), + ], + ), + ); + } + + // ── Avatar ── + Widget _buildAvatarSection(AgentProfile agent) { + final screenWidth = MediaQuery.of(context).size.width; + final avatarWidth = screenWidth * 0.877; // 377/430 from Figma + return Center( + child: ClipRRect( + borderRadius: BorderRadius.circular(20), + child: SizedBox( + width: avatarWidth, + height: 346, + child: Stack( + fit: StackFit.expand, + children: [ + S3Image( + imageUrl: agent.avatarUrl, + width: avatarWidth, + height: 346, + fit: BoxFit.cover, + errorWidget: (_) => Container( + color: const Color(0xFFC4D9D4), + child: const Icon(Icons.person, + size: 80, color: AppColors.primaryDark), + ), + ), + // Gradient overlay + Positioned( + bottom: 0, + left: 0, + right: 0, + height: 100, + child: Container( + decoration: BoxDecoration( + gradient: LinearGradient( + begin: Alignment.bottomCenter, + end: Alignment.topCenter, + colors: [ + Colors.black.withValues(alpha: 0.5), + Colors.transparent, + ], + ), + ), + ), + ), + ], + ), + ), + ), + ); + } + + // ── Status + Connect ── + Widget _buildStatusButtons(AgentDetailState state) { + final agent = state.agent!; + final isAvailable = agent.isAvailable; + final connState = state.connectionState; + + return Padding( + padding: const EdgeInsets.symmetric(horizontal: 38), + child: Column( + children: [ + // Available row + _buildStatusRow( + isAvailable: true, + label: 'Available.', + active: isAvailable, + connState: connState, + onConnect: isAvailable + ? () => _handleConnect(state) + : null, + ), + const SizedBox(height: 10), + // Unavailable row + _buildStatusRow( + isAvailable: false, + label: 'Unavailable.', + active: !isAvailable, + connState: connState, + onConnect: !isAvailable + ? () => _handleConnect(state) + : null, + ), + ], + ), + ); + } + + Widget _buildStatusRow({ + required bool isAvailable, + required String label, + required bool active, + required String connState, + VoidCallback? onConnect, + }) { + return Container( + height: 55, + decoration: BoxDecoration( + borderRadius: BorderRadius.circular(15), + border: Border.all( + color: AppColors.primaryDark.withValues(alpha: 0.15), width: 1), + ), + child: Row( + children: [ + const SizedBox(width: 20), + // Status dot + Container( + width: 14, + height: 12, + decoration: BoxDecoration( + shape: BoxShape.circle, + color: active + ? (isAvailable ? const Color(0xFF4CAF50) : Colors.transparent) + : Colors.transparent, + border: Border.all( + color: active + ? (isAvailable + ? const Color(0xFF4CAF50) + : AppColors.primaryDark.withValues(alpha: 0.3)) + : AppColors.primaryDark.withValues(alpha: 0.15), + width: 2, + ), + ), + ), + const SizedBox(width: 12), + Text( + label, + style: TextStyle( + fontFamily: 'Fractul', + fontSize: 12, + fontWeight: FontWeight.w700, + color: active + ? AppColors.primaryDark + : AppColors.primaryDark.withValues(alpha: 0.4), + ), + ), + const Spacer(), + // Connect button + if (active) _buildConnectButton(connState, isAvailable, onConnect), + const SizedBox(width: 4), + ], + ), + ); + } + + Widget _buildConnectButton( + String connState, bool isAvailable, VoidCallback? onConnect) { + Color bgColor; + Color textColor; + String label; + bool enabled; + + switch (connState) { + case 'pending': + bgColor = const Color(0xFFFFF3CD); + textColor = const Color(0xFF856404); + label = 'Pending'; + enabled = false; + break; + case 'accepted': + bgColor = AppColors.primaryDark; + textColor = const Color(0xFFF0F5FC); + label = 'Unlink'; + enabled = true; + break; + default: + bgColor = isAvailable ? AppColors.accentOrange : AppColors.primaryDark; + textColor = isAvailable ? AppColors.primaryDark : const Color(0xFFF0F5FC); + label = 'Connect'; + enabled = true; + } + + return GestureDetector( + onTap: enabled ? onConnect : null, + child: Container( + height: 55, + width: 164, + decoration: BoxDecoration( + color: bgColor, + borderRadius: BorderRadius.circular(15), + border: isAvailable && connState == 'none' + ? Border.all(color: AppColors.accentOrange) + : null, + ), + alignment: Alignment.center, + child: Text( + label, + style: TextStyle( + fontFamily: 'Fractul', + fontSize: 14, + fontWeight: FontWeight.w400, + color: textColor, + ), + ), + ), + ); + } + + void _handleConnect(AgentDetailState state) { + final authState = ref.read(authProvider); + if (authState.status != AuthStatus.authenticated) { + context.push('/login'); + return; + } + if (state.connectionState == 'accepted') { + ref.read(agentDetailProvider(widget.agentId).notifier).cancelConnection(); + return; + } + _showConnectModal(state); + } + + void _showConnectModal(AgentDetailState state) { + final controller = TextEditingController(); + showModalBottomSheet( + context: context, + isScrollControlled: true, + backgroundColor: Colors.transparent, + builder: (ctx) => Padding( + padding: EdgeInsets.only( + bottom: MediaQuery.of(ctx).viewInsets.bottom), + child: Container( + decoration: const BoxDecoration( + color: Colors.white, + borderRadius: BorderRadius.vertical(top: Radius.circular(20)), + ), + padding: const EdgeInsets.all(24), + child: Column( + mainAxisSize: MainAxisSize.min, + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Row( + mainAxisAlignment: MainAxisAlignment.spaceBetween, + children: [ + Text( + 'Connect with ${state.agent?.fullName ?? ''}', + style: const TextStyle( + fontFamily: 'Fractul', + fontSize: 18, + fontWeight: FontWeight.w700, + color: AppColors.primaryDark, + ), + ), + GestureDetector( + onTap: () => Navigator.pop(ctx), + child: const Icon(Icons.close, color: AppColors.primaryDark), + ), + ], + ), + const SizedBox(height: 8), + const Text( + 'Send a connection request to this agent.', + style: TextStyle( + fontFamily: 'SourceSerif4', + fontSize: 14, + color: AppColors.primaryDark, + ), + ), + const SizedBox(height: 16), + TextField( + controller: controller, + maxLines: 4, + maxLength: 1000, + decoration: InputDecoration( + hintText: 'Introduce yourself... (optional)', + hintStyle: TextStyle( + fontFamily: 'SourceSerif4', + fontSize: 14, + color: AppColors.primaryDark.withValues(alpha: 0.4), + ), + border: OutlineInputBorder( + borderRadius: BorderRadius.circular(10), + borderSide: BorderSide( + color: AppColors.primaryDark.withValues(alpha: 0.15)), + ), + ), + ), + const SizedBox(height: 16), + SizedBox( + width: double.infinity, + height: 50, + child: ElevatedButton( + onPressed: () async { + Navigator.pop(ctx); + await ref + .read(agentDetailProvider(widget.agentId).notifier) + .connect(message: controller.text); + }, + style: ElevatedButton.styleFrom( + backgroundColor: AppColors.accentOrange, + shape: RoundedRectangleBorder( + borderRadius: BorderRadius.circular(15)), + ), + child: const Text( + 'Send Request', + style: TextStyle( + fontFamily: 'Fractul', + fontSize: 16, + fontWeight: FontWeight.w700, + color: Colors.white, + ), + ), + ), + ), + ], + ), + ), + ), + ); + } + + // ── Contact Info ── + Widget _buildContactInfo(AgentDetailState state) { + final email = state.contactEmail; + final phone = state.contactPhone; + if (email == null && phone == null) return const SizedBox.shrink(); + + return Padding( + padding: const EdgeInsets.symmetric(horizontal: 38), + child: Container( + height: 98, + padding: const EdgeInsets.symmetric(horizontal: 18, vertical: 16), + decoration: BoxDecoration( + borderRadius: BorderRadius.circular(15), + border: Border.all( + color: AppColors.primaryDark.withValues(alpha: 0.15), width: 1), + ), + child: Column( + mainAxisAlignment: MainAxisAlignment.center, + children: [ + if (email != null) + _buildContactRow( + label: 'Email:', + value: _emailVisible ? email : state.maskEmail(email), + onToggle: () => setState(() => _emailVisible = !_emailVisible), + visible: _emailVisible, + ), + if (email != null && phone != null) const SizedBox(height: 12), + if (phone != null) + _buildContactRow( + label: 'Ph.No:', + value: _phoneVisible ? phone : state.maskPhone(phone), + onToggle: () => setState(() => _phoneVisible = !_phoneVisible), + visible: _phoneVisible, + ), + ], + ), + ), + ); + } + + Widget _buildContactRow({ + required String label, + required String value, + required VoidCallback onToggle, + required bool visible, + }) { + return Row( + children: [ + Text( + label, + style: const TextStyle( + fontFamily: 'Fractul', + fontSize: 14, + fontWeight: FontWeight.w700, + color: AppColors.primaryDark, + ), + ), + const SizedBox(width: 8), + Expanded( + child: Text( + value, + style: const TextStyle( + fontFamily: 'SourceSerif4', + fontSize: 14, + color: AppColors.primaryDark, + ), + ), + ), + const SizedBox(width: 8), + GestureDetector( + onTap: onToggle, + child: Icon( + visible ? Icons.visibility : Icons.visibility_off, + size: 18, + color: AppColors.primaryDark.withValues(alpha: 0.6), + ), + ), + ], + ); + } + + // ── Profile Info (Name, Title, Location, Bio) ── + Widget _buildProfileInfo(AgentProfile agent, AgentDetailState state) { + return Padding( + padding: const EdgeInsets.symmetric(horizontal: 38), + child: Column( + crossAxisAlignment: CrossAxisAlignment.center, + children: [ + // Name + Verified badge + (Verified Expert) on same line + Row( + mainAxisAlignment: MainAxisAlignment.center, + crossAxisAlignment: CrossAxisAlignment.baseline, + textBaseline: TextBaseline.alphabetic, + children: [ + Text( + agent.firstName, + style: const TextStyle( + fontFamily: 'Fractul', + fontSize: 18, + fontWeight: FontWeight.w700, + color: AppColors.primaryDark, + ), + ), + const SizedBox(width: 4), + if (agent.isVerified) ...[ + const Padding( + padding: EdgeInsets.only(bottom: 1), + child: Icon(Icons.verified, size: 16, color: Color(0xFF2196F3)), + ), + const SizedBox(width: 4), + ], + Text( + agent.lastName, + style: const TextStyle( + fontFamily: 'Fractul', + fontSize: 18, + fontWeight: FontWeight.w400, + color: AppColors.primaryDark, + ), + ), + if (agent.isVerified) ...[ + const SizedBox(width: 6), + const Text( + '(Verified Expert)', + style: TextStyle( + fontFamily: 'SourceSerif4', + fontSize: 14, + fontWeight: FontWeight.w500, + color: Color(0xFF638559), + ), + ), + ], + ], + ), + // Title + if (agent.agentType != null) ...[ + const SizedBox(height: 6), + Text( + agent.headline ?? agent.agentType!.name, + style: const TextStyle( + fontFamily: 'Fractul', + fontSize: 14, + color: AppColors.primaryDark, + ), + textAlign: TextAlign.center, + ), + ], + const SizedBox(height: 8), + // Location + Member Since + Row( + mainAxisAlignment: MainAxisAlignment.center, + children: [ + if (agent.location.isNotEmpty) ...[ + Icon(Icons.location_on, + size: 16, color: AppColors.primaryDark.withValues(alpha: 0.7)), + const SizedBox(width: 4), + Text( + agent.location, + style: const TextStyle( + fontFamily: 'SourceSerif4', + fontSize: 14, + fontWeight: FontWeight.w700, + color: AppColors.primaryDark, + ), + ), + const SizedBox(width: 20), + ], + Icon(Icons.calendar_today, + size: 14, color: AppColors.primaryDark.withValues(alpha: 0.7)), + const SizedBox(width: 4), + Text( + agent.memberSinceText, + style: const TextStyle( + fontFamily: 'SourceSerif4', + fontSize: 13, + fontWeight: FontWeight.w500, + color: AppColors.primaryDark, + ), + ), + ], + ), + // Bio + if (agent.description.isNotEmpty) ...[ + const SizedBox(height: 16), + SizedBox( + width: 338, + child: Text( + agent.description, + style: const TextStyle( + fontFamily: 'SourceSerif4', + fontSize: 14, + color: AppColors.primaryDark, + height: 1.43, + ), + textAlign: TextAlign.center, + ), + ), + ], + ], + ), + ); + } + + // ── Experience ── + bool _hasExperience(AgentDetailState s) => + s.yearsInExperience != '-' || + s.contractsClosed != '-' || + s.licensingAreas.isNotEmpty || + s.expertiseYears.isNotEmpty || + s.certifications.isNotEmpty; + + Widget _buildExperienceSection(AgentDetailState state) { + return Padding( + padding: const EdgeInsets.symmetric(horizontal: 17), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + const Center( + child: Text( + 'Experience', + style: TextStyle( + fontFamily: 'Fractul', + fontSize: 20, + fontWeight: FontWeight.w700, + color: AppColors.primaryDark, + ), + ), + ), + const SizedBox(height: 22), + // Years + if (state.yearsInExperience != '-') + _buildExperienceItem( + 'Years in Experience', [state.yearsInExperience]), + // Contracts + if (state.contractsClosed != '-') + _buildExperienceItem( + 'Number of contracts closed', [state.contractsClosed]), + // Licensing Areas + if (state.licensingAreas.isNotEmpty) + _buildLicensingAreas(state.licensingAreas), + // Expertise Years + if (state.expertiseYears.isNotEmpty) + _buildExpertiseYears(state.expertiseYears), + // Certifications + if (state.certifications.isNotEmpty) + _buildCertifications(state.certifications), + ], + ), + ); + } + + Widget _buildExperienceItem(String title, List items) { + return Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text( + title, + style: const TextStyle( + fontFamily: 'Fractul', + fontSize: 14, + fontWeight: FontWeight.w700, + color: AppColors.primaryDark, + ), + ), + const SizedBox(height: 14), + ...items.map((item) => Padding( + padding: const EdgeInsets.only(left: 10, bottom: 4), + child: Row( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + const Padding( + padding: EdgeInsets.only(top: 2), + child: Text('\u2022 ', + style: TextStyle(fontSize: 15, color: AppColors.primaryDark)), + ), + Expanded( + child: Text( + item, + style: const TextStyle( + fontFamily: 'SourceSerif4', + fontSize: 15, + fontWeight: FontWeight.w500, + color: AppColors.primaryDark, + ), + ), + ), + ], + ), + )), + const SizedBox(height: 16), + Divider(color: AppColors.primaryDark.withValues(alpha: 0.15), height: 1), + const SizedBox(height: 10), + ], + ); + } + + Widget _buildLicensingAreas(List areas) { + return _ExpandableChipsSection( + title: 'Licensing & Areas', + items: areas, + initialCount: 6, + ); + } + + Widget _buildExpertiseYears(List> items) { + final chips = items.map((e) { + final years = e['years'] ?? ''; + return years.isNotEmpty ? '${e['area']} – $years' : e['area'] ?? ''; + }).toList(); + return _ExpandableChipsSection( + title: 'Areas in expertise & Years', + items: chips, + initialCount: 4, + ); + } + + Widget _buildCertifications(List> certs) { + return Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + const Text( + 'Certifications', + style: TextStyle( + fontFamily: 'Fractul', + fontSize: 14, + fontWeight: FontWeight.w700, + color: AppColors.primaryDark, + ), + ), + const SizedBox(height: 16), + ...certs.map((cert) => Padding( + padding: const EdgeInsets.only(left: 14, bottom: 16), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Row( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + const Padding( + padding: EdgeInsets.only(top: 2), + child: Text('\u2022 ', + style: TextStyle( + fontSize: 14, color: AppColors.primaryDark)), + ), + Expanded( + child: Text( + cert['name'] ?? '', + style: const TextStyle( + fontFamily: 'SourceSerif4', + fontSize: 14, + color: AppColors.primaryDark, + ), + ), + ), + ], + ), + if ((cert['org'] ?? '').isNotEmpty) + Padding( + padding: const EdgeInsets.only(left: 20, top: 2), + child: Text( + cert['org']!.toUpperCase(), + style: TextStyle( + fontFamily: 'SourceSerif4', + fontSize: 14, + color: AppColors.primaryDark.withValues(alpha: 0.5), + ), + ), + ), + ], + ), + )), + const SizedBox(height: 4), + ], + ); + } + + // ── Info Cards (Availability, Work Env, Best Experience) ── + Widget _buildInfoCards(AgentDetailState state) { + // Work environment and best experience from fieldValues + String workEnv = ''; + String bestExp = ''; + for (final fv in state.fieldValues) { + final slug = fv.fieldSlug.toLowerCase(); + if (slug.contains('work_environment') || slug.contains('preferred_environment')) { + if (fv.textValue != null) workEnv = fv.textValue!; + if (fv.jsonValue is List) { + workEnv = (fv.jsonValue as List).map((e) => e.toString()).join(', '); + } + } + if (slug.contains('best_experience') || slug.contains('amazing_experience')) { + if (fv.textValue != null) bestExp = fv.textValue!; + } + } + + return Padding( + padding: const EdgeInsets.symmetric(horizontal: 36), + child: Column( + children: [ + // Availability card + if (state.availabilityType.isNotEmpty) + _buildInfoCard( + icon: Icons.access_time_filled, + title: 'Availability', + subtitle: state.availabilityType, + items: state.availabilitySchedule, + ), + if (workEnv.isNotEmpty) ...[ + const SizedBox(height: 16), + _buildInfoCard( + icon: Icons.landscape_outlined, + title: '', + subtitle: '', + description: workEnv, + ), + ], + if (bestExp.isNotEmpty) ...[ + const SizedBox(height: 16), + _buildInfoCard( + icon: Icons.star_rounded, + title: '', + subtitle: '', + description: bestExp, + ), + ], + ], + ), + ); + } + + Widget _buildInfoCard({ + required IconData icon, + required String title, + required String subtitle, + List? items, + String? description, + }) { + return Container( + width: double.infinity, + constraints: const BoxConstraints(minHeight: 217), + padding: const EdgeInsets.all(20), + decoration: BoxDecoration( + color: Colors.white, + borderRadius: BorderRadius.circular(10), + boxShadow: [ + BoxShadow( + color: const Color(0xFFD9D9D9).withValues(alpha: 0.5), + offset: const Offset(0, 10), + blurRadius: 20, + ), + ], + ), + child: Column( + mainAxisAlignment: MainAxisAlignment.start, + children: [ + Icon(icon, size: 36, color: AppColors.accentOrange), + if (title.isNotEmpty) ...[ + const SizedBox(height: 8), + Text( + title, + style: const TextStyle( + fontFamily: 'SourceSerif4', + fontSize: 14, + fontWeight: FontWeight.w600, + color: AppColors.primaryDark, + ), + ), + ], + if (subtitle.isNotEmpty) ...[ + const SizedBox(height: 2), + Text( + subtitle, + style: const TextStyle( + fontFamily: 'SourceSerif4', + fontSize: 14, + fontWeight: FontWeight.w600, + color: AppColors.primaryDark, + ), + ), + ], + if (items != null && items.isNotEmpty) ...[ + const SizedBox(height: 8), + ...items.map((item) => Padding( + padding: const EdgeInsets.symmetric(vertical: 1), + child: Text( + item, + style: const TextStyle( + fontFamily: 'SourceSerif4', + fontSize: 14, + color: AppColors.primaryDark, + ), + ), + )), + ], + if (description != null) ...[ + const SizedBox(height: 8), + Text( + description, + style: const TextStyle( + fontFamily: 'SourceSerif4', + fontSize: 14, + fontWeight: FontWeight.w600, + color: AppColors.primaryDark, + ), + textAlign: TextAlign.center, + ), + ], + ], + ), + ); + } + + // ── Specialization Section ── + Widget _buildSpecializationSection(AgentDetailState state) { + return Container( + width: double.infinity, + color: const Color(0xFFE6E6E6), + padding: const EdgeInsets.symmetric(vertical: 24), + child: Column( + children: [ + const Text( + 'Specialization', + style: TextStyle( + fontFamily: 'Fractul', + fontSize: 20, + fontWeight: FontWeight.w700, + color: AppColors.primaryDark, + ), + ), + const SizedBox(height: 4), + const Text( + 'Area Of Expertise and Focus', + style: TextStyle( + fontFamily: 'Fractul', + fontSize: 14, + fontWeight: FontWeight.w500, + color: AppColors.primaryDark, + ), + ), + const SizedBox(height: 16), + ...state.specializationCards + .map((card) => Padding( + padding: const EdgeInsets.only(bottom: 12), + child: _SpecializationCardWidget(card: card), + )), + ], + ), + ); + } + + // ── Testimonials ── + Widget _buildTestimonialsSection(AgentDetailState state) { + return Padding( + padding: const EdgeInsets.symmetric(horizontal: 24), + child: Column( + children: [ + const Text( + 'Testimonials', + style: TextStyle( + fontFamily: 'Fractul', + fontSize: 20, + fontWeight: FontWeight.w700, + color: AppColors.primaryDark, + ), + ), + const SizedBox(height: 8), + Divider( + color: AppColors.primaryDark.withValues(alpha: 0.15), height: 1), + const SizedBox(height: 8), + const Text( + 'Clients rate our real estate services 4.9 out of 5 on average, based on recent client reviews.', + style: TextStyle( + fontFamily: 'SourceSerif4', + fontSize: 14, + color: AppColors.primaryDark, + ), + textAlign: TextAlign.center, + ), + const SizedBox(height: 16), + SizedBox( + height: 320, + child: ListView.separated( + scrollDirection: Axis.horizontal, + itemCount: state.testimonials.length, + separatorBuilder: (_, __) => const SizedBox(width: 12), + itemBuilder: (_, i) => + _TestimonialCard(data: state.testimonials[i]), + ), + ), + ], + ), + ); + } + + // ── Bottom Nav ── + Widget _buildBottomNavBar() { + return Container( + decoration: const BoxDecoration( + color: Colors.white, + border: Border(top: BorderSide(color: Color(0xFFE8E8E8), width: 1)), + ), + child: SafeArea( + top: false, + child: Padding( + padding: const EdgeInsets.symmetric(horizontal: 24, vertical: 12), + child: Row( + mainAxisAlignment: MainAxisAlignment.spaceBetween, + children: [ + _buildNavItem( + svgPath: 'assets/icons/nav_home_icon.svg', + fallbackIcon: Icons.home, + onTap: () => context.go('/home'), + ), + _buildNavItem( + svgPath: 'assets/icons/nav_list_icon.svg', + fallbackIcon: Icons.list, + isSelected: true, + onTap: () => context.go('/agents/search'), + ), + _buildNavItem( + svgPath: 'assets/icons/nav_messages_icon.svg', + fallbackIcon: Icons.chat_bubble, + onTap: () { + if (ref.read(authProvider).status != + AuthStatus.authenticated) { + context.push('/login'); + return; + } + }, + ), + _buildNavItem( + svgPath: 'assets/icons/nav_profile_icon.svg', + fallbackIcon: Icons.person_outline, + onTap: () { + if (ref.read(authProvider).status != + AuthStatus.authenticated) { + context.push('/login'); + return; + } + }, + ), + ], + ), + ), + ), + ); + } + + Widget _buildNavItem({ + required String svgPath, + required IconData fallbackIcon, + bool isSelected = false, + required VoidCallback onTap, + }) { + return GestureDetector( + onTap: onTap, + behavior: HitTestBehavior.opaque, + child: Padding( + padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 8), + child: SvgPicture.asset( + svgPath, + width: 24, + height: 24, + colorFilter: ColorFilter.mode( + isSelected ? AppColors.primaryDark : AppColors.accentOrange, + BlendMode.srcIn, + ), + placeholderBuilder: (_) => Icon( + fallbackIcon, + size: 24, + color: isSelected ? AppColors.primaryDark : AppColors.accentOrange, + ), + ), + ), + ); + } +} + +// ── Expandable Chips Section ── + +class _ExpandableChipsSection extends StatefulWidget { + final String title; + final List items; + final int initialCount; + + const _ExpandableChipsSection({ + required this.title, + required this.items, + this.initialCount = 6, + }); + + @override + State<_ExpandableChipsSection> createState() => + _ExpandableChipsSectionState(); +} + +class _ExpandableChipsSectionState extends State<_ExpandableChipsSection> { + bool _expanded = false; + + @override + Widget build(BuildContext context) { + final showItems = _expanded + ? widget.items + : widget.items.take(widget.initialCount).toList(); + final hasMore = widget.items.length > widget.initialCount; + + return Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text( + widget.title, + style: const TextStyle( + fontFamily: 'Fractul', + fontSize: 14, + fontWeight: FontWeight.w700, + color: AppColors.primaryDark, + ), + ), + const SizedBox(height: 14), + Wrap( + spacing: 10, + runSpacing: 10, + children: [ + ...showItems.map((item) => Container( + height: 28, + padding: const EdgeInsets.symmetric(horizontal: 8), + alignment: Alignment.center, + decoration: BoxDecoration( + borderRadius: BorderRadius.circular(15), + border: Border.all( + color: AppColors.primaryDark, + width: 0.7), + ), + child: Text( + item, + style: const TextStyle( + fontFamily: 'SourceSerif4', + fontSize: 14, + fontWeight: FontWeight.w500, + color: AppColors.primaryDark, + ), + ), + )), + if (hasMore && !_expanded) + GestureDetector( + onTap: () => setState(() => _expanded = true), + child: Container( + height: 28, + padding: const EdgeInsets.symmetric(horizontal: 8), + alignment: Alignment.center, + decoration: BoxDecoration( + borderRadius: BorderRadius.circular(15), + border: Border.all( + color: AppColors.primaryDark.withValues(alpha: 0.15), + width: 1), + ), + child: Text( + '+${widget.items.length - widget.initialCount} More', + style: const TextStyle( + fontFamily: 'SourceSerif4', + fontSize: 15, + fontWeight: FontWeight.w300, + color: AppColors.primaryDark, + ), + ), + ), + ), + ], + ), + const SizedBox(height: 14), + Divider(color: AppColors.primaryDark.withValues(alpha: 0.15), height: 1), + const SizedBox(height: 10), + ], + ); + } +} + +// ── Specialization Card ── + +class _SpecializationCardWidget extends StatefulWidget { + final SpecializationCard card; + + const _SpecializationCardWidget({required this.card}); + + @override + State<_SpecializationCardWidget> createState() => + _SpecializationCardWidgetState(); +} + +class _SpecializationCardWidgetState extends State<_SpecializationCardWidget> { + bool _expanded = false; + static const _initialCount = 5; + + IconData get _icon { + final slug = widget.card.slug.toLowerCase(); + if (slug.contains('loan') || slug.contains('price')) { + return Icons.attach_money; + } + if (slug.contains('property')) return Icons.apartment; + if (slug.contains('hobby') || slug.contains('interest')) { + return Icons.favorite_border; + } + return Icons.home_outlined; + } + + @override + Widget build(BuildContext context) { + final values = _expanded + ? widget.card.values + : widget.card.values.take(_initialCount).toList(); + final hasMore = widget.card.values.length > _initialCount; + + return Container( + width: 298, + constraints: const BoxConstraints(minHeight: 236), + padding: const EdgeInsets.symmetric(vertical: 16), + decoration: BoxDecoration( + color: Colors.white, + borderRadius: BorderRadius.circular(15), + border: Border.all( + color: AppColors.primaryDark.withValues(alpha: 0.7), width: 0.7), + ), + child: Column( + children: [ + Icon(_icon, size: 32, color: AppColors.accentOrange), + const SizedBox(height: 8), + Text( + widget.card.name, + style: const TextStyle( + fontFamily: 'Fractul', + fontSize: 14, + fontWeight: FontWeight.w700, + color: AppColors.primaryDark, + ), + ), + const SizedBox(height: 12), + ...values.map((v) => Padding( + padding: const EdgeInsets.symmetric(vertical: 4), + child: Text( + AgentDetailState.titleCase(v), + style: const TextStyle( + fontFamily: 'SourceSerif4', + fontSize: 14, + color: AppColors.primaryDark, + height: 1.6, + ), + textAlign: TextAlign.center, + ), + )), + if (hasMore) ...[ + const SizedBox(height: 10), + GestureDetector( + onTap: () => setState(() => _expanded = !_expanded), + child: Container( + padding: + const EdgeInsets.symmetric(horizontal: 14, vertical: 4), + decoration: BoxDecoration( + borderRadius: BorderRadius.circular(15), + border: + Border.all(color: AppColors.accentOrange, width: 1), + ), + child: Row( + mainAxisSize: MainAxisSize.min, + children: [ + Text( + _expanded ? 'Show Less' : 'Show More', + style: const TextStyle( + fontFamily: 'SourceSerif4', + fontSize: 10, + fontWeight: FontWeight.w700, + color: AppColors.accentOrange, + ), + ), + const SizedBox(width: 2), + Icon( + _expanded + ? Icons.keyboard_arrow_up + : Icons.keyboard_arrow_down, + size: 14, + color: AppColors.accentOrange, + ), + ], + ), + ), + ), + ], + ], + ), + ); + } +} + +// ── Testimonial Card ── + +class _TestimonialCard extends StatelessWidget { + final Map data; + + const _TestimonialCard({required this.data}); + + @override + Widget build(BuildContext context) { + final text = data['text'] as String? ?? ''; + final authorName = data['authorName'] as String? ?? ''; + final authorRole = data['authorRole'] as String? ?? ''; + final rating = (data['rating'] as num?)?.toInt() ?? 5; + + return SizedBox( + width: 320, + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + // Main card body with speech bubble shape + Expanded( + child: Container( + width: double.infinity, + padding: const EdgeInsets.fromLTRB(20, 16, 20, 20), + decoration: BoxDecoration( + color: Colors.white, + borderRadius: BorderRadius.circular(15), + boxShadow: [ + BoxShadow( + color: const Color(0xFFD9D9D9).withValues(alpha: 0.5), + offset: const Offset(0, 10), + blurRadius: 20, + ), + ], + ), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + // Quote icon + const Text( + '\u201C', + style: TextStyle( + fontFamily: 'Fractul', + fontSize: 36, + fontWeight: FontWeight.w700, + color: AppColors.primaryDark, + height: 0.8, + ), + ), + const SizedBox(height: 6), + // Title snippet + Text( + text.length > 35 ? '${text.substring(0, 35)} ..' : text, + style: const TextStyle( + fontFamily: 'Fractul', + fontSize: 14, + fontWeight: FontWeight.w700, + color: AppColors.primaryDark, + ), + maxLines: 1, + overflow: TextOverflow.ellipsis, + ), + const SizedBox(height: 10), + // Full text + Expanded( + child: Text( + text, + style: const TextStyle( + fontFamily: 'SourceSerif4', + fontSize: 14, + color: AppColors.primaryDark, + height: 1.5, + ), + overflow: TextOverflow.fade, + ), + ), + ], + ), + ), + ), + // Speech bubble triangle + Padding( + padding: const EdgeInsets.only(left: 30), + child: CustomPaint( + size: const Size(16, 10), + painter: _TrianglePainter(color: Colors.white), + ), + ), + const SizedBox(height: 6), + // Stars + Author info + Padding( + padding: const EdgeInsets.only(left: 4), + child: Row( + children: [ + // Stars + Row( + mainAxisSize: MainAxisSize.min, + children: List.generate( + 5, + (i) => Icon( + i < rating ? Icons.star : Icons.star_border, + size: 16, + color: i < rating + ? AppColors.accentOrange + : AppColors.primaryDark.withValues(alpha: 0.3), + ), + ), + ), + const SizedBox(width: 10), + // Author + Expanded( + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text( + authorName, + style: const TextStyle( + fontFamily: 'SourceSerif4', + fontSize: 14, + fontWeight: FontWeight.w500, + color: AppColors.primaryDark, + ), + ), + if (authorRole.isNotEmpty) + Text( + authorRole, + style: const TextStyle( + fontFamily: 'SourceSerif4', + fontSize: 14, + fontWeight: FontWeight.w600, + color: AppColors.primaryDark, + ), + ), + ], + ), + ), + ], + ), + ), + ], + ), + ); + } +} + +// ── Triangle painter for speech bubble ── +class _TrianglePainter extends CustomPainter { + final Color color; + _TrianglePainter({required this.color}); + + @override + void paint(Canvas canvas, Size size) { + final paint = Paint() + ..color = color + ..style = PaintingStyle.fill; + final shadowPaint = Paint() + ..color = const Color(0xFFD9D9D9).withAlpha(40) + ..maskFilter = const MaskFilter.blur(BlurStyle.normal, 4); + + final path = Path() + ..moveTo(0, 0) + ..lineTo(size.width, 0) + ..lineTo(size.width / 2, size.height) + ..close(); + + canvas.drawPath(path, shadowPaint); + canvas.drawPath(path, paint); + } + + @override + bool shouldRepaint(covariant CustomPainter oldDelegate) => false; +} diff --git a/lib/features/agents/presentation/screens/agent_search_screen.dart b/lib/features/agents/presentation/screens/agent_search_screen.dart index 0c64d46..6620d14 100644 --- a/lib/features/agents/presentation/screens/agent_search_screen.dart +++ b/lib/features/agents/presentation/screens/agent_search_screen.dart @@ -350,7 +350,10 @@ class _AgentSearchScreenState extends ConsumerState { } return Padding( padding: const EdgeInsets.only(bottom: 16), - child: _AgentCard(agent: state.agents[index]), + child: GestureDetector( + onTap: () => context.push('/agents/detail/${state.agents[index].id}'), + child: _AgentCard(agent: state.agents[index]), + ), ); }, ); @@ -397,6 +400,7 @@ class _AgentSearchScreenState extends ConsumerState { context.push('/login'); return; } + context.go('/messages'); }, ), _buildNavItem( diff --git a/lib/features/home/presentation/screens/home_screen.dart b/lib/features/home/presentation/screens/home_screen.dart index 87381b8..9d43860 100644 --- a/lib/features/home/presentation/screens/home_screen.dart +++ b/lib/features/home/presentation/screens/home_screen.dart @@ -150,7 +150,16 @@ class _HomeScreenState extends ConsumerState { context.push('/agents/search'); 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); if (authState.status != AuthStatus.authenticated) { context.push('/login'); diff --git a/lib/features/messaging/data/messaging_repository.dart b/lib/features/messaging/data/messaging_repository.dart new file mode 100644 index 0000000..c37ecaf --- /dev/null +++ b/lib/features/messaging/data/messaging_repository.dart @@ -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> getConversations() async { + try { + final response = await _dio.get(ApiConstants.conversations); + final data = response.data['data'] as List; + return data + .map((e) => Conversation.fromJson(e as Map)) + .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 startConversation(String agentProfileId) async { + try { + final response = await _dio.post( + ApiConstants.conversations, + data: {'agentProfileId': agentProfileId}, + ); + return Conversation.fromJson( + response.data['data'] as Map); + } 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 getConversation(String id) async { + try { + final response = await _dio.get('${ApiConstants.conversations}/$id'); + return Conversation.fromJson( + response.data['data'] as Map); + } 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 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; + final messages = (data['messages'] as List) + .map((e) => ChatMessage.fromJson(e as Map)) + .toList(); + final pagination = PaginationInfo.fromJson( + data['pagination'] as Map); + 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 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); + } 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 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 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((ref) { + return MessagingRepository(); +}); diff --git a/lib/features/messaging/data/models/conversation.dart b/lib/features/messaging/data/models/conversation.dart new file mode 100644 index 0000000..cc8e387 --- /dev/null +++ b/lib/features/messaging/data/models/conversation.dart @@ -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; diff --git a/lib/features/messaging/data/models/message.dart b/lib/features/messaging/data/models/message.dart new file mode 100644 index 0000000..d243b3b --- /dev/null +++ b/lib/features/messaging/data/models/message.dart @@ -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; diff --git a/lib/features/messaging/data/models/messaging_models.dart b/lib/features/messaging/data/models/messaging_models.dart new file mode 100644 index 0000000..5c9fa34 --- /dev/null +++ b/lib/features/messaging/data/models/messaging_models.dart @@ -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 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? 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 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), + messages: (json['messages'] as List?) + ?.map((e) => ChatMessage.fromJson(e as Map)) + .toList(), + ); + } + + Conversation copyWith({ + int? unreadCount, + String? lastMessageAt, + String? lastMessageText, + OtherParty? otherParty, + List? 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 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 json) { + return MessageSender( + id: json['id'] as String, + role: json['role'] as String? ?? '', + userProfile: json['userProfile'] != null + ? SenderProfile.fromJson( + json['userProfile'] as Map) + : null, + agentProfile: json['agentProfile'] != null + ? SenderProfile.fromJson( + json['agentProfile'] as Map) + : 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 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) + : 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 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, + ); + } +} diff --git a/lib/features/messaging/data/socket_service.dart b/lib/features/messaging/data/socket_service.dart new file mode 100644 index 0000000..cbc52d4 --- /dev/null +++ b/lib/features/messaging/data/socket_service.dart @@ -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.broadcast(); + final _typingStartController = StreamController>.broadcast(); + final _typingStopController = StreamController>.broadcast(); + final _statusController = StreamController>.broadcast(); + final _readController = StreamController>.broadcast(); + final _connectionController = StreamController.broadcast(); + + Stream get onNewMessage => _messageController.stream; + Stream> get onTypingStart => _typingStartController.stream; + Stream> get onTypingStop => _typingStopController.stream; + Stream> get onStatusChange => _statusController.stream; + Stream> get onMessagesRead => _readController.stream; + Stream get onConnectionChange => _connectionController.stream; + bool get isConnected => _isConnected; + + Future 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); + _messageController.add(message); + } catch (e) { + _log.e('Error parsing new_message: $e'); + } + }); + + _socket!.on('typing_start', (data) { + _typingStartController.add(Map.from(data as Map)); + }); + + _socket!.on('typing_stop', (data) { + _typingStopController.add(Map.from(data as Map)); + }); + + _socket!.on('user_status_change', (data) { + _statusController.add(Map.from(data as Map)); + }); + + _socket!.on('messages_read', (data) { + _readController.add(Map.from(data as Map)); + }); + + _socket!.connect(); + } + + Future _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 joinConversation(String conversationId) async { + if (_socket == null || !_isConnected) return; + final completer = Completer(); + _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 sendMessage( + String conversationId, + Map messageData, + ) async { + if (_socket == null || !_isConnected) return null; + + final completer = Completer(); + _socket!.emitWithAck('send_message', { + 'conversationId': conversationId, + 'message': messageData, + }, ack: (data) { + try { + final response = data as Map; + if (response['success'] == true && response['message'] != null) { + completer.complete( + ChatMessage.fromJson(response['message'] as Map), + ); + } 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(); + } +} diff --git a/lib/features/messaging/presentation/providers/messaging_provider.dart b/lib/features/messaging/presentation/providers/messaging_provider.dart new file mode 100644 index 0000000..ada7f09 --- /dev/null +++ b/lib/features/messaging/presentation/providers/messaging_provider.dart @@ -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 conversations; + final Map> messages; + final Map pagination; + final Set 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? conversations, + Map>? messages, + Map? pagination, + Set? 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 { + final MessagingRepository _repository; + final Ref _ref; + + MessagingNotifier(this._repository, this._ref) + : super(const MessagingState()); + + String? get _currentUserId => _ref.read(authProvider).user?.id; + + Future 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 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] ?? []) : []; + final mergedMessages = + loadMore ? [...existingMessages, ...newMessages] : newMessages; + + final updatedMessages = + Map>.from(state.messages) + ..[conversationId] = mergedMessages; + + final updatedPagination = + Map.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 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.from(state.messages[conversationId] ?? []); + currentMessages.insert(0, tempMessage); + + final updatedMessages = + Map>.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.from(state.messages[conversationId] ?? []); + final tempIndex = messagesAfterSend.indexWhere((m) => m.id == tempId); + if (tempIndex != -1) { + messagesAfterSend[tempIndex] = realMessage; + } + + final finalMessages = + Map>.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.from(state.messages[conversationId] ?? []); + messagesAfterError.removeWhere((m) => m.id == tempId); + + final errorMessages = + Map>.from(state.messages) + ..[conversationId] = messagesAfterError; + + state = state.copyWith( + messages: errorMessages, + isSending: false, + error: 'Failed to send message', + ); + } + } + + Future 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( + 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 loadUnreadCount() async { + try { + final count = await _repository.getUnreadCount(); + state = state.copyWith(unreadCount: count); + } catch (_) {} + } + + Future 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((ref) { + return MessagingNotifier(ref.watch(messagingRepositoryProvider), ref); +}); + +final currentUserIdProvider = Provider((ref) { + return ref.watch(authProvider).user?.id; +}); diff --git a/lib/features/messaging/presentation/screens/chat_screen.dart b/lib/features/messaging/presentation/screens/chat_screen.dart new file mode 100644 index 0000000..a92de00 --- /dev/null +++ b/lib/features/messaging/presentation/screens/chat_screen.dart @@ -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 createState() => _ChatScreenState(); +} + +class _ChatScreenState extends ConsumerState { + 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 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() + .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 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( + 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, + ), + ), + ); + } +} diff --git a/lib/features/messaging/presentation/screens/conversations_screen.dart b/lib/features/messaging/presentation/screens/conversations_screen.dart new file mode 100644 index 0000000..da24b8d --- /dev/null +++ b/lib/features/messaging/presentation/screens/conversations_screen.dart @@ -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 createState() => + _ConversationsScreenState(); +} + +class _ConversationsScreenState extends ConsumerState { + 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 _filteredConversations(List 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 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}'; +} diff --git a/lib/routing/app_router.dart b/lib/routing/app_router.dart index 2d28ea2..23e8d40 100644 --- a/lib/routing/app_router.dart +++ b/lib/routing/app_router.dart @@ -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/screens/login_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/faq/presentation/screens/faq_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((ref) { final authRefreshNotifier = _AuthRefreshNotifier(); @@ -20,7 +23,7 @@ final routerProvider = Provider((ref) { }); // 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']; return GoRouter( @@ -71,6 +74,24 @@ final routerProvider = Provider((ref) { 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( path: '/faq', builder: (context, state) => const FaqScreen(), diff --git a/linux/flutter/generated_plugin_registrant.cc b/linux/flutter/generated_plugin_registrant.cc index d0e7f79..dd8425c 100644 --- a/linux/flutter/generated_plugin_registrant.cc +++ b/linux/flutter/generated_plugin_registrant.cc @@ -6,9 +6,13 @@ #include "generated_plugin_registrant.h" +#include #include 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 = fl_plugin_registry_get_registrar_for_plugin(registry, "FlutterSecureStorageLinuxPlugin"); flutter_secure_storage_linux_plugin_register_with_registrar(flutter_secure_storage_linux_registrar); diff --git a/linux/flutter/generated_plugins.cmake b/linux/flutter/generated_plugins.cmake index b29e9ba..a39777e 100644 --- a/linux/flutter/generated_plugins.cmake +++ b/linux/flutter/generated_plugins.cmake @@ -3,6 +3,7 @@ # list(APPEND FLUTTER_PLUGIN_LIST + emoji_picker_flutter flutter_secure_storage_linux ) diff --git a/macos/Flutter/GeneratedPluginRegistrant.swift b/macos/Flutter/GeneratedPluginRegistrant.swift index bbb5747..84cdd3c 100644 --- a/macos/Flutter/GeneratedPluginRegistrant.swift +++ b/macos/Flutter/GeneratedPluginRegistrant.swift @@ -5,12 +5,14 @@ import FlutterMacOS import Foundation +import emoji_picker_flutter import flutter_secure_storage_macos import path_provider_foundation import shared_preferences_foundation import sqflite_darwin func RegisterGeneratedPlugins(registry: FlutterPluginRegistry) { + EmojiPickerFlutterPlugin.register(with: registry.registrar(forPlugin: "EmojiPickerFlutterPlugin")) FlutterSecureStoragePlugin.register(with: registry.registrar(forPlugin: "FlutterSecureStoragePlugin")) PathProviderPlugin.register(with: registry.registrar(forPlugin: "PathProviderPlugin")) SharedPreferencesPlugin.register(with: registry.registrar(forPlugin: "SharedPreferencesPlugin")) diff --git a/pubspec.lock b/pubspec.lock index b31cedb..70013ac 100644 --- a/pubspec.lock +++ b/pubspec.lock @@ -249,6 +249,14 @@ packages: url: "https://pub.dev" source: hosted 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: dependency: transitive description: @@ -861,6 +869,22 @@ packages: description: flutter source: sdk 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: dependency: transitive description: diff --git a/pubspec.yaml b/pubspec.yaml index 38ca9a7..158fefd 100644 --- a/pubspec.yaml +++ b/pubspec.yaml @@ -36,6 +36,10 @@ dependencies: cached_network_image: ^3.4.1 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 path_provider_foundation: 2.4.0 diff --git a/windows/flutter/generated_plugin_registrant.cc b/windows/flutter/generated_plugin_registrant.cc index 0c50753..a799fa3 100644 --- a/windows/flutter/generated_plugin_registrant.cc +++ b/windows/flutter/generated_plugin_registrant.cc @@ -6,9 +6,12 @@ #include "generated_plugin_registrant.h" +#include #include void RegisterPlugins(flutter::PluginRegistry* registry) { + EmojiPickerFlutterPluginCApiRegisterWithRegistrar( + registry->GetRegistrarForPlugin("EmojiPickerFlutterPluginCApi")); FlutterSecureStorageWindowsPluginRegisterWithRegistrar( registry->GetRegistrarForPlugin("FlutterSecureStorageWindowsPlugin")); } diff --git a/windows/flutter/generated_plugins.cmake b/windows/flutter/generated_plugins.cmake index 4fc759c..6595782 100644 --- a/windows/flutter/generated_plugins.cmake +++ b/windows/flutter/generated_plugins.cmake @@ -3,6 +3,7 @@ # list(APPEND FLUTTER_PLUGIN_LIST + emoji_picker_flutter flutter_secure_storage_windows )