1512 lines
48 KiB
Dart
1512 lines
48 KiB
Dart
import 'package:flutter/material.dart';
|
||
import 'package:flutter_riverpod/flutter_riverpod.dart';
|
||
import 'package:flutter_svg/flutter_svg.dart';
|
||
import 'package:go_router/go_router.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/auth/presentation/providers/auth_provider.dart';
|
||
import 'package:real_estate_mobile/features/home/presentation/widgets/home_header.dart';
|
||
|
||
class AgentDetailScreen extends ConsumerStatefulWidget {
|
||
final String agentId;
|
||
|
||
const AgentDetailScreen({super.key, required this.agentId});
|
||
|
||
@override
|
||
ConsumerState<AgentDetailScreen> createState() => _AgentDetailScreenState();
|
||
}
|
||
|
||
class _AgentDetailScreenState extends ConsumerState<AgentDetailScreen> {
|
||
bool _emailVisible = false;
|
||
bool _phoneVisible = false;
|
||
|
||
@override
|
||
void initState() {
|
||
super.initState();
|
||
_loadConnectionIfAuth();
|
||
}
|
||
|
||
void _loadConnectionIfAuth() {
|
||
WidgetsBinding.instance.addPostFrameCallback((_) {
|
||
final authState = ref.read(authProvider);
|
||
if (authState.status == AuthStatus.authenticated) {
|
||
ref.read(agentDetailProvider(widget.agentId).notifier).loadConnectionStatus();
|
||
}
|
||
});
|
||
}
|
||
|
||
@override
|
||
Widget build(BuildContext context) {
|
||
final state = ref.watch(agentDetailProvider(widget.agentId));
|
||
|
||
return Scaffold(
|
||
backgroundColor: Colors.white,
|
||
body: state.isLoading
|
||
? const Center(
|
||
child: CircularProgressIndicator(color: AppColors.accentOrange))
|
||
: state.error != null
|
||
? _buildError(state.error!)
|
||
: _buildContent(state),
|
||
bottomNavigationBar: _buildBottomNavBar(),
|
||
);
|
||
}
|
||
|
||
Widget _buildError(String error) {
|
||
return Center(
|
||
child: Padding(
|
||
padding: const EdgeInsets.all(24),
|
||
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: () => context.pop(),
|
||
child: const Text('Go Back',
|
||
style: TextStyle(color: AppColors.accentOrange)),
|
||
),
|
||
],
|
||
),
|
||
),
|
||
);
|
||
}
|
||
|
||
Widget _buildContent(AgentDetailState state) {
|
||
final agent = state.agent!;
|
||
return SingleChildScrollView(
|
||
child: Column(
|
||
children: [
|
||
SafeArea(bottom: false, child: const HomeHeader()),
|
||
|
||
// ── Avatar Section ──
|
||
const SizedBox(height: 16),
|
||
_buildAvatarSection(agent),
|
||
|
||
// ── Status + Connect Buttons ──
|
||
const SizedBox(height: 16),
|
||
_buildStatusButtons(state),
|
||
|
||
// ── Contact Info ──
|
||
const SizedBox(height: 12),
|
||
_buildContactInfo(state),
|
||
|
||
// ── Profile Info ──
|
||
const SizedBox(height: 16),
|
||
_buildProfileInfo(agent, state),
|
||
|
||
// ── Experience Section ──
|
||
if (_hasExperience(state)) ...[
|
||
const SizedBox(height: 24),
|
||
_buildExperienceSection(state),
|
||
],
|
||
|
||
// ── Info Cards (Availability, Work Env, Best Experience) ──
|
||
const SizedBox(height: 24),
|
||
_buildInfoCards(state),
|
||
|
||
// ── Specialization Section ──
|
||
if (state.specializationCards.isNotEmpty) ...[
|
||
const SizedBox(height: 24),
|
||
_buildSpecializationSection(state),
|
||
],
|
||
|
||
// ── Testimonials Section ──
|
||
if (state.testimonials.isNotEmpty) ...[
|
||
const SizedBox(height: 24),
|
||
_buildTestimonialsSection(state),
|
||
],
|
||
|
||
const SizedBox(height: 30),
|
||
],
|
||
),
|
||
);
|
||
}
|
||
|
||
// ── Avatar ──
|
||
Widget _buildAvatarSection(AgentProfile agent) {
|
||
final screenWidth = MediaQuery.of(context).size.width;
|
||
final avatarWidth = screenWidth * 0.877; // 377/430 from Figma
|
||
return Center(
|
||
child: ClipRRect(
|
||
borderRadius: BorderRadius.circular(20),
|
||
child: SizedBox(
|
||
width: avatarWidth,
|
||
height: 346,
|
||
child: Stack(
|
||
fit: StackFit.expand,
|
||
children: [
|
||
S3Image(
|
||
imageUrl: agent.avatarUrl,
|
||
width: avatarWidth,
|
||
height: 346,
|
||
fit: BoxFit.cover,
|
||
errorWidget: (_) => Container(
|
||
color: const Color(0xFFC4D9D4),
|
||
child: const Icon(Icons.person,
|
||
size: 80, color: AppColors.primaryDark),
|
||
),
|
||
),
|
||
// Gradient overlay
|
||
Positioned(
|
||
bottom: 0,
|
||
left: 0,
|
||
right: 0,
|
||
height: 100,
|
||
child: Container(
|
||
decoration: BoxDecoration(
|
||
gradient: LinearGradient(
|
||
begin: Alignment.bottomCenter,
|
||
end: Alignment.topCenter,
|
||
colors: [
|
||
Colors.black.withValues(alpha: 0.5),
|
||
Colors.transparent,
|
||
],
|
||
),
|
||
),
|
||
),
|
||
),
|
||
],
|
||
),
|
||
),
|
||
),
|
||
);
|
||
}
|
||
|
||
// ── Status + Connect ──
|
||
Widget _buildStatusButtons(AgentDetailState state) {
|
||
final agent = state.agent!;
|
||
final isAvailable = agent.isAvailable;
|
||
final connState = state.connectionState;
|
||
|
||
return Padding(
|
||
padding: const EdgeInsets.symmetric(horizontal: 38),
|
||
child: Column(
|
||
children: [
|
||
// Available row
|
||
_buildStatusRow(
|
||
isAvailable: true,
|
||
label: 'Available.',
|
||
active: isAvailable,
|
||
connState: connState,
|
||
onConnect: isAvailable
|
||
? () => _handleConnect(state)
|
||
: null,
|
||
),
|
||
const SizedBox(height: 10),
|
||
// Unavailable row
|
||
_buildStatusRow(
|
||
isAvailable: false,
|
||
label: 'Unavailable.',
|
||
active: !isAvailable,
|
||
connState: connState,
|
||
onConnect: !isAvailable
|
||
? () => _handleConnect(state)
|
||
: null,
|
||
),
|
||
],
|
||
),
|
||
);
|
||
}
|
||
|
||
Widget _buildStatusRow({
|
||
required bool isAvailable,
|
||
required String label,
|
||
required bool active,
|
||
required String connState,
|
||
VoidCallback? onConnect,
|
||
}) {
|
||
return Container(
|
||
height: 55,
|
||
decoration: BoxDecoration(
|
||
borderRadius: BorderRadius.circular(15),
|
||
border: Border.all(
|
||
color: AppColors.primaryDark.withValues(alpha: 0.15), width: 1),
|
||
),
|
||
child: Row(
|
||
children: [
|
||
const SizedBox(width: 20),
|
||
// Status dot
|
||
Container(
|
||
width: 14,
|
||
height: 12,
|
||
decoration: BoxDecoration(
|
||
shape: BoxShape.circle,
|
||
color: active
|
||
? (isAvailable ? const Color(0xFF4CAF50) : Colors.transparent)
|
||
: Colors.transparent,
|
||
border: Border.all(
|
||
color: active
|
||
? (isAvailable
|
||
? const Color(0xFF4CAF50)
|
||
: AppColors.primaryDark.withValues(alpha: 0.3))
|
||
: AppColors.primaryDark.withValues(alpha: 0.15),
|
||
width: 2,
|
||
),
|
||
),
|
||
),
|
||
const SizedBox(width: 12),
|
||
Text(
|
||
label,
|
||
style: TextStyle(
|
||
fontFamily: 'Fractul',
|
||
fontSize: 12,
|
||
fontWeight: FontWeight.w700,
|
||
color: active
|
||
? AppColors.primaryDark
|
||
: AppColors.primaryDark.withValues(alpha: 0.4),
|
||
),
|
||
),
|
||
const Spacer(),
|
||
// Connect button
|
||
if (active) _buildConnectButton(connState, isAvailable, onConnect),
|
||
const SizedBox(width: 4),
|
||
],
|
||
),
|
||
);
|
||
}
|
||
|
||
Widget _buildConnectButton(
|
||
String connState, bool isAvailable, VoidCallback? onConnect) {
|
||
Color bgColor;
|
||
Color textColor;
|
||
String label;
|
||
bool enabled;
|
||
|
||
switch (connState) {
|
||
case 'pending':
|
||
bgColor = const Color(0xFFFFF3CD);
|
||
textColor = const Color(0xFF856404);
|
||
label = 'Pending';
|
||
enabled = false;
|
||
break;
|
||
case 'accepted':
|
||
bgColor = AppColors.primaryDark;
|
||
textColor = const Color(0xFFF0F5FC);
|
||
label = 'Unlink';
|
||
enabled = true;
|
||
break;
|
||
default:
|
||
bgColor = isAvailable ? AppColors.accentOrange : AppColors.primaryDark;
|
||
textColor = isAvailable ? AppColors.primaryDark : const Color(0xFFF0F5FC);
|
||
label = 'Connect';
|
||
enabled = true;
|
||
}
|
||
|
||
return GestureDetector(
|
||
onTap: enabled ? onConnect : null,
|
||
child: Container(
|
||
height: 55,
|
||
width: 164,
|
||
decoration: BoxDecoration(
|
||
color: bgColor,
|
||
borderRadius: BorderRadius.circular(15),
|
||
border: isAvailable && connState == 'none'
|
||
? Border.all(color: AppColors.accentOrange)
|
||
: null,
|
||
),
|
||
alignment: Alignment.center,
|
||
child: Text(
|
||
label,
|
||
style: TextStyle(
|
||
fontFamily: 'Fractul',
|
||
fontSize: 14,
|
||
fontWeight: FontWeight.w400,
|
||
color: textColor,
|
||
),
|
||
),
|
||
),
|
||
);
|
||
}
|
||
|
||
void _handleConnect(AgentDetailState state) {
|
||
final authState = ref.read(authProvider);
|
||
if (authState.status != AuthStatus.authenticated) {
|
||
context.push('/login');
|
||
return;
|
||
}
|
||
if (state.connectionState == 'accepted') {
|
||
ref.read(agentDetailProvider(widget.agentId).notifier).cancelConnection();
|
||
return;
|
||
}
|
||
_showConnectModal(state);
|
||
}
|
||
|
||
void _showConnectModal(AgentDetailState state) {
|
||
final controller = TextEditingController();
|
||
showModalBottomSheet(
|
||
context: context,
|
||
isScrollControlled: true,
|
||
backgroundColor: Colors.transparent,
|
||
builder: (ctx) => Padding(
|
||
padding: EdgeInsets.only(
|
||
bottom: MediaQuery.of(ctx).viewInsets.bottom),
|
||
child: Container(
|
||
decoration: const BoxDecoration(
|
||
color: Colors.white,
|
||
borderRadius: BorderRadius.vertical(top: Radius.circular(20)),
|
||
),
|
||
padding: const EdgeInsets.all(24),
|
||
child: Column(
|
||
mainAxisSize: MainAxisSize.min,
|
||
crossAxisAlignment: CrossAxisAlignment.start,
|
||
children: [
|
||
Row(
|
||
mainAxisAlignment: MainAxisAlignment.spaceBetween,
|
||
children: [
|
||
Text(
|
||
'Connect with ${state.agent?.fullName ?? ''}',
|
||
style: const TextStyle(
|
||
fontFamily: 'Fractul',
|
||
fontSize: 18,
|
||
fontWeight: FontWeight.w700,
|
||
color: AppColors.primaryDark,
|
||
),
|
||
),
|
||
GestureDetector(
|
||
onTap: () => Navigator.pop(ctx),
|
||
child: const Icon(Icons.close, color: AppColors.primaryDark),
|
||
),
|
||
],
|
||
),
|
||
const SizedBox(height: 8),
|
||
const Text(
|
||
'Send a connection request to this agent.',
|
||
style: TextStyle(
|
||
fontFamily: 'SourceSerif4',
|
||
fontSize: 14,
|
||
color: AppColors.primaryDark,
|
||
),
|
||
),
|
||
const SizedBox(height: 16),
|
||
TextField(
|
||
controller: controller,
|
||
maxLines: 4,
|
||
maxLength: 1000,
|
||
decoration: InputDecoration(
|
||
hintText: 'Introduce yourself... (optional)',
|
||
hintStyle: TextStyle(
|
||
fontFamily: 'SourceSerif4',
|
||
fontSize: 14,
|
||
color: AppColors.primaryDark.withValues(alpha: 0.4),
|
||
),
|
||
border: OutlineInputBorder(
|
||
borderRadius: BorderRadius.circular(10),
|
||
borderSide: BorderSide(
|
||
color: AppColors.primaryDark.withValues(alpha: 0.15)),
|
||
),
|
||
),
|
||
),
|
||
const SizedBox(height: 16),
|
||
SizedBox(
|
||
width: double.infinity,
|
||
height: 50,
|
||
child: ElevatedButton(
|
||
onPressed: () async {
|
||
Navigator.pop(ctx);
|
||
await ref
|
||
.read(agentDetailProvider(widget.agentId).notifier)
|
||
.connect(message: controller.text);
|
||
},
|
||
style: ElevatedButton.styleFrom(
|
||
backgroundColor: AppColors.accentOrange,
|
||
shape: RoundedRectangleBorder(
|
||
borderRadius: BorderRadius.circular(15)),
|
||
),
|
||
child: const Text(
|
||
'Send Request',
|
||
style: TextStyle(
|
||
fontFamily: 'Fractul',
|
||
fontSize: 16,
|
||
fontWeight: FontWeight.w700,
|
||
color: Colors.white,
|
||
),
|
||
),
|
||
),
|
||
),
|
||
],
|
||
),
|
||
),
|
||
),
|
||
);
|
||
}
|
||
|
||
// ── Contact Info ──
|
||
Widget _buildContactInfo(AgentDetailState state) {
|
||
final email = state.contactEmail;
|
||
final phone = state.contactPhone;
|
||
if (email == null && phone == null) return const SizedBox.shrink();
|
||
|
||
return Padding(
|
||
padding: const EdgeInsets.symmetric(horizontal: 38),
|
||
child: Container(
|
||
height: 98,
|
||
padding: const EdgeInsets.symmetric(horizontal: 18, vertical: 16),
|
||
decoration: BoxDecoration(
|
||
borderRadius: BorderRadius.circular(15),
|
||
border: Border.all(
|
||
color: AppColors.primaryDark.withValues(alpha: 0.15), width: 1),
|
||
),
|
||
child: Column(
|
||
mainAxisAlignment: MainAxisAlignment.center,
|
||
children: [
|
||
if (email != null)
|
||
_buildContactRow(
|
||
label: 'Email:',
|
||
value: _emailVisible ? email : state.maskEmail(email),
|
||
onToggle: () => setState(() => _emailVisible = !_emailVisible),
|
||
visible: _emailVisible,
|
||
),
|
||
if (email != null && phone != null) const SizedBox(height: 12),
|
||
if (phone != null)
|
||
_buildContactRow(
|
||
label: 'Ph.No:',
|
||
value: _phoneVisible ? phone : state.maskPhone(phone),
|
||
onToggle: () => setState(() => _phoneVisible = !_phoneVisible),
|
||
visible: _phoneVisible,
|
||
),
|
||
],
|
||
),
|
||
),
|
||
);
|
||
}
|
||
|
||
Widget _buildContactRow({
|
||
required String label,
|
||
required String value,
|
||
required VoidCallback onToggle,
|
||
required bool visible,
|
||
}) {
|
||
return Row(
|
||
children: [
|
||
Text(
|
||
label,
|
||
style: const TextStyle(
|
||
fontFamily: 'Fractul',
|
||
fontSize: 14,
|
||
fontWeight: FontWeight.w700,
|
||
color: AppColors.primaryDark,
|
||
),
|
||
),
|
||
const SizedBox(width: 8),
|
||
Expanded(
|
||
child: Text(
|
||
value,
|
||
style: const TextStyle(
|
||
fontFamily: 'SourceSerif4',
|
||
fontSize: 14,
|
||
color: AppColors.primaryDark,
|
||
),
|
||
),
|
||
),
|
||
const SizedBox(width: 8),
|
||
GestureDetector(
|
||
onTap: onToggle,
|
||
child: Icon(
|
||
visible ? Icons.visibility : Icons.visibility_off,
|
||
size: 18,
|
||
color: AppColors.primaryDark.withValues(alpha: 0.6),
|
||
),
|
||
),
|
||
],
|
||
);
|
||
}
|
||
|
||
// ── Profile Info (Name, Title, Location, Bio) ──
|
||
Widget _buildProfileInfo(AgentProfile agent, AgentDetailState state) {
|
||
return Padding(
|
||
padding: const EdgeInsets.symmetric(horizontal: 38),
|
||
child: 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,
|
||
),
|
||
),
|
||
],
|
||
],
|
||
),
|
||
);
|
||
}
|
||
|
||
// ── Experience ──
|
||
bool _hasExperience(AgentDetailState s) =>
|
||
s.yearsInExperience != '-' ||
|
||
s.contractsClosed != '-' ||
|
||
s.licensingAreas.isNotEmpty ||
|
||
s.expertiseYears.isNotEmpty ||
|
||
s.certifications.isNotEmpty;
|
||
|
||
Widget _buildExperienceSection(AgentDetailState state) {
|
||
return Padding(
|
||
padding: const EdgeInsets.symmetric(horizontal: 17),
|
||
child: Column(
|
||
crossAxisAlignment: CrossAxisAlignment.start,
|
||
children: [
|
||
const Center(
|
||
child: Text(
|
||
'Experience',
|
||
style: TextStyle(
|
||
fontFamily: 'Fractul',
|
||
fontSize: 20,
|
||
fontWeight: FontWeight.w700,
|
||
color: AppColors.primaryDark,
|
||
),
|
||
),
|
||
),
|
||
const SizedBox(height: 22),
|
||
// Years
|
||
if (state.yearsInExperience != '-')
|
||
_buildExperienceItem(
|
||
'Years in Experience', [state.yearsInExperience]),
|
||
// Contracts
|
||
if (state.contractsClosed != '-')
|
||
_buildExperienceItem(
|
||
'Number of contracts closed', [state.contractsClosed]),
|
||
// Licensing Areas
|
||
if (state.licensingAreas.isNotEmpty)
|
||
_buildLicensingAreas(state.licensingAreas),
|
||
// Expertise Years
|
||
if (state.expertiseYears.isNotEmpty)
|
||
_buildExpertiseYears(state.expertiseYears),
|
||
// Certifications
|
||
if (state.certifications.isNotEmpty)
|
||
_buildCertifications(state.certifications),
|
||
],
|
||
),
|
||
);
|
||
}
|
||
|
||
Widget _buildExperienceItem(String title, List<String> items) {
|
||
return Column(
|
||
crossAxisAlignment: CrossAxisAlignment.start,
|
||
children: [
|
||
Text(
|
||
title,
|
||
style: const TextStyle(
|
||
fontFamily: 'Fractul',
|
||
fontSize: 14,
|
||
fontWeight: FontWeight.w700,
|
||
color: AppColors.primaryDark,
|
||
),
|
||
),
|
||
const SizedBox(height: 14),
|
||
...items.map((item) => Padding(
|
||
padding: const EdgeInsets.only(left: 10, bottom: 4),
|
||
child: Row(
|
||
crossAxisAlignment: CrossAxisAlignment.start,
|
||
children: [
|
||
const Padding(
|
||
padding: EdgeInsets.only(top: 2),
|
||
child: Text('\u2022 ',
|
||
style: TextStyle(fontSize: 15, color: AppColors.primaryDark)),
|
||
),
|
||
Expanded(
|
||
child: Text(
|
||
item,
|
||
style: const TextStyle(
|
||
fontFamily: 'SourceSerif4',
|
||
fontSize: 15,
|
||
fontWeight: FontWeight.w500,
|
||
color: AppColors.primaryDark,
|
||
),
|
||
),
|
||
),
|
||
],
|
||
),
|
||
)),
|
||
const SizedBox(height: 16),
|
||
Divider(color: AppColors.primaryDark.withValues(alpha: 0.15), height: 1),
|
||
const SizedBox(height: 10),
|
||
],
|
||
);
|
||
}
|
||
|
||
Widget _buildLicensingAreas(List<String> areas) {
|
||
return _ExpandableChipsSection(
|
||
title: 'Licensing & Areas',
|
||
items: areas,
|
||
initialCount: 6,
|
||
);
|
||
}
|
||
|
||
Widget _buildExpertiseYears(List<Map<String, String>> items) {
|
||
final chips = items.map((e) {
|
||
final years = e['years'] ?? '';
|
||
return years.isNotEmpty ? '${e['area']} – $years' : e['area'] ?? '';
|
||
}).toList();
|
||
return _ExpandableChipsSection(
|
||
title: 'Areas in expertise & Years',
|
||
items: chips,
|
||
initialCount: 4,
|
||
);
|
||
}
|
||
|
||
Widget _buildCertifications(List<Map<String, String>> certs) {
|
||
return Column(
|
||
crossAxisAlignment: CrossAxisAlignment.start,
|
||
children: [
|
||
const Text(
|
||
'Certifications',
|
||
style: TextStyle(
|
||
fontFamily: 'Fractul',
|
||
fontSize: 14,
|
||
fontWeight: FontWeight.w700,
|
||
color: AppColors.primaryDark,
|
||
),
|
||
),
|
||
const SizedBox(height: 16),
|
||
...certs.map((cert) => Padding(
|
||
padding: const EdgeInsets.only(left: 14, bottom: 16),
|
||
child: Column(
|
||
crossAxisAlignment: CrossAxisAlignment.start,
|
||
children: [
|
||
Row(
|
||
crossAxisAlignment: CrossAxisAlignment.start,
|
||
children: [
|
||
const Padding(
|
||
padding: EdgeInsets.only(top: 2),
|
||
child: 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: 20, top: 2),
|
||
child: Text(
|
||
cert['org']!.toUpperCase(),
|
||
style: TextStyle(
|
||
fontFamily: 'SourceSerif4',
|
||
fontSize: 14,
|
||
color: AppColors.primaryDark.withValues(alpha: 0.5),
|
||
),
|
||
),
|
||
),
|
||
],
|
||
),
|
||
)),
|
||
const SizedBox(height: 4),
|
||
],
|
||
);
|
||
}
|
||
|
||
// ── Info Cards (Availability, Work Env, Best Experience) ──
|
||
Widget _buildInfoCards(AgentDetailState state) {
|
||
// 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,
|
||
title: '',
|
||
subtitle: '',
|
||
description: workEnv,
|
||
),
|
||
],
|
||
if (bestExp.isNotEmpty) ...[
|
||
const SizedBox(height: 16),
|
||
_buildInfoCard(
|
||
icon: Icons.star_rounded,
|
||
title: '',
|
||
subtitle: '',
|
||
description: bestExp,
|
||
),
|
||
],
|
||
],
|
||
),
|
||
);
|
||
}
|
||
|
||
Widget _buildInfoCard({
|
||
required IconData icon,
|
||
required String title,
|
||
required 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) ...[
|
||
const SizedBox(height: 8),
|
||
Text(
|
||
description,
|
||
style: const TextStyle(
|
||
fontFamily: 'SourceSerif4',
|
||
fontSize: 14,
|
||
fontWeight: FontWeight.w600,
|
||
color: AppColors.primaryDark,
|
||
),
|
||
textAlign: TextAlign.center,
|
||
),
|
||
],
|
||
],
|
||
),
|
||
);
|
||
}
|
||
|
||
// ── Specialization Section ──
|
||
Widget _buildSpecializationSection(AgentDetailState state) {
|
||
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),
|
||
...state.specializationCards
|
||
.map((card) => Padding(
|
||
padding: const EdgeInsets.only(bottom: 12),
|
||
child: _SpecializationCardWidget(card: card),
|
||
)),
|
||
],
|
||
),
|
||
);
|
||
}
|
||
|
||
// ── Testimonials ──
|
||
Widget _buildTestimonialsSection(AgentDetailState state) {
|
||
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]),
|
||
),
|
||
),
|
||
],
|
||
),
|
||
);
|
||
}
|
||
|
||
// ── Bottom Nav ──
|
||
Widget _buildBottomNavBar() {
|
||
return Container(
|
||
decoration: const BoxDecoration(
|
||
color: Colors.white,
|
||
border: Border(top: BorderSide(color: Color(0xFFE8E8E8), width: 1)),
|
||
),
|
||
child: SafeArea(
|
||
top: false,
|
||
child: Padding(
|
||
padding: const EdgeInsets.symmetric(horizontal: 24, vertical: 12),
|
||
child: Row(
|
||
mainAxisAlignment: MainAxisAlignment.spaceBetween,
|
||
children: [
|
||
_buildNavItem(
|
||
svgPath: 'assets/icons/nav_home_icon.svg',
|
||
fallbackIcon: Icons.home,
|
||
onTap: () => context.go('/home'),
|
||
),
|
||
_buildNavItem(
|
||
svgPath: 'assets/icons/nav_list_icon.svg',
|
||
fallbackIcon: Icons.list,
|
||
isSelected: true,
|
||
onTap: () => context.go('/agents/search'),
|
||
),
|
||
_buildNavItem(
|
||
svgPath: 'assets/icons/nav_messages_icon.svg',
|
||
fallbackIcon: Icons.chat_bubble,
|
||
onTap: () {
|
||
if (ref.read(authProvider).status !=
|
||
AuthStatus.authenticated) {
|
||
context.push('/login');
|
||
return;
|
||
}
|
||
context.go('/messages');
|
||
},
|
||
),
|
||
_buildNavItem(
|
||
svgPath: 'assets/icons/nav_profile_icon.svg',
|
||
fallbackIcon: Icons.person_outline,
|
||
onTap: () {
|
||
if (ref.read(authProvider).status !=
|
||
AuthStatus.authenticated) {
|
||
context.push('/login');
|
||
return;
|
||
}
|
||
context.push('/profile');
|
||
},
|
||
),
|
||
],
|
||
),
|
||
),
|
||
),
|
||
);
|
||
}
|
||
|
||
Widget _buildNavItem({
|
||
required String svgPath,
|
||
required IconData fallbackIcon,
|
||
bool isSelected = false,
|
||
required VoidCallback onTap,
|
||
}) {
|
||
return GestureDetector(
|
||
onTap: onTap,
|
||
behavior: HitTestBehavior.opaque,
|
||
child: Padding(
|
||
padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 8),
|
||
child: SvgPicture.asset(
|
||
svgPath,
|
||
width: 24,
|
||
height: 24,
|
||
colorFilter: ColorFilter.mode(
|
||
isSelected ? AppColors.primaryDark : AppColors.accentOrange,
|
||
BlendMode.srcIn,
|
||
),
|
||
placeholderBuilder: (_) => Icon(
|
||
fallbackIcon,
|
||
size: 24,
|
||
color: isSelected ? AppColors.primaryDark : AppColors.accentOrange,
|
||
),
|
||
),
|
||
),
|
||
);
|
||
}
|
||
}
|
||
|
||
// ── Expandable Chips Section ──
|
||
|
||
class _ExpandableChipsSection extends StatefulWidget {
|
||
final String title;
|
||
final List<String> items;
|
||
final int initialCount;
|
||
|
||
const _ExpandableChipsSection({
|
||
required this.title,
|
||
required this.items,
|
||
this.initialCount = 6,
|
||
});
|
||
|
||
@override
|
||
State<_ExpandableChipsSection> createState() =>
|
||
_ExpandableChipsSectionState();
|
||
}
|
||
|
||
class _ExpandableChipsSectionState extends State<_ExpandableChipsSection> {
|
||
bool _expanded = false;
|
||
|
||
@override
|
||
Widget build(BuildContext context) {
|
||
final showItems = _expanded
|
||
? widget.items
|
||
: widget.items.take(widget.initialCount).toList();
|
||
final hasMore = widget.items.length > widget.initialCount;
|
||
|
||
return Column(
|
||
crossAxisAlignment: CrossAxisAlignment.start,
|
||
children: [
|
||
Text(
|
||
widget.title,
|
||
style: const TextStyle(
|
||
fontFamily: 'Fractul',
|
||
fontSize: 14,
|
||
fontWeight: FontWeight.w700,
|
||
color: AppColors.primaryDark,
|
||
),
|
||
),
|
||
const SizedBox(height: 14),
|
||
Wrap(
|
||
spacing: 10,
|
||
runSpacing: 10,
|
||
children: [
|
||
...showItems.map((item) => IntrinsicWidth(
|
||
child: Container(
|
||
height: 28,
|
||
padding: const EdgeInsets.symmetric(horizontal: 10),
|
||
alignment: Alignment.center,
|
||
decoration: BoxDecoration(
|
||
borderRadius: BorderRadius.circular(15),
|
||
border: Border.all(
|
||
color: AppColors.primaryDark,
|
||
width: 0.7),
|
||
),
|
||
child: Text(
|
||
item,
|
||
style: const TextStyle(
|
||
fontFamily: 'SourceSerif4',
|
||
fontSize: 14,
|
||
fontWeight: FontWeight.w500,
|
||
color: AppColors.primaryDark,
|
||
),
|
||
),
|
||
),
|
||
)),
|
||
if (hasMore && !_expanded)
|
||
GestureDetector(
|
||
onTap: () => setState(() => _expanded = true),
|
||
child: IntrinsicWidth(
|
||
child: Container(
|
||
height: 28,
|
||
padding: const EdgeInsets.symmetric(horizontal: 10),
|
||
alignment: Alignment.center,
|
||
decoration: BoxDecoration(
|
||
borderRadius: BorderRadius.circular(15),
|
||
border: Border.all(
|
||
color: AppColors.primaryDark.withValues(alpha: 0.15),
|
||
width: 1),
|
||
),
|
||
child: Text(
|
||
'+${widget.items.length - widget.initialCount} More',
|
||
style: const TextStyle(
|
||
fontFamily: 'SourceSerif4',
|
||
fontSize: 15,
|
||
fontWeight: FontWeight.w300,
|
||
color: AppColors.primaryDark,
|
||
),
|
||
),
|
||
),
|
||
),
|
||
),
|
||
],
|
||
),
|
||
const SizedBox(height: 14),
|
||
Divider(color: AppColors.primaryDark.withValues(alpha: 0.15), height: 1),
|
||
const SizedBox(height: 10),
|
||
],
|
||
);
|
||
}
|
||
}
|
||
|
||
// ── Specialization Card ──
|
||
|
||
class _SpecializationCardWidget extends StatefulWidget {
|
||
final SpecializationCard card;
|
||
|
||
const _SpecializationCardWidget({required this.card});
|
||
|
||
@override
|
||
State<_SpecializationCardWidget> createState() =>
|
||
_SpecializationCardWidgetState();
|
||
}
|
||
|
||
class _SpecializationCardWidgetState extends State<_SpecializationCardWidget> {
|
||
bool _expanded = false;
|
||
static const _initialCount = 3;
|
||
|
||
IconData get _icon {
|
||
final slug = widget.card.slug.toLowerCase();
|
||
if (slug.contains('loan') || slug.contains('price')) {
|
||
return Icons.attach_money;
|
||
}
|
||
if (slug.contains('property')) return Icons.apartment;
|
||
if (slug.contains('hobby') || slug.contains('interest')) {
|
||
return Icons.favorite_border;
|
||
}
|
||
return Icons.home_outlined;
|
||
}
|
||
|
||
@override
|
||
Widget build(BuildContext context) {
|
||
final values = _expanded
|
||
? widget.card.values
|
||
: widget.card.values.take(_initialCount).toList();
|
||
final hasMore = widget.card.values.length > _initialCount;
|
||
|
||
return Container(
|
||
width: 298,
|
||
constraints: const BoxConstraints(minHeight: 236),
|
||
padding: const EdgeInsets.symmetric(vertical: 16),
|
||
decoration: BoxDecoration(
|
||
color: Colors.white,
|
||
borderRadius: BorderRadius.circular(15),
|
||
border: Border.all(
|
||
color: AppColors.primaryDark.withValues(alpha: 0.7), width: 0.7),
|
||
),
|
||
child: Column(
|
||
children: [
|
||
Icon(_icon, size: 32, color: AppColors.accentOrange),
|
||
const SizedBox(height: 8),
|
||
Text(
|
||
widget.card.name,
|
||
style: const TextStyle(
|
||
fontFamily: 'Fractul',
|
||
fontSize: 14,
|
||
fontWeight: FontWeight.w700,
|
||
color: AppColors.primaryDark,
|
||
),
|
||
),
|
||
const SizedBox(height: 12),
|
||
...values.map((v) => Padding(
|
||
padding: const EdgeInsets.symmetric(vertical: 4),
|
||
child: Text(
|
||
AgentDetailState.titleCase(v),
|
||
style: const TextStyle(
|
||
fontFamily: 'SourceSerif4',
|
||
fontSize: 14,
|
||
color: AppColors.primaryDark,
|
||
height: 1.6,
|
||
),
|
||
textAlign: TextAlign.center,
|
||
),
|
||
)),
|
||
if (hasMore) ...[
|
||
const SizedBox(height: 10),
|
||
GestureDetector(
|
||
onTap: () => setState(() => _expanded = !_expanded),
|
||
child: Container(
|
||
padding:
|
||
const EdgeInsets.symmetric(horizontal: 14, vertical: 4),
|
||
decoration: BoxDecoration(
|
||
borderRadius: BorderRadius.circular(15),
|
||
border:
|
||
Border.all(color: AppColors.accentOrange, width: 1),
|
||
),
|
||
child: Row(
|
||
mainAxisSize: MainAxisSize.min,
|
||
children: [
|
||
Text(
|
||
_expanded ? 'Show Less' : 'Show More',
|
||
style: const TextStyle(
|
||
fontFamily: 'SourceSerif4',
|
||
fontSize: 10,
|
||
fontWeight: FontWeight.w700,
|
||
color: AppColors.accentOrange,
|
||
),
|
||
),
|
||
const SizedBox(width: 2),
|
||
Icon(
|
||
_expanded
|
||
? Icons.keyboard_arrow_up
|
||
: Icons.keyboard_arrow_down,
|
||
size: 14,
|
||
color: AppColors.accentOrange,
|
||
),
|
||
],
|
||
),
|
||
),
|
||
),
|
||
],
|
||
],
|
||
),
|
||
);
|
||
}
|
||
}
|
||
|
||
// ── Testimonial Card ──
|
||
|
||
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).withAlpha(40)
|
||
..maskFilter = const MaskFilter.blur(BlurStyle.normal, 4);
|
||
|
||
final path = Path()
|
||
..moveTo(0, 0)
|
||
..lineTo(size.width, 0)
|
||
..lineTo(size.width / 2, size.height)
|
||
..close();
|
||
|
||
canvas.drawPath(path, shadowPaint);
|
||
canvas.drawPath(path, paint);
|
||
}
|
||
|
||
@override
|
||
bool shouldRepaint(covariant CustomPainter oldDelegate) => false;
|
||
}
|