feat: Implement dynamic top professionals section with agent/lender tabs using Riverpod for state management.

This commit is contained in:
pradeepkumar
2026-02-24 03:28:13 +05:30
parent 0bc8852c17
commit 1f48ff3852
8 changed files with 1034 additions and 285 deletions

View File

@@ -1,6 +1,7 @@
class ApiConstants {
ApiConstants._();
// Auth
static const String authRegister = '/auth/register';
static const String authLogin = '/auth/login';
static const String authForgotPassword = '/auth/forgot-password';
@@ -10,4 +11,8 @@ class ApiConstants {
static const String authMe = '/auth/me';
static const String authRefresh = '/auth/refresh';
static const String authLogout = '/auth/logout';
// Agents
static const String agents = '/agents';
static const String agentTypes = '/agent-types';
}

View File

@@ -1,5 +1,8 @@
import 'dart:async';
import 'package:dio/dio.dart';
import 'package:real_estate_mobile/config/app_config.dart';
import 'package:real_estate_mobile/core/constants/api_constants.dart';
import 'package:real_estate_mobile/core/network/api_exceptions.dart';
import 'package:real_estate_mobile/core/storage/secure_storage.dart';
@@ -7,6 +10,13 @@ class ApiClient {
static ApiClient? _instance;
late final Dio _dio;
// Token refresh state
bool _isRefreshing = false;
final List<_QueuedRequest> _failedQueue = [];
// Callback to notify app of forced logout
static void Function()? onForceLogout;
ApiClient._() {
_dio = Dio(
BaseOptions(
@@ -47,46 +57,209 @@ class ApiClient {
InterceptorsWrapper _errorInterceptor() {
return InterceptorsWrapper(
onError: (error, handler) {
final response = error.response;
if (response != null) {
final data = response.data;
if (data is Map<String, dynamic>) {
final message = data['message'] is List
? (data['message'] as List).join(', ')
: data['message']?.toString() ?? 'An error occurred';
onError: (error, handler) async {
// Handle 401 — attempt token refresh
if (error.response?.statusCode == 401) {
final requestUrl = error.requestOptions.path;
final errors = (data['errors'] as List<dynamic>?)
?.map((e) => FieldError.fromJson(e as Map<String, dynamic>))
.toList() ??
[];
// Don't try to refresh if this was the refresh request itself
if (requestUrl.contains(ApiConstants.authRefresh)) {
await _forceLogout();
handler.reject(_wrapError(error));
return;
}
handler.reject(
DioException(
requestOptions: error.requestOptions,
response: error.response,
error: ApiException(
message: message,
statusCode: response.statusCode,
fieldErrors: errors,
// Don't refresh for auth endpoints (login, register, etc.)
if (_isAuthEndpoint(requestUrl)) {
handler.reject(_wrapError(error));
return;
}
// If already refreshing, queue this request
if (_isRefreshing) {
try {
final response = await _queueRequest(error.requestOptions);
handler.resolve(response);
} catch (e) {
handler.reject(
DioException(
requestOptions: error.requestOptions,
error: e,
),
),
);
);
}
return;
}
// Attempt refresh
_isRefreshing = true;
try {
final newAccessToken = await _refreshToken();
if (newAccessToken != null) {
// Retry original request with new token
error.requestOptions.headers['Authorization'] =
'Bearer $newAccessToken';
// Process queued requests
_processQueue(null, newAccessToken);
_isRefreshing = false;
// Retry the original request
final response = await _dio.fetch(error.requestOptions);
handler.resolve(response);
return;
} else {
// No refresh token available
_processQueue(error);
_isRefreshing = false;
await _forceLogout();
handler.reject(_wrapError(error));
return;
}
} catch (refreshError) {
// Refresh failed
_processQueue(error);
_isRefreshing = false;
await _forceLogout();
handler.reject(_wrapError(error));
return;
}
}
handler.reject(
DioException(
requestOptions: error.requestOptions,
response: error.response,
error: ApiException(
message: error.message ?? 'Network error occurred',
statusCode: response?.statusCode,
),
),
);
// Non-401 errors — parse as before
handler.reject(_wrapError(error));
},
);
}
/// Attempt to refresh the access token using the stored refresh token.
Future<String?> _refreshToken() async {
final refreshToken = await SecureStorage.getRefreshToken();
if (refreshToken == null) return null;
// Use a separate Dio instance to avoid interceptor loops
final refreshDio = Dio(
BaseOptions(
baseUrl: AppConfig.apiBaseUrl,
connectTimeout: const Duration(seconds: 15),
receiveTimeout: const Duration(seconds: 15),
headers: {
'Content-Type': 'application/json',
'Accept': 'application/json',
},
),
);
final response = await refreshDio.post(
ApiConstants.authRefresh,
data: {'refreshToken': refreshToken},
);
final data = response.data['data'] as Map<String, dynamic>;
final newAccessToken = data['accessToken'] as String;
final newRefreshToken = data['refreshToken'] as String;
await SecureStorage.saveTokens(
accessToken: newAccessToken,
refreshToken: newRefreshToken,
);
return newAccessToken;
}
/// Queue a request to be retried after token refresh completes.
Future<Response> _queueRequest(RequestOptions requestOptions) {
final completer = Completer<Response>();
_failedQueue.add(_QueuedRequest(
completer: completer,
requestOptions: requestOptions,
));
return completer.future;
}
/// Process all queued requests after refresh succeeds or fails.
void _processQueue(DioException? error, [String? newToken]) {
for (final queued in _failedQueue) {
if (error != null) {
queued.completer.completeError(error);
} else {
// Update token and retry
queued.requestOptions.headers['Authorization'] =
'Bearer $newToken';
_dio.fetch(queued.requestOptions).then(
queued.completer.complete,
onError: queued.completer.completeError,
);
}
}
_failedQueue.clear();
}
/// Force logout — clear tokens and notify the app.
Future<void> _forceLogout() async {
await SecureStorage.clearTokens();
onForceLogout?.call();
}
/// Check if the URL is an auth endpoint that shouldn't trigger refresh.
bool _isAuthEndpoint(String url) {
const authPaths = [
ApiConstants.authLogin,
ApiConstants.authRegister,
ApiConstants.authForgotPassword,
ApiConstants.authResetPassword,
ApiConstants.authVerifyEmail,
];
return authPaths.any((path) => url.contains(path));
}
/// Wrap a DioException with parsed error data.
DioException _wrapError(DioException error) {
final response = error.response;
if (response != null) {
final data = response.data;
if (data is Map<String, dynamic>) {
final message = data['message'] is List
? (data['message'] as List).join(', ')
: data['message']?.toString() ?? 'An error occurred';
final errors = (data['errors'] as List<dynamic>?)
?.map(
(e) => FieldError.fromJson(e as Map<String, dynamic>))
.toList() ??
[];
return DioException(
requestOptions: error.requestOptions,
response: error.response,
error: ApiException(
message: message,
statusCode: response.statusCode,
fieldErrors: errors,
),
);
}
}
return DioException(
requestOptions: error.requestOptions,
response: error.response,
error: ApiException(
message: error.message ?? 'Network error occurred',
statusCode: response?.statusCode,
),
);
}
}
class _QueuedRequest {
final Completer<Response> completer;
final RequestOptions requestOptions;
_QueuedRequest({
required this.completer,
required this.requestOptions,
});
}

View File

@@ -0,0 +1,91 @@
import 'package:dio/dio.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/agents/data/models/agent_profile.dart';
import 'package:real_estate_mobile/features/agents/data/models/agent_type.dart';
class SearchAgentsResponse {
final List<AgentProfile> data;
final int total;
final int page;
final int limit;
final int totalPages;
const SearchAgentsResponse({
required this.data,
required this.total,
required this.page,
required this.limit,
required this.totalPages,
});
factory SearchAgentsResponse.fromJson(Map<String, dynamic> json) {
return SearchAgentsResponse(
data: (json['data'] as List<dynamic>)
.map((e) => AgentProfile.fromJson(e as Map<String, dynamic>))
.toList(),
total: json['total'] as int? ?? 0,
page: json['page'] as int? ?? 1,
limit: json['limit'] as int? ?? 10,
totalPages: json['totalPages'] as int? ?? 1,
);
}
}
class AgentsRepository {
final Dio _dio = ApiClient.instance.dio;
Future<SearchAgentsResponse> searchAgents({
String? agentTypeId,
int page = 1,
int limit = 10,
String? sortBy,
String? sortOrder,
}) async {
try {
final queryParams = <String, dynamic>{
'page': page,
'limit': limit,
};
if (agentTypeId != null) queryParams['agentTypeId'] = agentTypeId;
if (sortBy != null) queryParams['sortBy'] = sortBy;
if (sortOrder != null) queryParams['sortOrder'] = sortOrder;
final response = await _dio.get(
ApiConstants.agents,
queryParameters: queryParams,
);
final data = response.data['data'] as Map<String, dynamic>;
return SearchAgentsResponse.fromJson(data);
} on DioException catch (e) {
if (e.error is ApiException) {
throw e.error as ApiException;
}
throw ApiException(
message: e.message ?? 'Failed to fetch agents',
statusCode: e.response?.statusCode,
);
}
}
Future<List<AgentType>> getAgentTypes() async {
try {
final response = await _dio.get(ApiConstants.agentTypes);
final data = response.data['data'] as List<dynamic>;
return data
.map((e) => AgentType.fromJson(e as Map<String, dynamic>))
.toList();
} on DioException catch (e) {
if (e.error is ApiException) {
throw e.error as ApiException;
}
throw ApiException(
message: e.message ?? 'Failed to fetch agent types',
statusCode: e.response?.statusCode,
);
}
}
}

View File

@@ -0,0 +1,165 @@
import 'package:real_estate_mobile/features/agents/data/models/agent_type.dart';
class AgentProfile {
final String id;
final String firstName;
final String lastName;
final String slug;
final String? email;
final String? phone;
final String? bio;
final String? avatar;
final String? licenseNumber;
final int? experience;
final List<String> specializations;
final List<String> languages;
final List<String> serviceAreas;
final String? city;
final String? state;
final String? country;
final double? rating;
final int reviewCount;
final bool isVerified;
final bool isFeatured;
final bool isAvailable;
final String? agentTypeId;
final AgentType? agentType;
final List<AgentFieldValue> fieldValues;
const AgentProfile({
required this.id,
required this.firstName,
required this.lastName,
required this.slug,
this.email,
this.phone,
this.bio,
this.avatar,
this.licenseNumber,
this.experience,
this.specializations = const [],
this.languages = const [],
this.serviceAreas = const [],
this.city,
this.state,
this.country,
this.rating,
this.reviewCount = 0,
this.isVerified = false,
this.isFeatured = false,
this.isAvailable = false,
this.agentTypeId,
this.agentType,
this.fieldValues = const [],
});
String get fullName => '$firstName $lastName'.trim();
String get location {
if (city != null && state != null) return '$city, $state';
if (city != null) return city!;
if (state != null) return state!;
if (serviceAreas.isNotEmpty) return serviceAreas.first;
return '';
}
String get experienceText {
if (experience == null) return '';
return '$experience+ years in the real estate industry.';
}
factory AgentProfile.fromJson(Map<String, dynamic> json) {
return AgentProfile(
id: json['id'] as String,
firstName: json['firstName'] as String? ?? '',
lastName: json['lastName'] as String? ?? '',
slug: json['slug'] as String? ?? '',
email: json['email'] as String?,
phone: json['phone'] as String?,
bio: json['bio'] as String?,
avatar: json['avatar'] as String?,
licenseNumber: json['licenseNumber'] as String?,
experience: json['experience'] as int?,
specializations: _parseStringList(json['specializations']),
languages: _parseStringList(json['languages']),
serviceAreas: _parseStringList(json['serviceAreas']),
city: json['city'] as String?,
state: json['state'] as String?,
country: json['country'] as String?,
rating: (json['rating'] as num?)?.toDouble(),
reviewCount: json['reviewCount'] as int? ?? 0,
isVerified: json['isVerified'] as bool? ?? false,
isFeatured: json['isFeatured'] as bool? ?? false,
isAvailable: json['isAvailable'] as bool? ?? false,
agentTypeId: json['agentTypeId'] as String?,
agentType: json['agentType'] != null
? AgentType.fromJson(json['agentType'] as Map<String, dynamic>)
: null,
fieldValues: (json['fieldValues'] as List<dynamic>?)
?.map((e) => AgentFieldValue.fromJson(e as Map<String, dynamic>))
.toList() ??
const [],
);
}
static List<String> _parseStringList(dynamic value) {
if (value == null) return const [];
if (value is List) return value.map((e) => e.toString()).toList();
return const [];
}
}
class AgentFieldValue {
final String id;
final String fieldId;
final String? textValue;
final num? numberValue;
final bool? booleanValue;
final dynamic jsonValue;
final String? dateValue;
final AgentField field;
const AgentFieldValue({
required this.id,
required this.fieldId,
this.textValue,
this.numberValue,
this.booleanValue,
this.jsonValue,
this.dateValue,
required this.field,
});
factory AgentFieldValue.fromJson(Map<String, dynamic> json) {
return AgentFieldValue(
id: json['id'] as String,
fieldId: json['fieldId'] as String,
textValue: json['textValue'] as String?,
numberValue: json['numberValue'] as num?,
booleanValue: json['booleanValue'] as bool?,
jsonValue: json['jsonValue'],
dateValue: json['dateValue'] as String?,
field: AgentField.fromJson(json['field'] as Map<String, dynamic>),
);
}
}
class AgentField {
final String slug;
final String name;
final String fieldType;
const AgentField({
required this.slug,
required this.name,
required this.fieldType,
});
factory AgentField.fromJson(Map<String, dynamic> json) {
return AgentField(
slug: json['slug'] as String,
name: json['name'] as String,
fieldType: json['fieldType'] as String,
);
}
}

View File

@@ -0,0 +1,22 @@
class AgentType {
final String id;
final String name;
final String slug;
final String? description;
const AgentType({
required this.id,
required this.name,
required this.slug,
this.description,
});
factory AgentType.fromJson(Map<String, dynamic> json) {
return AgentType(
id: json['id'] as String,
name: json['name'] as String,
slug: json['slug'] as String,
description: json['description'] as String?,
);
}
}

View File

@@ -0,0 +1,129 @@
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/data/models/agent_type.dart';
// Repository provider
final agentsRepositoryProvider = Provider<AgentsRepository>((ref) {
return AgentsRepository();
});
// Agent types provider
final agentTypesProvider = FutureProvider<List<AgentType>>((ref) async {
final repository = ref.watch(agentsRepositoryProvider);
return repository.getAgentTypes();
});
// Top professionals state
class TopProfessionalsState {
final List<AgentProfile> agents;
final List<AgentProfile> lenders;
final List<AgentType> agentTypes;
final bool isLoading;
final String? error;
final String activeTab; // 'agents' or 'lenders'
const TopProfessionalsState({
this.agents = const [],
this.lenders = const [],
this.agentTypes = const [],
this.isLoading = false,
this.error,
this.activeTab = 'agents',
});
TopProfessionalsState copyWith({
List<AgentProfile>? agents,
List<AgentProfile>? lenders,
List<AgentType>? agentTypes,
bool? isLoading,
String? error,
String? activeTab,
}) {
return TopProfessionalsState(
agents: agents ?? this.agents,
lenders: lenders ?? this.lenders,
agentTypes: agentTypes ?? this.agentTypes,
isLoading: isLoading ?? this.isLoading,
error: error,
activeTab: activeTab ?? this.activeTab,
);
}
List<AgentProfile> get activeList =>
activeTab == 'agents' ? agents : lenders;
}
class TopProfessionalsNotifier extends StateNotifier<TopProfessionalsState> {
final AgentsRepository _repository;
TopProfessionalsNotifier(this._repository)
: super(const TopProfessionalsState()) {
_loadData();
}
Future<void> _loadData() async {
state = state.copyWith(isLoading: true, error: null);
try {
// Fetch agent types first to find agent/lender type IDs
final agentTypes = await _repository.getAgentTypes();
state = state.copyWith(agentTypes: agentTypes);
// Find agent and lender type IDs
String? agentTypeId;
String? lenderTypeId;
for (final type in agentTypes) {
final slug = type.slug.toLowerCase();
if (slug.contains('agent')) {
agentTypeId = type.id;
} else if (slug.contains('lender')) {
lenderTypeId = type.id;
}
}
// Fetch agents and lenders in parallel
final results = await Future.wait([
_repository.searchAgents(
agentTypeId: agentTypeId,
limit: 10,
sortBy: 'averageRating',
sortOrder: 'desc',
),
_repository.searchAgents(
agentTypeId: lenderTypeId,
limit: 10,
sortBy: 'averageRating',
sortOrder: 'desc',
),
]);
state = state.copyWith(
agents: results[0].data,
lenders: results[1].data,
isLoading: false,
);
} catch (e) {
state = state.copyWith(
isLoading: false,
error: 'Failed to load professionals',
);
}
}
void setActiveTab(String tab) {
state = state.copyWith(activeTab: tab);
}
Future<void> refresh() async {
await _loadData();
}
}
final topProfessionalsProvider =
StateNotifierProvider<TopProfessionalsNotifier, TopProfessionalsState>(
(ref) {
final repository = ref.watch(agentsRepositoryProvider);
return TopProfessionalsNotifier(repository);
});

View File

@@ -1,4 +1,5 @@
import 'package:flutter_riverpod/flutter_riverpod.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/core/storage/secure_storage.dart';
import 'package:real_estate_mobile/features/auth/data/auth_repository.dart';
@@ -51,6 +52,11 @@ class AuthNotifier extends StateNotifier<AuthState> {
final AuthRepository _repository;
AuthNotifier(this._repository) : super(const AuthState()) {
// Wire up force logout callback so API client can reset auth state
// when token refresh fails
ApiClient.onForceLogout = () {
state = const AuthState();
};
checkAuthStatus();
}

View File

@@ -1,12 +1,17 @@
import 'package:flutter/material.dart';
import 'package:flutter_riverpod/flutter_riverpod.dart';
import 'package:flutter_svg/flutter_svg.dart';
import 'package:real_estate_mobile/core/constants/app_colors.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 TopProfessionalsSection extends StatelessWidget {
class TopProfessionalsSection extends ConsumerWidget {
const TopProfessionalsSection({super.key});
@override
Widget build(BuildContext context) {
Widget build(BuildContext context, WidgetRef ref) {
final state = ref.watch(topProfessionalsProvider);
return Padding(
padding: const EdgeInsets.symmetric(horizontal: 24),
child: Column(
@@ -24,15 +29,38 @@ class TopProfessionalsSection extends StatelessWidget {
const SizedBox(height: 24),
// Agents / Lenders Tab
_buildCategoryTab(),
_buildCategoryTab(ref, state.activeTab),
const SizedBox(height: 24),
// Agent Card
_buildAgentCard(),
const SizedBox(height: 12),
// Content
if (state.isLoading)
const Padding(
padding: EdgeInsets.symmetric(vertical: 40),
child: CircularProgressIndicator(
color: AppColors.accentOrange,
),
)
else if (state.error != null)
_buildErrorState(ref, state.error!)
else if (state.activeList.isEmpty)
_buildEmptyState(state.activeTab)
else ...[
// Agent Cards - horizontal scroll
SizedBox(
height: 480,
child: PageView.builder(
controller: PageController(viewportFraction: 1.0),
itemCount: state.activeList.length,
itemBuilder: (context, index) {
return _buildAgentCard(state.activeList[index]);
},
),
),
const SizedBox(height: 12),
// Progress indicator
_buildProgressIndicator(),
// Progress indicator
_buildProgressIndicator(state.activeList.length),
],
const SizedBox(height: 32),
// See All Agents CTA
@@ -42,7 +70,7 @@ class TopProfessionalsSection extends StatelessWidget {
);
}
Widget _buildCategoryTab() {
Widget _buildCategoryTab(WidgetRef ref, String activeTab) {
return Container(
decoration: BoxDecoration(
borderRadius: BorderRadius.circular(15),
@@ -50,69 +78,95 @@ class TopProfessionalsSection extends StatelessWidget {
),
child: Row(
children: [
// Active tab - Agents
// Agents tab
Expanded(
child: Container(
padding: const EdgeInsets.symmetric(vertical: 12),
decoration: BoxDecoration(
color: AppColors.accentOrange,
borderRadius: BorderRadius.circular(15),
),
child: Row(
mainAxisAlignment: MainAxisAlignment.center,
children: [
SvgPicture.asset(
'assets/icons/agents_tab_icon.svg',
width: 24,
height: 24,
placeholderBuilder: (_) => const Icon(
Icons.people,
color: Colors.white,
size: 24,
child: GestureDetector(
onTap: () => ref
.read(topProfessionalsProvider.notifier)
.setActiveTab('agents'),
child: Container(
padding: const EdgeInsets.symmetric(vertical: 12),
decoration: BoxDecoration(
color: activeTab == 'agents'
? AppColors.accentOrange
: Colors.transparent,
borderRadius: BorderRadius.circular(15),
),
child: Row(
mainAxisAlignment: MainAxisAlignment.center,
children: [
SvgPicture.asset(
'assets/icons/agents_tab_icon.svg',
width: 24,
height: 24,
placeholderBuilder: (_) => Icon(
Icons.people,
color: activeTab == 'agents'
? Colors.white
: AppColors.primaryDark,
size: 24,
),
),
),
const SizedBox(width: 8),
const Text(
'Agents',
style: TextStyle(
fontFamily: 'SourceSerif4',
fontSize: 17,
fontWeight: FontWeight.w600,
color: Colors.white,
const SizedBox(width: 8),
Text(
'Agents',
style: TextStyle(
fontFamily: 'SourceSerif4',
fontSize: 17,
fontWeight: FontWeight.w600,
color: activeTab == 'agents'
? Colors.white
: AppColors.primaryDark,
),
),
),
],
],
),
),
),
),
// Inactive tab - Lenders
// Lenders tab
Expanded(
child: Container(
padding: const EdgeInsets.symmetric(vertical: 12),
child: Row(
mainAxisAlignment: MainAxisAlignment.center,
children: [
SvgPicture.asset(
'assets/icons/lenders_tab_icon.svg',
width: 24,
height: 24,
placeholderBuilder: (_) => const Icon(
Icons.calendar_today,
color: AppColors.primaryDark,
size: 24,
child: GestureDetector(
onTap: () => ref
.read(topProfessionalsProvider.notifier)
.setActiveTab('lenders'),
child: Container(
padding: const EdgeInsets.symmetric(vertical: 12),
decoration: BoxDecoration(
color: activeTab == 'lenders'
? AppColors.accentOrange
: Colors.transparent,
borderRadius: BorderRadius.circular(15),
),
child: Row(
mainAxisAlignment: MainAxisAlignment.center,
children: [
SvgPicture.asset(
'assets/icons/lenders_tab_icon.svg',
width: 24,
height: 24,
placeholderBuilder: (_) => Icon(
Icons.calendar_today,
color: activeTab == 'lenders'
? Colors.white
: AppColors.primaryDark,
size: 24,
),
),
),
const SizedBox(width: 8),
const Text(
'Lenders',
style: TextStyle(
fontFamily: 'SourceSerif4',
fontSize: 17,
fontWeight: FontWeight.w600,
color: AppColors.primaryDark,
const SizedBox(width: 8),
Text(
'Lenders',
style: TextStyle(
fontFamily: 'SourceSerif4',
fontSize: 17,
fontWeight: FontWeight.w600,
color: activeTab == 'lenders'
? Colors.white
: AppColors.primaryDark,
),
),
),
],
],
),
),
),
),
@@ -121,7 +175,7 @@ class TopProfessionalsSection extends StatelessWidget {
);
}
Widget _buildAgentCard() {
Widget _buildAgentCard(AgentProfile agent) {
return Container(
decoration: BoxDecoration(
color: Colors.white,
@@ -133,184 +187,208 @@ class TopProfessionalsSection extends StatelessWidget {
children: [
// Agent image
ClipRRect(
borderRadius: const BorderRadius.vertical(top: Radius.circular(15)),
child: Image.asset(
'assets/images/agent_profile_image.png',
width: double.infinity,
height: 192,
fit: BoxFit.cover,
errorBuilder: (_, __, ___) => Container(
width: double.infinity,
height: 192,
color: AppColors.gradientStart,
child: const Icon(Icons.person, size: 60, color: AppColors.primaryDark),
),
),
borderRadius:
const BorderRadius.vertical(top: Radius.circular(15)),
child: agent.avatar != null && agent.avatar!.isNotEmpty
? Image.network(
agent.avatar!,
width: double.infinity,
height: 192,
fit: BoxFit.cover,
errorBuilder: (_, __, ___) => _buildAvatarPlaceholder(),
)
: _buildAvatarPlaceholder(),
),
Padding(
padding: const EdgeInsets.all(16),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
// Name and badges row
Row(
children: [
const Text(
'Arjun Mehta',
style: TextStyle(
fontFamily: 'Fractul',
fontSize: 14,
fontWeight: FontWeight.w700,
color: AppColors.primaryDark,
),
),
const SizedBox(width: 8),
SvgPicture.asset(
'assets/icons/verified_badge_icon.svg',
width: 16,
height: 16,
placeholderBuilder: (_) => const Icon(
Icons.verified,
color: Colors.blue,
size: 16,
),
),
const Spacer(),
SvgPicture.asset(
'assets/icons/star_rating_icon.svg',
width: 16,
height: 16,
placeholderBuilder: (_) => const Icon(
Icons.star,
color: AppColors.accentOrange,
size: 16,
),
),
const SizedBox(width: 4),
const Text(
'4.9 Rating',
style: TextStyle(
fontFamily: 'SourceSerif4',
fontSize: 14,
fontWeight: FontWeight.w400,
color: AppColors.primaryDark,
),
),
],
),
const SizedBox(height: 4),
// Verified and Location
const Text(
'"Verified local agent"',
style: TextStyle(
fontFamily: 'SourceSerif4',
fontSize: 14,
fontWeight: FontWeight.w500,
color: Color(0xFF638559),
),
),
const SizedBox(height: 8),
// Location
Row(
children: [
const Text(
'Location:',
style: TextStyle(
fontFamily: 'Fractul',
fontSize: 14,
fontWeight: FontWeight.w700,
color: AppColors.primaryDark,
),
),
const SizedBox(width: 4),
SvgPicture.asset(
'assets/icons/location_pin_icon.svg',
width: 14,
height: 14,
placeholderBuilder: (_) => const Icon(
Icons.location_on,
color: AppColors.accentOrange,
size: 14,
),
),
const SizedBox(width: 4),
const Text(
'San Francisco, CA',
style: TextStyle(
fontFamily: 'SourceSerif4',
fontSize: 10,
fontWeight: FontWeight.w400,
color: AppColors.primaryDark,
),
),
],
),
const SizedBox(height: 8),
// Expertise
Row(
children: [
const Text(
'Expertise:',
style: TextStyle(
fontFamily: 'Fractul',
fontSize: 14,
fontWeight: FontWeight.w700,
color: AppColors.primaryDark,
),
),
const SizedBox(width: 8),
const Text(
'(Residential Property Expert)',
style: TextStyle(
fontFamily: 'SourceSerif4',
fontSize: 10,
fontWeight: FontWeight.w400,
color: AppColors.primaryDark,
),
),
],
),
const SizedBox(height: 12),
// Tags
Wrap(
spacing: 8,
runSpacing: 8,
children: [
_buildTag('Residential'),
_buildTag('Rental'),
_buildTag('Commercial'),
_buildTag('Inspection'),
_buildTag('Land'),
_buildTag('Rental'),
],
),
const SizedBox(height: 12),
// Experience
RichText(
text: const TextSpan(
Expanded(
child: Padding(
padding: const EdgeInsets.all(16),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
// Name and rating row
Row(
children: [
TextSpan(
text: 'Experience: ',
style: TextStyle(
fontFamily: 'Fractul',
fontSize: 14,
fontWeight: FontWeight.w700,
color: AppColors.primaryDark,
Expanded(
child: Row(
children: [
Flexible(
child: Text(
agent.fullName,
style: const TextStyle(
fontFamily: 'Fractul',
fontSize: 14,
fontWeight: FontWeight.w700,
color: AppColors.primaryDark,
),
overflow: TextOverflow.ellipsis,
),
),
if (agent.isVerified) ...[
const SizedBox(width: 8),
SvgPicture.asset(
'assets/icons/verified_badge_icon.svg',
width: 16,
height: 16,
placeholderBuilder: (_) => const Icon(
Icons.verified,
color: Colors.blue,
size: 16,
),
),
],
],
),
),
TextSpan(
text: '10+ years in the real estate industry.',
style: TextStyle(
fontFamily: 'SourceSerif4',
fontSize: 14,
fontWeight: FontWeight.w400,
color: AppColors.primaryDark,
if (agent.rating != null) ...[
SvgPicture.asset(
'assets/icons/star_rating_icon.svg',
width: 16,
height: 16,
placeholderBuilder: (_) => const Icon(
Icons.star,
color: AppColors.accentOrange,
size: 16,
),
),
),
const SizedBox(width: 4),
Text(
'${agent.rating!.toStringAsFixed(1)} Rating',
style: const TextStyle(
fontFamily: 'SourceSerif4',
fontSize: 14,
fontWeight: FontWeight.w400,
color: AppColors.primaryDark,
),
),
],
],
),
),
],
const SizedBox(height: 4),
// Verified text
if (agent.isVerified)
Text(
'"Verified local ${agent.agentType?.name.toLowerCase() ?? 'agent'}"',
style: const TextStyle(
fontFamily: 'SourceSerif4',
fontSize: 14,
fontWeight: FontWeight.w500,
color: Color(0xFF638559),
),
),
const SizedBox(height: 8),
// Location
if (agent.location.isNotEmpty)
Row(
children: [
const Text(
'Location:',
style: TextStyle(
fontFamily: 'Fractul',
fontSize: 14,
fontWeight: FontWeight.w700,
color: AppColors.primaryDark,
),
),
const SizedBox(width: 4),
SvgPicture.asset(
'assets/icons/location_pin_icon.svg',
width: 14,
height: 14,
placeholderBuilder: (_) => const Icon(
Icons.location_on,
color: AppColors.accentOrange,
size: 14,
),
),
const SizedBox(width: 4),
Expanded(
child: Text(
agent.location,
style: const TextStyle(
fontFamily: 'SourceSerif4',
fontSize: 10,
fontWeight: FontWeight.w400,
color: AppColors.primaryDark,
),
overflow: TextOverflow.ellipsis,
),
),
],
),
const SizedBox(height: 8),
// Expertise
if (agent.agentType != null)
Row(
children: [
const Text(
'Expertise:',
style: TextStyle(
fontFamily: 'Fractul',
fontSize: 14,
fontWeight: FontWeight.w700,
color: AppColors.primaryDark,
),
),
const SizedBox(width: 8),
Expanded(
child: Text(
'(${agent.agentType!.name})',
style: const TextStyle(
fontFamily: 'SourceSerif4',
fontSize: 10,
fontWeight: FontWeight.w400,
color: AppColors.primaryDark,
),
overflow: TextOverflow.ellipsis,
),
),
],
),
const SizedBox(height: 12),
// Tags from specializations
if (agent.specializations.isNotEmpty)
Wrap(
spacing: 8,
runSpacing: 8,
children: agent.specializations
.take(6)
.map((tag) => _buildTag(tag))
.toList(),
),
const Spacer(),
// Experience
if (agent.experienceText.isNotEmpty)
RichText(
text: TextSpan(
children: [
const TextSpan(
text: 'Experience: ',
style: TextStyle(
fontFamily: 'Fractul',
fontSize: 14,
fontWeight: FontWeight.w700,
color: AppColors.primaryDark,
),
),
TextSpan(
text: agent.experienceText,
style: const TextStyle(
fontFamily: 'SourceSerif4',
fontSize: 14,
fontWeight: FontWeight.w400,
color: AppColors.primaryDark,
),
),
],
),
),
],
),
),
),
],
@@ -318,6 +396,15 @@ class TopProfessionalsSection extends StatelessWidget {
);
}
Widget _buildAvatarPlaceholder() {
return Container(
width: double.infinity,
height: 192,
color: AppColors.gradientStart,
child: const Icon(Icons.person, size: 60, color: AppColors.primaryDark),
);
}
Widget _buildTag(String label) {
return Container(
padding: const EdgeInsets.symmetric(horizontal: 10, vertical: 4),
@@ -337,26 +424,97 @@ class TopProfessionalsSection extends StatelessWidget {
);
}
Widget _buildProgressIndicator() {
return Stack(
children: [
Container(
height: 5,
decoration: BoxDecoration(
color: Colors.white,
borderRadius: BorderRadius.circular(15),
border: Border.all(color: AppColors.primaryDark, width: 0.1),
Widget _buildProgressIndicator(int totalCards) {
return LayoutBuilder(
builder: (context, constraints) {
final indicatorWidth = totalCards > 0
? constraints.maxWidth / totalCards
: 103.0;
return Stack(
children: [
Container(
height: 5,
decoration: BoxDecoration(
color: Colors.white,
borderRadius: BorderRadius.circular(15),
border: Border.all(color: AppColors.primaryDark, width: 0.1),
),
),
Container(
height: 5,
width: indicatorWidth.clamp(40.0, 150.0),
decoration: BoxDecoration(
color: AppColors.primaryDark,
borderRadius: BorderRadius.circular(15),
),
),
],
);
},
);
}
Widget _buildErrorState(WidgetRef ref, String error) {
return Padding(
padding: const EdgeInsets.symmetric(vertical: 40),
child: Column(
children: [
const Icon(
Icons.error_outline,
color: AppColors.accentOrange,
size: 48,
),
),
Container(
height: 5,
width: 103,
decoration: BoxDecoration(
color: AppColors.primaryDark,
borderRadius: BorderRadius.circular(15),
const SizedBox(height: 12),
Text(
error,
style: const TextStyle(
fontFamily: 'Fractul',
fontSize: 14,
fontWeight: FontWeight.w400,
color: AppColors.primaryDark,
),
),
),
],
const SizedBox(height: 16),
TextButton(
onPressed: () =>
ref.read(topProfessionalsProvider.notifier).refresh(),
child: const Text(
'Retry',
style: TextStyle(
fontFamily: 'Fractul',
fontSize: 14,
fontWeight: FontWeight.w600,
color: AppColors.accentOrange,
),
),
),
],
),
);
}
Widget _buildEmptyState(String activeTab) {
return Padding(
padding: const EdgeInsets.symmetric(vertical: 40),
child: Column(
children: [
Icon(
activeTab == 'agents' ? Icons.people_outline : Icons.account_balance_outlined,
color: AppColors.hintText,
size: 48,
),
const SizedBox(height: 12),
Text(
'No ${activeTab == 'agents' ? 'agents' : 'lenders'} found',
style: const TextStyle(
fontFamily: 'Fractul',
fontSize: 14,
fontWeight: FontWeight.w400,
color: AppColors.hintText,
),
),
],
),
);
}