1337 lines
44 KiB
Dart
1337 lines
44 KiB
Dart
import 'dart:io';
|
||
|
||
import 'package:flutter/material.dart';
|
||
import 'package:flutter_riverpod/flutter_riverpod.dart';
|
||
import 'package:flutter_svg/flutter_svg.dart';
|
||
import 'package:image_picker/image_picker.dart';
|
||
import 'package:real_estate_mobile/core/constants/app_colors.dart';
|
||
import 'package:real_estate_mobile/core/widgets/s3_image.dart';
|
||
import 'package:real_estate_mobile/features/agents/data/models/agent_profile.dart';
|
||
import 'package:real_estate_mobile/features/agents/presentation/providers/agent_detail_provider.dart';
|
||
import 'package:real_estate_mobile/features/profile/data/profile_repository.dart';
|
||
import 'package:real_estate_mobile/features/home/presentation/screens/home_screen.dart';
|
||
import 'package:go_router/go_router.dart';
|
||
|
||
/// 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(
|
||
loading: () => const Center(
|
||
child: CircularProgressIndicator(
|
||
color: AppColors.accentOrange,
|
||
strokeWidth: 2,
|
||
),
|
||
),
|
||
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)),
|
||
),
|
||
],
|
||
),
|
||
),
|
||
data: (agentProfileId) {
|
||
if (agentProfileId == null) {
|
||
return const Center(
|
||
child: Text('No agent profile found',
|
||
style: TextStyle(
|
||
fontFamily: 'Fractul',
|
||
fontSize: 16,
|
||
color: AppColors.primaryDark)),
|
||
);
|
||
}
|
||
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> {
|
||
bool _isUploadingAvatar = false;
|
||
bool _isTogglingAvailability = false;
|
||
bool _showEmail = false;
|
||
bool _showPhone = false;
|
||
final Set<String> _expandedTagSections = {};
|
||
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);
|
||
|
||
// Optimistically update the UI immediately
|
||
ref
|
||
.read(agentDetailProvider(widget.agentProfileId).notifier)
|
||
.updateAvailability(newValue);
|
||
|
||
try {
|
||
final repo = ProfileRepository();
|
||
await repo.updateProfile('AGENT', {'isAvailable': newValue});
|
||
} catch (e) {
|
||
// Revert on failure
|
||
if (mounted) {
|
||
ref
|
||
.read(agentDetailProvider(widget.agentProfileId).notifier)
|
||
.updateAvailability(!newValue);
|
||
ScaffoldMessenger.of(context).showSnackBar(
|
||
SnackBar(content: Text('Failed to update availability: $e')),
|
||
);
|
||
}
|
||
} finally {
|
||
if (mounted) {
|
||
setState(() => _isTogglingAvailability = false);
|
||
}
|
||
}
|
||
}
|
||
|
||
@override
|
||
Widget build(BuildContext context) {
|
||
final state = ref.watch(agentDetailProvider(widget.agentProfileId));
|
||
|
||
if (state.isLoading) {
|
||
return const Center(
|
||
child: CircularProgressIndicator(color: AppColors.accentOrange));
|
||
}
|
||
if (state.error != null) {
|
||
return _buildError(state.error!);
|
||
}
|
||
return _buildContent(state, ref);
|
||
}
|
||
|
||
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)),
|
||
),
|
||
],
|
||
),
|
||
);
|
||
}
|
||
|
||
Widget _buildContent(AgentDetailState state, WidgetRef ref) {
|
||
final agent = state.agent!;
|
||
final scrollController = ref.watch(homeScrollControllerProvider);
|
||
return SingleChildScrollView(
|
||
controller: scrollController,
|
||
child: Column(
|
||
children: [
|
||
const SizedBox(height: 12),
|
||
_buildCoverAndAvatar(agent),
|
||
const SizedBox(height: 16),
|
||
_buildStatusSection(agent),
|
||
const SizedBox(height: 12),
|
||
_buildContactInfo(state),
|
||
const SizedBox(height: 16),
|
||
_buildProfileInfo(agent),
|
||
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),
|
||
],
|
||
),
|
||
);
|
||
}
|
||
|
||
// ── 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,
|
||
child: _localAvatarFile != null
|
||
? Image.file(
|
||
_localAvatarFile!,
|
||
width: coverWidth,
|
||
height: 346,
|
||
fit: BoxFit.cover,
|
||
)
|
||
: 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),
|
||
),
|
||
),
|
||
),
|
||
// 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,
|
||
),
|
||
),
|
||
),
|
||
),
|
||
),
|
||
// Orange edit icon top-right (tabler:edit style)
|
||
Positioned(
|
||
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,
|
||
),
|
||
),
|
||
),
|
||
),
|
||
),
|
||
],
|
||
),
|
||
);
|
||
}
|
||
|
||
// ── Available / Unavailable status toggle (matches web design) ──
|
||
Widget _buildStatusSection(AgentProfile agent) {
|
||
final isAvailable = agent.isAvailable;
|
||
|
||
return Padding(
|
||
padding: const EdgeInsets.symmetric(horizontal: 24),
|
||
child: Column(
|
||
children: [
|
||
_buildStatusOption(
|
||
label: 'Available.',
|
||
isActive: isAvailable,
|
||
dotColor: const Color(0xFF4CAF50),
|
||
activeColor: const Color(0xFFE8F5E9),
|
||
activeBorderColor: const Color(0xFF4CAF50),
|
||
onTap: isAvailable ? null : () => _toggleAvailability(true),
|
||
),
|
||
const SizedBox(height: 12),
|
||
_buildStatusOption(
|
||
label: 'Unavailable.',
|
||
isActive: !isAvailable,
|
||
dotColor: const Color(0xFF00293D),
|
||
activeColor: const Color(0xFFE8EDF2),
|
||
activeBorderColor: const Color(0xFF00293D),
|
||
onTap: !isAvailable ? null : () => _toggleAvailability(false),
|
||
),
|
||
],
|
||
),
|
||
);
|
||
}
|
||
|
||
Widget _buildStatusOption({
|
||
required String label,
|
||
required bool isActive,
|
||
required Color dotColor,
|
||
required Color activeColor,
|
||
required Color activeBorderColor,
|
||
VoidCallback? onTap,
|
||
}) {
|
||
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,
|
||
),
|
||
),
|
||
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,
|
||
),
|
||
),
|
||
),
|
||
const SizedBox(width: 10),
|
||
Text(
|
||
label,
|
||
style: TextStyle(
|
||
fontFamily: 'Fractul',
|
||
fontSize: 14,
|
||
fontWeight: isActive ? FontWeight.w700 : FontWeight.w500,
|
||
color: AppColors.primaryDark,
|
||
),
|
||
),
|
||
const Spacer(),
|
||
if (isActive)
|
||
Text(
|
||
'Active',
|
||
style: TextStyle(
|
||
fontFamily: 'Fractul',
|
||
fontSize: 13,
|
||
fontWeight: FontWeight.w500,
|
||
color: activeBorderColor,
|
||
),
|
||
),
|
||
],
|
||
),
|
||
),
|
||
);
|
||
}
|
||
|
||
// ── 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(
|
||
email.isNotEmpty
|
||
? (_showEmail ? email : state.maskEmail(email))
|
||
: '-',
|
||
style: const TextStyle(
|
||
fontFamily: 'SourceSerif4',
|
||
fontSize: 14,
|
||
color: AppColors.primaryDark),
|
||
maxLines: 1,
|
||
overflow: TextOverflow.ellipsis,
|
||
),
|
||
),
|
||
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,
|
||
),
|
||
),
|
||
),
|
||
],
|
||
),
|
||
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(
|
||
phone.isNotEmpty
|
||
? (_showPhone ? phone : state.maskPhone(phone))
|
||
: '-',
|
||
style: const TextStyle(
|
||
fontFamily: 'SourceSerif4',
|
||
fontSize: 14,
|
||
color: AppColors.primaryDark),
|
||
maxLines: 1,
|
||
overflow: TextOverflow.ellipsis,
|
||
),
|
||
),
|
||
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,
|
||
),
|
||
),
|
||
),
|
||
],
|
||
),
|
||
],
|
||
),
|
||
);
|
||
}
|
||
|
||
// ── Name, verified badge, title, location, member since, bio ──
|
||
Widget _buildProfileInfo(AgentProfile agent) {
|
||
return Padding(
|
||
padding: const EdgeInsets.symmetric(horizontal: 24),
|
||
child: Column(
|
||
crossAxisAlignment: CrossAxisAlignment.center,
|
||
children: [
|
||
// Name + Verified badge + (Verified Expert) + edit icon
|
||
Row(
|
||
mainAxisAlignment: MainAxisAlignment.center,
|
||
crossAxisAlignment: CrossAxisAlignment.center,
|
||
children: [
|
||
Flexible(
|
||
child: Text.rich(
|
||
TextSpan(
|
||
children: [
|
||
TextSpan(
|
||
text: '${agent.firstName} ${agent.lastName}',
|
||
style: const TextStyle(
|
||
fontFamily: 'Fractul',
|
||
fontSize: 18,
|
||
fontWeight: FontWeight.w700,
|
||
color: AppColors.primaryDark,
|
||
),
|
||
),
|
||
],
|
||
),
|
||
overflow: TextOverflow.ellipsis,
|
||
),
|
||
),
|
||
if (agent.isVerified) ...[
|
||
const Padding(
|
||
padding: EdgeInsets.only(left: 6, right: 4),
|
||
child: Icon(Icons.verified,
|
||
size: 16, color: Color(0xFF2196F3)),
|
||
),
|
||
const Text(
|
||
'(Verified Expert)',
|
||
style: TextStyle(
|
||
fontFamily: 'SourceSerif4',
|
||
fontSize: 14,
|
||
fontWeight: FontWeight.w500,
|
||
color: Color(0xFF638559),
|
||
),
|
||
),
|
||
],
|
||
const SizedBox(width: 8),
|
||
GestureDetector(
|
||
behavior: HitTestBehavior.opaque,
|
||
onTap: () => context.push('/agent/edit-profile'),
|
||
child: Padding(
|
||
padding: const EdgeInsets.all(4),
|
||
child: SvgPicture.asset(
|
||
'assets/icons/edit_icon.svg',
|
||
width: 20,
|
||
height: 20,
|
||
),
|
||
),
|
||
),
|
||
],
|
||
),
|
||
// 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),
|
||
// Location + Member Since
|
||
Row(
|
||
mainAxisAlignment: MainAxisAlignment.center,
|
||
children: [
|
||
if (agent.location.isNotEmpty) ...[
|
||
SvgPicture.asset(
|
||
'assets/icons/location_filled_icon.svg',
|
||
width: 16,
|
||
height: 16,
|
||
),
|
||
const SizedBox(width: 4),
|
||
Text(
|
||
agent.location,
|
||
style: const TextStyle(
|
||
fontFamily: 'SourceSerif4',
|
||
fontSize: 14,
|
||
fontWeight: FontWeight.w700,
|
||
color: AppColors.primaryDark,
|
||
),
|
||
),
|
||
const SizedBox(width: 20),
|
||
],
|
||
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',
|
||
fontSize: 13,
|
||
fontWeight: FontWeight.w500,
|
||
color: AppColors.primaryDark,
|
||
),
|
||
),
|
||
],
|
||
),
|
||
// 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,
|
||
),
|
||
),
|
||
],
|
||
],
|
||
),
|
||
);
|
||
}
|
||
|
||
// ── Experience Section ──
|
||
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(
|
||
'Years in Experience', state.yearsInExperience),
|
||
const Divider(height: 24, thickness: 0.2),
|
||
_buildExperienceRow(
|
||
'Number of contract closed', state.contractsClosed),
|
||
const Divider(height: 24, thickness: 0.2),
|
||
if (state.licensingAreas.isNotEmpty) ...[
|
||
const Align(
|
||
alignment: Alignment.centerLeft,
|
||
child: Text('Licensing & Areas',
|
||
style: TextStyle(
|
||
fontFamily: 'Fractul',
|
||
fontSize: 14,
|
||
fontWeight: FontWeight.w700,
|
||
color: AppColors.primaryDark)),
|
||
),
|
||
const SizedBox(height: 8),
|
||
_buildTagsWrap(state.licensingAreas, sectionKey: 'licensing'),
|
||
const SizedBox(height: 16),
|
||
],
|
||
if (state.expertiseYears.isNotEmpty) ...[
|
||
const Align(
|
||
alignment: Alignment.centerLeft,
|
||
child: Text('Areas in expertise & Years',
|
||
style: TextStyle(
|
||
fontFamily: 'Fractul',
|
||
fontSize: 14,
|
||
fontWeight: FontWeight.w700,
|
||
color: AppColors.primaryDark)),
|
||
),
|
||
const SizedBox(height: 8),
|
||
_buildTagsWrap(state.expertiseYears
|
||
.map((e) => e['years']!.isNotEmpty
|
||
? '${e['area']} – ${e['years']}'
|
||
: e['area']!)
|
||
.toList(), sectionKey: 'expertise'),
|
||
],
|
||
if (state.certifications.isNotEmpty) ...[
|
||
const SizedBox(height: 16),
|
||
const Align(
|
||
alignment: Alignment.centerLeft,
|
||
child: Text('Certifications',
|
||
style: TextStyle(
|
||
fontFamily: 'Fractul',
|
||
fontSize: 14,
|
||
fontWeight: FontWeight.w700,
|
||
color: AppColors.primaryDark)),
|
||
),
|
||
const SizedBox(height: 8),
|
||
...state.certifications.map((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)),
|
||
),
|
||
),
|
||
],
|
||
),
|
||
)),
|
||
],
|
||
],
|
||
),
|
||
);
|
||
}
|
||
|
||
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)),
|
||
],
|
||
),
|
||
),
|
||
],
|
||
);
|
||
}
|
||
|
||
Widget _buildTagsWrap(List<String> tags, {required String sectionKey}) {
|
||
final isExpanded = _expandedTagSections.contains(sectionKey);
|
||
final displayTags = isExpanded ? tags : tags.take(6).toList();
|
||
final remaining = tags.length - 6;
|
||
|
||
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)
|
||
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)),
|
||
),
|
||
),
|
||
],
|
||
);
|
||
}
|
||
|
||
// ── Info Cards (Availability, Work Env, Testimonial) — matches web ──
|
||
Widget _buildInfoCards(AgentDetailState state) {
|
||
// Work environment — try fieldValues first, fallback to mock (same as web)
|
||
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(', ');
|
||
}
|
||
}
|
||
}
|
||
// 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)}...';
|
||
}
|
||
}
|
||
|
||
return Padding(
|
||
padding: const EdgeInsets.symmetric(horizontal: 36),
|
||
child: Column(
|
||
children: [
|
||
// Availability card (with title — matches web)
|
||
_buildInfoCard(
|
||
icon: Icons.access_time_filled,
|
||
title: 'Availability',
|
||
subtitle: state.availabilityType.isNotEmpty
|
||
? state.availabilityType
|
||
: 'Not specified',
|
||
items: state.availabilitySchedule.isNotEmpty
|
||
? state.availabilitySchedule
|
||
: null,
|
||
isEmpty: state.availabilityType.isEmpty,
|
||
),
|
||
const SizedBox(height: 16),
|
||
// Work environment (no title — matches web)
|
||
_buildInfoCard(
|
||
icon: Icons.landscape_outlined,
|
||
title: '',
|
||
subtitle: '',
|
||
description: workEnv,
|
||
),
|
||
const SizedBox(height: 16),
|
||
// Testimonial preview (no title — matches web)
|
||
_buildInfoCard(
|
||
icon: Icons.star_rounded,
|
||
title: '',
|
||
subtitle: '',
|
||
description: testimonialText.isNotEmpty
|
||
? '\u201C$testimonialText\u201D'
|
||
: 'No testimonials yet',
|
||
isEmpty: testimonialText.isEmpty,
|
||
),
|
||
],
|
||
),
|
||
);
|
||
}
|
||
|
||
Widget _buildInfoCard({
|
||
required IconData icon,
|
||
required String title,
|
||
required String subtitle,
|
||
List<String>? items,
|
||
String? description,
|
||
bool isEmpty = false,
|
||
}) {
|
||
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,
|
||
style: TextStyle(
|
||
fontFamily: 'SourceSerif4',
|
||
fontSize: 14,
|
||
fontWeight: FontWeight.w600,
|
||
color: isEmpty
|
||
? AppColors.primaryDark.withValues(alpha: 0.4)
|
||
: AppColors.primaryDark,
|
||
),
|
||
),
|
||
],
|
||
if (items != null && items.isNotEmpty) ...[
|
||
const SizedBox(height: 8),
|
||
...items.map(
|
||
(item) => Padding(
|
||
padding: const EdgeInsets.symmetric(vertical: 1),
|
||
child: Text(
|
||
item,
|
||
style: const TextStyle(
|
||
fontFamily: 'SourceSerif4',
|
||
fontSize: 14,
|
||
color: AppColors.primaryDark,
|
||
),
|
||
),
|
||
),
|
||
),
|
||
],
|
||
if (description != null) ...[
|
||
const SizedBox(height: 8),
|
||
Text(
|
||
description,
|
||
style: TextStyle(
|
||
fontFamily: 'SourceSerif4',
|
||
fontSize: 14,
|
||
fontWeight: FontWeight.w600,
|
||
color: isEmpty
|
||
? AppColors.primaryDark.withValues(alpha: 0.4)
|
||
: AppColors.primaryDark,
|
||
),
|
||
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);
|
||
final sectionKey = 'spec_${card.slug}';
|
||
final isExpanded = _expandedTagSections.contains(sectionKey);
|
||
final displayValues = isExpanded ? card.values : card.values.take(3).toList();
|
||
final hasMore = card.values.length > 3;
|
||
|
||
return Container(
|
||
margin: const EdgeInsets.symmetric(horizontal: 66, vertical: 8),
|
||
width: 298,
|
||
height: isExpanded ? null : 236,
|
||
padding: const EdgeInsets.symmetric(vertical: 16, horizontal: 20),
|
||
clipBehavior: Clip.antiAlias,
|
||
decoration: BoxDecoration(
|
||
color: Colors.white,
|
||
borderRadius: BorderRadius.circular(15),
|
||
border: Border.all(color: AppColors.primaryDark, width: 0.7),
|
||
),
|
||
child: Column(
|
||
mainAxisSize: isExpanded ? MainAxisSize.min : MainAxisSize.max,
|
||
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),
|
||
...displayValues.map((val) => Padding(
|
||
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),
|
||
),
|
||
)),
|
||
if (hasMore) ...[
|
||
const SizedBox(height: 8),
|
||
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),
|
||
],
|
||
),
|
||
),
|
||
),
|
||
],
|
||
],
|
||
),
|
||
);
|
||
}
|
||
|
||
// ── Testimonials ──
|
||
Widget _buildTestimonialsSection(AgentDetailState state) {
|
||
if (state.testimonials.isEmpty) return const SizedBox.shrink();
|
||
|
||
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(
|
||
color: AppColors.primaryDark.withValues(alpha: 0.15),
|
||
height: 1,
|
||
),
|
||
const SizedBox(height: 8),
|
||
const Text(
|
||
'Clients rate our real estate services 4.9 out of 5 on average, based on recent client reviews.',
|
||
style: TextStyle(
|
||
fontFamily: 'SourceSerif4',
|
||
fontSize: 14,
|
||
color: AppColors.primaryDark,
|
||
),
|
||
textAlign: TextAlign.center,
|
||
),
|
||
const SizedBox(height: 16),
|
||
SizedBox(
|
||
height: 380,
|
||
child: ListView.separated(
|
||
scrollDirection: Axis.horizontal,
|
||
itemCount: state.testimonials.length,
|
||
separatorBuilder: (_, __) => const SizedBox(width: 12),
|
||
itemBuilder: (_, i) =>
|
||
_TestimonialCard(data: state.testimonials[i]),
|
||
),
|
||
),
|
||
],
|
||
),
|
||
);
|
||
}
|
||
|
||
}
|
||
|
||
// ── Testimonial Card (matches Figma & web TestimonialCard) ──
|
||
|
||
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;
|
||
|
||
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,
|
||
),
|
||
),
|
||
child: Column(
|
||
crossAxisAlignment: CrossAxisAlignment.start,
|
||
children: [
|
||
// 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
|
||
Expanded(
|
||
child: Text(
|
||
text,
|
||
style: const TextStyle(
|
||
fontFamily: 'SourceSerif4',
|
||
fontSize: 14,
|
||
fontWeight: FontWeight.w400,
|
||
color: AppColors.primaryDark,
|
||
height: 1.4,
|
||
),
|
||
overflow: TextOverflow.fade,
|
||
),
|
||
),
|
||
const SizedBox(height: 12),
|
||
// Divider
|
||
Divider(
|
||
color: AppColors.primaryDark.withValues(alpha: 0.08),
|
||
height: 1,
|
||
),
|
||
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),
|
||
),
|
||
),
|
||
),
|
||
),
|
||
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,
|
||
),
|
||
),
|
||
],
|
||
],
|
||
),
|
||
);
|
||
}
|
||
}
|