feat: update app icons and refactor home top professionals section to display dynamic agent profiles.
|
Before Width: | Height: | Size: 4.1 KiB After Width: | Height: | Size: 3.8 KiB |
|
Before Width: | Height: | Size: 2.5 KiB After Width: | Height: | Size: 2.1 KiB |
|
Before Width: | Height: | Size: 6.1 KiB After Width: | Height: | Size: 5.7 KiB |
|
Before Width: | Height: | Size: 10 KiB After Width: | Height: | Size: 10 KiB |
|
Before Width: | Height: | Size: 15 KiB After Width: | Height: | Size: 16 KiB |
|
Before Width: | Height: | Size: 258 KiB After Width: | Height: | Size: 276 KiB |
|
Before Width: | Height: | Size: 159 KiB After Width: | Height: | Size: 188 KiB |
|
Before Width: | Height: | Size: 736 B After Width: | Height: | Size: 627 B |
|
Before Width: | Height: | Size: 1.7 KiB After Width: | Height: | Size: 1.4 KiB |
|
Before Width: | Height: | Size: 2.8 KiB After Width: | Height: | Size: 2.3 KiB |
|
Before Width: | Height: | Size: 1.2 KiB After Width: | Height: | Size: 981 B |
|
Before Width: | Height: | Size: 2.8 KiB After Width: | Height: | Size: 2.2 KiB |
|
Before Width: | Height: | Size: 4.5 KiB After Width: | Height: | Size: 4.0 KiB |
|
Before Width: | Height: | Size: 1.7 KiB After Width: | Height: | Size: 1.4 KiB |
|
Before Width: | Height: | Size: 4.0 KiB After Width: | Height: | Size: 3.5 KiB |
|
Before Width: | Height: | Size: 6.9 KiB After Width: | Height: | Size: 6.3 KiB |
|
Before Width: | Height: | Size: 2.2 KiB After Width: | Height: | Size: 1.8 KiB |
|
Before Width: | Height: | Size: 5.4 KiB After Width: | Height: | Size: 4.9 KiB |
|
Before Width: | Height: | Size: 2.8 KiB After Width: | Height: | Size: 2.2 KiB |
|
Before Width: | Height: | Size: 6.5 KiB After Width: | Height: | Size: 5.9 KiB |
|
Before Width: | Height: | Size: 6.9 KiB After Width: | Height: | Size: 6.3 KiB |
|
Before Width: | Height: | Size: 12 KiB After Width: | Height: | Size: 12 KiB |
|
Before Width: | Height: | Size: 3.5 KiB After Width: | Height: | Size: 3.1 KiB |
|
Before Width: | Height: | Size: 8.8 KiB After Width: | Height: | Size: 8.5 KiB |
|
Before Width: | Height: | Size: 3.8 KiB After Width: | Height: | Size: 3.3 KiB |
|
Before Width: | Height: | Size: 9.5 KiB After Width: | Height: | Size: 9.5 KiB |
|
Before Width: | Height: | Size: 11 KiB After Width: | Height: | Size: 11 KiB |
@@ -5,6 +5,8 @@ import 'package:go_router/go_router.dart';
|
|||||||
import 'package:shimmer/shimmer.dart';
|
import 'package:shimmer/shimmer.dart';
|
||||||
import 'package:real_estate_mobile/core/constants/app_colors.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/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/data/models/landing_page_content.dart';
|
||||||
import 'package:real_estate_mobile/features/home/presentation/providers/home_provider.dart';
|
import 'package:real_estate_mobile/features/home/presentation/providers/home_provider.dart';
|
||||||
|
|
||||||
@@ -64,14 +66,16 @@ class _TopProfessionalsSectionState
|
|||||||
|
|
||||||
@override
|
@override
|
||||||
Widget build(BuildContext context) {
|
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 homeState = ref.watch(homeProvider);
|
||||||
final cmsContent = homeState.content?.topProfessionals ?? _defaultCmsContent;
|
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'
|
final activeList = _activeTab == 'agents'
|
||||||
? cmsContent.agents
|
? profState.agents
|
||||||
: cmsContent.lenders;
|
: profState.lenders;
|
||||||
|
final isLoading = profState.isLoading || homeState.isLoading;
|
||||||
|
|
||||||
return Padding(
|
return Padding(
|
||||||
padding: const EdgeInsets.symmetric(horizontal: 24),
|
padding: const EdgeInsets.symmetric(horizontal: 24),
|
||||||
@@ -96,7 +100,7 @@ class _TopProfessionalsSectionState
|
|||||||
const SizedBox(height: 24),
|
const SizedBox(height: 24),
|
||||||
|
|
||||||
// Content
|
// Content
|
||||||
if (homeState.isLoading)
|
if (isLoading)
|
||||||
_buildShimmerCards()
|
_buildShimmerCards()
|
||||||
else if (activeList.isEmpty)
|
else if (activeList.isEmpty)
|
||||||
_buildEmptyState(_activeTab)
|
_buildEmptyState(_activeTab)
|
||||||
@@ -118,7 +122,7 @@ class _TopProfessionalsSectionState
|
|||||||
left: index == 0 ? 0 : 8,
|
left: index == 0 ? 0 : 8,
|
||||||
right: index == activeList.length - 1 ? 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 --
|
// -- Professional Card using dynamic AgentProfile data (matching web) --
|
||||||
Widget _buildCmsProfessionalCard(ProfessionalItem item, {int index = 0}) {
|
Widget _buildProfessionalCard(AgentProfile agent, {int index = 0}) {
|
||||||
final imageUrl = item.imageUrl;
|
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(
|
return Container(
|
||||||
decoration: BoxDecoration(
|
decoration: BoxDecoration(
|
||||||
color: Colors.white,
|
color: Colors.white,
|
||||||
borderRadius: BorderRadius.circular(15),
|
borderRadius: BorderRadius.circular(15),
|
||||||
border: Border.all(color: AppColors.primaryDark, width: 0.1),
|
border: Border.all(color: AppColors.primaryDark, width: 0.1),
|
||||||
),
|
),
|
||||||
clipBehavior: Clip.antiAlias,
|
clipBehavior: Clip.antiAlias,
|
||||||
child: Column(
|
child: Column(
|
||||||
crossAxisAlignment: CrossAxisAlignment.start,
|
crossAxisAlignment: CrossAxisAlignment.start,
|
||||||
children: [
|
children: [
|
||||||
// Image (226px per Figma)
|
// Image (226px per Figma)
|
||||||
SizedBox(
|
SizedBox(
|
||||||
width: double.infinity,
|
width: double.infinity,
|
||||||
height: 226,
|
height: 226,
|
||||||
child: ClipRRect(
|
child: ClipRRect(
|
||||||
borderRadius:
|
borderRadius:
|
||||||
const BorderRadius.vertical(top: Radius.circular(15)),
|
const BorderRadius.vertical(top: Radius.circular(15)),
|
||||||
child: imageUrl.isNotEmpty
|
child: imageUrl.isNotEmpty
|
||||||
? S3Image(
|
? S3Image(
|
||||||
imageUrl: imageUrl,
|
imageUrl: imageUrl,
|
||||||
width: double.infinity,
|
width: double.infinity,
|
||||||
height: 226,
|
height: 226,
|
||||||
fit: BoxFit.cover,
|
fit: BoxFit.cover,
|
||||||
alignment: Alignment.topCenter,
|
alignment: Alignment.topCenter,
|
||||||
placeholder: (_) => _buildImagePlaceholder(index),
|
placeholder: (_) => _buildImagePlaceholder(index),
|
||||||
errorWidget: (_) => _buildImagePlaceholder(index),
|
errorWidget: (_) => _buildImagePlaceholder(index),
|
||||||
)
|
)
|
||||||
: _buildImagePlaceholder(index),
|
: _buildImagePlaceholder(index),
|
||||||
|
),
|
||||||
),
|
),
|
||||||
),
|
|
||||||
|
|
||||||
// Content
|
// Content
|
||||||
Expanded(
|
Expanded(
|
||||||
child: Padding(
|
child: Padding(
|
||||||
padding: const EdgeInsets.fromLTRB(16, 12, 16, 12),
|
padding: const EdgeInsets.fromLTRB(16, 12, 16, 12),
|
||||||
child: Column(
|
child: Column(
|
||||||
crossAxisAlignment: CrossAxisAlignment.start,
|
crossAxisAlignment: CrossAxisAlignment.start,
|
||||||
children: [
|
children: [
|
||||||
// Name
|
// 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)
|
|
||||||
Text(
|
Text(
|
||||||
item.subtitle,
|
agent.fullName,
|
||||||
style: const TextStyle(
|
style: const TextStyle(
|
||||||
fontFamily: 'SourceSerif4',
|
fontFamily: 'Fractul',
|
||||||
fontSize: 10,
|
fontSize: 14,
|
||||||
fontWeight: FontWeight.w400,
|
fontWeight: FontWeight.w700,
|
||||||
color: AppColors.primaryDark,
|
color: AppColors.primaryDark,
|
||||||
),
|
),
|
||||||
maxLines: 1,
|
|
||||||
overflow: TextOverflow.ellipsis,
|
overflow: TextOverflow.ellipsis,
|
||||||
),
|
),
|
||||||
const SizedBox(height: 4),
|
const SizedBox(height: 4),
|
||||||
|
|
||||||
// Verified badge
|
// Subtitle (agent type)
|
||||||
Row(
|
if (subtitle.isNotEmpty)
|
||||||
children: [
|
Text(
|
||||||
SvgPicture.asset(
|
subtitle,
|
||||||
'assets/icons/verified_badge_icon.svg',
|
style: const TextStyle(
|
||||||
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(
|
|
||||||
fontFamily: 'SourceSerif4',
|
fontFamily: 'SourceSerif4',
|
||||||
fontSize: 14,
|
fontSize: 10,
|
||||||
fontWeight: FontWeight.w500,
|
fontWeight: FontWeight.w400,
|
||||||
color: Color(0xFF638559),
|
color: AppColors.primaryDark,
|
||||||
),
|
),
|
||||||
|
maxLines: 1,
|
||||||
|
overflow: TextOverflow.ellipsis,
|
||||||
),
|
),
|
||||||
],
|
const SizedBox(height: 4),
|
||||||
),
|
|
||||||
const SizedBox(height: 8),
|
|
||||||
|
|
||||||
// Location
|
// Verified badge
|
||||||
if (item.location.isNotEmpty) ...[
|
if (agent.isVerified)
|
||||||
const Text(
|
Row(
|
||||||
'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(
|
|
||||||
children: [
|
children: [
|
||||||
const TextSpan(
|
SvgPicture.asset(
|
||||||
text: 'Experience: ',
|
'assets/icons/verified_badge_icon.svg',
|
||||||
style: TextStyle(
|
width: 16,
|
||||||
fontFamily: 'Fractul',
|
height: 16,
|
||||||
fontSize: 14,
|
placeholderBuilder: (_) => const Icon(
|
||||||
fontWeight: FontWeight.w700,
|
Icons.verified,
|
||||||
color: AppColors.primaryDark,
|
color: Color(0xFF638559),
|
||||||
|
size: 16,
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
TextSpan(
|
const SizedBox(width: 6),
|
||||||
text: item.experience,
|
const Text(
|
||||||
style: const TextStyle(
|
'\u201CVerified local agent\u201D',
|
||||||
|
style: TextStyle(
|
||||||
fontFamily: 'SourceSerif4',
|
fontFamily: 'SourceSerif4',
|
||||||
fontSize: 14,
|
fontSize: 14,
|
||||||
fontWeight: FontWeight.w400,
|
fontWeight: FontWeight.w500,
|
||||||
color: AppColors.primaryDark,
|
color: Color(0xFF638559),
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
],
|
],
|
||||||
),
|
),
|
||||||
maxLines: 2,
|
|
||||||
overflow: TextOverflow.ellipsis,
|
|
||||||
),
|
|
||||||
const SizedBox(height: 8),
|
const SizedBox(height: 8),
|
||||||
],
|
|
||||||
|
|
||||||
// Star Rating
|
// Location
|
||||||
Row(
|
if (locationText.isNotEmpty) ...[
|
||||||
children: List.generate(5, (i) {
|
const Text(
|
||||||
return Padding(
|
'Location:',
|
||||||
padding: const EdgeInsets.only(right: 4),
|
style: TextStyle(
|
||||||
child: Icon(
|
fontFamily: 'Fractul',
|
||||||
i < item.rating.round()
|
fontSize: 14,
|
||||||
? Icons.star_rounded
|
fontWeight: FontWeight.w700,
|
||||||
: Icons.star_outline_rounded,
|
color: AppColors.primaryDark,
|
||||||
color: const Color(0xFFE5A625),
|
|
||||||
size: 24,
|
|
||||||
),
|
),
|
||||||
);
|
),
|
||||||
}),
|
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() {
|
Widget _buildShimmerCards() {
|
||||||
return SizedBox(
|
return SizedBox(
|
||||||
height: 540,
|
height: 540,
|
||||||
|
|||||||
@@ -1,6 +1,7 @@
|
|||||||
import 'dart:async';
|
import 'dart:async';
|
||||||
import 'package:logger/logger.dart';
|
import 'package:logger/logger.dart';
|
||||||
import 'package:real_estate_mobile/config/app_config.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/core/storage/secure_storage.dart';
|
||||||
import 'package:real_estate_mobile/features/messaging/data/models/message.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';
|
import 'package:real_estate_mobile/features/support_chat/data/models/support_chat_models.dart';
|
||||||
@@ -144,12 +145,31 @@ class SocketService {
|
|||||||
}
|
}
|
||||||
|
|
||||||
Future<void> _reconnectWithFreshToken() async {
|
Future<void> _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();
|
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;
|
_currentToken = token;
|
||||||
_socket?.io.options?['auth'] = {'token': token};
|
|
||||||
_socket?.connect();
|
|
||||||
}
|
}
|
||||||
|
|
||||||
|
_socket?.io.options?['auth'] = {'token': _currentToken};
|
||||||
|
_socket?.connect();
|
||||||
}
|
}
|
||||||
|
|
||||||
void disconnect() {
|
void disconnect() {
|
||||||
|
|||||||
@@ -97,7 +97,7 @@ class MessagingNotifier extends StateNotifier<MessagingState> {
|
|||||||
final currentMessages =
|
final currentMessages =
|
||||||
List<ChatMessage>.from(state.messages[convId] ?? []);
|
List<ChatMessage>.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;
|
if (currentMessages.any((m) => m.id == message.id)) return;
|
||||||
|
|
||||||
currentMessages.insert(0, message);
|
currentMessages.insert(0, message);
|
||||||
@@ -105,12 +105,22 @@ class MessagingNotifier extends StateNotifier<MessagingState> {
|
|||||||
Map<String, List<ChatMessage>>.from(state.messages)
|
Map<String, List<ChatMessage>>.from(state.messages)
|
||||||
..[convId] = currentMessages;
|
..[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
|
// Update conversation's last message and unread count
|
||||||
final updatedConversations = state.conversations.map((c) {
|
final updatedConversations = state.conversations.map((c) {
|
||||||
if (c.id == convId) {
|
if (c.id == convId) {
|
||||||
final isActive = state.activeConversationId == convId;
|
final isActive = state.activeConversationId == convId;
|
||||||
return c.copyWith(
|
return c.copyWith(
|
||||||
lastMessageText: message.content,
|
lastMessageText: previewText,
|
||||||
lastMessageAt: message.createdAt,
|
lastMessageAt: message.createdAt,
|
||||||
unreadCount: isActive ? c.unreadCount : c.unreadCount + 1,
|
unreadCount: isActive ? c.unreadCount : c.unreadCount + 1,
|
||||||
);
|
);
|
||||||
@@ -155,6 +165,59 @@ class MessagingNotifier extends StateNotifier<MessagingState> {
|
|||||||
}).toList();
|
}).toList();
|
||||||
state = state.copyWith(conversations: updatedConversations);
|
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<void> _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<ChatMessage>.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<String, List<ChatMessage>>.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)
|
/// Update online status for a user across all conversations (matching web)
|
||||||
@@ -320,12 +383,22 @@ class MessagingNotifier extends StateNotifier<MessagingState> {
|
|||||||
Map<String, List<ChatMessage>>.from(state.messages)
|
Map<String, List<ChatMessage>>.from(state.messages)
|
||||||
..[conversationId] = messagesAfterSend;
|
..[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) {
|
final updatedConversations = state.conversations.map((c) {
|
||||||
if (c.id == conversationId) {
|
if (c.id == conversationId) {
|
||||||
return c.copyWith(
|
return c.copyWith(
|
||||||
lastMessageText: realMessage!.content,
|
lastMessageText: sentPreview,
|
||||||
lastMessageAt: realMessage.createdAt,
|
lastMessageAt: sent.createdAt,
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
return c;
|
return c;
|
||||||
|
|||||||