2026-03-08 21:48:56 +05:30
|
|
|
|
import 'dart:io';
|
|
|
|
|
|
|
2026-03-08 12:15:15 +05:30
|
|
|
|
import 'package:flutter/material.dart';
|
|
|
|
|
|
import 'package:flutter_riverpod/flutter_riverpod.dart';
|
|
|
|
|
|
import 'package:flutter_svg/flutter_svg.dart';
|
2026-03-08 21:48:56 +05:30
|
|
|
|
import 'package:image_picker/image_picker.dart';
|
2026-03-08 12:15:15 +05:30
|
|
|
|
import 'package:real_estate_mobile/core/constants/app_colors.dart';
|
|
|
|
|
|
import 'package:real_estate_mobile/core/widgets/s3_image.dart';
|
|
|
|
|
|
import 'package:real_estate_mobile/features/agents/data/models/agent_profile.dart';
|
|
|
|
|
|
import 'package:real_estate_mobile/features/agents/presentation/providers/agent_detail_provider.dart';
|
|
|
|
|
|
import 'package:real_estate_mobile/features/profile/data/profile_repository.dart';
|
2026-03-15 22:28:59 +05:30
|
|
|
|
import 'package:real_estate_mobile/features/home/presentation/screens/home_screen.dart';
|
2026-03-09 06:04:54 +05:30
|
|
|
|
import 'package:go_router/go_router.dart';
|
2026-03-08 12:15:15 +05:30
|
|
|
|
|
|
|
|
|
|
/// Provider that fetches the agent's own profile ID from /agents/profile/me
|
|
|
|
|
|
final _agentMyProfileProvider =
|
|
|
|
|
|
FutureProvider.autoDispose<String?>((ref) async {
|
|
|
|
|
|
final repo = ProfileRepository();
|
|
|
|
|
|
try {
|
|
|
|
|
|
final data = await repo.getMyProfile('AGENT');
|
|
|
|
|
|
return data['id'] as String?;
|
|
|
|
|
|
} catch (_) {
|
|
|
|
|
|
return null;
|
|
|
|
|
|
}
|
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
|
|
class AgentHomeScreen extends ConsumerWidget {
|
|
|
|
|
|
const AgentHomeScreen({super.key});
|
|
|
|
|
|
|
|
|
|
|
|
@override
|
|
|
|
|
|
Widget build(BuildContext context, WidgetRef ref) {
|
|
|
|
|
|
final myProfileAsync = ref.watch(_agentMyProfileProvider);
|
|
|
|
|
|
|
|
|
|
|
|
return myProfileAsync.when(
|
2026-03-08 16:05:08 +05:30
|
|
|
|
loading: () => const Center(
|
|
|
|
|
|
child: CircularProgressIndicator(
|
|
|
|
|
|
color: AppColors.accentOrange,
|
|
|
|
|
|
strokeWidth: 2,
|
2026-03-08 12:15:15 +05:30
|
|
|
|
),
|
|
|
|
|
|
),
|
2026-03-08 16:05:08 +05:30
|
|
|
|
error: (_, __) => Center(
|
|
|
|
|
|
child: Column(
|
|
|
|
|
|
mainAxisSize: MainAxisSize.min,
|
|
|
|
|
|
children: [
|
|
|
|
|
|
const Icon(Icons.error_outline,
|
|
|
|
|
|
size: 48, color: AppColors.accentOrange),
|
|
|
|
|
|
const SizedBox(height: 16),
|
|
|
|
|
|
const Text('Failed to load profile',
|
|
|
|
|
|
style: TextStyle(
|
|
|
|
|
|
fontFamily: 'Fractul',
|
|
|
|
|
|
fontSize: 16,
|
|
|
|
|
|
fontWeight: FontWeight.w600,
|
|
|
|
|
|
color: AppColors.primaryDark)),
|
|
|
|
|
|
const SizedBox(height: 12),
|
|
|
|
|
|
TextButton(
|
|
|
|
|
|
onPressed: () => ref.invalidate(_agentMyProfileProvider),
|
|
|
|
|
|
child: const Text('Retry',
|
|
|
|
|
|
style: TextStyle(color: AppColors.accentOrange)),
|
|
|
|
|
|
),
|
|
|
|
|
|
],
|
2026-03-08 12:15:15 +05:30
|
|
|
|
),
|
|
|
|
|
|
),
|
|
|
|
|
|
data: (agentProfileId) {
|
|
|
|
|
|
if (agentProfileId == null) {
|
2026-03-08 16:05:08 +05:30
|
|
|
|
return const Center(
|
|
|
|
|
|
child: Text('No agent profile found',
|
|
|
|
|
|
style: TextStyle(
|
|
|
|
|
|
fontFamily: 'Fractul',
|
|
|
|
|
|
fontSize: 16,
|
|
|
|
|
|
color: AppColors.primaryDark)),
|
2026-03-08 12:15:15 +05:30
|
|
|
|
);
|
|
|
|
|
|
}
|
|
|
|
|
|
return _AgentHomeContent(agentProfileId: agentProfileId);
|
|
|
|
|
|
},
|
|
|
|
|
|
);
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
class _AgentHomeContent extends ConsumerStatefulWidget {
|
|
|
|
|
|
final String agentProfileId;
|
|
|
|
|
|
|
|
|
|
|
|
const _AgentHomeContent({required this.agentProfileId});
|
|
|
|
|
|
|
|
|
|
|
|
@override
|
|
|
|
|
|
ConsumerState<_AgentHomeContent> createState() => _AgentHomeContentState();
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
class _AgentHomeContentState extends ConsumerState<_AgentHomeContent> {
|
2026-03-08 21:48:56 +05:30
|
|
|
|
bool _isUploadingAvatar = false;
|
|
|
|
|
|
bool _isTogglingAvailability = false;
|
2026-03-15 00:35:39 +05:30
|
|
|
|
bool _showEmail = false;
|
|
|
|
|
|
bool _showPhone = false;
|
2026-03-15 00:41:31 +05:30
|
|
|
|
final Set<String> _expandedTagSections = {};
|
2026-03-08 21:48:56 +05:30
|
|
|
|
File? _localAvatarFile;
|
|
|
|
|
|
|
|
|
|
|
|
Future<void> _pickAndUploadAvatar() async {
|
|
|
|
|
|
final picker = ImagePicker();
|
|
|
|
|
|
final picked = await picker.pickImage(
|
|
|
|
|
|
source: ImageSource.gallery,
|
|
|
|
|
|
maxWidth: 1200,
|
|
|
|
|
|
maxHeight: 1200,
|
|
|
|
|
|
imageQuality: 85,
|
|
|
|
|
|
);
|
|
|
|
|
|
if (picked == null) return;
|
|
|
|
|
|
|
|
|
|
|
|
final file = File(picked.path);
|
|
|
|
|
|
final fileName = picked.name;
|
|
|
|
|
|
final ext = fileName.split('.').last.toLowerCase();
|
|
|
|
|
|
|
|
|
|
|
|
// Validate file type
|
|
|
|
|
|
const allowedTypes = ['jpg', 'jpeg', 'png', 'gif'];
|
|
|
|
|
|
if (!allowedTypes.contains(ext)) {
|
|
|
|
|
|
if (mounted) {
|
|
|
|
|
|
ScaffoldMessenger.of(context).showSnackBar(
|
|
|
|
|
|
const SnackBar(content: Text('Only JPG, PNG, and GIF images are allowed')),
|
|
|
|
|
|
);
|
|
|
|
|
|
}
|
|
|
|
|
|
return;
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
// Validate file size (2MB max)
|
|
|
|
|
|
final fileSize = await file.length();
|
|
|
|
|
|
if (fileSize > 2 * 1024 * 1024) {
|
|
|
|
|
|
if (mounted) {
|
|
|
|
|
|
ScaffoldMessenger.of(context).showSnackBar(
|
|
|
|
|
|
const SnackBar(content: Text('Image must be smaller than 2MB')),
|
|
|
|
|
|
);
|
|
|
|
|
|
}
|
|
|
|
|
|
return;
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
final contentType = ext == 'png'
|
|
|
|
|
|
? 'image/png'
|
|
|
|
|
|
: ext == 'gif'
|
|
|
|
|
|
? 'image/gif'
|
|
|
|
|
|
: 'image/jpeg';
|
|
|
|
|
|
|
|
|
|
|
|
setState(() {
|
|
|
|
|
|
_localAvatarFile = file;
|
|
|
|
|
|
_isUploadingAvatar = true;
|
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
|
|
try {
|
|
|
|
|
|
final repo = ProfileRepository();
|
|
|
|
|
|
|
|
|
|
|
|
// 1. Get presigned upload URL
|
|
|
|
|
|
final uploadData = await repo.getAvatarUploadUrl('AGENT', fileName, contentType);
|
|
|
|
|
|
final uploadUrl = uploadData['uploadUrl']!;
|
|
|
|
|
|
final key = uploadData['key']!;
|
|
|
|
|
|
|
|
|
|
|
|
// 2. Upload to S3
|
|
|
|
|
|
final fileBytes = await file.readAsBytes();
|
|
|
|
|
|
await repo.uploadToS3(uploadUrl, fileBytes, contentType);
|
|
|
|
|
|
|
|
|
|
|
|
// 3. Update profile with new avatar key
|
|
|
|
|
|
await repo.updateProfile('AGENT', {'avatar': key});
|
|
|
|
|
|
|
|
|
|
|
|
// 4. Refresh the agent detail provider
|
|
|
|
|
|
ref.invalidate(agentDetailProvider(widget.agentProfileId));
|
|
|
|
|
|
|
|
|
|
|
|
if (mounted) {
|
|
|
|
|
|
ScaffoldMessenger.of(context).showSnackBar(
|
|
|
|
|
|
const SnackBar(content: Text('Photo updated successfully')),
|
|
|
|
|
|
);
|
|
|
|
|
|
}
|
|
|
|
|
|
} catch (e) {
|
|
|
|
|
|
if (mounted) {
|
|
|
|
|
|
ScaffoldMessenger.of(context).showSnackBar(
|
|
|
|
|
|
SnackBar(content: Text('Failed to upload photo: ${e.toString()}')),
|
|
|
|
|
|
);
|
|
|
|
|
|
}
|
|
|
|
|
|
} finally {
|
|
|
|
|
|
if (mounted) {
|
|
|
|
|
|
setState(() {
|
|
|
|
|
|
_isUploadingAvatar = false;
|
|
|
|
|
|
_localAvatarFile = null;
|
|
|
|
|
|
});
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
Future<void> _toggleAvailability(bool newValue) async {
|
|
|
|
|
|
if (_isTogglingAvailability) return;
|
|
|
|
|
|
|
|
|
|
|
|
setState(() => _isTogglingAvailability = true);
|
2026-03-15 00:35:39 +05:30
|
|
|
|
|
|
|
|
|
|
// Optimistically update the UI immediately
|
|
|
|
|
|
ref
|
|
|
|
|
|
.read(agentDetailProvider(widget.agentProfileId).notifier)
|
|
|
|
|
|
.updateAvailability(newValue);
|
|
|
|
|
|
|
2026-03-08 21:48:56 +05:30
|
|
|
|
try {
|
|
|
|
|
|
final repo = ProfileRepository();
|
|
|
|
|
|
await repo.updateProfile('AGENT', {'isAvailable': newValue});
|
|
|
|
|
|
} catch (e) {
|
2026-03-15 00:35:39 +05:30
|
|
|
|
// Revert on failure
|
2026-03-08 21:48:56 +05:30
|
|
|
|
if (mounted) {
|
2026-03-15 00:35:39 +05:30
|
|
|
|
ref
|
|
|
|
|
|
.read(agentDetailProvider(widget.agentProfileId).notifier)
|
|
|
|
|
|
.updateAvailability(!newValue);
|
2026-03-08 21:48:56 +05:30
|
|
|
|
ScaffoldMessenger.of(context).showSnackBar(
|
|
|
|
|
|
SnackBar(content: Text('Failed to update availability: $e')),
|
|
|
|
|
|
);
|
|
|
|
|
|
}
|
|
|
|
|
|
} finally {
|
|
|
|
|
|
if (mounted) {
|
|
|
|
|
|
setState(() => _isTogglingAvailability = false);
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2026-03-08 12:15:15 +05:30
|
|
|
|
@override
|
|
|
|
|
|
Widget build(BuildContext context) {
|
|
|
|
|
|
final state = ref.watch(agentDetailProvider(widget.agentProfileId));
|
|
|
|
|
|
|
2026-03-08 16:05:08 +05:30
|
|
|
|
if (state.isLoading) {
|
|
|
|
|
|
return const Center(
|
|
|
|
|
|
child: CircularProgressIndicator(color: AppColors.accentOrange));
|
|
|
|
|
|
}
|
|
|
|
|
|
if (state.error != null) {
|
|
|
|
|
|
return _buildError(state.error!);
|
|
|
|
|
|
}
|
2026-03-15 22:28:59 +05:30
|
|
|
|
return _buildContent(state, ref);
|
2026-03-08 12:15:15 +05:30
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
Widget _buildError(String error) {
|
|
|
|
|
|
return Center(
|
|
|
|
|
|
child: Column(
|
|
|
|
|
|
mainAxisSize: MainAxisSize.min,
|
|
|
|
|
|
children: [
|
|
|
|
|
|
const Icon(Icons.error_outline,
|
|
|
|
|
|
size: 48, color: AppColors.accentOrange),
|
|
|
|
|
|
const SizedBox(height: 16),
|
|
|
|
|
|
const Text('Unable to Load Profile',
|
|
|
|
|
|
style: TextStyle(
|
|
|
|
|
|
fontFamily: 'Fractul',
|
|
|
|
|
|
fontSize: 18,
|
|
|
|
|
|
fontWeight: FontWeight.w700,
|
|
|
|
|
|
color: AppColors.primaryDark)),
|
|
|
|
|
|
const SizedBox(height: 12),
|
|
|
|
|
|
TextButton(
|
|
|
|
|
|
onPressed: () =>
|
|
|
|
|
|
ref.invalidate(agentDetailProvider(widget.agentProfileId)),
|
|
|
|
|
|
child: const Text('Retry',
|
|
|
|
|
|
style: TextStyle(color: AppColors.accentOrange)),
|
|
|
|
|
|
),
|
|
|
|
|
|
],
|
|
|
|
|
|
),
|
|
|
|
|
|
);
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2026-03-15 22:28:59 +05:30
|
|
|
|
Widget _buildContent(AgentDetailState state, WidgetRef ref) {
|
2026-03-08 12:15:15 +05:30
|
|
|
|
final agent = state.agent!;
|
2026-03-15 22:28:59 +05:30
|
|
|
|
final scrollController = ref.watch(homeScrollControllerProvider);
|
2026-03-08 12:15:15 +05:30
|
|
|
|
return SingleChildScrollView(
|
2026-03-15 22:28:59 +05:30
|
|
|
|
controller: scrollController,
|
2026-03-08 12:15:15 +05:30
|
|
|
|
child: Column(
|
|
|
|
|
|
children: [
|
2026-03-09 06:33:55 +05:30
|
|
|
|
const SizedBox(height: 12),
|
2026-03-21 08:55:43 +05:30
|
|
|
|
_buildVerificationBanner(agent),
|
2026-03-08 12:15:15 +05:30
|
|
|
|
_buildCoverAndAvatar(agent),
|
|
|
|
|
|
const SizedBox(height: 16),
|
|
|
|
|
|
_buildStatusSection(agent),
|
|
|
|
|
|
const SizedBox(height: 12),
|
|
|
|
|
|
_buildContactInfo(state),
|
|
|
|
|
|
const SizedBox(height: 16),
|
2026-04-15 18:18:02 +05:30
|
|
|
|
_buildProfileInfo(agent, state),
|
2026-03-08 12:15:15 +05:30
|
|
|
|
const SizedBox(height: 24),
|
|
|
|
|
|
_buildExperienceSection(state),
|
|
|
|
|
|
const SizedBox(height: 24),
|
|
|
|
|
|
_buildInfoCards(state),
|
|
|
|
|
|
const SizedBox(height: 24),
|
|
|
|
|
|
_buildSpecializationSection(state),
|
|
|
|
|
|
const SizedBox(height: 24),
|
|
|
|
|
|
_buildTestimonialsSection(state),
|
|
|
|
|
|
const SizedBox(height: 24),
|
|
|
|
|
|
],
|
|
|
|
|
|
),
|
|
|
|
|
|
);
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2026-03-21 08:55:43 +05:30
|
|
|
|
// ── Verification status banner ──
|
|
|
|
|
|
Widget _buildVerificationBanner(AgentProfile agent) {
|
|
|
|
|
|
final status = agent.verificationStatus;
|
|
|
|
|
|
if (status == null || status == 'APPROVED') return const SizedBox.shrink();
|
|
|
|
|
|
|
|
|
|
|
|
if (status == 'REJECTED') {
|
|
|
|
|
|
return Padding(
|
|
|
|
|
|
padding: const EdgeInsets.symmetric(horizontal: 22, vertical: 0),
|
|
|
|
|
|
child: Container(
|
|
|
|
|
|
margin: const EdgeInsets.only(bottom: 12),
|
|
|
|
|
|
padding: const EdgeInsets.all(14),
|
|
|
|
|
|
decoration: BoxDecoration(
|
|
|
|
|
|
color: const Color(0xFFFEF2F2),
|
|
|
|
|
|
border: Border.all(color: const Color(0xFFFECACA)),
|
|
|
|
|
|
borderRadius: BorderRadius.circular(12),
|
|
|
|
|
|
),
|
|
|
|
|
|
child: Column(
|
|
|
|
|
|
crossAxisAlignment: CrossAxisAlignment.start,
|
|
|
|
|
|
children: [
|
|
|
|
|
|
Row(
|
|
|
|
|
|
children: [
|
|
|
|
|
|
Icon(Icons.warning_amber_rounded, size: 18, color: Colors.red[700]),
|
|
|
|
|
|
const SizedBox(width: 8),
|
|
|
|
|
|
Text('Profile Verification Rejected',
|
|
|
|
|
|
style: TextStyle(fontFamily: 'Fractul', fontSize: 14, fontWeight: FontWeight.w700, color: Colors.red[800])),
|
|
|
|
|
|
],
|
|
|
|
|
|
),
|
|
|
|
|
|
if (agent.verificationNote != null && agent.verificationNote!.isNotEmpty) ...[
|
|
|
|
|
|
const SizedBox(height: 6),
|
|
|
|
|
|
Text('Reason: ${agent.verificationNote}',
|
|
|
|
|
|
style: TextStyle(fontFamily: 'SourceSerif4', fontSize: 13, color: Colors.red[700])),
|
|
|
|
|
|
],
|
|
|
|
|
|
const SizedBox(height: 8),
|
|
|
|
|
|
Text('Please update your profile and documents, then resubmit.',
|
|
|
|
|
|
style: TextStyle(fontFamily: 'SourceSerif4', fontSize: 13, color: Colors.red[600])),
|
|
|
|
|
|
const SizedBox(height: 10),
|
|
|
|
|
|
GestureDetector(
|
|
|
|
|
|
onTap: () => context.push('/agent/edit-profile'),
|
|
|
|
|
|
child: Container(
|
|
|
|
|
|
padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 8),
|
|
|
|
|
|
decoration: BoxDecoration(
|
|
|
|
|
|
color: AppColors.accentOrange,
|
|
|
|
|
|
borderRadius: BorderRadius.circular(8),
|
|
|
|
|
|
),
|
|
|
|
|
|
child: const Text('Update & Resubmit',
|
|
|
|
|
|
style: TextStyle(fontFamily: 'SourceSerif4', fontSize: 13, color: Colors.white, fontWeight: FontWeight.w600)),
|
|
|
|
|
|
),
|
|
|
|
|
|
),
|
|
|
|
|
|
],
|
|
|
|
|
|
),
|
|
|
|
|
|
),
|
|
|
|
|
|
);
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
if (status == 'PENDING_REVIEW') {
|
|
|
|
|
|
return Padding(
|
|
|
|
|
|
padding: const EdgeInsets.symmetric(horizontal: 22),
|
|
|
|
|
|
child: Container(
|
|
|
|
|
|
margin: const EdgeInsets.only(bottom: 12),
|
|
|
|
|
|
padding: const EdgeInsets.all(14),
|
|
|
|
|
|
decoration: BoxDecoration(
|
|
|
|
|
|
color: const Color(0xFFFEFCE8),
|
|
|
|
|
|
border: Border.all(color: const Color(0xFFFDE68A)),
|
|
|
|
|
|
borderRadius: BorderRadius.circular(12),
|
|
|
|
|
|
),
|
|
|
|
|
|
child: Row(
|
|
|
|
|
|
children: [
|
|
|
|
|
|
Container(width: 10, height: 10, decoration: const BoxDecoration(color: Color(0xFFFBBF24), shape: BoxShape.circle)),
|
|
|
|
|
|
const SizedBox(width: 10),
|
|
|
|
|
|
const Expanded(
|
|
|
|
|
|
child: Text('Profile verification is under review. You will be notified once approved.',
|
|
|
|
|
|
style: TextStyle(fontFamily: 'Fractul', fontSize: 13, fontWeight: FontWeight.w700, color: Color(0xFF854D0E))),
|
|
|
|
|
|
),
|
|
|
|
|
|
],
|
|
|
|
|
|
),
|
|
|
|
|
|
),
|
|
|
|
|
|
);
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
if (status == 'NONE') {
|
|
|
|
|
|
return Padding(
|
|
|
|
|
|
padding: const EdgeInsets.symmetric(horizontal: 22),
|
|
|
|
|
|
child: Container(
|
|
|
|
|
|
margin: const EdgeInsets.only(bottom: 12),
|
|
|
|
|
|
padding: const EdgeInsets.all(14),
|
|
|
|
|
|
decoration: BoxDecoration(
|
|
|
|
|
|
color: const Color(0xFFEFF6FF),
|
|
|
|
|
|
border: Border.all(color: const Color(0xFFBFDBFE)),
|
|
|
|
|
|
borderRadius: BorderRadius.circular(12),
|
|
|
|
|
|
),
|
|
|
|
|
|
child: Column(
|
|
|
|
|
|
crossAxisAlignment: CrossAxisAlignment.start,
|
|
|
|
|
|
children: [
|
|
|
|
|
|
Row(
|
|
|
|
|
|
children: [
|
|
|
|
|
|
Icon(Icons.info_outline, size: 18, color: Colors.blue[700]),
|
|
|
|
|
|
const SizedBox(width: 8),
|
|
|
|
|
|
const Expanded(
|
|
|
|
|
|
child: Text('Complete your profile and upload verification documents to get verified.',
|
|
|
|
|
|
style: TextStyle(fontFamily: 'Fractul', fontSize: 13, fontWeight: FontWeight.w700, color: Color(0xFF1E40AF))),
|
|
|
|
|
|
),
|
|
|
|
|
|
],
|
|
|
|
|
|
),
|
|
|
|
|
|
const SizedBox(height: 10),
|
|
|
|
|
|
GestureDetector(
|
|
|
|
|
|
onTap: () => context.push('/agent/edit-profile'),
|
|
|
|
|
|
child: Container(
|
|
|
|
|
|
padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 8),
|
|
|
|
|
|
decoration: BoxDecoration(
|
|
|
|
|
|
color: AppColors.accentOrange,
|
|
|
|
|
|
borderRadius: BorderRadius.circular(8),
|
|
|
|
|
|
),
|
|
|
|
|
|
child: const Text('Complete Profile',
|
|
|
|
|
|
style: TextStyle(fontFamily: 'SourceSerif4', fontSize: 13, color: Colors.white, fontWeight: FontWeight.w600)),
|
|
|
|
|
|
),
|
|
|
|
|
|
),
|
|
|
|
|
|
],
|
|
|
|
|
|
),
|
|
|
|
|
|
),
|
|
|
|
|
|
);
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
return const SizedBox.shrink();
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2026-03-08 12:15:15 +05:30
|
|
|
|
// ── Agent profile photo as cover image ──
|
|
|
|
|
|
Widget _buildCoverAndAvatar(AgentProfile agent) {
|
|
|
|
|
|
final screenWidth = MediaQuery.of(context).size.width;
|
|
|
|
|
|
final coverWidth = screenWidth * 0.877; // 377/430 from Figma
|
|
|
|
|
|
|
|
|
|
|
|
return Center(
|
|
|
|
|
|
child: Stack(
|
|
|
|
|
|
children: [
|
|
|
|
|
|
// Agent photo as the cover
|
|
|
|
|
|
ClipRRect(
|
|
|
|
|
|
borderRadius: BorderRadius.circular(20),
|
|
|
|
|
|
child: SizedBox(
|
|
|
|
|
|
width: coverWidth,
|
|
|
|
|
|
height: 346,
|
2026-03-08 21:48:56 +05:30
|
|
|
|
child: _localAvatarFile != null
|
|
|
|
|
|
? Image.file(
|
|
|
|
|
|
_localAvatarFile!,
|
2026-03-08 12:15:15 +05:30
|
|
|
|
width: coverWidth,
|
|
|
|
|
|
height: 346,
|
|
|
|
|
|
fit: BoxFit.cover,
|
|
|
|
|
|
)
|
2026-03-08 21:48:56 +05:30
|
|
|
|
: agent.avatarUrl != null && agent.avatarUrl!.isNotEmpty
|
|
|
|
|
|
? S3Image(
|
|
|
|
|
|
imageUrl: agent.avatarUrl!,
|
|
|
|
|
|
width: coverWidth,
|
|
|
|
|
|
height: 346,
|
|
|
|
|
|
fit: BoxFit.cover,
|
|
|
|
|
|
)
|
|
|
|
|
|
: Container(
|
|
|
|
|
|
color: const Color(0xFFC4D9D4),
|
|
|
|
|
|
alignment: Alignment.center,
|
|
|
|
|
|
child: Icon(Icons.person,
|
|
|
|
|
|
size: 80, color: AppColors.primaryDark),
|
|
|
|
|
|
),
|
2026-03-08 12:15:15 +05:30
|
|
|
|
),
|
|
|
|
|
|
),
|
2026-03-08 21:48:56 +05:30
|
|
|
|
// Loading overlay during upload
|
|
|
|
|
|
if (_isUploadingAvatar)
|
|
|
|
|
|
Positioned.fill(
|
|
|
|
|
|
child: ClipRRect(
|
|
|
|
|
|
borderRadius: BorderRadius.circular(20),
|
|
|
|
|
|
child: Container(
|
|
|
|
|
|
color: Colors.black.withValues(alpha: 0.4),
|
|
|
|
|
|
child: const Center(
|
|
|
|
|
|
child: CircularProgressIndicator(
|
|
|
|
|
|
color: Colors.white,
|
|
|
|
|
|
strokeWidth: 3,
|
|
|
|
|
|
),
|
|
|
|
|
|
),
|
|
|
|
|
|
),
|
|
|
|
|
|
),
|
|
|
|
|
|
),
|
2026-03-08 12:15:15 +05:30
|
|
|
|
// Orange edit icon top-right (tabler:edit style)
|
|
|
|
|
|
Positioned(
|
2026-03-08 21:48:56 +05:30
|
|
|
|
top: 8,
|
|
|
|
|
|
right: 6,
|
|
|
|
|
|
child: GestureDetector(
|
|
|
|
|
|
behavior: HitTestBehavior.opaque,
|
|
|
|
|
|
onTap: _isUploadingAvatar ? null : _pickAndUploadAvatar,
|
|
|
|
|
|
child: Padding(
|
|
|
|
|
|
padding: const EdgeInsets.all(8),
|
|
|
|
|
|
child: Container(
|
|
|
|
|
|
width: 28,
|
|
|
|
|
|
height: 26,
|
|
|
|
|
|
decoration: BoxDecoration(
|
|
|
|
|
|
color: Colors.white,
|
|
|
|
|
|
borderRadius: BorderRadius.circular(7),
|
|
|
|
|
|
),
|
|
|
|
|
|
alignment: Alignment.center,
|
|
|
|
|
|
child: SvgPicture.asset(
|
|
|
|
|
|
'assets/icons/edit_icon.svg',
|
|
|
|
|
|
width: 20,
|
|
|
|
|
|
height: 20,
|
|
|
|
|
|
),
|
|
|
|
|
|
),
|
2026-03-08 12:15:15 +05:30
|
|
|
|
),
|
|
|
|
|
|
),
|
|
|
|
|
|
),
|
|
|
|
|
|
],
|
|
|
|
|
|
),
|
|
|
|
|
|
);
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2026-03-08 21:48:56 +05:30
|
|
|
|
// ── Available / Unavailable status toggle (matches web design) ──
|
2026-03-08 12:15:15 +05:30
|
|
|
|
Widget _buildStatusSection(AgentProfile agent) {
|
2026-03-08 21:48:56 +05:30
|
|
|
|
final isAvailable = agent.isAvailable;
|
|
|
|
|
|
|
2026-03-08 12:15:15 +05:30
|
|
|
|
return Padding(
|
|
|
|
|
|
padding: const EdgeInsets.symmetric(horizontal: 24),
|
|
|
|
|
|
child: Column(
|
|
|
|
|
|
children: [
|
2026-03-08 21:48:56 +05:30
|
|
|
|
_buildStatusOption(
|
2026-03-08 12:15:15 +05:30
|
|
|
|
label: 'Available.',
|
2026-03-08 21:48:56 +05:30
|
|
|
|
isActive: isAvailable,
|
|
|
|
|
|
dotColor: const Color(0xFF4CAF50),
|
|
|
|
|
|
activeColor: const Color(0xFFE8F5E9),
|
|
|
|
|
|
activeBorderColor: const Color(0xFF4CAF50),
|
|
|
|
|
|
onTap: isAvailable ? null : () => _toggleAvailability(true),
|
2026-03-08 12:15:15 +05:30
|
|
|
|
),
|
|
|
|
|
|
const SizedBox(height: 12),
|
2026-03-08 21:48:56 +05:30
|
|
|
|
_buildStatusOption(
|
2026-03-08 12:15:15 +05:30
|
|
|
|
label: 'Unavailable.',
|
2026-03-08 21:48:56 +05:30
|
|
|
|
isActive: !isAvailable,
|
|
|
|
|
|
dotColor: const Color(0xFF00293D),
|
|
|
|
|
|
activeColor: const Color(0xFFE8EDF2),
|
|
|
|
|
|
activeBorderColor: const Color(0xFF00293D),
|
|
|
|
|
|
onTap: !isAvailable ? null : () => _toggleAvailability(false),
|
2026-03-08 12:15:15 +05:30
|
|
|
|
),
|
|
|
|
|
|
],
|
|
|
|
|
|
),
|
|
|
|
|
|
);
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2026-03-08 21:48:56 +05:30
|
|
|
|
Widget _buildStatusOption({
|
2026-03-08 12:15:15 +05:30
|
|
|
|
required String label,
|
2026-03-08 21:48:56 +05:30
|
|
|
|
required bool isActive,
|
|
|
|
|
|
required Color dotColor,
|
|
|
|
|
|
required Color activeColor,
|
|
|
|
|
|
required Color activeBorderColor,
|
|
|
|
|
|
VoidCallback? onTap,
|
2026-03-08 12:15:15 +05:30
|
|
|
|
}) {
|
2026-03-08 21:48:56 +05:30
|
|
|
|
return GestureDetector(
|
|
|
|
|
|
onTap: _isTogglingAvailability ? null : onTap,
|
|
|
|
|
|
child: AnimatedContainer(
|
|
|
|
|
|
duration: const Duration(milliseconds: 200),
|
|
|
|
|
|
height: 50,
|
|
|
|
|
|
padding: const EdgeInsets.symmetric(horizontal: 20),
|
|
|
|
|
|
decoration: BoxDecoration(
|
|
|
|
|
|
color: isActive ? activeColor : Colors.white,
|
|
|
|
|
|
borderRadius: BorderRadius.circular(15),
|
|
|
|
|
|
border: Border.all(
|
|
|
|
|
|
color: isActive ? activeBorderColor : const Color(0xFFE0E0E0),
|
|
|
|
|
|
width: isActive ? 1.5 : 1,
|
2026-03-08 12:15:15 +05:30
|
|
|
|
),
|
2026-03-08 21:48:56 +05:30
|
|
|
|
),
|
|
|
|
|
|
child: Row(
|
|
|
|
|
|
children: [
|
|
|
|
|
|
Container(
|
|
|
|
|
|
width: 12,
|
|
|
|
|
|
height: 12,
|
|
|
|
|
|
decoration: BoxDecoration(
|
|
|
|
|
|
shape: BoxShape.circle,
|
|
|
|
|
|
color: isActive ? dotColor : Colors.white,
|
|
|
|
|
|
border: Border.all(
|
|
|
|
|
|
color: isActive ? dotColor : const Color(0xFFD0D0D0),
|
|
|
|
|
|
width: 1.5,
|
|
|
|
|
|
),
|
|
|
|
|
|
),
|
2026-03-08 12:15:15 +05:30
|
|
|
|
),
|
2026-03-08 21:48:56 +05:30
|
|
|
|
const SizedBox(width: 10),
|
|
|
|
|
|
Text(
|
|
|
|
|
|
label,
|
2026-03-08 12:15:15 +05:30
|
|
|
|
style: TextStyle(
|
|
|
|
|
|
fontFamily: 'Fractul',
|
|
|
|
|
|
fontSize: 14,
|
2026-03-08 21:48:56 +05:30
|
|
|
|
fontWeight: isActive ? FontWeight.w700 : FontWeight.w500,
|
|
|
|
|
|
color: AppColors.primaryDark,
|
2026-03-08 12:15:15 +05:30
|
|
|
|
),
|
|
|
|
|
|
),
|
2026-03-08 21:48:56 +05:30
|
|
|
|
const Spacer(),
|
2026-03-15 22:40:24 +05:30
|
|
|
|
if (isActive)
|
2026-03-08 21:48:56 +05:30
|
|
|
|
Text(
|
|
|
|
|
|
'Active',
|
|
|
|
|
|
style: TextStyle(
|
|
|
|
|
|
fontFamily: 'Fractul',
|
|
|
|
|
|
fontSize: 13,
|
|
|
|
|
|
fontWeight: FontWeight.w500,
|
|
|
|
|
|
color: activeBorderColor,
|
|
|
|
|
|
),
|
|
|
|
|
|
),
|
|
|
|
|
|
],
|
|
|
|
|
|
),
|
2026-03-08 12:15:15 +05:30
|
|
|
|
),
|
|
|
|
|
|
);
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
// ── Contact Info ──
|
|
|
|
|
|
Widget _buildContactInfo(AgentDetailState state) {
|
|
|
|
|
|
final email = state.contactEmail ?? '';
|
|
|
|
|
|
final phone = state.contactPhone ?? '';
|
|
|
|
|
|
|
|
|
|
|
|
return Container(
|
|
|
|
|
|
margin: const EdgeInsets.symmetric(horizontal: 24),
|
|
|
|
|
|
padding: const EdgeInsets.symmetric(horizontal: 20, vertical: 18),
|
|
|
|
|
|
decoration: BoxDecoration(
|
|
|
|
|
|
borderRadius: BorderRadius.circular(15),
|
|
|
|
|
|
border: Border.all(color: AppColors.primaryDark, width: 0.1),
|
|
|
|
|
|
),
|
|
|
|
|
|
child: Column(
|
|
|
|
|
|
mainAxisSize: MainAxisSize.min,
|
|
|
|
|
|
children: [
|
|
|
|
|
|
Row(
|
|
|
|
|
|
children: [
|
|
|
|
|
|
const Text('Email:',
|
|
|
|
|
|
style: TextStyle(
|
|
|
|
|
|
fontFamily: 'Fractul',
|
|
|
|
|
|
fontSize: 14,
|
|
|
|
|
|
fontWeight: FontWeight.w700,
|
|
|
|
|
|
color: AppColors.primaryDark)),
|
|
|
|
|
|
const SizedBox(width: 16),
|
|
|
|
|
|
Expanded(
|
|
|
|
|
|
child: Text(
|
2026-03-15 00:35:39 +05:30
|
|
|
|
email.isNotEmpty
|
|
|
|
|
|
? (_showEmail ? email : state.maskEmail(email))
|
|
|
|
|
|
: '-',
|
2026-03-08 12:15:15 +05:30
|
|
|
|
style: const TextStyle(
|
|
|
|
|
|
fontFamily: 'SourceSerif4',
|
|
|
|
|
|
fontSize: 14,
|
|
|
|
|
|
color: AppColors.primaryDark),
|
2026-03-15 00:35:39 +05:30
|
|
|
|
maxLines: 1,
|
|
|
|
|
|
overflow: TextOverflow.ellipsis,
|
2026-03-08 12:15:15 +05:30
|
|
|
|
),
|
|
|
|
|
|
),
|
2026-03-15 00:35:39 +05:30
|
|
|
|
if (email.isNotEmpty)
|
|
|
|
|
|
GestureDetector(
|
|
|
|
|
|
onTap: () => setState(() => _showEmail = !_showEmail),
|
|
|
|
|
|
behavior: HitTestBehavior.opaque,
|
|
|
|
|
|
child: Padding(
|
|
|
|
|
|
padding: const EdgeInsets.all(4),
|
|
|
|
|
|
child: Icon(
|
|
|
|
|
|
_showEmail
|
|
|
|
|
|
? Icons.visibility_outlined
|
|
|
|
|
|
: Icons.visibility_off_outlined,
|
|
|
|
|
|
size: 18,
|
|
|
|
|
|
color: AppColors.primaryDark,
|
|
|
|
|
|
),
|
|
|
|
|
|
),
|
|
|
|
|
|
),
|
2026-03-08 12:15:15 +05:30
|
|
|
|
],
|
|
|
|
|
|
),
|
|
|
|
|
|
const SizedBox(height: 14),
|
|
|
|
|
|
Row(
|
|
|
|
|
|
children: [
|
|
|
|
|
|
const Text('Ph.No:',
|
|
|
|
|
|
style: TextStyle(
|
|
|
|
|
|
fontFamily: 'Fractul',
|
|
|
|
|
|
fontSize: 14,
|
|
|
|
|
|
fontWeight: FontWeight.w700,
|
|
|
|
|
|
color: AppColors.primaryDark)),
|
|
|
|
|
|
const SizedBox(width: 16),
|
|
|
|
|
|
Expanded(
|
|
|
|
|
|
child: Text(
|
2026-03-15 00:35:39 +05:30
|
|
|
|
phone.isNotEmpty
|
|
|
|
|
|
? (_showPhone ? phone : state.maskPhone(phone))
|
|
|
|
|
|
: '-',
|
2026-03-08 12:15:15 +05:30
|
|
|
|
style: const TextStyle(
|
|
|
|
|
|
fontFamily: 'SourceSerif4',
|
|
|
|
|
|
fontSize: 14,
|
|
|
|
|
|
color: AppColors.primaryDark),
|
2026-03-15 00:35:39 +05:30
|
|
|
|
maxLines: 1,
|
|
|
|
|
|
overflow: TextOverflow.ellipsis,
|
2026-03-08 12:15:15 +05:30
|
|
|
|
),
|
|
|
|
|
|
),
|
2026-03-15 00:35:39 +05:30
|
|
|
|
if (phone.isNotEmpty)
|
|
|
|
|
|
GestureDetector(
|
|
|
|
|
|
onTap: () => setState(() => _showPhone = !_showPhone),
|
|
|
|
|
|
behavior: HitTestBehavior.opaque,
|
|
|
|
|
|
child: Padding(
|
|
|
|
|
|
padding: const EdgeInsets.all(4),
|
|
|
|
|
|
child: Icon(
|
|
|
|
|
|
_showPhone
|
|
|
|
|
|
? Icons.visibility_outlined
|
|
|
|
|
|
: Icons.visibility_off_outlined,
|
|
|
|
|
|
size: 18,
|
|
|
|
|
|
color: AppColors.primaryDark,
|
|
|
|
|
|
),
|
|
|
|
|
|
),
|
|
|
|
|
|
),
|
2026-03-08 12:15:15 +05:30
|
|
|
|
],
|
|
|
|
|
|
),
|
|
|
|
|
|
],
|
|
|
|
|
|
),
|
|
|
|
|
|
);
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
// ── Name, verified badge, title, location, member since, bio ──
|
2026-04-15 18:18:02 +05:30
|
|
|
|
Widget _buildProfileInfo(AgentProfile agent, AgentDetailState state) {
|
|
|
|
|
|
// Prefer fieldValues-derived location (state.locationParts, with uppercased
|
|
|
|
|
|
// 2-letter state codes); fall back to agent model location when empty.
|
|
|
|
|
|
final locationDisplay = state.locationParts.isNotEmpty
|
|
|
|
|
|
? state.locationParts.join(', ')
|
|
|
|
|
|
: agent.location;
|
2026-03-08 12:15:15 +05:30
|
|
|
|
return Padding(
|
2026-03-08 21:55:52 +05:30
|
|
|
|
padding: const EdgeInsets.symmetric(horizontal: 24),
|
|
|
|
|
|
child: Column(
|
|
|
|
|
|
crossAxisAlignment: CrossAxisAlignment.center,
|
2026-03-08 12:15:15 +05:30
|
|
|
|
children: [
|
2026-03-08 21:55:52 +05:30
|
|
|
|
// Name + Verified badge + (Verified Expert) + edit icon
|
|
|
|
|
|
Row(
|
|
|
|
|
|
mainAxisAlignment: MainAxisAlignment.center,
|
2026-03-08 12:15:15 +05:30
|
|
|
|
crossAxisAlignment: CrossAxisAlignment.center,
|
|
|
|
|
|
children: [
|
2026-03-08 21:55:52 +05:30
|
|
|
|
Flexible(
|
2026-03-10 23:15:11 +05:30
|
|
|
|
child: Text.rich(
|
|
|
|
|
|
TextSpan(
|
|
|
|
|
|
children: [
|
|
|
|
|
|
TextSpan(
|
|
|
|
|
|
text: '${agent.firstName} ${agent.lastName}',
|
2026-03-08 21:55:52 +05:30
|
|
|
|
style: const TextStyle(
|
|
|
|
|
|
fontFamily: 'Fractul',
|
|
|
|
|
|
fontSize: 18,
|
|
|
|
|
|
fontWeight: FontWeight.w700,
|
|
|
|
|
|
color: AppColors.primaryDark,
|
|
|
|
|
|
),
|
|
|
|
|
|
),
|
2026-03-10 23:15:11 +05:30
|
|
|
|
],
|
|
|
|
|
|
),
|
|
|
|
|
|
overflow: TextOverflow.ellipsis,
|
2026-03-08 21:55:52 +05:30
|
|
|
|
),
|
2026-03-08 12:15:15 +05:30
|
|
|
|
),
|
2026-03-08 21:55:52 +05:30
|
|
|
|
if (agent.isVerified) ...[
|
2026-03-10 23:15:11 +05:30
|
|
|
|
const Padding(
|
|
|
|
|
|
padding: EdgeInsets.only(left: 6, right: 4),
|
|
|
|
|
|
child: Icon(Icons.verified,
|
|
|
|
|
|
size: 16, color: Color(0xFF2196F3)),
|
|
|
|
|
|
),
|
2026-03-08 21:55:52 +05:30
|
|
|
|
const Text(
|
|
|
|
|
|
'(Verified Expert)',
|
|
|
|
|
|
style: TextStyle(
|
|
|
|
|
|
fontFamily: 'SourceSerif4',
|
2026-03-08 12:15:15 +05:30
|
|
|
|
fontSize: 14,
|
2026-03-08 21:55:52 +05:30
|
|
|
|
fontWeight: FontWeight.w500,
|
|
|
|
|
|
color: Color(0xFF638559),
|
2026-03-08 12:15:15 +05:30
|
|
|
|
),
|
|
|
|
|
|
),
|
|
|
|
|
|
],
|
2026-03-08 21:55:52 +05:30
|
|
|
|
const SizedBox(width: 8),
|
|
|
|
|
|
GestureDetector(
|
|
|
|
|
|
behavior: HitTestBehavior.opaque,
|
2026-03-09 06:04:54 +05:30
|
|
|
|
onTap: () => context.push('/agent/edit-profile'),
|
2026-03-08 21:55:52 +05:30
|
|
|
|
child: Padding(
|
|
|
|
|
|
padding: const EdgeInsets.all(4),
|
|
|
|
|
|
child: SvgPicture.asset(
|
|
|
|
|
|
'assets/icons/edit_icon.svg',
|
|
|
|
|
|
width: 20,
|
|
|
|
|
|
height: 20,
|
2026-03-08 12:15:15 +05:30
|
|
|
|
),
|
2026-03-08 21:55:52 +05:30
|
|
|
|
),
|
2026-03-08 12:15:15 +05:30
|
|
|
|
),
|
2026-03-08 21:55:52 +05:30
|
|
|
|
],
|
|
|
|
|
|
),
|
|
|
|
|
|
// Title
|
|
|
|
|
|
if (agent.agentType != null) ...[
|
|
|
|
|
|
const SizedBox(height: 6),
|
|
|
|
|
|
Text(
|
|
|
|
|
|
agent.headline ?? agent.agentType!.name,
|
|
|
|
|
|
style: const TextStyle(
|
|
|
|
|
|
fontFamily: 'Fractul',
|
|
|
|
|
|
fontSize: 14,
|
|
|
|
|
|
color: AppColors.primaryDark,
|
|
|
|
|
|
),
|
|
|
|
|
|
textAlign: TextAlign.center,
|
|
|
|
|
|
),
|
|
|
|
|
|
],
|
|
|
|
|
|
const SizedBox(height: 8),
|
2026-04-18 11:22:27 +05:30
|
|
|
|
// Location + Member Since (center-aligned, uniform sizing)
|
2026-04-18 11:44:25 +05:30
|
|
|
|
// Show up to 4 locations with "+N more" matching web's ProfileCard.
|
|
|
|
|
|
if (state.locationParts.isNotEmpty ||
|
|
|
|
|
|
locationDisplay.isNotEmpty) ...[
|
|
|
|
|
|
Builder(builder: (_) {
|
|
|
|
|
|
final parts = state.locationParts.isNotEmpty
|
|
|
|
|
|
? state.locationParts
|
|
|
|
|
|
: locationDisplay
|
|
|
|
|
|
.split(',')
|
|
|
|
|
|
.map((s) => s.trim())
|
|
|
|
|
|
.where((s) => s.isNotEmpty)
|
|
|
|
|
|
.toList();
|
|
|
|
|
|
const maxVisible = 4;
|
|
|
|
|
|
final visible = parts.take(maxVisible).join(', ');
|
|
|
|
|
|
final remaining = parts.length - maxVisible;
|
|
|
|
|
|
|
|
|
|
|
|
return GestureDetector(
|
|
|
|
|
|
onTap: remaining > 0
|
|
|
|
|
|
? () => _showLocationModal(context, parts)
|
|
|
|
|
|
: null,
|
|
|
|
|
|
child: Row(
|
|
|
|
|
|
mainAxisAlignment: MainAxisAlignment.center,
|
|
|
|
|
|
crossAxisAlignment: CrossAxisAlignment.center,
|
|
|
|
|
|
children: [
|
|
|
|
|
|
SvgPicture.asset(
|
|
|
|
|
|
'assets/icons/location_filled_icon.svg',
|
|
|
|
|
|
width: 16,
|
|
|
|
|
|
height: 16,
|
2026-04-18 11:22:27 +05:30
|
|
|
|
),
|
2026-04-18 11:44:25 +05:30
|
|
|
|
const SizedBox(width: 4),
|
|
|
|
|
|
Flexible(
|
|
|
|
|
|
child: Text.rich(
|
|
|
|
|
|
TextSpan(
|
|
|
|
|
|
children: [
|
|
|
|
|
|
TextSpan(
|
|
|
|
|
|
text: visible,
|
|
|
|
|
|
style: const TextStyle(
|
|
|
|
|
|
fontFamily: 'SourceSerif4',
|
|
|
|
|
|
fontSize: 14,
|
|
|
|
|
|
fontWeight: FontWeight.w600,
|
|
|
|
|
|
color: AppColors.primaryDark,
|
|
|
|
|
|
),
|
|
|
|
|
|
),
|
|
|
|
|
|
if (remaining > 0)
|
|
|
|
|
|
TextSpan(
|
|
|
|
|
|
text: ' +$remaining more',
|
|
|
|
|
|
style: const TextStyle(
|
|
|
|
|
|
fontFamily: 'SourceSerif4',
|
|
|
|
|
|
fontSize: 14,
|
|
|
|
|
|
fontWeight: FontWeight.w500,
|
|
|
|
|
|
color: AppColors.accentOrange,
|
|
|
|
|
|
),
|
|
|
|
|
|
),
|
|
|
|
|
|
],
|
|
|
|
|
|
),
|
|
|
|
|
|
textAlign: TextAlign.center,
|
|
|
|
|
|
),
|
|
|
|
|
|
),
|
|
|
|
|
|
],
|
2026-03-08 12:15:15 +05:30
|
|
|
|
),
|
2026-04-18 11:44:25 +05:30
|
|
|
|
);
|
|
|
|
|
|
}),
|
2026-04-18 11:22:27 +05:30
|
|
|
|
const SizedBox(height: 6),
|
|
|
|
|
|
],
|
|
|
|
|
|
Row(
|
|
|
|
|
|
mainAxisAlignment: MainAxisAlignment.center,
|
|
|
|
|
|
crossAxisAlignment: CrossAxisAlignment.center,
|
|
|
|
|
|
children: [
|
2026-03-08 21:55:52 +05:30
|
|
|
|
SvgPicture.asset(
|
|
|
|
|
|
'assets/icons/calendar_icon.svg',
|
|
|
|
|
|
width: 16,
|
|
|
|
|
|
height: 16,
|
|
|
|
|
|
colorFilter: const ColorFilter.mode(
|
|
|
|
|
|
AppColors.primaryDark,
|
|
|
|
|
|
BlendMode.srcIn,
|
|
|
|
|
|
),
|
|
|
|
|
|
),
|
|
|
|
|
|
const SizedBox(width: 4),
|
|
|
|
|
|
Text(
|
|
|
|
|
|
agent.memberSinceText,
|
|
|
|
|
|
style: const TextStyle(
|
|
|
|
|
|
fontFamily: 'SourceSerif4',
|
2026-04-18 11:22:27 +05:30
|
|
|
|
fontSize: 14,
|
|
|
|
|
|
fontWeight: FontWeight.w600,
|
2026-03-08 21:55:52 +05:30
|
|
|
|
color: AppColors.primaryDark,
|
|
|
|
|
|
),
|
|
|
|
|
|
),
|
2026-03-08 12:15:15 +05:30
|
|
|
|
],
|
|
|
|
|
|
),
|
2026-03-08 21:55:52 +05:30
|
|
|
|
// Bio
|
|
|
|
|
|
if (agent.description.isNotEmpty) ...[
|
|
|
|
|
|
const SizedBox(height: 16),
|
|
|
|
|
|
SizedBox(
|
|
|
|
|
|
width: 338,
|
|
|
|
|
|
child: Text(
|
|
|
|
|
|
agent.description,
|
|
|
|
|
|
style: const TextStyle(
|
|
|
|
|
|
fontFamily: 'SourceSerif4',
|
|
|
|
|
|
fontSize: 14,
|
|
|
|
|
|
color: AppColors.primaryDark,
|
|
|
|
|
|
height: 1.43,
|
|
|
|
|
|
),
|
|
|
|
|
|
textAlign: TextAlign.center,
|
2026-03-08 12:15:15 +05:30
|
|
|
|
),
|
|
|
|
|
|
),
|
2026-03-08 21:55:52 +05:30
|
|
|
|
],
|
2026-03-08 12:15:15 +05:30
|
|
|
|
],
|
|
|
|
|
|
),
|
|
|
|
|
|
);
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
// ── Experience Section ──
|
2026-04-18 11:44:25 +05:30
|
|
|
|
// Full service-areas list modal (matches web's ProfileCard location modal)
|
|
|
|
|
|
void _showLocationModal(BuildContext context, List<String> locations) {
|
|
|
|
|
|
showModalBottomSheet(
|
|
|
|
|
|
context: context,
|
|
|
|
|
|
isScrollControlled: true,
|
|
|
|
|
|
backgroundColor: Colors.transparent,
|
|
|
|
|
|
builder: (ctx) => Container(
|
|
|
|
|
|
decoration: const BoxDecoration(
|
|
|
|
|
|
color: Colors.white,
|
|
|
|
|
|
borderRadius: BorderRadius.vertical(top: Radius.circular(20)),
|
|
|
|
|
|
),
|
|
|
|
|
|
padding: EdgeInsets.fromLTRB(
|
|
|
|
|
|
24, 24, 24, 24 + MediaQuery.of(ctx).padding.bottom,
|
|
|
|
|
|
),
|
|
|
|
|
|
child: Column(
|
|
|
|
|
|
mainAxisSize: MainAxisSize.min,
|
|
|
|
|
|
crossAxisAlignment: CrossAxisAlignment.start,
|
|
|
|
|
|
children: [
|
|
|
|
|
|
Row(
|
|
|
|
|
|
mainAxisAlignment: MainAxisAlignment.spaceBetween,
|
|
|
|
|
|
children: [
|
|
|
|
|
|
const Text(
|
|
|
|
|
|
'Service Areas',
|
|
|
|
|
|
style: TextStyle(
|
|
|
|
|
|
fontFamily: 'Fractul',
|
|
|
|
|
|
fontSize: 18,
|
|
|
|
|
|
fontWeight: FontWeight.w700,
|
|
|
|
|
|
color: AppColors.primaryDark,
|
|
|
|
|
|
),
|
|
|
|
|
|
),
|
|
|
|
|
|
GestureDetector(
|
|
|
|
|
|
onTap: () => Navigator.of(ctx).pop(),
|
|
|
|
|
|
child: Icon(
|
|
|
|
|
|
Icons.close,
|
|
|
|
|
|
color: AppColors.primaryDark.withValues(alpha: 0.5),
|
|
|
|
|
|
),
|
|
|
|
|
|
),
|
|
|
|
|
|
],
|
|
|
|
|
|
),
|
|
|
|
|
|
const SizedBox(height: 16),
|
|
|
|
|
|
Wrap(
|
|
|
|
|
|
spacing: 8,
|
|
|
|
|
|
runSpacing: 8,
|
|
|
|
|
|
children: locations
|
|
|
|
|
|
.map((loc) => Container(
|
|
|
|
|
|
padding: const EdgeInsets.symmetric(
|
|
|
|
|
|
horizontal: 12, vertical: 6),
|
|
|
|
|
|
decoration: BoxDecoration(
|
|
|
|
|
|
color: const Color(0xFFE8E8E8),
|
|
|
|
|
|
borderRadius: BorderRadius.circular(10),
|
|
|
|
|
|
),
|
|
|
|
|
|
child: Text(
|
|
|
|
|
|
loc,
|
|
|
|
|
|
style: const TextStyle(
|
|
|
|
|
|
fontFamily: 'SourceSerif4',
|
|
|
|
|
|
fontSize: 13,
|
|
|
|
|
|
color: AppColors.primaryDark,
|
|
|
|
|
|
),
|
|
|
|
|
|
),
|
|
|
|
|
|
))
|
|
|
|
|
|
.toList(),
|
|
|
|
|
|
),
|
|
|
|
|
|
],
|
|
|
|
|
|
),
|
|
|
|
|
|
),
|
|
|
|
|
|
);
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2026-03-08 12:15:15 +05:30
|
|
|
|
Widget _buildExperienceSection(AgentDetailState state) {
|
|
|
|
|
|
return Padding(
|
|
|
|
|
|
padding: const EdgeInsets.symmetric(horizontal: 16),
|
|
|
|
|
|
child: Column(
|
|
|
|
|
|
crossAxisAlignment: CrossAxisAlignment.center,
|
|
|
|
|
|
children: [
|
|
|
|
|
|
const Text('Experience',
|
|
|
|
|
|
style: TextStyle(
|
|
|
|
|
|
fontFamily: 'Fractul',
|
|
|
|
|
|
fontSize: 20,
|
|
|
|
|
|
fontWeight: FontWeight.w700,
|
|
|
|
|
|
color: AppColors.primaryDark)),
|
|
|
|
|
|
const SizedBox(height: 16),
|
|
|
|
|
|
_buildExperienceRow(
|
2026-04-15 16:50:35 +05:30
|
|
|
|
state.yearsInExperienceLabel, state.yearsInExperience),
|
2026-03-08 12:15:15 +05:30
|
|
|
|
const Divider(height: 24, thickness: 0.2),
|
|
|
|
|
|
_buildExperienceRow(
|
2026-04-15 16:50:35 +05:30
|
|
|
|
state.contractsClosedLabel, state.contractsClosed),
|
2026-03-08 12:15:15 +05:30
|
|
|
|
const Divider(height: 24, thickness: 0.2),
|
|
|
|
|
|
if (state.expertiseYears.isNotEmpty) ...[
|
2026-04-15 16:50:35 +05:30
|
|
|
|
Align(
|
2026-03-08 12:15:15 +05:30
|
|
|
|
alignment: Alignment.centerLeft,
|
2026-04-15 16:50:35 +05:30
|
|
|
|
child: Text(state.expertiseYearsLabel,
|
|
|
|
|
|
style: const TextStyle(
|
2026-03-08 12:15:15 +05:30
|
|
|
|
fontFamily: 'Fractul',
|
|
|
|
|
|
fontSize: 14,
|
|
|
|
|
|
fontWeight: FontWeight.w700,
|
|
|
|
|
|
color: AppColors.primaryDark)),
|
|
|
|
|
|
),
|
|
|
|
|
|
const SizedBox(height: 8),
|
2026-04-15 19:02:49 +05:30
|
|
|
|
_buildTagsWrap(
|
|
|
|
|
|
state.expertiseYears
|
|
|
|
|
|
.map((e) => e['years']!.isNotEmpty
|
|
|
|
|
|
? '${e['area']} – ${e['years']}'
|
|
|
|
|
|
: e['area']!)
|
|
|
|
|
|
.toList(),
|
|
|
|
|
|
sectionKey: 'expertise',
|
|
|
|
|
|
initialCount: 4,
|
|
|
|
|
|
),
|
2026-03-08 12:15:15 +05:30
|
|
|
|
],
|
|
|
|
|
|
if (state.certifications.isNotEmpty) ...[
|
|
|
|
|
|
const SizedBox(height: 16),
|
2026-04-15 16:50:35 +05:30
|
|
|
|
Align(
|
2026-03-08 12:15:15 +05:30
|
|
|
|
alignment: Alignment.centerLeft,
|
2026-04-15 16:50:35 +05:30
|
|
|
|
child: Text(state.certificationsLabel,
|
|
|
|
|
|
style: const TextStyle(
|
2026-03-08 12:15:15 +05:30
|
|
|
|
fontFamily: 'Fractul',
|
|
|
|
|
|
fontSize: 14,
|
|
|
|
|
|
fontWeight: FontWeight.w700,
|
|
|
|
|
|
color: AppColors.primaryDark)),
|
|
|
|
|
|
),
|
|
|
|
|
|
const SizedBox(height: 8),
|
2026-04-15 19:02:49 +05:30
|
|
|
|
_buildCertificationsList(state.certifications),
|
2026-03-08 12:15:15 +05:30
|
|
|
|
],
|
|
|
|
|
|
],
|
|
|
|
|
|
),
|
|
|
|
|
|
);
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2026-04-15 19:02:49 +05:30
|
|
|
|
// Certifications list with "+N More" (initial 2, matching web)
|
|
|
|
|
|
Widget _buildCertificationsList(List<Map<String, String>> certs) {
|
|
|
|
|
|
const initialCount = 2;
|
|
|
|
|
|
final sectionKey = 'certifications';
|
|
|
|
|
|
final isExpanded = _expandedTagSections.contains(sectionKey);
|
|
|
|
|
|
final display = isExpanded ? certs : certs.take(initialCount).toList();
|
|
|
|
|
|
final remaining = certs.length - initialCount;
|
|
|
|
|
|
|
|
|
|
|
|
Widget certRow(Map<String, String> cert) => Padding(
|
|
|
|
|
|
padding: const EdgeInsets.only(bottom: 12, left: 8),
|
|
|
|
|
|
child: Column(
|
|
|
|
|
|
crossAxisAlignment: CrossAxisAlignment.start,
|
|
|
|
|
|
children: [
|
|
|
|
|
|
Row(
|
|
|
|
|
|
children: [
|
|
|
|
|
|
const Text('\u2022 ',
|
|
|
|
|
|
style: TextStyle(
|
|
|
|
|
|
fontSize: 14, color: AppColors.primaryDark)),
|
|
|
|
|
|
Expanded(
|
|
|
|
|
|
child: Text(cert['name']!,
|
|
|
|
|
|
style: const TextStyle(
|
|
|
|
|
|
fontFamily: 'SourceSerif4',
|
|
|
|
|
|
fontSize: 14,
|
|
|
|
|
|
color: AppColors.primaryDark)),
|
|
|
|
|
|
),
|
|
|
|
|
|
],
|
|
|
|
|
|
),
|
|
|
|
|
|
if ((cert['org'] ?? '').isNotEmpty)
|
|
|
|
|
|
Padding(
|
|
|
|
|
|
padding: const EdgeInsets.only(left: 16),
|
|
|
|
|
|
child: Opacity(
|
|
|
|
|
|
opacity: 0.5,
|
|
|
|
|
|
child: Text(cert['org']!,
|
|
|
|
|
|
style: const TextStyle(
|
|
|
|
|
|
fontFamily: 'SourceSerif4',
|
|
|
|
|
|
fontSize: 14,
|
|
|
|
|
|
color: AppColors.primaryDark)),
|
|
|
|
|
|
),
|
|
|
|
|
|
),
|
|
|
|
|
|
],
|
|
|
|
|
|
),
|
|
|
|
|
|
);
|
|
|
|
|
|
|
|
|
|
|
|
return Column(
|
|
|
|
|
|
crossAxisAlignment: CrossAxisAlignment.start,
|
|
|
|
|
|
children: [
|
|
|
|
|
|
...display.map(certRow),
|
|
|
|
|
|
if (remaining > 0)
|
|
|
|
|
|
Padding(
|
|
|
|
|
|
padding: const EdgeInsets.only(left: 8, top: 4),
|
|
|
|
|
|
child: GestureDetector(
|
|
|
|
|
|
behavior: HitTestBehavior.opaque,
|
|
|
|
|
|
onTap: () => setState(() {
|
|
|
|
|
|
if (isExpanded) {
|
|
|
|
|
|
_expandedTagSections.remove(sectionKey);
|
|
|
|
|
|
} else {
|
|
|
|
|
|
_expandedTagSections.add(sectionKey);
|
|
|
|
|
|
}
|
|
|
|
|
|
}),
|
|
|
|
|
|
child: Text(
|
|
|
|
|
|
isExpanded ? 'Show Less' : '+$remaining More',
|
|
|
|
|
|
style: const TextStyle(
|
|
|
|
|
|
fontFamily: 'SourceSerif4',
|
|
|
|
|
|
fontSize: 14,
|
|
|
|
|
|
fontWeight: FontWeight.w600,
|
|
|
|
|
|
color: AppColors.accentOrange,
|
|
|
|
|
|
),
|
|
|
|
|
|
),
|
|
|
|
|
|
),
|
|
|
|
|
|
),
|
|
|
|
|
|
],
|
|
|
|
|
|
);
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2026-03-08 12:15:15 +05:30
|
|
|
|
Widget _buildExperienceRow(String label, String value) {
|
|
|
|
|
|
return Column(
|
|
|
|
|
|
crossAxisAlignment: CrossAxisAlignment.start,
|
|
|
|
|
|
children: [
|
|
|
|
|
|
Text(label,
|
|
|
|
|
|
style: const TextStyle(
|
|
|
|
|
|
fontFamily: 'Fractul',
|
|
|
|
|
|
fontSize: 14,
|
|
|
|
|
|
fontWeight: FontWeight.w700,
|
|
|
|
|
|
color: AppColors.primaryDark)),
|
|
|
|
|
|
const SizedBox(height: 6),
|
|
|
|
|
|
Padding(
|
|
|
|
|
|
padding: const EdgeInsets.only(left: 16),
|
|
|
|
|
|
child: Row(
|
|
|
|
|
|
children: [
|
|
|
|
|
|
const Text('\u2022 ',
|
|
|
|
|
|
style: TextStyle(
|
|
|
|
|
|
fontSize: 15, color: AppColors.primaryDark)),
|
|
|
|
|
|
Text(value,
|
|
|
|
|
|
style: const TextStyle(
|
|
|
|
|
|
fontFamily: 'SourceSerif4',
|
|
|
|
|
|
fontSize: 15,
|
|
|
|
|
|
fontWeight: FontWeight.w500,
|
|
|
|
|
|
color: AppColors.primaryDark)),
|
|
|
|
|
|
],
|
|
|
|
|
|
),
|
|
|
|
|
|
),
|
|
|
|
|
|
],
|
|
|
|
|
|
);
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2026-04-15 19:02:49 +05:30
|
|
|
|
Widget _buildTagsWrap(
|
|
|
|
|
|
List<String> tags, {
|
|
|
|
|
|
required String sectionKey,
|
|
|
|
|
|
int initialCount = 6,
|
|
|
|
|
|
}) {
|
2026-03-15 00:41:31 +05:30
|
|
|
|
final isExpanded = _expandedTagSections.contains(sectionKey);
|
2026-04-15 19:02:49 +05:30
|
|
|
|
final displayTags =
|
|
|
|
|
|
isExpanded ? tags : tags.take(initialCount).toList();
|
|
|
|
|
|
final remaining = tags.length - initialCount;
|
2026-03-08 12:15:15 +05:30
|
|
|
|
|
|
|
|
|
|
return Wrap(
|
|
|
|
|
|
spacing: 8,
|
|
|
|
|
|
runSpacing: 8,
|
|
|
|
|
|
children: [
|
|
|
|
|
|
...displayTags.map((tag) => Container(
|
|
|
|
|
|
padding:
|
|
|
|
|
|
const EdgeInsets.symmetric(horizontal: 12, vertical: 6),
|
|
|
|
|
|
decoration: BoxDecoration(
|
|
|
|
|
|
borderRadius: BorderRadius.circular(15),
|
|
|
|
|
|
border:
|
|
|
|
|
|
Border.all(color: AppColors.primaryDark, width: 0.7),
|
|
|
|
|
|
),
|
|
|
|
|
|
child: Text(tag,
|
|
|
|
|
|
style: const TextStyle(
|
|
|
|
|
|
fontFamily: 'SourceSerif4',
|
|
|
|
|
|
fontSize: 14,
|
|
|
|
|
|
fontWeight: FontWeight.w500,
|
|
|
|
|
|
color: AppColors.primaryDark)),
|
|
|
|
|
|
)),
|
|
|
|
|
|
if (remaining > 0)
|
2026-03-15 00:41:31 +05:30
|
|
|
|
GestureDetector(
|
|
|
|
|
|
onTap: () {
|
|
|
|
|
|
setState(() {
|
|
|
|
|
|
if (isExpanded) {
|
|
|
|
|
|
_expandedTagSections.remove(sectionKey);
|
|
|
|
|
|
} else {
|
|
|
|
|
|
_expandedTagSections.add(sectionKey);
|
|
|
|
|
|
}
|
|
|
|
|
|
});
|
|
|
|
|
|
},
|
|
|
|
|
|
behavior: HitTestBehavior.opaque,
|
|
|
|
|
|
child: Container(
|
|
|
|
|
|
padding:
|
|
|
|
|
|
const EdgeInsets.symmetric(horizontal: 12, vertical: 6),
|
|
|
|
|
|
decoration: BoxDecoration(
|
|
|
|
|
|
borderRadius: BorderRadius.circular(15),
|
|
|
|
|
|
border: Border.all(color: AppColors.primaryDark, width: 0.1),
|
|
|
|
|
|
),
|
|
|
|
|
|
child: Text(
|
|
|
|
|
|
isExpanded ? 'Show Less' : '+$remaining More',
|
|
|
|
|
|
style: const TextStyle(
|
|
|
|
|
|
fontFamily: 'SourceSerif4',
|
|
|
|
|
|
fontSize: 15,
|
|
|
|
|
|
fontWeight: FontWeight.w300,
|
|
|
|
|
|
color: AppColors.primaryDark)),
|
2026-03-08 12:15:15 +05:30
|
|
|
|
),
|
|
|
|
|
|
),
|
|
|
|
|
|
],
|
|
|
|
|
|
);
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2026-03-19 03:11:34 +05:30
|
|
|
|
// ── Info Cards (Availability, Work Env, Testimonial) — matches web ──
|
2026-03-08 12:15:15 +05:30
|
|
|
|
Widget _buildInfoCards(AgentDetailState state) {
|
2026-03-19 03:11:34 +05:30
|
|
|
|
// Work environment — try fieldValues first, fallback to mock (same as web)
|
2026-03-08 12:15:15 +05:30
|
|
|
|
String workEnv = '';
|
|
|
|
|
|
for (final fv in state.fieldValues) {
|
|
|
|
|
|
final slug = fv.fieldSlug.toLowerCase();
|
|
|
|
|
|
if (slug.contains('work_environment') ||
|
|
|
|
|
|
slug.contains('preferred_environment')) {
|
|
|
|
|
|
if (fv.textValue != null) workEnv = fv.textValue!;
|
|
|
|
|
|
if (fv.jsonValue is List) {
|
|
|
|
|
|
workEnv =
|
|
|
|
|
|
(fv.jsonValue as List).map((e) => e.toString()).join(', ');
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
2026-03-19 03:11:34 +05:30
|
|
|
|
}
|
|
|
|
|
|
// Fallback mock data (same as web dashboard)
|
|
|
|
|
|
if (workEnv.isEmpty) {
|
|
|
|
|
|
workEnv =
|
|
|
|
|
|
'Preferred work environment includes on-site property visits, '
|
|
|
|
|
|
'in-depth market research, client consultations, property tours, '
|
|
|
|
|
|
'and active involvement in negotiations to ensure the best outcomes';
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
// Extract first testimonial for preview
|
|
|
|
|
|
String testimonialText = '';
|
|
|
|
|
|
if (state.testimonials.isNotEmpty) {
|
|
|
|
|
|
final first = state.testimonials.first;
|
|
|
|
|
|
testimonialText =
|
|
|
|
|
|
(first['text'] ?? first['content'] ?? first['review'] ?? '')
|
|
|
|
|
|
.toString();
|
|
|
|
|
|
if (testimonialText.length > 150) {
|
|
|
|
|
|
testimonialText = '${testimonialText.substring(0, 150)}...';
|
2026-03-08 12:15:15 +05:30
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
return Padding(
|
|
|
|
|
|
padding: const EdgeInsets.symmetric(horizontal: 36),
|
|
|
|
|
|
child: Column(
|
|
|
|
|
|
children: [
|
2026-03-19 03:11:34 +05:30
|
|
|
|
// Availability card (with title — matches web)
|
|
|
|
|
|
_buildInfoCard(
|
|
|
|
|
|
icon: Icons.access_time_filled,
|
2026-04-15 15:57:52 +05:30
|
|
|
|
title: state.availabilityLabel,
|
2026-04-15 16:58:02 +05:30
|
|
|
|
subtitle: state.availabilitySchedule.isEmpty ? 'Not specified' : '',
|
2026-03-19 03:11:34 +05:30
|
|
|
|
items: state.availabilitySchedule.isNotEmpty
|
|
|
|
|
|
? state.availabilitySchedule
|
|
|
|
|
|
: null,
|
2026-04-15 16:58:02 +05:30
|
|
|
|
isEmpty: state.availabilitySchedule.isEmpty,
|
2026-03-19 03:11:34 +05:30
|
|
|
|
),
|
|
|
|
|
|
const SizedBox(height: 16),
|
2026-04-15 15:57:52 +05:30
|
|
|
|
// Work environment
|
2026-03-19 03:11:34 +05:30
|
|
|
|
_buildInfoCard(
|
|
|
|
|
|
icon: Icons.landscape_outlined,
|
2026-04-15 15:57:52 +05:30
|
|
|
|
title: state.workEnvironmentLabel,
|
2026-03-19 03:11:34 +05:30
|
|
|
|
subtitle: '',
|
|
|
|
|
|
description: workEnv,
|
|
|
|
|
|
),
|
|
|
|
|
|
const SizedBox(height: 16),
|
2026-04-15 15:57:52 +05:30
|
|
|
|
// Testimonial preview
|
2026-03-19 03:11:34 +05:30
|
|
|
|
_buildInfoCard(
|
|
|
|
|
|
icon: Icons.star_rounded,
|
2026-04-15 15:57:52 +05:30
|
|
|
|
title: state.bestExperienceLabel,
|
2026-03-19 03:11:34 +05:30
|
|
|
|
subtitle: '',
|
|
|
|
|
|
description: testimonialText.isNotEmpty
|
|
|
|
|
|
? '\u201C$testimonialText\u201D'
|
|
|
|
|
|
: 'No testimonials yet',
|
|
|
|
|
|
isEmpty: testimonialText.isEmpty,
|
|
|
|
|
|
),
|
2026-03-08 12:15:15 +05:30
|
|
|
|
],
|
|
|
|
|
|
),
|
|
|
|
|
|
);
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
Widget _buildInfoCard({
|
|
|
|
|
|
required IconData icon,
|
2026-03-19 03:11:34 +05:30
|
|
|
|
required String title,
|
|
|
|
|
|
required String subtitle,
|
2026-03-08 12:15:15 +05:30
|
|
|
|
List<String>? items,
|
|
|
|
|
|
String? description,
|
2026-03-19 03:11:34 +05:30
|
|
|
|
bool isEmpty = false,
|
2026-03-08 12:15:15 +05:30
|
|
|
|
}) {
|
|
|
|
|
|
return Container(
|
|
|
|
|
|
width: double.infinity,
|
|
|
|
|
|
constraints: const BoxConstraints(minHeight: 217),
|
|
|
|
|
|
padding: const EdgeInsets.all(20),
|
|
|
|
|
|
decoration: BoxDecoration(
|
|
|
|
|
|
color: Colors.white,
|
|
|
|
|
|
borderRadius: BorderRadius.circular(10),
|
|
|
|
|
|
boxShadow: [
|
|
|
|
|
|
BoxShadow(
|
|
|
|
|
|
color: const Color(0xFFD9D9D9).withValues(alpha: 0.5),
|
|
|
|
|
|
offset: const Offset(0, 10),
|
|
|
|
|
|
blurRadius: 20,
|
|
|
|
|
|
),
|
|
|
|
|
|
],
|
|
|
|
|
|
),
|
|
|
|
|
|
child: Column(
|
|
|
|
|
|
mainAxisAlignment: MainAxisAlignment.start,
|
|
|
|
|
|
children: [
|
|
|
|
|
|
Icon(icon, size: 36, color: AppColors.accentOrange),
|
|
|
|
|
|
if (title.isNotEmpty) ...[
|
|
|
|
|
|
const SizedBox(height: 8),
|
|
|
|
|
|
Text(
|
|
|
|
|
|
title,
|
|
|
|
|
|
style: const TextStyle(
|
|
|
|
|
|
fontFamily: 'SourceSerif4',
|
|
|
|
|
|
fontSize: 14,
|
|
|
|
|
|
fontWeight: FontWeight.w600,
|
|
|
|
|
|
color: AppColors.primaryDark,
|
|
|
|
|
|
),
|
|
|
|
|
|
),
|
|
|
|
|
|
],
|
|
|
|
|
|
if (subtitle.isNotEmpty) ...[
|
|
|
|
|
|
const SizedBox(height: 2),
|
|
|
|
|
|
Text(
|
|
|
|
|
|
subtitle,
|
2026-03-19 03:11:34 +05:30
|
|
|
|
style: TextStyle(
|
2026-03-08 12:15:15 +05:30
|
|
|
|
fontFamily: 'SourceSerif4',
|
|
|
|
|
|
fontSize: 14,
|
|
|
|
|
|
fontWeight: FontWeight.w600,
|
2026-03-19 03:11:34 +05:30
|
|
|
|
color: isEmpty
|
|
|
|
|
|
? AppColors.primaryDark.withValues(alpha: 0.4)
|
|
|
|
|
|
: AppColors.primaryDark,
|
2026-03-08 12:15:15 +05:30
|
|
|
|
),
|
|
|
|
|
|
),
|
|
|
|
|
|
],
|
|
|
|
|
|
if (items != null && items.isNotEmpty) ...[
|
|
|
|
|
|
const SizedBox(height: 8),
|
2026-03-19 03:11:34 +05:30
|
|
|
|
...items.map(
|
|
|
|
|
|
(item) => Padding(
|
|
|
|
|
|
padding: const EdgeInsets.symmetric(vertical: 1),
|
|
|
|
|
|
child: Text(
|
|
|
|
|
|
item,
|
|
|
|
|
|
style: const TextStyle(
|
|
|
|
|
|
fontFamily: 'SourceSerif4',
|
|
|
|
|
|
fontSize: 14,
|
|
|
|
|
|
color: AppColors.primaryDark,
|
2026-03-08 12:15:15 +05:30
|
|
|
|
),
|
2026-03-19 03:11:34 +05:30
|
|
|
|
),
|
|
|
|
|
|
),
|
|
|
|
|
|
),
|
2026-03-08 12:15:15 +05:30
|
|
|
|
],
|
2026-03-19 03:11:34 +05:30
|
|
|
|
if (description != null) ...[
|
2026-03-08 12:15:15 +05:30
|
|
|
|
const SizedBox(height: 8),
|
|
|
|
|
|
Text(
|
|
|
|
|
|
description,
|
2026-03-19 03:11:34 +05:30
|
|
|
|
style: TextStyle(
|
2026-03-08 12:15:15 +05:30
|
|
|
|
fontFamily: 'SourceSerif4',
|
|
|
|
|
|
fontSize: 14,
|
|
|
|
|
|
fontWeight: FontWeight.w600,
|
2026-03-19 03:11:34 +05:30
|
|
|
|
color: isEmpty
|
|
|
|
|
|
? AppColors.primaryDark.withValues(alpha: 0.4)
|
|
|
|
|
|
: AppColors.primaryDark,
|
2026-03-08 12:15:15 +05:30
|
|
|
|
),
|
|
|
|
|
|
textAlign: TextAlign.center,
|
|
|
|
|
|
),
|
|
|
|
|
|
],
|
|
|
|
|
|
],
|
|
|
|
|
|
),
|
|
|
|
|
|
);
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
// ── Specialization Section (grey background) ──
|
|
|
|
|
|
Widget _buildSpecializationSection(AgentDetailState state) {
|
|
|
|
|
|
final cards = state.specializationCards;
|
|
|
|
|
|
if (cards.isEmpty) return const SizedBox.shrink();
|
|
|
|
|
|
|
|
|
|
|
|
return Container(
|
|
|
|
|
|
width: double.infinity,
|
|
|
|
|
|
color: const Color(0xFFE6E6E6),
|
|
|
|
|
|
padding: const EdgeInsets.symmetric(vertical: 24),
|
|
|
|
|
|
child: Column(
|
|
|
|
|
|
children: [
|
|
|
|
|
|
const Text('Specialization',
|
|
|
|
|
|
style: TextStyle(
|
|
|
|
|
|
fontFamily: 'Fractul',
|
|
|
|
|
|
fontSize: 20,
|
|
|
|
|
|
fontWeight: FontWeight.w700,
|
|
|
|
|
|
color: AppColors.primaryDark)),
|
|
|
|
|
|
const SizedBox(height: 4),
|
|
|
|
|
|
const Text('Area Of Expertise and Focus',
|
|
|
|
|
|
style: TextStyle(
|
|
|
|
|
|
fontFamily: 'Fractul',
|
|
|
|
|
|
fontSize: 14,
|
|
|
|
|
|
fontWeight: FontWeight.w500,
|
|
|
|
|
|
color: AppColors.primaryDark)),
|
|
|
|
|
|
const SizedBox(height: 16),
|
|
|
|
|
|
...cards.map((card) => _buildSpecCard(card)),
|
|
|
|
|
|
],
|
|
|
|
|
|
),
|
|
|
|
|
|
);
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
Widget _buildSpecCard(SpecializationCard card) {
|
|
|
|
|
|
final displayName = AgentDetailState.titleCase(card.name);
|
2026-03-15 00:50:03 +05:30
|
|
|
|
final sectionKey = 'spec_${card.slug}';
|
|
|
|
|
|
final isExpanded = _expandedTagSections.contains(sectionKey);
|
2026-03-19 03:58:03 +05:30
|
|
|
|
final displayValues = isExpanded ? card.values : card.values.take(3).toList();
|
|
|
|
|
|
final hasMore = card.values.length > 3;
|
2026-03-15 00:50:03 +05:30
|
|
|
|
|
2026-03-08 12:15:15 +05:30
|
|
|
|
return Container(
|
|
|
|
|
|
margin: const EdgeInsets.symmetric(horizontal: 66, vertical: 8),
|
|
|
|
|
|
width: 298,
|
|
|
|
|
|
padding: const EdgeInsets.symmetric(vertical: 16, horizontal: 20),
|
|
|
|
|
|
decoration: BoxDecoration(
|
|
|
|
|
|
color: Colors.white,
|
|
|
|
|
|
borderRadius: BorderRadius.circular(15),
|
|
|
|
|
|
border: Border.all(color: AppColors.primaryDark, width: 0.7),
|
|
|
|
|
|
),
|
|
|
|
|
|
child: Column(
|
2026-03-30 15:35:35 +05:30
|
|
|
|
mainAxisSize: MainAxisSize.min,
|
2026-03-08 12:15:15 +05:30
|
|
|
|
children: [
|
|
|
|
|
|
const Icon(Icons.category_outlined,
|
|
|
|
|
|
size: 28, color: AppColors.accentOrange),
|
|
|
|
|
|
const SizedBox(height: 10),
|
|
|
|
|
|
Text(displayName,
|
|
|
|
|
|
style: const TextStyle(
|
|
|
|
|
|
fontFamily: 'Fractul',
|
|
|
|
|
|
fontSize: 14,
|
|
|
|
|
|
fontWeight: FontWeight.w700,
|
|
|
|
|
|
color: AppColors.primaryDark)),
|
|
|
|
|
|
const SizedBox(height: 12),
|
2026-03-15 00:50:03 +05:30
|
|
|
|
...displayValues.map((val) => Padding(
|
2026-03-08 12:15:15 +05:30
|
|
|
|
padding: const EdgeInsets.symmetric(vertical: 4),
|
|
|
|
|
|
child: Text(
|
|
|
|
|
|
AgentDetailState.titleCase(val),
|
|
|
|
|
|
textAlign: TextAlign.center,
|
|
|
|
|
|
style: const TextStyle(
|
|
|
|
|
|
fontFamily: 'SourceSerif4',
|
|
|
|
|
|
fontSize: 14,
|
|
|
|
|
|
color: AppColors.primaryDark,
|
|
|
|
|
|
height: 1.8),
|
|
|
|
|
|
),
|
|
|
|
|
|
)),
|
2026-03-15 00:50:03 +05:30
|
|
|
|
if (hasMore) ...[
|
2026-03-08 12:15:15 +05:30
|
|
|
|
const SizedBox(height: 8),
|
2026-03-15 00:50:03 +05:30
|
|
|
|
GestureDetector(
|
|
|
|
|
|
onTap: () {
|
|
|
|
|
|
setState(() {
|
|
|
|
|
|
if (isExpanded) {
|
|
|
|
|
|
_expandedTagSections.remove(sectionKey);
|
|
|
|
|
|
} else {
|
|
|
|
|
|
_expandedTagSections.add(sectionKey);
|
|
|
|
|
|
}
|
|
|
|
|
|
});
|
|
|
|
|
|
},
|
|
|
|
|
|
behavior: HitTestBehavior.opaque,
|
|
|
|
|
|
child: Container(
|
|
|
|
|
|
padding:
|
|
|
|
|
|
const EdgeInsets.symmetric(horizontal: 16, vertical: 4),
|
|
|
|
|
|
decoration: BoxDecoration(
|
|
|
|
|
|
borderRadius: BorderRadius.circular(15),
|
|
|
|
|
|
border:
|
|
|
|
|
|
Border.all(color: AppColors.accentOrange, width: 1),
|
|
|
|
|
|
),
|
|
|
|
|
|
child: Row(
|
|
|
|
|
|
mainAxisSize: MainAxisSize.min,
|
|
|
|
|
|
children: [
|
|
|
|
|
|
Text(isExpanded ? 'Show Less' : 'Show More',
|
|
|
|
|
|
style: const TextStyle(
|
|
|
|
|
|
fontFamily: 'SourceSerif4',
|
|
|
|
|
|
fontSize: 10,
|
|
|
|
|
|
fontWeight: FontWeight.w700,
|
|
|
|
|
|
color: AppColors.accentOrange)),
|
|
|
|
|
|
const SizedBox(width: 4),
|
|
|
|
|
|
Icon(
|
|
|
|
|
|
isExpanded
|
|
|
|
|
|
? Icons.keyboard_arrow_up
|
|
|
|
|
|
: Icons.keyboard_arrow_down,
|
|
|
|
|
|
size: 14,
|
|
|
|
|
|
color: AppColors.accentOrange),
|
|
|
|
|
|
],
|
|
|
|
|
|
),
|
2026-03-08 12:15:15 +05:30
|
|
|
|
),
|
|
|
|
|
|
),
|
|
|
|
|
|
],
|
|
|
|
|
|
],
|
|
|
|
|
|
),
|
|
|
|
|
|
);
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2026-03-19 03:11:34 +05:30
|
|
|
|
// ── Testimonials ──
|
2026-03-08 12:15:15 +05:30
|
|
|
|
Widget _buildTestimonialsSection(AgentDetailState state) {
|
|
|
|
|
|
if (state.testimonials.isEmpty) return const SizedBox.shrink();
|
|
|
|
|
|
|
2026-03-27 06:32:46 +05:30
|
|
|
|
// Calculate average rating dynamically
|
|
|
|
|
|
final totalRating = state.testimonials.fold<double>(
|
|
|
|
|
|
0, (sum, t) => sum + ((t['rating'] as num?)?.toDouble() ?? 5.0),
|
|
|
|
|
|
);
|
|
|
|
|
|
final avgRating = (totalRating / state.testimonials.length).toStringAsFixed(1);
|
|
|
|
|
|
final count = state.testimonials.length;
|
|
|
|
|
|
|
2026-03-08 12:15:15 +05:30
|
|
|
|
return Padding(
|
|
|
|
|
|
padding: const EdgeInsets.symmetric(horizontal: 24),
|
|
|
|
|
|
child: Column(
|
|
|
|
|
|
children: [
|
|
|
|
|
|
const Text(
|
|
|
|
|
|
'Testimonials',
|
|
|
|
|
|
style: TextStyle(
|
|
|
|
|
|
fontFamily: 'Fractul',
|
|
|
|
|
|
fontSize: 20,
|
|
|
|
|
|
fontWeight: FontWeight.w700,
|
|
|
|
|
|
color: AppColors.primaryDark,
|
|
|
|
|
|
),
|
|
|
|
|
|
),
|
|
|
|
|
|
const SizedBox(height: 8),
|
|
|
|
|
|
Divider(
|
2026-03-19 03:11:34 +05:30
|
|
|
|
color: AppColors.primaryDark.withValues(alpha: 0.15),
|
|
|
|
|
|
height: 1,
|
|
|
|
|
|
),
|
2026-03-08 12:15:15 +05:30
|
|
|
|
const SizedBox(height: 8),
|
2026-03-27 06:32:46 +05:30
|
|
|
|
Text(
|
|
|
|
|
|
'Clients rate our real estate services $avgRating out of 5 on average, based on $count recent client review${count != 1 ? 's' : ''}.',
|
|
|
|
|
|
style: const TextStyle(
|
2026-03-08 12:15:15 +05:30
|
|
|
|
fontFamily: 'SourceSerif4',
|
|
|
|
|
|
fontSize: 14,
|
|
|
|
|
|
color: AppColors.primaryDark,
|
|
|
|
|
|
),
|
|
|
|
|
|
textAlign: TextAlign.center,
|
|
|
|
|
|
),
|
|
|
|
|
|
const SizedBox(height: 16),
|
|
|
|
|
|
SizedBox(
|
2026-03-19 03:11:34 +05:30
|
|
|
|
height: 380,
|
2026-03-08 12:15:15 +05:30
|
|
|
|
child: ListView.separated(
|
|
|
|
|
|
scrollDirection: Axis.horizontal,
|
|
|
|
|
|
itemCount: state.testimonials.length,
|
|
|
|
|
|
separatorBuilder: (_, __) => const SizedBox(width: 12),
|
|
|
|
|
|
itemBuilder: (_, i) =>
|
|
|
|
|
|
_TestimonialCard(data: state.testimonials[i]),
|
|
|
|
|
|
),
|
|
|
|
|
|
),
|
|
|
|
|
|
],
|
|
|
|
|
|
),
|
|
|
|
|
|
);
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2026-03-19 03:11:34 +05:30
|
|
|
|
// ── Testimonial Card (matches Figma & web TestimonialCard) ──
|
|
|
|
|
|
|
2026-03-08 12:15:15 +05:30
|
|
|
|
class _TestimonialCard extends StatelessWidget {
|
|
|
|
|
|
final Map<String, dynamic> data;
|
|
|
|
|
|
|
|
|
|
|
|
const _TestimonialCard({required this.data});
|
|
|
|
|
|
|
|
|
|
|
|
@override
|
|
|
|
|
|
Widget build(BuildContext context) {
|
|
|
|
|
|
final text = data['text'] as String? ?? '';
|
|
|
|
|
|
final authorName = data['authorName'] as String? ?? '';
|
|
|
|
|
|
final authorRole = data['authorRole'] as String? ?? '';
|
|
|
|
|
|
final rating = (data['rating'] as num?)?.toInt() ?? 5;
|
|
|
|
|
|
|
2026-03-19 03:11:34 +05:30
|
|
|
|
return Container(
|
|
|
|
|
|
width: 300,
|
|
|
|
|
|
padding: const EdgeInsets.all(16),
|
|
|
|
|
|
decoration: BoxDecoration(
|
|
|
|
|
|
color: Colors.white,
|
|
|
|
|
|
borderRadius: BorderRadius.circular(15),
|
|
|
|
|
|
border: Border.all(
|
|
|
|
|
|
color: AppColors.primaryDark.withValues(alpha: 0.1),
|
|
|
|
|
|
width: 1,
|
|
|
|
|
|
),
|
|
|
|
|
|
),
|
2026-03-08 12:15:15 +05:30
|
|
|
|
child: Column(
|
|
|
|
|
|
crossAxisAlignment: CrossAxisAlignment.start,
|
|
|
|
|
|
children: [
|
2026-03-19 03:11:34 +05:30
|
|
|
|
// Quote icon
|
|
|
|
|
|
SvgPicture.asset(
|
|
|
|
|
|
'assets/icons/quote_icon.svg',
|
|
|
|
|
|
width: 23,
|
|
|
|
|
|
height: 13,
|
|
|
|
|
|
),
|
|
|
|
|
|
const SizedBox(height: 12),
|
|
|
|
|
|
// Title snippet (bold)
|
|
|
|
|
|
Text(
|
|
|
|
|
|
text.length > 35 ? '${text.substring(0, 35)} ..' : text,
|
|
|
|
|
|
style: const TextStyle(
|
|
|
|
|
|
fontFamily: 'Fractul',
|
|
|
|
|
|
fontSize: 14,
|
|
|
|
|
|
fontWeight: FontWeight.w500,
|
|
|
|
|
|
color: AppColors.primaryDark,
|
|
|
|
|
|
),
|
|
|
|
|
|
maxLines: 1,
|
|
|
|
|
|
overflow: TextOverflow.ellipsis,
|
|
|
|
|
|
),
|
|
|
|
|
|
const SizedBox(height: 12),
|
|
|
|
|
|
// Full text
|
2026-03-08 12:15:15 +05:30
|
|
|
|
Expanded(
|
2026-03-19 03:11:34 +05:30
|
|
|
|
child: Text(
|
|
|
|
|
|
text,
|
|
|
|
|
|
style: const TextStyle(
|
|
|
|
|
|
fontFamily: 'SourceSerif4',
|
|
|
|
|
|
fontSize: 14,
|
|
|
|
|
|
fontWeight: FontWeight.w400,
|
|
|
|
|
|
color: AppColors.primaryDark,
|
|
|
|
|
|
height: 1.4,
|
2026-03-08 12:15:15 +05:30
|
|
|
|
),
|
2026-03-19 03:11:34 +05:30
|
|
|
|
overflow: TextOverflow.fade,
|
2026-03-08 12:15:15 +05:30
|
|
|
|
),
|
|
|
|
|
|
),
|
2026-03-19 03:11:34 +05:30
|
|
|
|
const SizedBox(height: 12),
|
|
|
|
|
|
// Divider
|
|
|
|
|
|
Divider(
|
|
|
|
|
|
color: AppColors.primaryDark.withValues(alpha: 0.08),
|
|
|
|
|
|
height: 1,
|
2026-03-08 12:15:15 +05:30
|
|
|
|
),
|
2026-03-19 03:11:34 +05:30
|
|
|
|
const SizedBox(height: 12),
|
|
|
|
|
|
// Star rating
|
|
|
|
|
|
Row(
|
|
|
|
|
|
mainAxisSize: MainAxisSize.min,
|
|
|
|
|
|
children: List.generate(
|
|
|
|
|
|
5,
|
|
|
|
|
|
(i) => Padding(
|
|
|
|
|
|
padding: const EdgeInsets.only(right: 2),
|
|
|
|
|
|
child: Icon(
|
|
|
|
|
|
i < rating ? Icons.star : Icons.star_border,
|
|
|
|
|
|
size: 18,
|
|
|
|
|
|
color: i < rating
|
|
|
|
|
|
? const Color(0xFFFFDE21)
|
|
|
|
|
|
: AppColors.primaryDark.withValues(alpha: 0.3),
|
2026-03-08 12:15:15 +05:30
|
|
|
|
),
|
2026-03-19 03:11:34 +05:30
|
|
|
|
),
|
2026-03-08 12:15:15 +05:30
|
|
|
|
),
|
|
|
|
|
|
),
|
2026-03-19 03:11:34 +05:30
|
|
|
|
const SizedBox(height: 8),
|
|
|
|
|
|
// Author name
|
|
|
|
|
|
Text(
|
|
|
|
|
|
authorName,
|
|
|
|
|
|
style: const TextStyle(
|
|
|
|
|
|
fontFamily: 'Fractul',
|
|
|
|
|
|
fontSize: 14,
|
|
|
|
|
|
fontWeight: FontWeight.w400,
|
|
|
|
|
|
color: AppColors.primaryDark,
|
|
|
|
|
|
),
|
|
|
|
|
|
),
|
|
|
|
|
|
// Author role
|
|
|
|
|
|
if (authorRole.isNotEmpty) ...[
|
|
|
|
|
|
const SizedBox(height: 2),
|
|
|
|
|
|
Text(
|
|
|
|
|
|
authorRole,
|
|
|
|
|
|
style: const TextStyle(
|
|
|
|
|
|
fontFamily: 'Fractul',
|
|
|
|
|
|
fontSize: 14,
|
|
|
|
|
|
fontWeight: FontWeight.w500,
|
|
|
|
|
|
color: AppColors.primaryDark,
|
|
|
|
|
|
),
|
|
|
|
|
|
),
|
|
|
|
|
|
],
|
2026-03-08 12:15:15 +05:30
|
|
|
|
],
|
|
|
|
|
|
),
|
|
|
|
|
|
);
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|