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';
|
|
|
|
|
|
|
|
|
|
|
|
/// 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;
|
|
|
|
|
|
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);
|
|
|
|
|
|
try {
|
|
|
|
|
|
final repo = ProfileRepository();
|
|
|
|
|
|
await repo.updateProfile('AGENT', {'isAvailable': newValue});
|
|
|
|
|
|
// Refresh agent detail to reflect new status
|
|
|
|
|
|
ref.invalidate(agentDetailProvider(widget.agentProfileId));
|
|
|
|
|
|
} catch (e) {
|
|
|
|
|
|
if (mounted) {
|
|
|
|
|
|
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!);
|
|
|
|
|
|
}
|
|
|
|
|
|
return _buildContent(state);
|
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)),
|
|
|
|
|
|
),
|
|
|
|
|
|
],
|
|
|
|
|
|
),
|
|
|
|
|
|
);
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
Widget _buildContent(AgentDetailState state) {
|
|
|
|
|
|
final agent = state.agent!;
|
|
|
|
|
|
return SingleChildScrollView(
|
|
|
|
|
|
child: Column(
|
|
|
|
|
|
children: [
|
|
|
|
|
|
_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,
|
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(),
|
|
|
|
|
|
if (_isTogglingAvailability && !isActive)
|
|
|
|
|
|
const SizedBox(
|
|
|
|
|
|
width: 16,
|
|
|
|
|
|
height: 16,
|
|
|
|
|
|
child: CircularProgressIndicator(
|
|
|
|
|
|
strokeWidth: 2,
|
|
|
|
|
|
color: AppColors.accentOrange,
|
|
|
|
|
|
),
|
|
|
|
|
|
)
|
|
|
|
|
|
else if (isActive)
|
|
|
|
|
|
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(
|
|
|
|
|
|
email.isNotEmpty ? state.maskEmail(email) : '-',
|
|
|
|
|
|
style: const TextStyle(
|
|
|
|
|
|
fontFamily: 'SourceSerif4',
|
|
|
|
|
|
fontSize: 14,
|
|
|
|
|
|
color: AppColors.primaryDark),
|
|
|
|
|
|
),
|
|
|
|
|
|
),
|
|
|
|
|
|
const Icon(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 ? state.maskPhone(phone) : '-',
|
|
|
|
|
|
style: const TextStyle(
|
|
|
|
|
|
fontFamily: 'SourceSerif4',
|
|
|
|
|
|
fontSize: 14,
|
|
|
|
|
|
color: AppColors.primaryDark),
|
|
|
|
|
|
),
|
|
|
|
|
|
),
|
|
|
|
|
|
const Icon(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: 38),
|
|
|
|
|
|
child: Stack(
|
|
|
|
|
|
children: [
|
|
|
|
|
|
Column(
|
|
|
|
|
|
crossAxisAlignment: CrossAxisAlignment.center,
|
|
|
|
|
|
children: [
|
|
|
|
|
|
// Name + Verified badge + (Verified Expert) on same line
|
|
|
|
|
|
Row(
|
|
|
|
|
|
mainAxisAlignment: MainAxisAlignment.center,
|
|
|
|
|
|
crossAxisAlignment: CrossAxisAlignment.baseline,
|
|
|
|
|
|
textBaseline: TextBaseline.alphabetic,
|
|
|
|
|
|
children: [
|
|
|
|
|
|
Text(
|
|
|
|
|
|
agent.firstName,
|
|
|
|
|
|
style: const TextStyle(
|
|
|
|
|
|
fontFamily: 'Fractul',
|
|
|
|
|
|
fontSize: 18,
|
|
|
|
|
|
fontWeight: FontWeight.w700,
|
|
|
|
|
|
color: AppColors.primaryDark,
|
|
|
|
|
|
),
|
|
|
|
|
|
),
|
|
|
|
|
|
const SizedBox(width: 4),
|
|
|
|
|
|
if (agent.isVerified) ...[
|
|
|
|
|
|
const Padding(
|
|
|
|
|
|
padding: EdgeInsets.only(bottom: 1),
|
|
|
|
|
|
child: Icon(Icons.verified,
|
|
|
|
|
|
size: 16, color: Color(0xFF2196F3)),
|
|
|
|
|
|
),
|
|
|
|
|
|
const SizedBox(width: 4),
|
|
|
|
|
|
],
|
|
|
|
|
|
Text(
|
|
|
|
|
|
agent.lastName,
|
|
|
|
|
|
style: const TextStyle(
|
|
|
|
|
|
fontFamily: 'Fractul',
|
|
|
|
|
|
fontSize: 18,
|
|
|
|
|
|
fontWeight: FontWeight.w400,
|
|
|
|
|
|
color: AppColors.primaryDark,
|
|
|
|
|
|
),
|
|
|
|
|
|
),
|
|
|
|
|
|
if (agent.isVerified) ...[
|
|
|
|
|
|
const SizedBox(width: 6),
|
|
|
|
|
|
const Text(
|
|
|
|
|
|
'(Verified Expert)',
|
|
|
|
|
|
style: TextStyle(
|
|
|
|
|
|
fontFamily: 'SourceSerif4',
|
|
|
|
|
|
fontSize: 14,
|
|
|
|
|
|
fontWeight: FontWeight.w500,
|
|
|
|
|
|
color: Color(0xFF638559),
|
|
|
|
|
|
),
|
|
|
|
|
|
),
|
|
|
|
|
|
],
|
|
|
|
|
|
],
|
|
|
|
|
|
),
|
|
|
|
|
|
// 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) ...[
|
|
|
|
|
|
Icon(Icons.location_on,
|
|
|
|
|
|
size: 16,
|
|
|
|
|
|
color:
|
|
|
|
|
|
AppColors.primaryDark.withValues(alpha: 0.7)),
|
|
|
|
|
|
const SizedBox(width: 4),
|
|
|
|
|
|
Text(
|
|
|
|
|
|
agent.location,
|
|
|
|
|
|
style: const TextStyle(
|
|
|
|
|
|
fontFamily: 'SourceSerif4',
|
|
|
|
|
|
fontSize: 14,
|
|
|
|
|
|
fontWeight: FontWeight.w700,
|
|
|
|
|
|
color: AppColors.primaryDark,
|
|
|
|
|
|
),
|
|
|
|
|
|
),
|
|
|
|
|
|
const SizedBox(width: 20),
|
|
|
|
|
|
],
|
|
|
|
|
|
Icon(Icons.calendar_today,
|
|
|
|
|
|
size: 14,
|
|
|
|
|
|
color:
|
|
|
|
|
|
AppColors.primaryDark.withValues(alpha: 0.7)),
|
|
|
|
|
|
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,
|
|
|
|
|
|
),
|
|
|
|
|
|
),
|
|
|
|
|
|
],
|
|
|
|
|
|
],
|
|
|
|
|
|
),
|
|
|
|
|
|
// Edit icon positioned at top-right
|
|
|
|
|
|
Positioned(
|
|
|
|
|
|
right: 0,
|
|
|
|
|
|
top: 0,
|
|
|
|
|
|
child: GestureDetector(
|
|
|
|
|
|
onTap: () {},
|
|
|
|
|
|
child: SvgPicture.asset(
|
|
|
|
|
|
'assets/icons/edit_icon.svg',
|
|
|
|
|
|
width: 22,
|
|
|
|
|
|
height: 22,
|
|
|
|
|
|
),
|
|
|
|
|
|
),
|
|
|
|
|
|
),
|
|
|
|
|
|
],
|
|
|
|
|
|
),
|
|
|
|
|
|
);
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
// ── 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),
|
|
|
|
|
|
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()),
|
|
|
|
|
|
],
|
|
|
|
|
|
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) {
|
|
|
|
|
|
final displayTags = 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)
|
|
|
|
|
|
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('+$remaining More',
|
|
|
|
|
|
style: const TextStyle(
|
|
|
|
|
|
fontFamily: 'SourceSerif4',
|
|
|
|
|
|
fontSize: 15,
|
|
|
|
|
|
fontWeight: FontWeight.w300,
|
|
|
|
|
|
color: AppColors.primaryDark)),
|
|
|
|
|
|
),
|
|
|
|
|
|
],
|
|
|
|
|
|
);
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
// ── Info Cards (Availability, Work Environment, Experience) ──
|
|
|
|
|
|
Widget _buildInfoCards(AgentDetailState state) {
|
|
|
|
|
|
// Extract work environment and best experience from fieldValues
|
|
|
|
|
|
String workEnv = '';
|
|
|
|
|
|
String bestExp = '';
|
|
|
|
|
|
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(', ');
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
if (slug.contains('best_experience') ||
|
|
|
|
|
|
slug.contains('amazing_experience')) {
|
|
|
|
|
|
if (fv.textValue != null) bestExp = fv.textValue!;
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
return Padding(
|
|
|
|
|
|
padding: const EdgeInsets.symmetric(horizontal: 36),
|
|
|
|
|
|
child: Column(
|
|
|
|
|
|
children: [
|
|
|
|
|
|
// Availability card
|
|
|
|
|
|
if (state.availabilityType.isNotEmpty)
|
|
|
|
|
|
_buildInfoCard(
|
|
|
|
|
|
icon: Icons.access_time_filled,
|
|
|
|
|
|
title: 'Availability',
|
|
|
|
|
|
subtitle: state.availabilityType,
|
|
|
|
|
|
items: state.availabilitySchedule,
|
|
|
|
|
|
),
|
|
|
|
|
|
if (workEnv.isNotEmpty) ...[
|
|
|
|
|
|
const SizedBox(height: 16),
|
|
|
|
|
|
_buildInfoCard(
|
|
|
|
|
|
icon: Icons.landscape_outlined,
|
|
|
|
|
|
description: workEnv,
|
|
|
|
|
|
),
|
|
|
|
|
|
],
|
|
|
|
|
|
if (bestExp.isNotEmpty) ...[
|
|
|
|
|
|
const SizedBox(height: 16),
|
|
|
|
|
|
_buildInfoCard(
|
|
|
|
|
|
icon: Icons.star_rounded,
|
|
|
|
|
|
description: bestExp,
|
|
|
|
|
|
),
|
|
|
|
|
|
],
|
|
|
|
|
|
],
|
|
|
|
|
|
),
|
|
|
|
|
|
);
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
Widget _buildInfoCard({
|
|
|
|
|
|
required IconData icon,
|
|
|
|
|
|
String title = '',
|
|
|
|
|
|
String subtitle = '',
|
|
|
|
|
|
List<String>? items,
|
|
|
|
|
|
String? description,
|
|
|
|
|
|
}) {
|
|
|
|
|
|
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: const TextStyle(
|
|
|
|
|
|
fontFamily: 'SourceSerif4',
|
|
|
|
|
|
fontSize: 14,
|
|
|
|
|
|
fontWeight: FontWeight.w600,
|
|
|
|
|
|
color: 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 && description.isNotEmpty) ...[
|
|
|
|
|
|
const SizedBox(height: 8),
|
|
|
|
|
|
Text(
|
|
|
|
|
|
description,
|
|
|
|
|
|
style: const TextStyle(
|
|
|
|
|
|
fontFamily: 'SourceSerif4',
|
|
|
|
|
|
fontSize: 14,
|
|
|
|
|
|
fontWeight: FontWeight.w600,
|
|
|
|
|
|
color: 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);
|
|
|
|
|
|
return Container(
|
|
|
|
|
|
margin: const EdgeInsets.symmetric(horizontal: 66, vertical: 8),
|
|
|
|
|
|
width: 298,
|
|
|
|
|
|
constraints: const BoxConstraints(minHeight: 236),
|
|
|
|
|
|
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(
|
|
|
|
|
|
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),
|
|
|
|
|
|
...card.values.take(5).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 (card.values.length > 5) ...[
|
|
|
|
|
|
const SizedBox(height: 8),
|
|
|
|
|
|
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: [
|
|
|
|
|
|
const Text('Show More',
|
|
|
|
|
|
style: TextStyle(
|
|
|
|
|
|
fontFamily: 'SourceSerif4',
|
|
|
|
|
|
fontSize: 10,
|
|
|
|
|
|
fontWeight: FontWeight.w700,
|
|
|
|
|
|
color: AppColors.accentOrange)),
|
|
|
|
|
|
const SizedBox(width: 4),
|
|
|
|
|
|
Icon(Icons.keyboard_arrow_down,
|
|
|
|
|
|
size: 14, color: AppColors.accentOrange),
|
|
|
|
|
|
],
|
|
|
|
|
|
),
|
|
|
|
|
|
),
|
|
|
|
|
|
],
|
|
|
|
|
|
],
|
|
|
|
|
|
),
|
|
|
|
|
|
);
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
// ── Testimonials Section ──
|
|
|
|
|
|
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: 320,
|
|
|
|
|
|
child: ListView.separated(
|
|
|
|
|
|
scrollDirection: Axis.horizontal,
|
|
|
|
|
|
itemCount: state.testimonials.length,
|
|
|
|
|
|
separatorBuilder: (_, __) => const SizedBox(width: 12),
|
|
|
|
|
|
itemBuilder: (_, i) =>
|
|
|
|
|
|
_TestimonialCard(data: state.testimonials[i]),
|
|
|
|
|
|
),
|
|
|
|
|
|
),
|
|
|
|
|
|
],
|
|
|
|
|
|
),
|
|
|
|
|
|
);
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
// ── Testimonial Card (same as agent detail screen) ──
|
|
|
|
|
|
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 SizedBox(
|
|
|
|
|
|
width: 320,
|
|
|
|
|
|
child: Column(
|
|
|
|
|
|
crossAxisAlignment: CrossAxisAlignment.start,
|
|
|
|
|
|
children: [
|
|
|
|
|
|
// Main card body with speech bubble shape
|
|
|
|
|
|
Expanded(
|
|
|
|
|
|
child: Container(
|
|
|
|
|
|
width: double.infinity,
|
|
|
|
|
|
padding: const EdgeInsets.fromLTRB(20, 16, 20, 20),
|
|
|
|
|
|
decoration: BoxDecoration(
|
|
|
|
|
|
color: Colors.white,
|
|
|
|
|
|
borderRadius: BorderRadius.circular(15),
|
|
|
|
|
|
boxShadow: [
|
|
|
|
|
|
BoxShadow(
|
|
|
|
|
|
color: const Color(0xFFD9D9D9).withValues(alpha: 0.5),
|
|
|
|
|
|
offset: const Offset(0, 10),
|
|
|
|
|
|
blurRadius: 20,
|
|
|
|
|
|
),
|
|
|
|
|
|
],
|
|
|
|
|
|
),
|
|
|
|
|
|
child: Column(
|
|
|
|
|
|
crossAxisAlignment: CrossAxisAlignment.start,
|
|
|
|
|
|
children: [
|
|
|
|
|
|
// Quote icon
|
|
|
|
|
|
const Text(
|
|
|
|
|
|
'\u201C',
|
|
|
|
|
|
style: TextStyle(
|
|
|
|
|
|
fontFamily: 'Fractul',
|
|
|
|
|
|
fontSize: 36,
|
|
|
|
|
|
fontWeight: FontWeight.w700,
|
|
|
|
|
|
color: AppColors.primaryDark,
|
|
|
|
|
|
height: 0.8,
|
|
|
|
|
|
),
|
|
|
|
|
|
),
|
|
|
|
|
|
const SizedBox(height: 6),
|
|
|
|
|
|
// Title snippet
|
|
|
|
|
|
Text(
|
|
|
|
|
|
text.length > 35
|
|
|
|
|
|
? '${text.substring(0, 35)} ..'
|
|
|
|
|
|
: text,
|
|
|
|
|
|
style: const TextStyle(
|
|
|
|
|
|
fontFamily: 'Fractul',
|
|
|
|
|
|
fontSize: 14,
|
|
|
|
|
|
fontWeight: FontWeight.w700,
|
|
|
|
|
|
color: AppColors.primaryDark,
|
|
|
|
|
|
),
|
|
|
|
|
|
maxLines: 1,
|
|
|
|
|
|
overflow: TextOverflow.ellipsis,
|
|
|
|
|
|
),
|
|
|
|
|
|
const SizedBox(height: 10),
|
|
|
|
|
|
// Full text
|
|
|
|
|
|
Expanded(
|
|
|
|
|
|
child: Text(
|
|
|
|
|
|
text,
|
|
|
|
|
|
style: const TextStyle(
|
|
|
|
|
|
fontFamily: 'SourceSerif4',
|
|
|
|
|
|
fontSize: 14,
|
|
|
|
|
|
color: AppColors.primaryDark,
|
|
|
|
|
|
height: 1.5,
|
|
|
|
|
|
),
|
|
|
|
|
|
overflow: TextOverflow.fade,
|
|
|
|
|
|
),
|
|
|
|
|
|
),
|
|
|
|
|
|
],
|
|
|
|
|
|
),
|
|
|
|
|
|
),
|
|
|
|
|
|
),
|
|
|
|
|
|
// Speech bubble triangle
|
|
|
|
|
|
Padding(
|
|
|
|
|
|
padding: const EdgeInsets.only(left: 30),
|
|
|
|
|
|
child: CustomPaint(
|
|
|
|
|
|
size: const Size(16, 10),
|
|
|
|
|
|
painter: _TrianglePainter(color: Colors.white),
|
|
|
|
|
|
),
|
|
|
|
|
|
),
|
|
|
|
|
|
const SizedBox(height: 6),
|
|
|
|
|
|
// Stars + Author info
|
|
|
|
|
|
Padding(
|
|
|
|
|
|
padding: const EdgeInsets.only(left: 4),
|
|
|
|
|
|
child: Row(
|
|
|
|
|
|
children: [
|
|
|
|
|
|
// Stars
|
|
|
|
|
|
Row(
|
|
|
|
|
|
mainAxisSize: MainAxisSize.min,
|
|
|
|
|
|
children: List.generate(
|
|
|
|
|
|
5,
|
|
|
|
|
|
(i) => Icon(
|
|
|
|
|
|
i < rating ? Icons.star : Icons.star_border,
|
|
|
|
|
|
size: 16,
|
|
|
|
|
|
color: i < rating
|
|
|
|
|
|
? AppColors.accentOrange
|
|
|
|
|
|
: AppColors.primaryDark.withValues(alpha: 0.3),
|
|
|
|
|
|
),
|
|
|
|
|
|
),
|
|
|
|
|
|
),
|
|
|
|
|
|
const SizedBox(width: 10),
|
|
|
|
|
|
// Author
|
|
|
|
|
|
Expanded(
|
|
|
|
|
|
child: Column(
|
|
|
|
|
|
crossAxisAlignment: CrossAxisAlignment.start,
|
|
|
|
|
|
children: [
|
|
|
|
|
|
Text(
|
|
|
|
|
|
authorName,
|
|
|
|
|
|
style: const TextStyle(
|
|
|
|
|
|
fontFamily: 'SourceSerif4',
|
|
|
|
|
|
fontSize: 14,
|
|
|
|
|
|
fontWeight: FontWeight.w500,
|
|
|
|
|
|
color: AppColors.primaryDark,
|
|
|
|
|
|
),
|
|
|
|
|
|
),
|
|
|
|
|
|
if (authorRole.isNotEmpty)
|
|
|
|
|
|
Text(
|
|
|
|
|
|
authorRole,
|
|
|
|
|
|
style: const TextStyle(
|
|
|
|
|
|
fontFamily: 'SourceSerif4',
|
|
|
|
|
|
fontSize: 14,
|
|
|
|
|
|
fontWeight: FontWeight.w600,
|
|
|
|
|
|
color: AppColors.primaryDark,
|
|
|
|
|
|
),
|
|
|
|
|
|
),
|
|
|
|
|
|
],
|
|
|
|
|
|
),
|
|
|
|
|
|
),
|
|
|
|
|
|
],
|
|
|
|
|
|
),
|
|
|
|
|
|
),
|
|
|
|
|
|
],
|
|
|
|
|
|
),
|
|
|
|
|
|
);
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
// ── Triangle painter for speech bubble ──
|
|
|
|
|
|
class _TrianglePainter extends CustomPainter {
|
|
|
|
|
|
final Color color;
|
|
|
|
|
|
_TrianglePainter({required this.color});
|
|
|
|
|
|
|
|
|
|
|
|
@override
|
|
|
|
|
|
void paint(Canvas canvas, Size size) {
|
|
|
|
|
|
final paint = Paint()
|
|
|
|
|
|
..color = color
|
|
|
|
|
|
..style = PaintingStyle.fill;
|
|
|
|
|
|
final shadowPaint = Paint()
|
|
|
|
|
|
..color = const Color(0xFFD9D9D9).withValues(alpha: 0.16)
|
|
|
|
|
|
..maskFilter = const MaskFilter.blur(BlurStyle.normal, 4);
|
|
|
|
|
|
|
|
|
|
|
|
final path = Path()
|
|
|
|
|
|
..moveTo(0, 0)
|
|
|
|
|
|
..lineTo(size.width / 2, size.height)
|
|
|
|
|
|
..lineTo(size.width, 0)
|
|
|
|
|
|
..close();
|
|
|
|
|
|
|
|
|
|
|
|
canvas.drawPath(path, shadowPaint);
|
|
|
|
|
|
canvas.drawPath(path, paint);
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
@override
|
|
|
|
|
|
bool shouldRepaint(covariant CustomPainter oldDelegate) => false;
|
|
|
|
|
|
}
|