diff --git a/android/app/src/main/res/mipmap-hdpi/ic_launcher.png b/android/app/src/main/res/mipmap-hdpi/ic_launcher.png index 8f6a349..9fdc7da 100644 Binary files a/android/app/src/main/res/mipmap-hdpi/ic_launcher.png and b/android/app/src/main/res/mipmap-hdpi/ic_launcher.png differ diff --git a/android/app/src/main/res/mipmap-mdpi/ic_launcher.png b/android/app/src/main/res/mipmap-mdpi/ic_launcher.png index 5a1c3d3..53c324b 100644 Binary files a/android/app/src/main/res/mipmap-mdpi/ic_launcher.png and b/android/app/src/main/res/mipmap-mdpi/ic_launcher.png differ diff --git a/android/app/src/main/res/mipmap-xhdpi/ic_launcher.png b/android/app/src/main/res/mipmap-xhdpi/ic_launcher.png index dfcf3a8..f0939d8 100644 Binary files a/android/app/src/main/res/mipmap-xhdpi/ic_launcher.png and b/android/app/src/main/res/mipmap-xhdpi/ic_launcher.png differ diff --git a/android/app/src/main/res/mipmap-xxhdpi/ic_launcher.png b/android/app/src/main/res/mipmap-xxhdpi/ic_launcher.png index 8fcffed..931dd9d 100644 Binary files a/android/app/src/main/res/mipmap-xxhdpi/ic_launcher.png and b/android/app/src/main/res/mipmap-xxhdpi/ic_launcher.png differ diff --git a/android/app/src/main/res/mipmap-xxxhdpi/ic_launcher.png b/android/app/src/main/res/mipmap-xxxhdpi/ic_launcher.png index 702cd03..09e9a3d 100644 Binary files a/android/app/src/main/res/mipmap-xxxhdpi/ic_launcher.png and b/android/app/src/main/res/mipmap-xxxhdpi/ic_launcher.png differ diff --git a/assets/app_icon.png b/assets/app_icon.png index 9568fb2..9766ae2 100644 Binary files a/assets/app_icon.png and b/assets/app_icon.png differ diff --git a/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-1024x1024@1x.png b/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-1024x1024@1x.png index a710ad2..abda540 100644 Binary files a/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-1024x1024@1x.png and b/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-1024x1024@1x.png differ diff --git a/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-20x20@1x.png b/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-20x20@1x.png index 7080724..ec2685e 100644 Binary files a/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-20x20@1x.png and b/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-20x20@1x.png differ diff --git a/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-20x20@2x.png b/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-20x20@2x.png index 6092c4b..51fc3c5 100644 Binary files a/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-20x20@2x.png and b/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-20x20@2x.png differ diff --git a/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-20x20@3x.png b/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-20x20@3x.png index 56056df..3f2b009 100644 Binary files a/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-20x20@3x.png and b/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-20x20@3x.png differ diff --git a/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-29x29@1x.png b/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-29x29@1x.png index fa64678..daf8d68 100644 Binary files a/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-29x29@1x.png and b/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-29x29@1x.png differ diff --git a/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-29x29@2x.png b/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-29x29@2x.png index 2356d92..860e5f2 100644 Binary files a/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-29x29@2x.png and b/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-29x29@2x.png differ diff --git a/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-29x29@3x.png b/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-29x29@3x.png index 0efec18..262ac4c 100644 Binary files a/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-29x29@3x.png and b/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-29x29@3x.png differ diff --git a/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-40x40@1x.png b/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-40x40@1x.png index 6092c4b..51fc3c5 100644 Binary files a/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-40x40@1x.png and b/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-40x40@1x.png differ diff --git a/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-40x40@2x.png b/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-40x40@2x.png index 019e035..f256e59 100644 Binary files a/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-40x40@2x.png and b/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-40x40@2x.png differ diff --git a/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-40x40@3x.png b/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-40x40@3x.png index 1664688..c04a583 100644 Binary files a/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-40x40@3x.png and b/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-40x40@3x.png differ diff --git a/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-50x50@1x.png b/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-50x50@1x.png index cc72af4..0cf8c20 100644 Binary files a/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-50x50@1x.png and b/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-50x50@1x.png differ diff --git a/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-50x50@2x.png b/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-50x50@2x.png index 8aaf89f..200667d 100644 Binary files a/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-50x50@2x.png and b/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-50x50@2x.png differ diff --git a/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-57x57@1x.png b/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-57x57@1x.png index 08ca412..0248ad7 100644 Binary files a/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-57x57@1x.png and b/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-57x57@1x.png differ diff --git a/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-57x57@2x.png b/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-57x57@2x.png index ffab54a..5223222 100644 Binary files a/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-57x57@2x.png and b/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-57x57@2x.png differ diff --git a/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-60x60@2x.png b/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-60x60@2x.png index 1664688..c04a583 100644 Binary files a/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-60x60@2x.png and b/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-60x60@2x.png differ diff --git a/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-60x60@3x.png b/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-60x60@3x.png index 40ee490..35d1da9 100644 Binary files a/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-60x60@3x.png and b/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-60x60@3x.png differ diff --git a/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-72x72@1x.png b/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-72x72@1x.png index ec95866..aacdc29 100644 Binary files a/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-72x72@1x.png and b/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-72x72@1x.png differ diff --git a/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-72x72@2x.png b/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-72x72@2x.png index 012c634..0607a5c 100644 Binary files a/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-72x72@2x.png and b/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-72x72@2x.png differ diff --git a/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-76x76@1x.png b/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-76x76@1x.png index 6f0c98b..5674eef 100644 Binary files a/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-76x76@1x.png and b/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-76x76@1x.png differ diff --git a/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-76x76@2x.png b/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-76x76@2x.png index bf75c5c..13bf832 100644 Binary files a/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-76x76@2x.png and b/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-76x76@2x.png differ diff --git a/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-83.5x83.5@2x.png b/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-83.5x83.5@2x.png index 9b03b20..b568bb3 100644 Binary files a/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-83.5x83.5@2x.png and b/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-83.5x83.5@2x.png differ diff --git a/lib/features/home/presentation/widgets/top_professionals_section.dart b/lib/features/home/presentation/widgets/top_professionals_section.dart index d166258..9103c53 100644 --- a/lib/features/home/presentation/widgets/top_professionals_section.dart +++ b/lib/features/home/presentation/widgets/top_professionals_section.dart @@ -5,6 +5,8 @@ import 'package:go_router/go_router.dart'; import 'package:shimmer/shimmer.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/agents_provider.dart'; import 'package:real_estate_mobile/features/home/data/models/landing_page_content.dart'; import 'package:real_estate_mobile/features/home/presentation/providers/home_provider.dart'; @@ -64,14 +66,16 @@ class _TopProfessionalsSectionState @override Widget build(BuildContext context) { - // CMS data for everything (title, CTA, agents, lenders) + // CMS data for title/CTA text only final homeState = ref.watch(homeProvider); final cmsContent = homeState.content?.topProfessionals ?? _defaultCmsContent; - // Use CMS agents/lenders list based on active tab + // Dynamic data from database (top-rated agents/lenders, matching web) + final profState = ref.watch(topProfessionalsProvider); final activeList = _activeTab == 'agents' - ? cmsContent.agents - : cmsContent.lenders; + ? profState.agents + : profState.lenders; + final isLoading = profState.isLoading || homeState.isLoading; return Padding( padding: const EdgeInsets.symmetric(horizontal: 24), @@ -96,7 +100,7 @@ class _TopProfessionalsSectionState const SizedBox(height: 24), // Content - if (homeState.isLoading) + if (isLoading) _buildShimmerCards() else if (activeList.isEmpty) _buildEmptyState(_activeTab) @@ -118,7 +122,7 @@ class _TopProfessionalsSectionState left: index == 0 ? 0 : 8, right: index == activeList.length - 1 ? 0 : 8, ), - child: _buildCmsProfessionalCard(activeList[index], index: index), + child: _buildProfessionalCard(activeList[index], index: index), ), ); }, @@ -279,223 +283,229 @@ class _TopProfessionalsSectionState ); } - // -- Professional Card using CMS ProfessionalItem data -- - Widget _buildCmsProfessionalCard(ProfessionalItem item, {int index = 0}) { - final imageUrl = item.imageUrl; + // -- Professional Card using dynamic AgentProfile data (matching web) -- + Widget _buildProfessionalCard(AgentProfile agent, {int index = 0}) { + final imageUrl = agent.avatarUrl ?? ''; + final expertiseTags = agent.specializations; + final experience = agent.experienceText; + final locationText = agent.location; + final subtitle = agent.agentType != null ? '(${agent.agentType!.name})' : ''; + final rating = agent.averageRating ?? 5.0; return Container( - decoration: BoxDecoration( - color: Colors.white, - borderRadius: BorderRadius.circular(15), - border: Border.all(color: AppColors.primaryDark, width: 0.1), - ), - clipBehavior: Clip.antiAlias, - child: Column( - crossAxisAlignment: CrossAxisAlignment.start, - children: [ - // Image (226px per Figma) - SizedBox( - width: double.infinity, - height: 226, - child: ClipRRect( - borderRadius: - const BorderRadius.vertical(top: Radius.circular(15)), - child: imageUrl.isNotEmpty - ? S3Image( - imageUrl: imageUrl, - width: double.infinity, - height: 226, - fit: BoxFit.cover, - alignment: Alignment.topCenter, - placeholder: (_) => _buildImagePlaceholder(index), - errorWidget: (_) => _buildImagePlaceholder(index), - ) - : _buildImagePlaceholder(index), + decoration: BoxDecoration( + color: Colors.white, + borderRadius: BorderRadius.circular(15), + border: Border.all(color: AppColors.primaryDark, width: 0.1), + ), + clipBehavior: Clip.antiAlias, + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + // Image (226px per Figma) + SizedBox( + width: double.infinity, + height: 226, + child: ClipRRect( + borderRadius: + const BorderRadius.vertical(top: Radius.circular(15)), + child: imageUrl.isNotEmpty + ? S3Image( + imageUrl: imageUrl, + width: double.infinity, + height: 226, + fit: BoxFit.cover, + alignment: Alignment.topCenter, + placeholder: (_) => _buildImagePlaceholder(index), + errorWidget: (_) => _buildImagePlaceholder(index), + ) + : _buildImagePlaceholder(index), + ), ), - ), - // Content - Expanded( - child: Padding( - padding: const EdgeInsets.fromLTRB(16, 12, 16, 12), - child: Column( - crossAxisAlignment: CrossAxisAlignment.start, - children: [ - // Name - Text( - item.name, - style: const TextStyle( - fontFamily: 'Fractul', - fontSize: 14, - fontWeight: FontWeight.w700, - color: AppColors.primaryDark, - ), - overflow: TextOverflow.ellipsis, - ), - const SizedBox(height: 4), - - // Subtitle - if (item.subtitle.isNotEmpty) + // Content + Expanded( + child: Padding( + padding: const EdgeInsets.fromLTRB(16, 12, 16, 12), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + // Name Text( - item.subtitle, + agent.fullName, style: const TextStyle( - fontFamily: 'SourceSerif4', - fontSize: 10, - fontWeight: FontWeight.w400, + fontFamily: 'Fractul', + fontSize: 14, + fontWeight: FontWeight.w700, color: AppColors.primaryDark, ), - maxLines: 1, overflow: TextOverflow.ellipsis, ), - const SizedBox(height: 4), + const SizedBox(height: 4), - // Verified badge - Row( - children: [ - SvgPicture.asset( - 'assets/icons/verified_badge_icon.svg', - width: 16, - height: 16, - placeholderBuilder: (_) => const Icon( - Icons.verified, - color: Color(0xFF638559), - size: 16, - ), - ), - const SizedBox(width: 6), - const Text( - '\u201CVerified local agent\u201D', - style: TextStyle( + // Subtitle (agent type) + if (subtitle.isNotEmpty) + Text( + subtitle, + style: const TextStyle( fontFamily: 'SourceSerif4', - fontSize: 14, - fontWeight: FontWeight.w500, - color: Color(0xFF638559), + fontSize: 10, + fontWeight: FontWeight.w400, + color: AppColors.primaryDark, ), + maxLines: 1, + overflow: TextOverflow.ellipsis, ), - ], - ), - const SizedBox(height: 8), + const SizedBox(height: 4), - // Location - if (item.location.isNotEmpty) ...[ - const Text( - 'Location:', - style: TextStyle( - fontFamily: 'Fractul', - fontSize: 14, - fontWeight: FontWeight.w700, - color: AppColors.primaryDark, - ), - ), - const SizedBox(height: 2), - Row( - children: [ - SvgPicture.asset( - 'assets/icons/location_orange_icon.svg', - width: 16, - height: 16, - placeholderBuilder: (_) => const Icon( - Icons.location_on, - color: AppColors.accentOrange, - size: 16, - ), - ), - const SizedBox(width: 4), - Expanded( - child: Text( - item.location, - style: const TextStyle( - fontFamily: 'SourceSerif4', - fontSize: 10, - fontWeight: FontWeight.w400, - color: AppColors.primaryDark, - ), - overflow: TextOverflow.ellipsis, - ), - ), - ], - ), - const SizedBox(height: 8), - ], - - // Expertise tags - if (item.expertise.isNotEmpty) ...[ - const Text( - 'Expertise:', - style: TextStyle( - fontFamily: 'Fractul', - fontSize: 14, - fontWeight: FontWeight.w700, - color: AppColors.primaryDark, - ), - ), - const SizedBox(height: 6), - Wrap( - spacing: 8, - runSpacing: 6, - children: item.expertise - .take(6) - .map((tag) => _buildTag(tag)) - .toList(), - ), - const SizedBox(height: 12), - ], - - // Experience - if (item.experience.isNotEmpty) ...[ - RichText( - text: TextSpan( + // Verified badge + if (agent.isVerified) + Row( children: [ - const TextSpan( - text: 'Experience: ', - style: TextStyle( - fontFamily: 'Fractul', - fontSize: 14, - fontWeight: FontWeight.w700, - color: AppColors.primaryDark, + SvgPicture.asset( + 'assets/icons/verified_badge_icon.svg', + width: 16, + height: 16, + placeholderBuilder: (_) => const Icon( + Icons.verified, + color: Color(0xFF638559), + size: 16, ), ), - TextSpan( - text: item.experience, - style: const TextStyle( + const SizedBox(width: 6), + const Text( + '\u201CVerified local agent\u201D', + style: TextStyle( fontFamily: 'SourceSerif4', fontSize: 14, - fontWeight: FontWeight.w400, - color: AppColors.primaryDark, + fontWeight: FontWeight.w500, + color: Color(0xFF638559), ), ), ], ), - maxLines: 2, - overflow: TextOverflow.ellipsis, - ), const SizedBox(height: 8), - ], - // Star Rating - Row( - children: List.generate(5, (i) { - return Padding( - padding: const EdgeInsets.only(right: 4), - child: Icon( - i < item.rating.round() - ? Icons.star_rounded - : Icons.star_outline_rounded, - color: const Color(0xFFE5A625), - size: 24, + // Location + if (locationText.isNotEmpty) ...[ + const Text( + 'Location:', + style: TextStyle( + fontFamily: 'Fractul', + fontSize: 14, + fontWeight: FontWeight.w700, + color: AppColors.primaryDark, ), - ); - }), - ), - ], + ), + const SizedBox(height: 2), + Row( + children: [ + SvgPicture.asset( + 'assets/icons/location_orange_icon.svg', + width: 16, + height: 16, + placeholderBuilder: (_) => const Icon( + Icons.location_on, + color: AppColors.accentOrange, + size: 16, + ), + ), + const SizedBox(width: 4), + Expanded( + child: Text( + locationText, + style: const TextStyle( + fontFamily: 'SourceSerif4', + fontSize: 10, + fontWeight: FontWeight.w400, + color: AppColors.primaryDark, + ), + overflow: TextOverflow.ellipsis, + ), + ), + ], + ), + const SizedBox(height: 8), + ], + + // Expertise tags + if (expertiseTags.isNotEmpty) ...[ + const Text( + 'Expertise:', + style: TextStyle( + fontFamily: 'Fractul', + fontSize: 14, + fontWeight: FontWeight.w700, + color: AppColors.primaryDark, + ), + ), + const SizedBox(height: 6), + Wrap( + spacing: 8, + runSpacing: 6, + children: expertiseTags + .take(6) + .map((tag) => _buildTag(tag)) + .toList(), + ), + const SizedBox(height: 12), + ], + + // Experience + if (experience.isNotEmpty) ...[ + RichText( + text: TextSpan( + children: [ + const TextSpan( + text: 'Experience: ', + style: TextStyle( + fontFamily: 'Fractul', + fontSize: 14, + fontWeight: FontWeight.w700, + color: AppColors.primaryDark, + ), + ), + TextSpan( + text: experience, + style: const TextStyle( + fontFamily: 'SourceSerif4', + fontSize: 14, + fontWeight: FontWeight.w400, + color: AppColors.primaryDark, + ), + ), + ], + ), + maxLines: 2, + overflow: TextOverflow.ellipsis, + ), + const SizedBox(height: 8), + ], + + // Star Rating + Row( + children: List.generate(5, (i) { + return Padding( + padding: const EdgeInsets.only(right: 4), + child: Icon( + i < rating.round() + ? Icons.star_rounded + : Icons.star_outline_rounded, + color: const Color(0xFFE5A625), + size: 24, + ), + ); + }), + ), + ], + ), ), - ), - ), - ], + ), + ], + ), ), ); } - Widget _buildShimmerCards() { return SizedBox( height: 540, diff --git a/lib/features/messaging/data/socket_service.dart b/lib/features/messaging/data/socket_service.dart index b914bc8..9afd1bc 100644 --- a/lib/features/messaging/data/socket_service.dart +++ b/lib/features/messaging/data/socket_service.dart @@ -1,6 +1,7 @@ import 'dart:async'; import 'package:logger/logger.dart'; import 'package:real_estate_mobile/config/app_config.dart'; +import 'package:real_estate_mobile/core/network/api_client.dart'; import 'package:real_estate_mobile/core/storage/secure_storage.dart'; import 'package:real_estate_mobile/features/messaging/data/models/message.dart'; import 'package:real_estate_mobile/features/support_chat/data/models/support_chat_models.dart'; @@ -144,12 +145,31 @@ class SocketService { } Future _reconnectWithFreshToken() async { + // Wait briefly for any in-flight REST token refresh to complete + await Future.delayed(const Duration(milliseconds: 500)); + final token = await SecureStorage.getAccessToken(); - if (token != null && token != _currentToken) { + if (token == null) return; + + // If token hasn't changed, try triggering a refresh via a lightweight API call + if (token == _currentToken) { + try { + // Make a lightweight call that triggers the API client's 401 interceptor + // which auto-refreshes the token + final dio = ApiClient.instance.dio; + await dio.get('/auth/me'); + final refreshedToken = await SecureStorage.getAccessToken(); + if (refreshedToken == null || refreshedToken == _currentToken) return; + _currentToken = refreshedToken; + } catch (_) { + return; // Token refresh failed — user will need to re-login + } + } else { _currentToken = token; - _socket?.io.options?['auth'] = {'token': token}; - _socket?.connect(); } + + _socket?.io.options?['auth'] = {'token': _currentToken}; + _socket?.connect(); } void disconnect() { diff --git a/lib/features/messaging/presentation/providers/messaging_provider.dart b/lib/features/messaging/presentation/providers/messaging_provider.dart index b7b0f19..663610f 100644 --- a/lib/features/messaging/presentation/providers/messaging_provider.dart +++ b/lib/features/messaging/presentation/providers/messaging_provider.dart @@ -97,7 +97,7 @@ class MessagingNotifier extends StateNotifier { final currentMessages = List.from(state.messages[convId] ?? []); - // Avoid duplicates + // Avoid duplicates (broadcast may arrive after ack for sender's own message) if (currentMessages.any((m) => m.id == message.id)) return; currentMessages.insert(0, message); @@ -105,12 +105,22 @@ class MessagingNotifier extends StateNotifier { Map>.from(state.messages) ..[convId] = currentMessages; + // Show friendly preview for non-text messages (matching web) + String previewText = message.content; + if (message.messageType == MessageType.image) { + previewText = 'Sent an image'; + } else if (message.messageType == MessageType.file) { + previewText = 'Sent a file'; + } else if (_isGifUrl(message.content)) { + previewText = 'GIF'; + } + // Update conversation's last message and unread count final updatedConversations = state.conversations.map((c) { if (c.id == convId) { final isActive = state.activeConversationId == convId; return c.copyWith( - lastMessageText: message.content, + lastMessageText: previewText, lastMessageAt: message.createdAt, unreadCount: isActive ? c.unreadCount : c.unreadCount + 1, ); @@ -155,6 +165,59 @@ class MessagingNotifier extends StateNotifier { }).toList(); state = state.copyWith(conversations: updatedConversations); })); + + // Reconnection: re-join active conversation room and catch up missed messages + _subscriptions.add(_socket.onConnectionChange.listen((connected) { + if (!mounted || !connected) return; + final activeConvId = state.activeConversationId; + if (activeConvId != null) { + _rejoinAndCatchUp(activeConvId); + } + // Refresh conversation list to catch any missed updates + loadConversations(); + })); + } + + /// Re-join socket room and fetch missed messages after reconnection (matching web) + Future _rejoinAndCatchUp(String conversationId, {int attempt = 1}) async { + try { + await _socket.joinConversation(conversationId); + } catch (_) { + if (attempt < 3 && mounted && state.activeConversationId == conversationId) { + await Future.delayed(Duration(seconds: attempt)); + return _rejoinAndCatchUp(conversationId, attempt: attempt + 1); + } + return; + } + + // Fetch recent messages to catch any missed during disconnect + if (!mounted || state.activeConversationId != conversationId) return; + try { + final result = await _repository.getMessages(conversationId, page: 1); + final recentMessages = result.messages.reversed.toList(); + final currentMessages = List.from(state.messages[conversationId] ?? []); + final existingIds = currentMessages.map((m) => m.id).toSet(); + final newMessages = recentMessages.where((m) => !existingIds.contains(m.id)).toList(); + + if (newMessages.isNotEmpty && mounted) { + currentMessages.insertAll(0, newMessages); + // Sort newest first + currentMessages.sort((a, b) => b.createdAt.compareTo(a.createdAt)); + final updatedMessages = Map>.from(state.messages) + ..[conversationId] = currentMessages; + state = state.copyWith(messages: updatedMessages); + } + } catch (e) { + _log.e('Failed to catch up messages after reconnect', error: e); + } + } + + static bool _isGifUrl(String text) { + final lower = text.trim().toLowerCase(); + return lower.endsWith('.gif') || + lower.contains('giphy.com/') || + lower.contains('giphy.gif') || + lower.contains('tenor.com/'); } /// Update online status for a user across all conversations (matching web) @@ -320,12 +383,22 @@ class MessagingNotifier extends StateNotifier { Map>.from(state.messages) ..[conversationId] = messagesAfterSend; - // Update conversation's last message + // Update conversation's last message with friendly preview + final sent = realMessage; + String sentPreview = sent.content; + if (sent.messageType == MessageType.image) { + sentPreview = 'Sent an image'; + } else if (sent.messageType == MessageType.file) { + sentPreview = 'Sent a file'; + } else if (_isGifUrl(sent.content)) { + sentPreview = 'GIF'; + } + final updatedConversations = state.conversations.map((c) { if (c.id == conversationId) { return c.copyWith( - lastMessageText: realMessage!.content, - lastMessageAt: realMessage.createdAt, + lastMessageText: sentPreview, + lastMessageAt: sent.createdAt, ); } return c;