refactor: improve specialization card layout and unify chat screen header and navigation with home screen components.

This commit is contained in:
pradeepkumar
2026-03-08 02:01:03 +05:30
parent bba9cd3936
commit 219acfc013
14 changed files with 2498 additions and 236 deletions

View File

@@ -56,18 +56,24 @@ class AppTheme {
),
inputDecorationTheme: InputDecorationTheme(
filled: true,
fillColor: AppColors.inputFill,
fillColor: Colors.white,
contentPadding: const EdgeInsets.symmetric(
horizontal: 24,
vertical: 20,
),
border: OutlineInputBorder(
borderRadius: BorderRadius.circular(20),
borderSide: BorderSide.none,
borderSide: const BorderSide(
color: Color(0xFFE0E0E0),
width: 1,
),
),
enabledBorder: OutlineInputBorder(
borderRadius: BorderRadius.circular(20),
borderSide: BorderSide.none,
borderSide: const BorderSide(
color: Color(0xFFE0E0E0),
width: 1,
),
),
focusedBorder: OutlineInputBorder(
borderRadius: BorderRadius.circular(20),

View File

@@ -1067,6 +1067,7 @@ class _AgentDetailScreenState extends ConsumerState<AgentDetailScreen> {
context.push('/login');
return;
}
context.go('/messages');
},
),
_buildNavItem(
@@ -1078,6 +1079,7 @@ class _AgentDetailScreenState extends ConsumerState<AgentDetailScreen> {
context.push('/login');
return;
}
context.push('/profile');
},
),
],
@@ -1162,9 +1164,10 @@ class _ExpandableChipsSectionState extends State<_ExpandableChipsSection> {
spacing: 10,
runSpacing: 10,
children: [
...showItems.map((item) => Container(
...showItems.map((item) => IntrinsicWidth(
child: Container(
height: 28,
padding: const EdgeInsets.symmetric(horizontal: 8),
padding: const EdgeInsets.symmetric(horizontal: 10),
alignment: Alignment.center,
decoration: BoxDecoration(
borderRadius: BorderRadius.circular(15),
@@ -1181,13 +1184,15 @@ class _ExpandableChipsSectionState extends State<_ExpandableChipsSection> {
color: AppColors.primaryDark,
),
),
),
)),
if (hasMore && !_expanded)
GestureDetector(
onTap: () => setState(() => _expanded = true),
child: IntrinsicWidth(
child: Container(
height: 28,
padding: const EdgeInsets.symmetric(horizontal: 8),
padding: const EdgeInsets.symmetric(horizontal: 10),
alignment: Alignment.center,
decoration: BoxDecoration(
borderRadius: BorderRadius.circular(15),
@@ -1206,6 +1211,7 @@ class _ExpandableChipsSectionState extends State<_ExpandableChipsSection> {
),
),
),
),
],
),
const SizedBox(height: 14),
@@ -1230,7 +1236,7 @@ class _SpecializationCardWidget extends StatefulWidget {
class _SpecializationCardWidgetState extends State<_SpecializationCardWidget> {
bool _expanded = false;
static const _initialCount = 5;
static const _initialCount = 3;
IconData get _icon {
final slug = widget.card.slug.toLowerCase();

View File

@@ -414,6 +414,7 @@ class _AgentSearchScreenState extends ConsumerState<AgentSearchScreen> {
context.push('/login');
return;
}
context.push('/profile');
},
),
],

View File

@@ -0,0 +1,691 @@
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/features/auth/presentation/providers/auth_provider.dart';
import 'package:real_estate_mobile/features/home/presentation/widgets/home_header.dart';
class ContactScreen extends ConsumerStatefulWidget {
const ContactScreen({super.key});
@override
ConsumerState<ContactScreen> createState() => _ContactScreenState();
}
class _ContactScreenState extends ConsumerState<ContactScreen> {
final _nameController = TextEditingController();
final _emailController = TextEditingController();
final _phoneController = TextEditingController();
final _messageController = TextEditingController();
bool _isSending = false;
@override
void dispose() {
_nameController.dispose();
_emailController.dispose();
_phoneController.dispose();
_messageController.dispose();
super.dispose();
}
@override
Widget build(BuildContext context) {
return Scaffold(
backgroundColor: Colors.white,
body: Column(
children: [
SafeArea(
bottom: false,
child: const HomeHeader(),
),
Expanded(
child: ListView(
padding: EdgeInsets.zero,
children: [
const SizedBox(height: 18),
_buildTitle(),
const SizedBox(height: 10),
_buildDescription(),
const SizedBox(height: 40),
_buildContactForm(),
const SizedBox(height: 20),
_buildContactDetails(),
const SizedBox(height: 40),
_buildFindAgentSection(),
const SizedBox(height: 30),
],
),
),
],
),
bottomNavigationBar: _buildBottomNavBar(),
);
}
// -- Title --
Widget _buildTitle() {
return const Center(
child: Text(
'Get In Touch',
style: TextStyle(
fontFamily: 'Fractul',
fontSize: 25,
fontWeight: FontWeight.w700,
color: AppColors.primaryDark,
),
),
);
}
// -- Description --
Widget _buildDescription() {
return const Padding(
padding: EdgeInsets.symmetric(horizontal: 20),
child: Text(
'Have a question about a property or need assistance? Fill out the form below and our team will get back to you shortly.',
textAlign: TextAlign.center,
style: TextStyle(
fontFamily: 'SourceSerif4',
fontSize: 14,
fontWeight: FontWeight.w400,
color: AppColors.primaryDark,
),
),
);
}
// -- Contact Form --
Widget _buildContactForm() {
return Padding(
padding: const EdgeInsets.symmetric(horizontal: 23),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
_buildLabel('Your Name'),
const SizedBox(height: 8),
_buildTextField(
controller: _nameController,
hintText: 'Brain Neeland',
),
const SizedBox(height: 24),
_buildLabel('Email Address'),
const SizedBox(height: 8),
_buildTextField(
controller: _emailController,
hintText: '123@gmail.com',
keyboardType: TextInputType.emailAddress,
prefixIcon: Icons.email,
),
const SizedBox(height: 24),
_buildLabel('Phone Number'),
const SizedBox(height: 8),
_buildTextField(
controller: _phoneController,
hintText: '12345678990',
keyboardType: TextInputType.phone,
prefixIcon: Icons.call,
),
const SizedBox(height: 24),
_buildLabel('Message'),
const SizedBox(height: 8),
_buildMessageField(),
const SizedBox(height: 30),
_buildFormButtons(),
],
),
);
}
Widget _buildLabel(String text) {
return Text(
text,
style: const TextStyle(
fontFamily: 'Fractul',
fontSize: 14,
fontWeight: FontWeight.w500,
color: AppColors.primaryDark,
),
);
}
Widget _buildTextField({
required TextEditingController controller,
required String hintText,
TextInputType? keyboardType,
IconData? prefixIcon,
}) {
final borderSide = BorderSide(
color: AppColors.primaryDark.withValues(alpha: 0.1),
width: 1,
);
final border = OutlineInputBorder(
borderRadius: BorderRadius.circular(15),
borderSide: borderSide,
);
return SizedBox(
height: 52,
child: TextField(
controller: controller,
keyboardType: keyboardType,
style: const TextStyle(
fontFamily: 'Fractul',
fontSize: 14,
fontWeight: FontWeight.w200,
color: Colors.black,
),
decoration: InputDecoration(
contentPadding: const EdgeInsets.symmetric(
horizontal: 20,
vertical: 14,
),
hintText: hintText,
hintStyle: TextStyle(
fontFamily: 'Fractul',
fontSize: 14,
fontWeight: FontWeight.w200,
color: Colors.black.withValues(alpha: 0.4),
),
border: border,
enabledBorder: border,
focusedBorder: border,
disabledBorder: border,
prefixIcon: prefixIcon != null
? Icon(prefixIcon, size: 22, color: AppColors.primaryDark)
: null,
prefixIconConstraints: prefixIcon != null
? const BoxConstraints(minWidth: 48)
: null,
),
),
);
}
Widget _buildMessageField() {
final borderSide = BorderSide(
color: AppColors.primaryDark.withValues(alpha: 0.1),
width: 1,
);
final border = OutlineInputBorder(
borderRadius: BorderRadius.circular(15),
borderSide: borderSide,
);
return SizedBox(
height: 183,
child: TextField(
controller: _messageController,
maxLines: null,
expands: true,
textAlignVertical: TextAlignVertical.top,
style: const TextStyle(
fontFamily: 'Fractul',
fontSize: 14,
fontWeight: FontWeight.w200,
color: Colors.black,
),
decoration: InputDecoration(
contentPadding: const EdgeInsets.symmetric(
horizontal: 20,
vertical: 16,
),
hintText: 'Write your message here...',
hintStyle: TextStyle(
fontFamily: 'Fractul',
fontSize: 14,
fontWeight: FontWeight.w200,
color: Colors.black.withValues(alpha: 0.4),
),
border: border,
enabledBorder: border,
focusedBorder: border,
disabledBorder: border,
),
),
);
}
Widget _buildFormButtons() {
return Row(
children: [
Expanded(
child: GestureDetector(
onTap: () => context.pop(),
child: Container(
height: 46,
decoration: BoxDecoration(
borderRadius: BorderRadius.circular(15),
border: Border.all(
color: AppColors.primaryDark.withValues(alpha: 0.1),
width: 1,
),
),
alignment: Alignment.center,
child: const Text(
'Cancel',
style: TextStyle(
fontFamily: 'Fractul',
fontSize: 14,
fontWeight: FontWeight.w700,
color: AppColors.primaryDark,
),
),
),
),
),
const SizedBox(width: 23),
Expanded(
child: GestureDetector(
onTap: _isSending ? null : _sendMessage,
child: Container(
height: 46,
decoration: BoxDecoration(
color: AppColors.accentOrange,
borderRadius: BorderRadius.circular(15),
),
alignment: Alignment.center,
child: _isSending
? const SizedBox(
width: 20,
height: 20,
child: CircularProgressIndicator(
strokeWidth: 2,
color: AppColors.primaryDark,
),
)
: const Text(
'Send Message',
style: TextStyle(
fontFamily: 'Fractul',
fontSize: 14,
fontWeight: FontWeight.w700,
color: AppColors.primaryDark,
),
),
),
),
),
],
);
}
// -- Contact Details Section --
Widget _buildContactDetails() {
return Padding(
padding: const EdgeInsets.symmetric(horizontal: 25),
child: Container(
decoration: BoxDecoration(
borderRadius: BorderRadius.circular(20),
border: Border.all(
color: AppColors.primaryDark.withValues(alpha: 0.1),
width: 1,
),
),
child: Column(
children: [
const SizedBox(height: 17),
// Title
const Text(
'Contact Details',
style: TextStyle(
fontFamily: 'Fractul',
fontSize: 14,
fontWeight: FontWeight.w500,
color: AppColors.primaryDark,
),
),
const SizedBox(height: 12),
_buildDetailDivider(),
// Email Support
_buildContactDetailRow(
icon: Icons.email_outlined,
label: 'Email Support',
value: '123support@gmail.com',
),
_buildDetailDivider(),
// Phone
_buildContactDetailRow(
icon: Icons.call_outlined,
label: 'Phone',
value: '1234567890',
subtitle: 'Mon-Fri 9am-6pm',
),
_buildDetailDivider(),
// Office
_buildContactDetailRow(
icon: Icons.location_on_outlined,
label: 'Office',
value: '123 Market Street\nNew York CA 234737',
),
const SizedBox(height: 16),
// Map placeholder
Padding(
padding: const EdgeInsets.symmetric(horizontal: 36),
child: Container(
height: 166,
decoration: BoxDecoration(
borderRadius: BorderRadius.circular(20),
border: Border.all(
color: AppColors.primaryDark.withValues(alpha: 0.1),
width: 1,
),
color: const Color(0xFFF5F5F5),
),
alignment: Alignment.center,
child: Icon(
Icons.map_outlined,
size: 48,
color: AppColors.primaryDark.withValues(alpha: 0.3),
),
),
),
const SizedBox(height: 16),
// Get Directions button
GestureDetector(
onTap: () {},
child: Container(
width: 120,
height: 35,
decoration: BoxDecoration(
borderRadius: BorderRadius.circular(15),
border: Border.all(
color: AppColors.accentOrange,
width: 1,
),
),
alignment: Alignment.center,
child: const Text(
'Get Directions',
style: TextStyle(
fontFamily: 'SourceSerif4',
fontSize: 14,
fontWeight: FontWeight.w400,
color: AppColors.accentOrange,
),
),
),
),
const SizedBox(height: 20),
],
),
),
);
}
Widget _buildDetailDivider() {
return Divider(
color: AppColors.primaryDark.withValues(alpha: 0.1),
height: 1,
thickness: 1,
indent: 1,
endIndent: 1,
);
}
Widget _buildContactDetailRow({
required IconData icon,
required String label,
required String value,
String? subtitle,
}) {
return Padding(
padding: const EdgeInsets.symmetric(horizontal: 20, vertical: 16),
child: Row(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Icon(
icon,
size: 22,
color: AppColors.primaryDark,
),
const SizedBox(width: 12),
Expanded(
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(
label,
style: const TextStyle(
fontFamily: 'SourceSerif4',
fontSize: 14,
fontWeight: FontWeight.w300,
color: AppColors.primaryDark,
),
),
const SizedBox(height: 4),
Text(
value,
style: const TextStyle(
fontFamily: 'SourceSerif4',
fontSize: 14,
fontWeight: FontWeight.w400,
color: AppColors.primaryDark,
),
),
if (subtitle != null) ...[
const SizedBox(height: 4),
Text(
subtitle,
style: const TextStyle(
fontFamily: 'SourceSerif4',
fontSize: 10,
fontWeight: FontWeight.w400,
color: AppColors.primaryDark,
),
),
],
],
),
),
],
),
);
}
// -- Find Agent Section --
Widget _buildFindAgentSection() {
return Padding(
padding: const EdgeInsets.symmetric(horizontal: 30),
child: Column(
children: [
const Text(
'Ready to find an agent?',
textAlign: TextAlign.center,
style: TextStyle(
fontFamily: 'Fractul',
fontSize: 25,
fontWeight: FontWeight.w700,
color: AppColors.primaryDark,
),
),
const SizedBox(height: 16),
const Text(
'Discover trusted agents and start your property journey with confidence.',
textAlign: TextAlign.center,
style: TextStyle(
fontFamily: 'SourceSerif4',
fontSize: 14,
fontWeight: FontWeight.w400,
color: AppColors.primaryDark,
),
),
const SizedBox(height: 24),
// Illustration placeholder
Container(
width: 254,
height: 254,
decoration: BoxDecoration(
shape: BoxShape.circle,
color: const Color(0xFFFFF5EB),
),
alignment: Alignment.center,
child: Icon(
Icons.real_estate_agent,
size: 80,
color: AppColors.accentOrange.withValues(alpha: 0.6),
),
),
const SizedBox(height: 24),
// Start Your Search button
GestureDetector(
onTap: () => context.push('/agents/search'),
child: Container(
height: 46,
width: 200,
decoration: BoxDecoration(
color: AppColors.accentOrange,
borderRadius: BorderRadius.circular(15),
),
child: Row(
mainAxisAlignment: MainAxisAlignment.center,
children: [
const Text(
'Start Your Search',
style: TextStyle(
fontFamily: 'Fractul',
fontSize: 16,
fontWeight: FontWeight.w700,
color: AppColors.primaryDark,
),
),
const SizedBox(width: 8),
const Icon(
Icons.arrow_forward,
size: 18,
color: AppColors.primaryDark,
),
],
),
),
),
],
),
);
}
// -- Send Message --
void _sendMessage() {
final name = _nameController.text.trim();
final email = _emailController.text.trim();
final message = _messageController.text.trim();
if (name.isEmpty || email.isEmpty || message.isEmpty) {
ScaffoldMessenger.of(context).showSnackBar(
const SnackBar(
content: Text('Please fill in all required fields'),
backgroundColor: Colors.red,
),
);
return;
}
setState(() => _isSending = true);
// Simulate sending — replace with actual API call
Future.delayed(const Duration(seconds: 2), () {
if (!mounted) return;
setState(() => _isSending = false);
_nameController.clear();
_emailController.clear();
_phoneController.clear();
_messageController.clear();
ScaffoldMessenger.of(context).showSnackBar(
const SnackBar(
content: Text('Message sent successfully!'),
backgroundColor: Color(0xFF638559),
),
);
});
}
// -- Bottom Navigation Bar --
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,
isSelected: false,
onTap: () => context.go('/home'),
),
_buildNavItem(
svgPath: 'assets/icons/nav_list_icon.svg',
fallbackIcon: Icons.list,
isSelected: false,
onTap: () => context.push('/agents/search'),
),
_buildNavItem(
svgPath: 'assets/icons/nav_messages_icon.svg',
fallbackIcon: Icons.chat_bubble,
isSelected: false,
onTap: () {
final authState = ref.read(authProvider);
if (authState.status != AuthStatus.authenticated) {
context.push('/login');
return;
}
context.go('/messages');
},
),
_buildNavItem(
svgPath: 'assets/icons/nav_profile_icon.svg',
fallbackIcon: Icons.person_outline,
isSelected: false,
onTap: () {
final authState = ref.read(authProvider);
if (authState.status != AuthStatus.authenticated) {
context.push('/login');
return;
}
context.push('/profile');
},
),
],
),
),
),
);
}
Widget _buildNavItem({
required String svgPath,
required IconData fallbackIcon,
required bool isSelected,
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: isSelected
? const ColorFilter.mode(AppColors.primaryDark, BlendMode.srcIn)
: const ColorFilter.mode(
AppColors.accentOrange, BlendMode.srcIn),
placeholderBuilder: (_) => Icon(
fallbackIcon,
size: 24,
color: isSelected ? AppColors.primaryDark : AppColors.accentOrange,
),
),
),
);
}
}

View File

@@ -275,6 +275,7 @@ class FaqScreen extends ConsumerWidget {
context.push('/login');
return;
}
context.go('/messages');
},
),
_buildNavItem(
@@ -288,6 +289,7 @@ class FaqScreen extends ConsumerWidget {
context.push('/login');
return;
}
context.push('/profile');
},
),
],

View File

@@ -165,6 +165,8 @@ class _HomeScreenState extends ConsumerState<HomeScreen> {
context.push('/login');
return;
}
context.push('/profile');
return;
}
setState(() => _selectedIndex = index);
},

View File

@@ -163,7 +163,10 @@ class _MenuDrawer extends ConsumerWidget {
context,
icon: Icons.mail_outline,
label: 'Contact',
onTap: () => Navigator.pop(context),
onTap: () {
Navigator.pop(context);
context.push('/contact');
},
),
const SizedBox(height: 16),
const Divider(color: AppColors.divider, height: 1),
@@ -174,13 +177,19 @@ class _MenuDrawer extends ConsumerWidget {
context,
icon: Icons.person_outline,
label: 'Account Settings',
onTap: () => Navigator.pop(context),
onTap: () {
Navigator.pop(context);
context.push('/profile');
},
),
_buildMenuItem(
context,
icon: Icons.notifications_outlined,
label: 'Notification Settings',
onTap: () => Navigator.pop(context),
onTap: () {
Navigator.pop(context);
context.push('/profile');
},
),
const SizedBox(height: 16),
const Divider(color: AppColors.divider, height: 1),

View File

@@ -1,6 +1,7 @@
import 'package:cached_network_image/cached_network_image.dart';
import 'package:flutter/material.dart';
import 'package:flutter_riverpod/flutter_riverpod.dart';
import 'package:go_router/go_router.dart';
import 'package:real_estate_mobile/config/app_config.dart';
import 'package:real_estate_mobile/core/constants/app_colors.dart';
import 'package:real_estate_mobile/features/auth/presentation/providers/auth_provider.dart';
@@ -91,7 +92,7 @@ class _ChatScreenState extends ConsumerState<ChatScreen> {
void _handleBack() {
ref.read(messagingProvider.notifier).setActiveConversation(null);
Navigator.pop(context);
context.go('/messages');
}
// ============================================================
@@ -183,11 +184,11 @@ class _ChatScreenState extends ConsumerState<ChatScreen> {
final otherPartyName = conversation?.otherParty.name ?? 'Chat';
final otherPartyAvatar = conversation?.otherParty.avatar;
final isOnline = conversation?.otherParty.isOnline ?? false;
return Scaffold(
backgroundColor: Colors.white,
body: SafeArea(
child: Column(
final otherPartyHeadline = conversation?.otherParty.headline;
return Column(
children: [
// Chat header with name + status
_buildHeader(
name: otherPartyName,
isOnline: isOnline,
@@ -200,25 +201,13 @@ class _ChatScreenState extends ConsumerState<ChatScreen> {
strokeWidth: 2,
),
)
: messages.isEmpty
? Center(
child: Text(
'No messages yet.\nSay hello!',
textAlign: TextAlign.center,
style: TextStyle(
fontFamily: 'SourceSerif4',
fontSize: 14,
fontWeight: FontWeight.w400,
color: AppColors.hintText,
),
),
)
: _buildMessagesList(
: _buildChatBody(
messages: messages,
currentUserId: currentUserId ?? '',
currentUserName: 'You',
otherPartyName: otherPartyName,
otherPartyAvatar: otherPartyAvatar,
otherPartyHeadline: otherPartyHeadline,
isOnline: isOnline,
isTyping: isTyping,
isLoadingMore: messagingState.isLoadingMessages &&
messages.isNotEmpty,
@@ -226,8 +215,6 @@ class _ChatScreenState extends ConsumerState<ChatScreen> {
),
_buildInputArea(),
],
),
),
);
}
@@ -240,16 +227,7 @@ class _ChatScreenState extends ConsumerState<ChatScreen> {
required bool isOnline,
}) {
return Padding(
padding: const EdgeInsets.symmetric(horizontal: 12, vertical: 8),
child: Container(
height: 55,
decoration: BoxDecoration(
border: Border.all(
color: AppColors.primaryDark,
width: 0.5,
),
borderRadius: BorderRadius.circular(7),
),
padding: const EdgeInsets.fromLTRB(12, 10, 12, 6),
child: Row(
children: [
// Back arrow
@@ -257,10 +235,10 @@ class _ChatScreenState extends ConsumerState<ChatScreen> {
onTap: _handleBack,
behavior: HitTestBehavior.opaque,
child: const Padding(
padding: EdgeInsets.symmetric(horizontal: 14),
padding: EdgeInsets.only(right: 16),
child: Icon(
Icons.arrow_back,
size: 23,
size: 22,
color: AppColors.primaryDark,
),
),
@@ -268,7 +246,6 @@ class _ChatScreenState extends ConsumerState<ChatScreen> {
// Name and active status
Expanded(
child: Column(
mainAxisAlignment: MainAxisAlignment.center,
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(
@@ -328,7 +305,7 @@ class _ChatScreenState extends ConsumerState<ChatScreen> {
onTap: () {},
behavior: HitTestBehavior.opaque,
child: Padding(
padding: const EdgeInsets.only(right: 14),
padding: const EdgeInsets.only(right: 4),
child: Icon(
Icons.star_rounded,
size: 24,
@@ -338,6 +315,189 @@ class _ChatScreenState extends ConsumerState<ChatScreen> {
),
],
),
);
}
// ============================================================
// CHAT BODY (profile section + messages)
// ============================================================
Widget _buildChatBody({
required List<ChatMessage> messages,
required String currentUserId,
required String otherPartyName,
String? otherPartyAvatar,
String? otherPartyHeadline,
required bool isOnline,
required bool isTyping,
required bool isLoadingMore,
}) {
if (messages.isEmpty) {
return SingleChildScrollView(
child: Column(
children: [
_buildProfileSection(
name: otherPartyName,
avatar: otherPartyAvatar,
headline: otherPartyHeadline,
isOnline: isOnline,
),
const SizedBox(height: 60),
Text(
'No messages yet.\nSay hello!',
textAlign: TextAlign.center,
style: TextStyle(
fontFamily: 'SourceSerif4',
fontSize: 14,
fontWeight: FontWeight.w400,
color: AppColors.hintText,
),
),
],
),
);
}
return _buildMessagesList(
messages: messages,
currentUserId: currentUserId,
currentUserName: 'You',
otherPartyName: otherPartyName,
otherPartyAvatar: otherPartyAvatar,
otherPartyHeadline: otherPartyHeadline,
isOnline: isOnline,
isTyping: isTyping,
isLoadingMore: isLoadingMore,
);
}
// ============================================================
// PROFILE SECTION (large avatar, name, specialties)
// ============================================================
Widget _buildProfileSection({
required String name,
String? avatar,
String? headline,
required bool isOnline,
}) {
final avatarUrl = _resolveAvatarUrl(avatar);
final initials = _getInitials(name);
return Padding(
padding: const EdgeInsets.symmetric(horizontal: 22, vertical: 16),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
// Large avatar with online dot
Stack(
children: [
ClipOval(
child: avatarUrl.isNotEmpty
? CachedNetworkImage(
imageUrl: avatarUrl,
width: 80,
height: 80,
fit: BoxFit.cover,
errorWidget: (_, __, ___) =>
_AvatarFallback(letter: initials, size: 80),
)
: _AvatarFallback(letter: initials, size: 80),
),
if (isOnline)
Positioned(
right: 2,
bottom: 2,
child: Container(
width: 14,
height: 14,
decoration: BoxDecoration(
color: const Color(0xFF4CAF50),
shape: BoxShape.circle,
border: Border.all(color: Colors.white, width: 2.5),
),
),
),
],
),
const SizedBox(height: 14),
// Name with pronouns and orange dot
Row(
children: [
Text(
name,
style: const TextStyle(
fontFamily: 'Fractul',
fontSize: 14,
fontWeight: FontWeight.w600,
color: AppColors.primaryDark,
),
),
const SizedBox(width: 4),
const Text(
'(He/Him)',
style: TextStyle(
fontFamily: 'Fractul',
fontSize: 14,
fontWeight: FontWeight.w400,
color: AppColors.primaryDark,
),
),
const SizedBox(width: 6),
Container(
width: 10,
height: 10,
decoration: const BoxDecoration(
color: AppColors.accentOrange,
shape: BoxShape.circle,
),
),
],
),
const SizedBox(height: 12),
// Specialties row
if (headline != null && headline.isNotEmpty)
_buildSpecialtiesRow(headline)
else
_buildSpecialtiesRow('Sales,Analytics,Inspection,Residential,Commercial'),
const SizedBox(height: 12),
// Divider
Divider(
color: AppColors.primaryDark.withValues(alpha: 0.1),
thickness: 0.5,
),
],
),
);
}
Widget _buildSpecialtiesRow(String headline) {
// Split by comma, pipe, or similar separators
final specialties = headline.split(RegExp(r'[,|;]'))
.map((s) => s.trim())
.where((s) => s.isNotEmpty)
.toList();
if (specialties.isEmpty) return const SizedBox.shrink();
return SingleChildScrollView(
scrollDirection: Axis.horizontal,
child: Row(
children: [
for (int i = 0; i < specialties.length; i++) ...[
Text(
specialties[i],
style: const TextStyle(
fontFamily: 'SourceSerif4',
fontSize: 14,
fontWeight: FontWeight.w400,
color: AppColors.primaryDark,
),
),
if (i < specialties.length - 1)
const SizedBox(width: 16),
],
],
),
);
}
@@ -352,16 +512,19 @@ class _ChatScreenState extends ConsumerState<ChatScreen> {
required String currentUserName,
required String otherPartyName,
String? otherPartyAvatar,
String? otherPartyHeadline,
required bool isOnline,
required bool isTyping,
required bool isLoadingMore,
}) {
// +1 for the profile section header at the end of reversed list
final itemCount =
messages.length + (isLoadingMore ? 1 : 0) + (isTyping ? 1 : 0);
messages.length + 1 + (isLoadingMore ? 1 : 0) + (isTyping ? 1 : 0);
return ListView.builder(
controller: _scrollController,
reverse: true,
padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 12),
padding: const EdgeInsets.symmetric(horizontal: 0, vertical: 0),
itemCount: itemCount,
itemBuilder: (context, index) {
if (isTyping && index == 0) {
@@ -370,6 +533,17 @@ class _ChatScreenState extends ConsumerState<ChatScreen> {
final adjustedIndex = isTyping ? index - 1 : index;
// Profile section at the top (last index in reversed list)
final profileIndex = messages.length + (isLoadingMore ? 1 : 0);
if (adjustedIndex == profileIndex) {
return _buildProfileSection(
name: otherPartyName,
avatar: otherPartyAvatar,
headline: otherPartyHeadline,
isOnline: isOnline,
);
}
if (isLoadingMore && adjustedIndex == messages.length) {
return const Padding(
padding: EdgeInsets.symmetric(vertical: 16),
@@ -404,7 +578,9 @@ class _ChatScreenState extends ConsumerState<ChatScreen> {
: otherPartyName;
final senderAvatar = isMine ? null : otherPartyAvatar;
return Column(
return Padding(
padding: const EdgeInsets.symmetric(horizontal: 16),
child: Column(
children: [
if (showDateSeparator)
_buildDateSeparator(
@@ -417,6 +593,7 @@ class _ChatScreenState extends ConsumerState<ChatScreen> {
: _buildReceivedMessage(message, senderName, senderAvatar),
),
],
),
);
},
);
@@ -495,6 +672,16 @@ class _ChatScreenState extends ConsumerState<ChatScreen> {
),
),
const SizedBox(width: 6),
const Text(
'He/Him',
style: TextStyle(
fontFamily: 'SourceSerif4',
fontSize: 10,
fontWeight: FontWeight.w400,
color: AppColors.primaryDark,
),
),
const SizedBox(width: 6),
Container(
width: 8,
height: 8,
@@ -567,6 +754,16 @@ class _ChatScreenState extends ConsumerState<ChatScreen> {
),
),
const SizedBox(width: 6),
const Text(
'He/Him',
style: TextStyle(
fontFamily: 'SourceSerif4',
fontSize: 10,
fontWeight: FontWeight.w400,
color: AppColors.primaryDark,
),
),
const SizedBox(width: 6),
Text(
timestamp,
style: const TextStyle(
@@ -667,21 +864,12 @@ class _ChatScreenState extends ConsumerState<ChatScreen> {
child: Row(
crossAxisAlignment: CrossAxisAlignment.center,
children: [
// Plus icon in circle border
// Plus icon (plain, no border)
GestureDetector(
onTap: _showAttachmentOptions,
child: Container(
width: 36,
height: 36,
decoration: BoxDecoration(
color: Colors.white,
shape: BoxShape.circle,
border: Border.all(
color: AppColors.primaryDark,
width: 0.5,
),
),
child: const Center(
behavior: HitTestBehavior.opaque,
child: const Padding(
padding: EdgeInsets.only(right: 10),
child: Icon(
Icons.add,
size: 24,
@@ -689,19 +877,17 @@ class _ChatScreenState extends ConsumerState<ChatScreen> {
),
),
),
),
const SizedBox(width: 10),
// Text input
// Text input pill with send icon inside
Expanded(
child: Container(
height: 38,
height: 40,
decoration: BoxDecoration(
color: Colors.white,
borderRadius: BorderRadius.circular(20),
border: Border.all(
color: AppColors.primaryDark,
width: 0.5,
width: 1.5,
),
),
child: Row(
@@ -736,7 +922,7 @@ class _ChatScreenState extends ConsumerState<ChatScreen> {
),
),
),
// Send icon inside the input field
// Send arrow inside the pill
GestureDetector(
onTap: _sendMessage,
behavior: HitTestBehavior.opaque,
@@ -755,25 +941,21 @@ class _ChatScreenState extends ConsumerState<ChatScreen> {
),
const SizedBox(width: 10),
// Mic icon in circle border
// Mic icon in filled dark circle
GestureDetector(
onTap: () => _showComingSoon('Speech to text'),
child: Container(
width: 36,
height: 36,
decoration: BoxDecoration(
color: Colors.white,
shape: BoxShape.circle,
border: Border.all(
decoration: const BoxDecoration(
color: AppColors.primaryDark,
width: 0.5,
),
shape: BoxShape.circle,
),
child: const Center(
child: Icon(
Icons.mic_none,
size: 20,
color: AppColors.primaryDark,
color: Colors.white,
),
),
),
@@ -873,6 +1055,7 @@ class _ChatScreenState extends ConsumerState<ChatScreen> {
shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(8)),
);
}
}
// -- Avatar Fallback --

View File

@@ -52,16 +52,11 @@ class _ConversationsScreenState extends ConsumerState<ConversationsScreen> {
final state = ref.watch(messagingProvider);
final filtered = _filteredConversations(state.conversations);
return Scaffold(
backgroundColor: Colors.white,
body: SafeArea(
child: Column(
return Column(
children: [
_buildHeader(),
Expanded(child: _buildConversationList(state, filtered)),
],
),
),
);
}
@@ -317,12 +312,13 @@ class _ConversationsScreenState extends ConsumerState<ConversationsScreen> {
return _ConversationTile(
conversation: filtered[index],
onTap: () =>
context.push('/messages/chat/${filtered[index].id}'),
context.go('/messages/chat/${filtered[index].id}'),
);
},
),
);
}
}
// -- Conversation Tile Widget --

View File

@@ -0,0 +1,117 @@
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/features/auth/presentation/providers/auth_provider.dart';
import 'package:real_estate_mobile/features/home/presentation/widgets/home_header.dart';
class MessagingShell extends ConsumerWidget {
final Widget child;
const MessagingShell({super.key, required this.child});
@override
Widget build(BuildContext context, WidgetRef ref) {
return Scaffold(
backgroundColor: Colors.white,
body: Column(
children: [
SafeArea(
bottom: false,
child: const HomeHeader(),
),
Expanded(child: child),
],
),
bottomNavigationBar: _buildBottomNavBar(context, ref),
);
}
Widget _buildBottomNavBar(BuildContext context, WidgetRef ref) {
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(
context: context,
svgPath: 'assets/icons/nav_home_icon.svg',
fallbackIcon: Icons.home,
isSelected: false,
onTap: () => context.go('/home'),
),
_buildNavItem(
context: context,
svgPath: 'assets/icons/nav_list_icon.svg',
fallbackIcon: Icons.list,
isSelected: false,
onTap: () => context.push('/agents/search'),
),
_buildNavItem(
context: context,
svgPath: 'assets/icons/nav_messages_icon.svg',
fallbackIcon: Icons.chat_bubble,
isSelected: true,
onTap: () => context.go('/messages'),
),
_buildNavItem(
context: context,
svgPath: 'assets/icons/nav_profile_icon.svg',
fallbackIcon: Icons.person_outline,
isSelected: false,
onTap: () {
final authState = ref.read(authProvider);
if (authState.status != AuthStatus.authenticated) {
context.push('/login');
return;
}
context.push('/profile');
},
),
],
),
),
),
);
}
Widget _buildNavItem({
required BuildContext context,
required String svgPath,
required IconData fallbackIcon,
required bool isSelected,
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: isSelected
? const ColorFilter.mode(AppColors.primaryDark, BlendMode.srcIn)
: const ColorFilter.mode(
AppColors.accentOrange, BlendMode.srcIn),
placeholderBuilder: (_) => Icon(
fallbackIcon,
size: 24,
color: isSelected ? AppColors.primaryDark : AppColors.accentOrange,
),
),
),
);
}
}

View File

@@ -0,0 +1,66 @@
import 'package:dio/dio.dart';
import 'package:real_estate_mobile/core/network/api_client.dart';
import 'package:real_estate_mobile/core/network/api_exceptions.dart';
class ProfileRepository {
final Dio _dio = ApiClient.instance.dio;
/// Fetch profile based on role.
/// Agent: GET /agents/profile/me
/// User: GET /users/profile/me
Future<Map<String, dynamic>> getMyProfile(String role) async {
try {
final endpoint = role == 'AGENT'
? '/agents/profile/me'
: '/users/profile/me';
final response = await _dio.get(endpoint);
return response.data['data'] as Map<String, dynamic>;
} on DioException catch (e) {
if (e.error is ApiException) throw e.error as ApiException;
throw ApiException(
message: e.message ?? 'Failed to fetch profile',
statusCode: e.response?.statusCode,
);
}
}
/// Update profile based on role.
/// Agent: PUT /agents/profile
/// User: PATCH /users/profile/me
Future<Map<String, dynamic>> updateProfile(
String role,
Map<String, dynamic> data,
) async {
try {
final Response response;
if (role == 'AGENT') {
response = await _dio.put('/agents/profile', data: data);
} else {
response = await _dio.patch('/users/profile/me', data: data);
}
return response.data['data'] as Map<String, dynamic>;
} on DioException catch (e) {
if (e.error is ApiException) throw e.error as ApiException;
throw ApiException(
message: e.message ?? 'Failed to update profile',
statusCode: e.response?.statusCode,
);
}
}
Future<String> getAvatarUploadUrl(String role, String fileName) async {
try {
final endpoint = role == 'AGENT'
? '/upload/avatar-presigned-url'
: '/upload/user-avatar-presigned-url';
final response = await _dio.post(endpoint, data: {'fileName': fileName});
return response.data['data']['url'] as String;
} on DioException catch (e) {
if (e.error is ApiException) throw e.error as ApiException;
throw ApiException(
message: e.message ?? 'Failed to get upload URL',
statusCode: e.response?.statusCode,
);
}
}
}

View File

@@ -0,0 +1,98 @@
import 'package:flutter_riverpod/flutter_riverpod.dart';
import 'package:real_estate_mobile/features/auth/presentation/providers/auth_provider.dart';
import 'package:real_estate_mobile/features/profile/data/profile_repository.dart';
// Profile state
class ProfileState {
final String role;
final Map<String, dynamic> profile;
final bool isLoading;
final bool isSaving;
final String? errorMessage;
final String? successMessage;
const ProfileState({
this.role = 'USER',
this.profile = const {},
this.isLoading = true,
this.isSaving = false,
this.errorMessage,
this.successMessage,
});
bool get isAgent => role == 'AGENT';
ProfileState copyWith({
String? role,
Map<String, dynamic>? profile,
bool? isLoading,
bool? isSaving,
String? errorMessage,
String? successMessage,
bool clearError = false,
bool clearSuccess = false,
}) {
return ProfileState(
role: role ?? this.role,
profile: profile ?? this.profile,
isLoading: isLoading ?? this.isLoading,
isSaving: isSaving ?? this.isSaving,
errorMessage: clearError ? null : (errorMessage ?? this.errorMessage),
successMessage:
clearSuccess ? null : (successMessage ?? this.successMessage),
);
}
}
class ProfileNotifier extends StateNotifier<ProfileState> {
final ProfileRepository _repository;
final String _role;
ProfileNotifier(this._repository, this._role)
: super(ProfileState(role: _role)) {
loadProfile();
}
Future<void> loadProfile() async {
state = state.copyWith(isLoading: true, clearError: true);
try {
final profile = await _repository.getMyProfile(_role);
state = state.copyWith(profile: profile, isLoading: false);
} catch (e) {
state = state.copyWith(
isLoading: false,
errorMessage: e.toString(),
);
}
}
Future<void> updateProfile(Map<String, dynamic> data) async {
state =
state.copyWith(isSaving: true, clearError: true, clearSuccess: true);
try {
final updated = await _repository.updateProfile(_role, data);
state = state.copyWith(
profile: updated,
isSaving: false,
successMessage: 'Profile updated successfully',
);
} catch (e) {
state = state.copyWith(
isSaving: false,
errorMessage: 'Failed to update profile',
);
}
}
}
// Providers
final profileRepositoryProvider = Provider<ProfileRepository>((ref) {
return ProfileRepository();
});
final profileProvider =
StateNotifierProvider.autoDispose<ProfileNotifier, ProfileState>((ref) {
final authState = ref.watch(authProvider);
final role = authState.user?.role ?? 'USER';
return ProfileNotifier(ref.watch(profileRepositoryProvider), role);
});

File diff suppressed because it is too large Load Diff

View File

@@ -10,6 +10,9 @@ import 'package:real_estate_mobile/features/faq/presentation/screens/faq_screen.
import 'package:real_estate_mobile/features/home/presentation/screens/home_screen.dart';
import 'package:real_estate_mobile/features/messaging/presentation/screens/conversations_screen.dart';
import 'package:real_estate_mobile/features/messaging/presentation/screens/chat_screen.dart';
import 'package:real_estate_mobile/features/messaging/presentation/screens/messaging_shell.dart';
import 'package:real_estate_mobile/features/contact/presentation/screens/contact_screen.dart';
import 'package:real_estate_mobile/features/profile/presentation/screens/profile_settings_screen.dart';
final routerProvider = Provider<GoRouter>((ref) {
final authRefreshNotifier = _AuthRefreshNotifier();
@@ -23,7 +26,7 @@ final routerProvider = Provider<GoRouter>((ref) {
});
// Public routes accessible without authentication (matching web middleware)
const publicRoutes = ['/home', '/agents/search', '/agents/detail', '/faq'];
const publicRoutes = ['/home', '/agents/search', '/agents/detail', '/faq', '/contact'];
const authRoutes = ['/login', '/signup'];
return GoRouter(
@@ -81,6 +84,9 @@ final routerProvider = Provider<GoRouter>((ref) {
return AgentDetailScreen(agentId: id);
},
),
ShellRoute(
builder: (context, state, child) => MessagingShell(child: child),
routes: [
GoRoute(
path: '/messages',
builder: (context, state) => const ConversationsScreen(),
@@ -92,10 +98,20 @@ final routerProvider = Provider<GoRouter>((ref) {
return ChatScreen(conversationId: id);
},
),
],
),
GoRoute(
path: '/faq',
builder: (context, state) => const FaqScreen(),
),
GoRoute(
path: '/contact',
builder: (context, state) => const ContactScreen(),
),
GoRoute(
path: '/profile',
builder: (context, state) => const ProfileSettingsScreen(),
),
],
);
});