feat: Refactor agent info cards to include testimonial previews and redesign the testimonial card to match web and Figma specifications.
This commit is contained in:
@@ -884,11 +884,10 @@ class _AgentHomeContentState extends ConsumerState<_AgentHomeContent> {
|
||||
);
|
||||
}
|
||||
|
||||
// ── Info Cards (Availability, Work Environment, Experience) ──
|
||||
// ── Info Cards (Availability, Work Env, Testimonial) — matches web ──
|
||||
Widget _buildInfoCards(AgentDetailState state) {
|
||||
// Extract work environment and best experience from fieldValues
|
||||
// Work environment — try fieldValues first, fallback to mock (same as web)
|
||||
String workEnv = '';
|
||||
String bestExp = '';
|
||||
for (final fv in state.fieldValues) {
|
||||
final slug = fv.fieldSlug.toLowerCase();
|
||||
if (slug.contains('work_environment') ||
|
||||
@@ -899,9 +898,24 @@ class _AgentHomeContentState extends ConsumerState<_AgentHomeContent> {
|
||||
(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!;
|
||||
}
|
||||
// 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)}...';
|
||||
}
|
||||
}
|
||||
|
||||
@@ -909,28 +923,37 @@ class _AgentHomeContentState extends ConsumerState<_AgentHomeContent> {
|
||||
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,
|
||||
),
|
||||
],
|
||||
// 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,
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
@@ -938,10 +961,11 @@ class _AgentHomeContentState extends ConsumerState<_AgentHomeContent> {
|
||||
|
||||
Widget _buildInfoCard({
|
||||
required IconData icon,
|
||||
String title = '',
|
||||
String subtitle = '',
|
||||
required String title,
|
||||
required String subtitle,
|
||||
List<String>? items,
|
||||
String? description,
|
||||
bool isEmpty = false,
|
||||
}) {
|
||||
return Container(
|
||||
width: double.infinity,
|
||||
@@ -978,37 +1002,43 @@ class _AgentHomeContentState extends ConsumerState<_AgentHomeContent> {
|
||||
const SizedBox(height: 2),
|
||||
Text(
|
||||
subtitle,
|
||||
style: const TextStyle(
|
||||
style: TextStyle(
|
||||
fontFamily: 'SourceSerif4',
|
||||
fontSize: 14,
|
||||
fontWeight: FontWeight.w600,
|
||||
color: AppColors.primaryDark,
|
||||
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,
|
||||
),
|
||||
...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) ...[
|
||||
if (description != null) ...[
|
||||
const SizedBox(height: 8),
|
||||
Text(
|
||||
description,
|
||||
style: const TextStyle(
|
||||
style: TextStyle(
|
||||
fontFamily: 'SourceSerif4',
|
||||
fontSize: 14,
|
||||
fontWeight: FontWeight.w600,
|
||||
color: AppColors.primaryDark,
|
||||
color: isEmpty
|
||||
? AppColors.primaryDark.withValues(alpha: 0.4)
|
||||
: AppColors.primaryDark,
|
||||
),
|
||||
textAlign: TextAlign.center,
|
||||
),
|
||||
@@ -1137,7 +1167,7 @@ class _AgentHomeContentState extends ConsumerState<_AgentHomeContent> {
|
||||
);
|
||||
}
|
||||
|
||||
// ── Testimonials Section ──
|
||||
// ── Testimonials ──
|
||||
Widget _buildTestimonialsSection(AgentDetailState state) {
|
||||
if (state.testimonials.isEmpty) return const SizedBox.shrink();
|
||||
|
||||
@@ -1156,8 +1186,9 @@ class _AgentHomeContentState extends ConsumerState<_AgentHomeContent> {
|
||||
),
|
||||
const SizedBox(height: 8),
|
||||
Divider(
|
||||
color: AppColors.primaryDark.withValues(alpha: 0.15),
|
||||
height: 1),
|
||||
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.',
|
||||
@@ -1170,7 +1201,7 @@ class _AgentHomeContentState extends ConsumerState<_AgentHomeContent> {
|
||||
),
|
||||
const SizedBox(height: 16),
|
||||
SizedBox(
|
||||
height: 320,
|
||||
height: 380,
|
||||
child: ListView.separated(
|
||||
scrollDirection: Axis.horizontal,
|
||||
itemCount: state.testimonials.length,
|
||||
@@ -1186,7 +1217,8 @@ class _AgentHomeContentState extends ConsumerState<_AgentHomeContent> {
|
||||
|
||||
}
|
||||
|
||||
// ── Testimonial Card (same as agent detail screen) ──
|
||||
// ── Testimonial Card (matches Figma & web TestimonialCard) ──
|
||||
|
||||
class _TestimonialCard extends StatelessWidget {
|
||||
final Map<String, dynamic> data;
|
||||
|
||||
@@ -1199,163 +1231,104 @@ class _TestimonialCard extends StatelessWidget {
|
||||
final authorRole = data['authorRole'] as String? ?? '';
|
||||
final rating = (data['rating'] as num?)?.toInt() ?? 5;
|
||||
|
||||
return SizedBox(
|
||||
width: 320,
|
||||
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: [
|
||||
// Main card body with speech bubble shape
|
||||
// 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: 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: Text(
|
||||
text,
|
||||
style: const TextStyle(
|
||||
fontFamily: 'SourceSerif4',
|
||||
fontSize: 14,
|
||||
fontWeight: FontWeight.w400,
|
||||
color: AppColors.primaryDark,
|
||||
height: 1.4,
|
||||
),
|
||||
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,
|
||||
),
|
||||
),
|
||||
],
|
||||
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),
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
// 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: 8),
|
||||
// Author name
|
||||
Text(
|
||||
authorName,
|
||||
style: const TextStyle(
|
||||
fontFamily: 'Fractul',
|
||||
fontSize: 14,
|
||||
fontWeight: FontWeight.w400,
|
||||
color: AppColors.primaryDark,
|
||||
),
|
||||
),
|
||||
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,
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
],
|
||||
// Author role
|
||||
if (authorRole.isNotEmpty) ...[
|
||||
const SizedBox(height: 2),
|
||||
Text(
|
||||
authorRole,
|
||||
style: const TextStyle(
|
||||
fontFamily: 'Fractul',
|
||||
fontSize: 14,
|
||||
fontWeight: FontWeight.w500,
|
||||
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;
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user