From 1f48ff385228260c82da806b28f49ffc9b7ab344 Mon Sep 17 00:00:00 2001 From: pradeepkumar Date: Tue, 24 Feb 2026 03:28:13 +0530 Subject: [PATCH] feat: Implement dynamic top professionals section with agent/lender tabs using Riverpod for state management. --- lib/core/constants/api_constants.dart | 5 + lib/core/network/api_client.dart | 237 ++++++- .../agents/data/agents_repository.dart | 91 +++ .../agents/data/models/agent_profile.dart | 165 +++++ .../agents/data/models/agent_type.dart | 22 + .../providers/agents_provider.dart | 129 ++++ .../presentation/providers/auth_provider.dart | 6 + .../widgets/top_professionals_section.dart | 664 +++++++++++------- 8 files changed, 1034 insertions(+), 285 deletions(-) create mode 100644 lib/features/agents/data/agents_repository.dart create mode 100644 lib/features/agents/data/models/agent_profile.dart create mode 100644 lib/features/agents/data/models/agent_type.dart create mode 100644 lib/features/agents/presentation/providers/agents_provider.dart diff --git a/lib/core/constants/api_constants.dart b/lib/core/constants/api_constants.dart index 251c2c5..44289c3 100644 --- a/lib/core/constants/api_constants.dart +++ b/lib/core/constants/api_constants.dart @@ -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'; } diff --git a/lib/core/network/api_client.dart b/lib/core/network/api_client.dart index 1e85cf2..020825c 100644 --- a/lib/core/network/api_client.dart +++ b/lib/core/network/api_client.dart @@ -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) { - 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?) - ?.map((e) => FieldError.fromJson(e as Map)) - .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 _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; + 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 _queueRequest(RequestOptions requestOptions) { + final completer = Completer(); + _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 _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) { + final message = data['message'] is List + ? (data['message'] as List).join(', ') + : data['message']?.toString() ?? 'An error occurred'; + + final errors = (data['errors'] as List?) + ?.map( + (e) => FieldError.fromJson(e as Map)) + .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 completer; + final RequestOptions requestOptions; + + _QueuedRequest({ + required this.completer, + required this.requestOptions, + }); } diff --git a/lib/features/agents/data/agents_repository.dart b/lib/features/agents/data/agents_repository.dart new file mode 100644 index 0000000..8824e7f --- /dev/null +++ b/lib/features/agents/data/agents_repository.dart @@ -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 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 json) { + return SearchAgentsResponse( + data: (json['data'] as List) + .map((e) => AgentProfile.fromJson(e as Map)) + .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 searchAgents({ + String? agentTypeId, + int page = 1, + int limit = 10, + String? sortBy, + String? sortOrder, + }) async { + try { + final queryParams = { + '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; + 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> getAgentTypes() async { + try { + final response = await _dio.get(ApiConstants.agentTypes); + final data = response.data['data'] as List; + return data + .map((e) => AgentType.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 agent types', + statusCode: e.response?.statusCode, + ); + } + } +} diff --git a/lib/features/agents/data/models/agent_profile.dart b/lib/features/agents/data/models/agent_profile.dart new file mode 100644 index 0000000..a22412f --- /dev/null +++ b/lib/features/agents/data/models/agent_profile.dart @@ -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 specializations; + final List languages; + final List 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 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 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) + : null, + fieldValues: (json['fieldValues'] as List?) + ?.map((e) => AgentFieldValue.fromJson(e as Map)) + .toList() ?? + const [], + ); + } + + static List _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 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), + ); + } +} + +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 json) { + return AgentField( + slug: json['slug'] as String, + name: json['name'] as String, + fieldType: json['fieldType'] as String, + ); + } +} diff --git a/lib/features/agents/data/models/agent_type.dart b/lib/features/agents/data/models/agent_type.dart new file mode 100644 index 0000000..ed8fb5c --- /dev/null +++ b/lib/features/agents/data/models/agent_type.dart @@ -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 json) { + return AgentType( + id: json['id'] as String, + name: json['name'] as String, + slug: json['slug'] as String, + description: json['description'] as String?, + ); + } +} diff --git a/lib/features/agents/presentation/providers/agents_provider.dart b/lib/features/agents/presentation/providers/agents_provider.dart new file mode 100644 index 0000000..e8bb00b --- /dev/null +++ b/lib/features/agents/presentation/providers/agents_provider.dart @@ -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((ref) { + return AgentsRepository(); +}); + +// Agent types provider +final agentTypesProvider = FutureProvider>((ref) async { + final repository = ref.watch(agentsRepositoryProvider); + return repository.getAgentTypes(); +}); + +// Top professionals state +class TopProfessionalsState { + final List agents; + final List lenders; + final List 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? agents, + List? lenders, + List? 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 get activeList => + activeTab == 'agents' ? agents : lenders; +} + +class TopProfessionalsNotifier extends StateNotifier { + final AgentsRepository _repository; + + TopProfessionalsNotifier(this._repository) + : super(const TopProfessionalsState()) { + _loadData(); + } + + Future _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 refresh() async { + await _loadData(); + } +} + +final topProfessionalsProvider = + StateNotifierProvider( + (ref) { + final repository = ref.watch(agentsRepositoryProvider); + return TopProfessionalsNotifier(repository); +}); diff --git a/lib/features/auth/presentation/providers/auth_provider.dart b/lib/features/auth/presentation/providers/auth_provider.dart index ae4e16d..8689de2 100644 --- a/lib/features/auth/presentation/providers/auth_provider.dart +++ b/lib/features/auth/presentation/providers/auth_provider.dart @@ -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 { 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(); } diff --git a/lib/features/home/presentation/widgets/top_professionals_section.dart b/lib/features/home/presentation/widgets/top_professionals_section.dart index 8af149f..60e443d 100644 --- a/lib/features/home/presentation/widgets/top_professionals_section.dart +++ b/lib/features/home/presentation/widgets/top_professionals_section.dart @@ -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, + ), + ), + ], + ), ); }