feat: Implement About Us and Agent Home screens, update routing, and add new assets and plugins.

This commit is contained in:
pradeepkumar
2026-03-08 12:15:15 +05:30
parent 219acfc013
commit b4d22df8ba
17 changed files with 2795 additions and 112 deletions

View File

@@ -0,0 +1,970 @@
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/network/api_client.dart';
import 'package:real_estate_mobile/core/widgets/s3_image.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';
// ── Data models (matching web CMS types) ──
class _AboutHero {
final String badge;
final String headline;
final String description;
final String ctaButtonText;
final String bannerImageUrl;
final String bannerOverlayText;
const _AboutHero({
this.badge = 'Our Mission',
this.headline = 'Connecting You with Trusted Agents.',
this.description =
'We believe finding the right home shouldn\u2019t be complicated. Our platform bridges the gap between buyers, sellers, and verified real estate agents for a smooth and transparent experience.',
this.ctaButtonText = 'Find Your Agent',
this.bannerImageUrl = '',
this.bannerOverlayText = 'Building the future of property.',
});
factory _AboutHero.fromJson(Map<String, dynamic> json) {
return _AboutHero(
badge: json['badge'] as String? ?? 'Our Mission',
headline: json['headline'] as String? ?? 'Connecting You with Trusted Agents.',
description: json['description'] as String? ??
'We believe finding the right home shouldn\u2019t be complicated. Our platform bridges the gap between buyers, sellers, and verified real estate agents for a smooth and transparent experience.',
ctaButtonText: json['ctaButtonText'] as String? ?? 'Find Your Agent',
bannerImageUrl: json['bannerImageUrl'] as String? ?? '',
bannerOverlayText: json['bannerOverlayText'] as String? ?? 'Building the future of property.',
);
}
}
class _AboutStatItem {
final String value;
final String label;
const _AboutStatItem({required this.value, required this.label});
factory _AboutStatItem.fromJson(Map<String, dynamic> json) {
return _AboutStatItem(
value: json['value'] as String? ?? '',
label: json['label'] as String? ?? '',
);
}
}
class _AboutFeatureItem {
final String iconPath;
final String title;
final String description;
const _AboutFeatureItem({
required this.iconPath,
required this.title,
required this.description,
});
factory _AboutFeatureItem.fromJson(Map<String, dynamic> json) {
return _AboutFeatureItem(
iconPath: json['iconPath'] as String? ?? '',
title: json['title'] as String? ?? '',
description: json['description'] as String? ?? '',
);
}
}
class _AboutTeamMember {
final String name;
final String role;
final String imageUrl;
const _AboutTeamMember({
required this.name,
required this.role,
required this.imageUrl,
});
factory _AboutTeamMember.fromJson(Map<String, dynamic> json) {
return _AboutTeamMember(
name: json['name'] as String? ?? '',
role: json['role'] as String? ?? '',
imageUrl: json['imageUrl'] as String? ?? '',
);
}
}
class _AboutCta {
final String title;
final String description;
final String buttonText;
const _AboutCta({
this.title = 'Ready to find a agent',
this.description =
'Search and connect with verified real estate agents who match your needs, location, and goals.',
this.buttonText = 'Find Agents Now',
});
factory _AboutCta.fromJson(Map<String, dynamic> json) {
return _AboutCta(
title: json['title'] as String? ?? 'Ready to find a agent',
description: json['description'] as String? ??
'Search and connect with verified real estate agents who match your needs, location, and goals.',
buttonText: json['buttonText'] as String? ?? 'Find Agents Now',
);
}
}
// ── Defaults ──
const _defaultStats = [
_AboutStatItem(value: '15k+', label: 'Verified Agents'),
_AboutStatItem(value: '98%', label: 'Customer Satisfaction'),
];
const _defaultFeatures = [
_AboutFeatureItem(
iconPath: '',
title: 'Verified Agents',
description:
'All agents on our platform are carefully verified to ensure trust, proven expertise, and consistent service, helping users connect with reliable professionals and enjoy a smooth, confident, and transparent property journey',
),
_AboutFeatureItem(
iconPath: '',
title: 'Total Transparency',
description:
'We maintain complete transparency by clearly displaying agent details, reviews, and activity, allowing users to make informed decisions with confidence, clarity, and trust throughout their entire property journey.',
),
_AboutFeatureItem(
iconPath: '',
title: 'Simple Journey',
description:
'Our platform simplifies every step of the property process, from discovering the right agent to closing with confidence, making the experience smooth, fast, and stress-free for users at every stage.',
),
];
const _defaultTeamMembers = [
_AboutTeamMember(name: 'Andrew', role: 'Founder & CEO', imageUrl: 'asset://professional-1'),
_AboutTeamMember(name: 'Thomas', role: 'Co-Founder', imageUrl: 'asset://professional-2'),
_AboutTeamMember(name: 'Darren', role: 'Advisor', imageUrl: 'asset://professional-3'),
];
// ── State ──
class _AboutState {
final bool isLoading;
final _AboutHero hero;
final List<_AboutStatItem> stats;
final String featuresBadge;
final List<_AboutFeatureItem> features;
final String teamTitle;
final String teamSubtitle;
final List<_AboutTeamMember> teamMembers;
final _AboutCta cta;
final int activeTeamIndex;
const _AboutState({
this.isLoading = true,
this.hero = const _AboutHero(),
this.stats = _defaultStats,
this.featuresBadge = 'Why Choose Us',
this.features = _defaultFeatures,
this.teamTitle = 'Meet the minds behind the platform',
this.teamSubtitle =
'We are a team of passionate innovators, real estate experts, and designers working together to redefine how people connect with trusted agents.',
this.teamMembers = _defaultTeamMembers,
this.cta = const _AboutCta(),
this.activeTeamIndex = 0,
});
_AboutState copyWith({
bool? isLoading,
_AboutHero? hero,
List<_AboutStatItem>? stats,
String? featuresBadge,
List<_AboutFeatureItem>? features,
String? teamTitle,
String? teamSubtitle,
List<_AboutTeamMember>? teamMembers,
_AboutCta? cta,
int? activeTeamIndex,
}) {
return _AboutState(
isLoading: isLoading ?? this.isLoading,
hero: hero ?? this.hero,
stats: stats ?? this.stats,
featuresBadge: featuresBadge ?? this.featuresBadge,
features: features ?? this.features,
teamTitle: teamTitle ?? this.teamTitle,
teamSubtitle: teamSubtitle ?? this.teamSubtitle,
teamMembers: teamMembers ?? this.teamMembers,
cta: cta ?? this.cta,
activeTeamIndex: activeTeamIndex ?? this.activeTeamIndex,
);
}
}
// ── Provider ──
class _AboutNotifier extends StateNotifier<_AboutState> {
_AboutNotifier() : super(const _AboutState()) {
_load();
}
Future<void> _load() async {
try {
final response = await ApiClient.instance.dio.get('/cms/page/about');
final data = response.data['data'] as List<dynamic>?;
if (data != null) {
for (final record in data) {
final map = record as Map<String, dynamic>;
final sectionKey = map['sectionKey'] as String?;
final content = map['content'] as Map<String, dynamic>?;
if (content == null) continue;
switch (sectionKey) {
case 'hero':
if (content['headline'] != null || content['description'] != null) {
state = state.copyWith(hero: _AboutHero.fromJson(content));
}
case 'stats':
final statsList = content['stats'] as List<dynamic>?;
if (statsList != null && statsList.isNotEmpty) {
state = state.copyWith(
stats: statsList
.map((e) => _AboutStatItem.fromJson(e as Map<String, dynamic>))
.toList(),
);
}
case 'features':
final badge = content['badge'] as String?;
final featuresList = content['features'] as List<dynamic>?;
if (badge != null) {
state = state.copyWith(featuresBadge: badge);
}
if (featuresList != null && featuresList.isNotEmpty) {
state = state.copyWith(
features: featuresList
.map((e) => _AboutFeatureItem.fromJson(e as Map<String, dynamic>))
.toList(),
);
}
case 'team':
final title = content['title'] as String?;
final subtitle = content['subtitle'] as String?;
final membersList = content['members'] as List<dynamic>?;
if (title != null) state = state.copyWith(teamTitle: title);
if (subtitle != null) state = state.copyWith(teamSubtitle: subtitle);
if (membersList != null && membersList.isNotEmpty) {
state = state.copyWith(
teamMembers: membersList
.map((e) => _AboutTeamMember.fromJson(e as Map<String, dynamic>))
.toList(),
);
}
case 'cta':
if (content['title'] != null || content['description'] != null) {
state = state.copyWith(cta: _AboutCta.fromJson(content));
}
}
}
}
} catch (_) {
// Fall through to defaults
}
state = state.copyWith(isLoading: false);
}
void setActiveTeamIndex(int index) {
state = state.copyWith(activeTeamIndex: index);
}
}
final _aboutProvider =
StateNotifierProvider.autoDispose<_AboutNotifier, _AboutState>((ref) {
return _AboutNotifier();
});
// ── Feature icon mapping (fallback when CMS has no iconPath) ──
const _featureIconFallback = <String, IconData>{
'Verified Agents': Icons.verified,
'Total Transparency': Icons.lock_outline,
'Simple Journey': Icons.check_circle_outline,
};
// ── Screen ──
class AboutScreen extends ConsumerWidget {
const AboutScreen({super.key});
@override
Widget build(BuildContext context, WidgetRef ref) {
final state = ref.watch(_aboutProvider);
return Scaffold(
backgroundColor: Colors.white,
body: SafeArea(
bottom: false,
child: Column(
children: [
const HomeHeader(),
Expanded(
child: state.isLoading
? const Center(
child: CircularProgressIndicator(
color: AppColors.accentOrange,
),
)
: SingleChildScrollView(
physics: const AlwaysScrollableScrollPhysics(),
child: Column(
children: [
const SizedBox(height: 18),
_buildHeroTitle(state.hero.headline),
const SizedBox(height: 16),
_buildHeroSubtitle(state.hero.description),
const SizedBox(height: 24),
_buildBannerImage(state.hero.bannerImageUrl),
const SizedBox(height: 10),
_buildBannerCaption(state.hero.bannerOverlayText),
const SizedBox(height: 16),
_buildStatsRow(state.stats),
const SizedBox(height: 32),
_buildSectionTitle(state.featuresBadge),
const SizedBox(height: 12),
_buildWhyChooseUsSubtitle(),
const SizedBox(height: 24),
...state.features.map((f) => Padding(
padding: const EdgeInsets.only(bottom: 15),
child: _buildFeatureCard(f),
)),
const SizedBox(height: 25),
_buildTeamSection(context, ref, state),
const SizedBox(height: 40),
_buildCtaSection(context, state.cta),
const SizedBox(height: 30),
],
),
),
),
],
),
),
bottomNavigationBar: _buildBottomNavBar(context, ref),
);
}
// -- Hero Title --
Widget _buildHeroTitle(String text) {
return Padding(
padding: const EdgeInsets.symmetric(horizontal: 40),
child: Text(
text,
textAlign: TextAlign.center,
style: const TextStyle(
fontFamily: 'Fractul',
fontSize: 25,
fontWeight: FontWeight.w700,
color: AppColors.primaryDark,
),
),
);
}
// -- Hero Subtitle --
Widget _buildHeroSubtitle(String text) {
return Padding(
padding: const EdgeInsets.symmetric(horizontal: 40),
child: Text(
text,
textAlign: TextAlign.center,
style: const TextStyle(
fontFamily: 'SourceSerif4',
fontSize: 14,
fontWeight: FontWeight.w500,
color: AppColors.primaryDark,
),
),
);
}
// -- Banner Image (from CMS or fallback to local asset) --
Widget _buildBannerImage(String imageUrl) {
return Padding(
padding: const EdgeInsets.symmetric(horizontal: 57),
child: ClipRRect(
borderRadius: BorderRadius.circular(15),
child: SizedBox(
height: 295,
width: double.infinity,
child: imageUrl.isNotEmpty
? S3Image(
imageUrl: imageUrl,
height: 295,
fit: BoxFit.cover,
errorWidget: (context) => Image.asset(
'assets/images/about_cityscape.png',
height: 295,
width: double.infinity,
fit: BoxFit.cover,
),
)
: Image.asset(
'assets/images/about_cityscape.png',
height: 295,
width: double.infinity,
fit: BoxFit.cover,
),
),
),
);
}
// -- Banner Caption --
Widget _buildBannerCaption(String text) {
return Center(
child: Text(
text,
textAlign: TextAlign.center,
style: const TextStyle(
fontFamily: 'Fractul',
fontSize: 14,
fontWeight: FontWeight.w500,
color: AppColors.primaryDark,
),
),
);
}
// -- Stats Row (dynamic) --
Widget _buildStatsRow(List<_AboutStatItem> stats) {
if (stats.isEmpty) return const SizedBox.shrink();
return Padding(
padding: const EdgeInsets.symmetric(horizontal: 40),
child: Row(
mainAxisAlignment: MainAxisAlignment.spaceAround,
children: stats.map((s) => _buildStatItem(s.value, s.label)).toList(),
),
);
}
Widget _buildStatItem(String value, String label) {
return Column(
children: [
Text(
value,
style: const TextStyle(
fontFamily: 'Fractul',
fontSize: 14,
fontWeight: FontWeight.w500,
color: AppColors.accentOrange,
),
),
const SizedBox(height: 2),
Text(
label,
style: const TextStyle(
fontFamily: 'SourceSerif4',
fontSize: 14,
fontWeight: FontWeight.w400,
color: AppColors.primaryDark,
),
),
],
);
}
// -- Section Title --
Widget _buildSectionTitle(String text) {
return Center(
child: Text(
text,
textAlign: TextAlign.center,
style: const TextStyle(
fontFamily: 'Fractul',
fontSize: 25,
fontWeight: FontWeight.w700,
color: AppColors.primaryDark,
),
),
);
}
// -- Why Choose Us Subtitle --
Widget _buildWhyChooseUsSubtitle() {
return const Padding(
padding: EdgeInsets.symmetric(horizontal: 65),
child: Text(
'Our core principles guide every interaction, ensuring you feel confident and supported at every step.',
textAlign: TextAlign.center,
style: TextStyle(
fontFamily: 'SourceSerif4',
fontSize: 14,
fontWeight: FontWeight.w400,
color: AppColors.primaryDark,
),
),
);
}
// -- Feature Card (dynamic with S3 icon support) --
Widget _buildFeatureCard(_AboutFeatureItem feature) {
final fallbackIcon = _featureIconFallback[feature.title] ?? Icons.star_outline;
return Padding(
padding: const EdgeInsets.symmetric(horizontal: 57),
child: Container(
padding: const EdgeInsets.symmetric(horizontal: 24, vertical: 24),
decoration: BoxDecoration(
borderRadius: BorderRadius.circular(15),
border: Border.all(
color: AppColors.primaryDark.withValues(alpha: 0.1),
width: 0.1,
),
),
child: Column(
children: [
if (feature.iconPath.isNotEmpty)
SizedBox(
width: 36,
height: 36,
child: S3Image(
imageUrl: feature.iconPath,
width: 36,
height: 36,
fit: BoxFit.contain,
errorWidget: (_) => Icon(
fallbackIcon,
size: 36,
color: AppColors.accentOrange,
),
),
)
else
Icon(
fallbackIcon,
size: 36,
color: AppColors.accentOrange,
),
const SizedBox(height: 10),
Text(
feature.title,
textAlign: TextAlign.center,
style: const TextStyle(
fontFamily: 'Fractul',
fontSize: 14,
fontWeight: FontWeight.w700,
color: AppColors.primaryDark,
),
),
const SizedBox(height: 8),
Text(
feature.description,
textAlign: TextAlign.center,
style: const TextStyle(
fontFamily: 'SourceSerif4',
fontSize: 14,
fontWeight: FontWeight.w400,
color: AppColors.primaryDark,
height: 1.5,
),
),
],
),
),
);
}
// -- Team Section (dynamic with carousel) --
Widget _buildTeamSection(
BuildContext context,
WidgetRef ref,
_AboutState state,
) {
final members = state.teamMembers;
if (members.isEmpty) return const SizedBox.shrink();
final activeIndex = state.activeTeamIndex.clamp(0, members.length - 1);
final screenWidth = MediaQuery.of(context).size.width;
final cardWidth = screenWidth - (69 * 2);
return Column(
children: [
Padding(
padding: const EdgeInsets.symmetric(horizontal: 57),
child: Text(
state.teamTitle,
textAlign: TextAlign.center,
style: const TextStyle(
fontFamily: 'Fractul',
fontSize: 25,
fontWeight: FontWeight.w700,
color: AppColors.primaryDark,
),
),
),
const SizedBox(height: 16),
Padding(
padding: const EdgeInsets.symmetric(horizontal: 57),
child: Text(
state.teamSubtitle,
textAlign: TextAlign.center,
style: const TextStyle(
fontFamily: 'SourceSerif4',
fontSize: 14,
fontWeight: FontWeight.w400,
color: AppColors.primaryDark,
),
),
),
const SizedBox(height: 40),
// Team carousel
SizedBox(
height: 330,
child: PageView.builder(
itemCount: members.length,
controller: PageController(
viewportFraction: cardWidth / screenWidth,
),
onPageChanged: (index) {
ref.read(_aboutProvider.notifier).setActiveTeamIndex(index);
},
itemBuilder: (context, index) {
final member = members[index];
return Padding(
padding: const EdgeInsets.symmetric(horizontal: 4),
child: Container(
decoration: BoxDecoration(
borderRadius: BorderRadius.circular(15),
border: Border.all(
color: AppColors.primaryDark.withValues(alpha: 0.1),
width: 0.1,
),
),
child: Column(
children: [
const SizedBox(height: 19),
Padding(
padding: const EdgeInsets.symmetric(horizontal: 16),
child: ClipRRect(
borderRadius: BorderRadius.circular(15),
child: SizedBox(
height: 211,
width: double.infinity,
child: _buildTeamMemberImage(member.imageUrl),
),
),
),
const SizedBox(height: 16),
Text(
member.name,
textAlign: TextAlign.center,
style: const TextStyle(
fontFamily: 'Fractul',
fontSize: 14,
fontWeight: FontWeight.w700,
color: Colors.black,
),
),
const SizedBox(height: 4),
Text(
member.role,
textAlign: TextAlign.center,
style: const TextStyle(
fontFamily: 'Fractul',
fontSize: 14,
fontWeight: FontWeight.w300,
color: Colors.black,
),
),
],
),
),
);
},
),
),
const SizedBox(height: 16),
// Page indicator
if (members.length > 1)
Padding(
padding: const EdgeInsets.symmetric(horizontal: 58),
child: LayoutBuilder(
builder: (context, constraints) {
final totalWidth = constraints.maxWidth;
final segmentWidth = totalWidth / members.length;
return Stack(
children: [
Container(
height: 5,
decoration: BoxDecoration(
color: Colors.white,
borderRadius: BorderRadius.circular(15),
border: Border.all(
color: AppColors.primaryDark.withValues(alpha: 0.1),
width: 0.1,
),
),
),
AnimatedPositioned(
duration: const Duration(milliseconds: 200),
left: segmentWidth * activeIndex,
child: Container(
height: 5,
width: segmentWidth,
decoration: BoxDecoration(
color: AppColors.primaryDark,
borderRadius: BorderRadius.circular(15),
),
),
),
],
);
},
),
),
],
);
}
// -- Team member image (handles asset://, S3 keys, and URLs) --
Widget _buildTeamMemberImage(String imageUrl) {
const placeholder = _TeamMemberPlaceholder();
if (imageUrl.isEmpty) return placeholder;
// Local asset images (defaults)
if (imageUrl.startsWith('asset://')) {
final name = imageUrl.replaceFirst('asset://', '');
return Image.asset(
'assets/images/$name.jpg',
height: 211,
width: double.infinity,
fit: BoxFit.cover,
errorBuilder: (context, error, stackTrace) => placeholder,
);
}
// S3 key or URL from CMS
return S3Image(
imageUrl: imageUrl,
height: 211,
fit: BoxFit.cover,
placeholder: (_) => placeholder,
errorWidget: (_) => placeholder,
);
}
// -- CTA Section (dynamic) --
Widget _buildCtaSection(BuildContext context, _AboutCta cta) {
return Column(
children: [
Text(
cta.title,
textAlign: TextAlign.center,
style: const TextStyle(
fontFamily: 'Fractul',
fontSize: 25,
fontWeight: FontWeight.w700,
color: AppColors.primaryDark,
),
),
const SizedBox(height: 16),
Padding(
padding: const EdgeInsets.symmetric(horizontal: 83),
child: Text(
cta.description,
textAlign: TextAlign.center,
style: const TextStyle(
fontFamily: 'SourceSerif4',
fontSize: 14,
fontWeight: FontWeight.w700,
color: AppColors.primaryDark,
),
),
),
const SizedBox(height: 24),
// Illustration
Padding(
padding: const EdgeInsets.symmetric(horizontal: 34),
child: ClipRRect(
borderRadius: BorderRadius.circular(56),
child: Image.asset(
'assets/images/about_find_agent.jpg',
height: 241,
width: double.infinity,
fit: BoxFit.cover,
),
),
),
const SizedBox(height: 24),
// Find Agents Now button
GestureDetector(
onTap: () => context.push('/agents/search'),
child: Container(
height: 50,
width: 210,
decoration: BoxDecoration(
color: AppColors.accentOrange,
borderRadius: BorderRadius.circular(25),
),
alignment: Alignment.center,
child: Text(
cta.buttonText,
style: const TextStyle(
fontFamily: 'Fractul',
fontSize: 16,
fontWeight: FontWeight.w700,
color: AppColors.primaryDark,
),
),
),
),
const SizedBox(height: 12),
// Get Help button
GestureDetector(
onTap: () => context.push('/contact'),
child: Container(
height: 50,
width: 210,
decoration: BoxDecoration(
borderRadius: BorderRadius.circular(25),
border: Border.all(
color: AppColors.primaryDark,
width: 1,
),
),
alignment: Alignment.center,
child: const Text(
'Get Help',
style: TextStyle(
fontFamily: 'Fractul',
fontSize: 16,
fontWeight: FontWeight.w700,
color: AppColors.primaryDark,
),
),
),
),
],
);
}
// -- Bottom Navigation Bar --
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,
ref: ref,
svgPath: 'assets/icons/nav_home_icon.svg',
fallbackIcon: Icons.home,
isSelected: false,
onTap: () => context.go('/home'),
),
_buildNavItem(
context: context,
ref: ref,
svgPath: 'assets/icons/nav_list_icon.svg',
fallbackIcon: Icons.list,
isSelected: false,
onTap: () => context.push('/agents/search'),
),
_buildNavItem(
context: context,
ref: ref,
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(
context: context,
ref: ref,
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 WidgetRef ref,
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,
),
),
),
);
}
}
class _TeamMemberPlaceholder extends StatelessWidget {
const _TeamMemberPlaceholder();
@override
Widget build(BuildContext context) {
return Container(
color: const Color(0xFFC4D9D4),
child: const Center(
child: Icon(Icons.person, size: 60, color: AppColors.primaryDark),
),
);
}
}

File diff suppressed because it is too large Load Diff

View File

@@ -148,7 +148,10 @@ class _MenuDrawer extends ConsumerWidget {
context,
icon: Icons.info_outline,
label: 'About Us',
onTap: () => Navigator.pop(context),
onTap: () {
Navigator.pop(context);
context.push('/about');
},
),
_buildMenuItem(
context,

View File

@@ -4,9 +4,12 @@ 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/core/utils/image_url_resolver.dart';
import 'package:real_estate_mobile/core/widgets/s3_image.dart';
import 'package:real_estate_mobile/features/auth/presentation/providers/auth_provider.dart';
import 'package:real_estate_mobile/features/messaging/data/models/messaging_models.dart';
import 'package:real_estate_mobile/features/messaging/presentation/providers/messaging_provider.dart';
import 'package:url_launcher/url_launcher.dart';
class ChatScreen extends ConsumerStatefulWidget {
final String conversationId;
@@ -102,10 +105,12 @@ class _ChatScreenState extends ConsumerState<ChatScreen> {
String _formatTime(String isoTimestamp) {
try {
final dt = DateTime.parse(isoTimestamp).toLocal();
final hour =
dt.hour > 12 ? dt.hour - 12 : (dt.hour == 0 ? 12 : dt.hour);
final hour = dt.hour > 12
? dt.hour - 12
: (dt.hour == 0 ? 12 : dt.hour);
final minute = dt.minute.toString().padLeft(2, '0');
return '$hour.$minute';
final amPm = dt.hour >= 12 ? 'PM' : 'AM';
return '$hour:$minute $amPm';
} catch (_) {
return '';
}
@@ -156,6 +161,18 @@ class _ChatScreenState extends ConsumerState<ChatScreen> {
return '$base/$avatarKey';
}
String _formatFileSize(int? bytes) {
if (bytes == null || bytes == 0) return '';
if (bytes < 1024) return '$bytes B';
if (bytes < 1024 * 1024) return '${(bytes / 1024).toStringAsFixed(1)} KB';
return '${(bytes / (1024 * 1024)).toStringAsFixed(1)} MB';
}
bool _isGiphyUrl(String text) {
final trimmed = text.trim();
return trimmed.contains('giphy.com/') || trimmed.contains('giphy.gif');
}
// ============================================================
// BUILD
// ============================================================
@@ -219,7 +236,7 @@ class _ChatScreenState extends ConsumerState<ChatScreen> {
}
// ============================================================
// HEADER - matches Figma: rounded border, back arrow, name, status, icons
// HEADER
// ============================================================
Widget _buildHeader({
@@ -319,7 +336,7 @@ class _ChatScreenState extends ConsumerState<ChatScreen> {
}
// ============================================================
// CHAT BODY (profile section + messages)
// CHAT BODY
// ============================================================
Widget _buildChatBody({
@@ -372,7 +389,7 @@ class _ChatScreenState extends ConsumerState<ChatScreen> {
}
// ============================================================
// PROFILE SECTION (large avatar, name, specialties)
// PROFILE SECTION
// ============================================================
Widget _buildProfileSection({
@@ -399,7 +416,7 @@ class _ChatScreenState extends ConsumerState<ChatScreen> {
width: 80,
height: 80,
fit: BoxFit.cover,
errorWidget: (_, __, ___) =>
errorWidget: (_, e, s) =>
_AvatarFallback(letter: initials, size: 80),
)
: _AvatarFallback(letter: initials, size: 80),
@@ -472,7 +489,6 @@ class _ChatScreenState extends ConsumerState<ChatScreen> {
}
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)
@@ -517,7 +533,6 @@ class _ChatScreenState extends ConsumerState<ChatScreen> {
required bool isTyping,
required bool isLoadingMore,
}) {
// +1 for the profile section header at the end of reversed list
final itemCount =
messages.length + 1 + (isLoadingMore ? 1 : 0) + (isTyping ? 1 : 0);
@@ -533,7 +548,6 @@ 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(
@@ -570,7 +584,6 @@ class _ChatScreenState extends ConsumerState<ChatScreen> {
final showDateSeparator =
_shouldShowDateSeparator(messages, adjustedIndex);
// Get sender info
final senderName = isMine
? (message.sender.displayName.isNotEmpty
? message.sender.displayName
@@ -634,9 +647,222 @@ class _ChatScreenState extends ConsumerState<ChatScreen> {
);
}
// -- Sent Message (right-aligned, with avatar on right) --
// ============================================================
// MESSAGE CONTENT (handles text, image, gif, file)
// ============================================================
Widget _buildMessageContent(ChatMessage message, {required bool isMine}) {
switch (message.messageType) {
case MessageType.image:
return _buildImageContent(message, isMine: isMine);
case MessageType.file:
return _buildFileContent(message);
case MessageType.system:
return _buildSystemContent(message);
case MessageType.text:
// Detect Giphy URLs sent as text and render as GIF image
if (_isGiphyUrl(message.content)) {
return _buildImageContent(message, isMine: isMine);
}
return Text(
message.content,
style: const TextStyle(
fontFamily: 'Fractul',
fontSize: 10,
fontWeight: FontWeight.w400,
color: AppColors.primaryDark,
),
textAlign: isMine ? TextAlign.right : TextAlign.left,
);
}
}
// -- Image / GIF content --
Widget _buildImageContent(ChatMessage message, {required bool isMine}) {
final imageUrl = message.fileUrl ?? message.content;
// GIF from Giphy (direct URL) or S3 image
final isDirectUrl = imageUrl.startsWith('http://') || imageUrl.startsWith('https://');
return GestureDetector(
onTap: () => _showImageFullScreen(context, imageUrl),
child: ClipRRect(
borderRadius: BorderRadius.circular(12),
child: ConstrainedBox(
constraints: const BoxConstraints(
maxWidth: 250,
maxHeight: 250,
),
child: isDirectUrl
? CachedNetworkImage(
imageUrl: imageUrl,
fit: BoxFit.cover,
placeholder: (context, url) => _buildImagePlaceholder(),
errorWidget: (context, url, error) => _buildImageError(),
)
: S3Image(
imageUrl: imageUrl,
fit: BoxFit.cover,
placeholder: (_) => _buildImagePlaceholder(),
errorWidget: (_) => _buildImageError(),
),
),
),
);
}
Widget _buildImagePlaceholder() {
return Container(
width: 200,
height: 150,
color: const Color(0xFFF0F5FC),
child: const Center(
child: CircularProgressIndicator(
color: AppColors.accentOrange,
strokeWidth: 2,
),
),
);
}
Widget _buildImageError() {
return Container(
width: 200,
height: 150,
color: const Color(0xFFF0F5FC),
child: const Column(
mainAxisAlignment: MainAxisAlignment.center,
children: [
Icon(Icons.broken_image_outlined, size: 40, color: AppColors.hintText),
SizedBox(height: 4),
Text(
'Image not available',
style: TextStyle(
fontFamily: 'SourceSerif4',
fontSize: 12,
color: AppColors.hintText,
),
),
],
),
);
}
// -- File content --
Widget _buildFileContent(ChatMessage message) {
final fileName = message.fileName ?? 'File';
final fileSize = _formatFileSize(message.fileSize);
return GestureDetector(
onTap: () => _openOrDownloadFile(message),
child: Container(
padding: const EdgeInsets.symmetric(horizontal: 14, vertical: 10),
decoration: BoxDecoration(
color: const Color(0xFFF0F5FC),
borderRadius: BorderRadius.circular(12),
border: Border.all(
color: AppColors.primaryDark.withValues(alpha: 0.1),
),
),
child: Row(
mainAxisSize: MainAxisSize.min,
children: [
Container(
width: 40,
height: 40,
decoration: BoxDecoration(
color: AppColors.accentOrange.withValues(alpha: 0.15),
borderRadius: BorderRadius.circular(8),
),
child: const Icon(
Icons.attach_file,
size: 20,
color: AppColors.accentOrange,
),
),
const SizedBox(width: 10),
Flexible(
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(
fileName,
style: const TextStyle(
fontFamily: 'Fractul',
fontSize: 12,
fontWeight: FontWeight.w600,
color: AppColors.primaryDark,
),
maxLines: 1,
overflow: TextOverflow.ellipsis,
),
if (fileSize.isNotEmpty)
Text(
fileSize,
style: const TextStyle(
fontFamily: 'SourceSerif4',
fontSize: 10,
fontWeight: FontWeight.w400,
color: AppColors.hintText,
),
),
],
),
),
const SizedBox(width: 8),
const Icon(
Icons.download_rounded,
size: 20,
color: AppColors.accentOrange,
),
],
),
),
);
}
// -- System message --
Widget _buildSystemContent(ChatMessage message) {
return Container(
padding: const EdgeInsets.symmetric(horizontal: 12, vertical: 6),
decoration: BoxDecoration(
color: const Color(0xFFF0F5FC),
borderRadius: BorderRadius.circular(8),
),
child: Text(
message.content,
style: const TextStyle(
fontFamily: 'SourceSerif4',
fontSize: 12,
fontWeight: FontWeight.w400,
color: AppColors.hintText,
fontStyle: FontStyle.italic,
),
textAlign: TextAlign.center,
),
);
}
// -- Message status indicator --
Widget _buildStatusIcon(MessageStatus status) {
switch (status) {
case MessageStatus.read:
return const Icon(Icons.done_all, size: 14, color: Color(0xFF2196F3));
case MessageStatus.delivered:
return Icon(Icons.done_all, size: 14, color: AppColors.hintText);
case MessageStatus.sent:
return Icon(Icons.done, size: 14, color: AppColors.hintText);
}
}
// ============================================================
// SENT MESSAGE (right-aligned)
// ============================================================
Widget _buildSentMessage(ChatMessage message, String senderName) {
final timestamp = _formatTime(message.createdAt);
final isMediaMessage = message.messageType == MessageType.image ||
message.messageType == MessageType.file;
return Row(
mainAxisAlignment: MainAxisAlignment.end,
@@ -648,10 +874,12 @@ class _ChatScreenState extends ConsumerState<ChatScreen> {
child: Column(
crossAxisAlignment: CrossAxisAlignment.end,
children: [
// Name row with timestamp
// Name row with timestamp and status
Row(
mainAxisSize: MainAxisSize.min,
children: [
_buildStatusIcon(message.status),
const SizedBox(width: 4),
Text(
timestamp,
style: const TextStyle(
@@ -693,17 +921,23 @@ class _ChatScreenState extends ConsumerState<ChatScreen> {
],
),
const SizedBox(height: 6),
// Message text
Text(
message.content,
style: const TextStyle(
fontFamily: 'Fractul',
fontSize: 10,
fontWeight: FontWeight.w400,
color: AppColors.primaryDark,
// Message content
_buildMessageContent(message, isMine: true),
// Show text content below media if present
if (isMediaMessage && message.content.isNotEmpty)
Padding(
padding: const EdgeInsets.only(top: 4),
child: Text(
message.content,
style: const TextStyle(
fontFamily: 'Fractul',
fontSize: 10,
fontWeight: FontWeight.w400,
color: AppColors.primaryDark,
),
textAlign: TextAlign.right,
),
),
textAlign: TextAlign.right,
),
],
),
),
@@ -714,10 +948,15 @@ class _ChatScreenState extends ConsumerState<ChatScreen> {
);
}
// -- Received Message (left-aligned, with avatar on left) --
// ============================================================
// RECEIVED MESSAGE (left-aligned)
// ============================================================
Widget _buildReceivedMessage(
ChatMessage message, String senderName, String? avatar) {
final timestamp = _formatTime(message.createdAt);
final isMediaMessage = message.messageType == MessageType.image ||
message.messageType == MessageType.file;
return Row(
mainAxisAlignment: MainAxisAlignment.start,
@@ -776,16 +1015,22 @@ class _ChatScreenState extends ConsumerState<ChatScreen> {
],
),
const SizedBox(height: 6),
// Message text
Text(
message.content,
style: const TextStyle(
fontFamily: 'Fractul',
fontSize: 10,
fontWeight: FontWeight.w400,
color: AppColors.primaryDark,
// Message content
_buildMessageContent(message, isMine: false),
// Show text content below media if present
if (isMediaMessage && message.content.isNotEmpty)
Padding(
padding: const EdgeInsets.only(top: 4),
child: Text(
message.content,
style: const TextStyle(
fontFamily: 'Fractul',
fontSize: 10,
fontWeight: FontWeight.w400,
color: AppColors.primaryDark,
),
),
),
),
],
),
),
@@ -806,7 +1051,7 @@ class _ChatScreenState extends ConsumerState<ChatScreen> {
width: size,
height: size,
fit: BoxFit.cover,
errorWidget: (_, __, ___) =>
errorWidget: (_, e, s) =>
_AvatarFallback(letter: initials, size: size),
)
: _AvatarFallback(letter: initials, size: size),
@@ -854,7 +1099,83 @@ class _ChatScreenState extends ConsumerState<ChatScreen> {
}
// ============================================================
// INPUT AREA - matches Figma: + circle, text input, send, mic circle
// IMAGE FULL SCREEN VIEWER
// ============================================================
void _showImageFullScreen(BuildContext context, String imageUrl) {
final isDirectUrl = imageUrl.startsWith('http://') || imageUrl.startsWith('https://');
Navigator.of(context).push(
MaterialPageRoute(
builder: (_) => Scaffold(
backgroundColor: Colors.black,
appBar: AppBar(
backgroundColor: Colors.black,
iconTheme: const IconThemeData(color: Colors.white),
elevation: 0,
),
body: Center(
child: InteractiveViewer(
minScale: 0.5,
maxScale: 4.0,
child: isDirectUrl
? CachedNetworkImage(
imageUrl: imageUrl,
fit: BoxFit.contain,
placeholder: (context, url) => const Center(
child: CircularProgressIndicator(color: Colors.white),
),
errorWidget: (context, url, error) => const Icon(
Icons.broken_image,
size: 80,
color: Colors.white54,
),
)
: S3Image(
imageUrl: imageUrl,
fit: BoxFit.contain,
placeholder: (_) => const Center(
child: CircularProgressIndicator(color: Colors.white),
),
errorWidget: (_) => const Icon(
Icons.broken_image,
size: 80,
color: Colors.white54,
),
),
),
),
),
),
);
}
// ============================================================
// FILE OPEN / DOWNLOAD
// ============================================================
Future<void> _openOrDownloadFile(ChatMessage message) async {
final fileUrl = message.fileUrl;
if (fileUrl == null || fileUrl.isEmpty) return;
String? url;
if (fileUrl.startsWith('http://') || fileUrl.startsWith('https://')) {
url = fileUrl;
} else {
// Resolve S3 key to presigned download URL
url = await ImageUrlResolver.instance.resolve(fileUrl);
}
if (url != null && url.isNotEmpty) {
final uri = Uri.parse(url);
if (await canLaunchUrl(uri)) {
await launchUrl(uri, mode: LaunchMode.externalApplication);
}
}
}
// ============================================================
// INPUT AREA
// ============================================================
Widget _buildInputArea() {
@@ -864,7 +1185,7 @@ class _ChatScreenState extends ConsumerState<ChatScreen> {
child: Row(
crossAxisAlignment: CrossAxisAlignment.center,
children: [
// Plus icon (plain, no border)
// Plus icon
GestureDetector(
onTap: _showAttachmentOptions,
behavior: HitTestBehavior.opaque,
@@ -881,15 +1202,17 @@ class _ChatScreenState extends ConsumerState<ChatScreen> {
// Text input pill with send icon inside
Expanded(
child: Container(
height: 40,
height: 44,
margin: const EdgeInsets.only(left: 4),
decoration: BoxDecoration(
color: Colors.white,
borderRadius: BorderRadius.circular(20),
borderRadius: BorderRadius.circular(22),
border: Border.all(
color: AppColors.primaryDark,
width: 1.5,
),
),
clipBehavior: Clip.antiAlias,
child: Row(
children: [
Expanded(
@@ -913,8 +1236,8 @@ class _ChatScreenState extends ConsumerState<ChatScreen> {
color: AppColors.primaryDark,
),
contentPadding: EdgeInsets.symmetric(
horizontal: 16,
vertical: 8,
horizontal: 18,
vertical: 10,
),
border: InputBorder.none,
enabledBorder: InputBorder.none,
@@ -927,7 +1250,7 @@ class _ChatScreenState extends ConsumerState<ChatScreen> {
onTap: _sendMessage,
behavior: HitTestBehavior.opaque,
child: Padding(
padding: const EdgeInsets.only(right: 12),
padding: const EdgeInsets.only(right: 14),
child: Icon(
Icons.send,
size: 20,

View File

@@ -63,15 +63,15 @@ class _ConversationsScreenState extends ConsumerState<ConversationsScreen> {
// -- Header with back arrow, search, icons --
Widget _buildHeader() {
return Container(
padding: const EdgeInsets.symmetric(horizontal: 12, vertical: 12),
padding: const EdgeInsets.fromLTRB(16, 8, 16, 12),
child: Container(
height: 51,
height: 48,
decoration: BoxDecoration(
border: Border.all(
color: AppColors.primaryDark,
width: 0.5,
color: AppColors.primaryDark.withValues(alpha: 0.2),
width: 1,
),
borderRadius: BorderRadius.circular(7),
borderRadius: BorderRadius.circular(15),
),
child: Row(
children: [
@@ -88,11 +88,11 @@ class _ConversationsScreenState extends ConsumerState<ConversationsScreen> {
}
},
behavior: HitTestBehavior.opaque,
child: Padding(
padding: const EdgeInsets.symmetric(horizontal: 12),
child: const Padding(
padding: EdgeInsets.symmetric(horizontal: 14),
child: Icon(
Icons.arrow_back_ios_new,
size: 17,
size: 16,
color: AppColors.primaryDark,
),
),
@@ -106,7 +106,7 @@ class _ConversationsScreenState extends ConsumerState<ConversationsScreen> {
style: const TextStyle(
fontFamily: 'Fractul',
fontSize: 15,
fontWeight: FontWeight.w700,
fontWeight: FontWeight.w600,
color: AppColors.primaryDark,
),
decoration: const InputDecoration(
@@ -114,7 +114,7 @@ class _ConversationsScreenState extends ConsumerState<ConversationsScreen> {
hintStyle: TextStyle(
fontFamily: 'Fractul',
fontSize: 15,
fontWeight: FontWeight.w700,
fontWeight: FontWeight.w600,
color: AppColors.hintText,
),
border: InputBorder.none,
@@ -129,7 +129,7 @@ class _ConversationsScreenState extends ConsumerState<ConversationsScreen> {
style: TextStyle(
fontFamily: 'Fractul',
fontSize: 15,
fontWeight: FontWeight.w700,
fontWeight: FontWeight.w600,
color: AppColors.primaryDark,
),
),
@@ -139,11 +139,11 @@ class _ConversationsScreenState extends ConsumerState<ConversationsScreen> {
GestureDetector(
onTap: () => setState(() => _isSearchActive = true),
behavior: HitTestBehavior.opaque,
child: Padding(
padding: const EdgeInsets.symmetric(horizontal: 8),
child: const Padding(
padding: EdgeInsets.symmetric(horizontal: 10),
child: Icon(
Icons.search,
size: 20,
size: 22,
color: AppColors.primaryDark,
),
),
@@ -152,11 +152,11 @@ class _ConversationsScreenState extends ConsumerState<ConversationsScreen> {
GestureDetector(
onTap: () {},
behavior: HitTestBehavior.opaque,
child: Padding(
padding: const EdgeInsets.only(right: 12, left: 4),
child: const Padding(
padding: EdgeInsets.only(right: 14, left: 2),
child: Icon(
Icons.more_horiz,
size: 20,
size: 22,
color: AppColors.primaryDark,
),
),
@@ -173,7 +173,7 @@ class _ConversationsScreenState extends ConsumerState<ConversationsScreen> {
baseColor: const Color(0xFFE8E8E8),
highlightColor: const Color(0xFFF5F5F5),
child: ListView.builder(
padding: const EdgeInsets.symmetric(horizontal: 12),
padding: const EdgeInsets.symmetric(horizontal: 16),
physics: const NeverScrollableScrollPhysics(),
itemCount: 6,
itemBuilder: (_, index) => Padding(
@@ -181,14 +181,14 @@ class _ConversationsScreenState extends ConsumerState<ConversationsScreen> {
child: Row(
children: [
Container(
width: 52,
height: 52,
width: 56,
height: 56,
decoration: const BoxDecoration(
color: Colors.white,
shape: BoxShape.circle,
),
),
const SizedBox(width: 10),
const SizedBox(width: 14),
Expanded(
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
@@ -215,7 +215,7 @@ class _ConversationsScreenState extends ConsumerState<ConversationsScreen> {
),
const SizedBox(width: 8),
Container(
width: 45,
width: 55,
height: 12,
decoration: BoxDecoration(
color: Colors.white,
@@ -303,10 +303,13 @@ class _ConversationsScreenState extends ConsumerState<ConversationsScreen> {
child: ListView.separated(
padding: EdgeInsets.zero,
itemCount: filtered.length,
separatorBuilder: (_, __) => Divider(
color: AppColors.primaryDark.withValues(alpha: 0.1),
height: 0.5,
thickness: 0.5,
separatorBuilder: (context, index) => Padding(
padding: const EdgeInsets.symmetric(horizontal: 16),
child: Divider(
color: AppColors.primaryDark.withValues(alpha: 0.1),
height: 1,
thickness: 1,
),
),
itemBuilder: (context, index) {
return _ConversationTile(
@@ -318,7 +321,6 @@ class _ConversationsScreenState extends ConsumerState<ConversationsScreen> {
),
);
}
}
// -- Conversation Tile Widget --
@@ -350,6 +352,7 @@ class _ConversationTile extends StatelessWidget {
final avatarUrl = _resolveAvatarUrl(otherParty.avatar);
final lastMessage = conversation.lastMessageText ?? '';
final timestamp = _formatTimestamp(conversation.lastMessageAt);
final unread = conversation.unreadCount;
final firstLetter = otherParty.name.isNotEmpty
? otherParty.name[0].toUpperCase()
: '?';
@@ -357,32 +360,45 @@ class _ConversationTile extends StatelessWidget {
return InkWell(
onTap: onTap,
child: Padding(
padding: const EdgeInsets.symmetric(horizontal: 12, vertical: 12),
padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 14),
child: Row(
crossAxisAlignment: CrossAxisAlignment.center,
children: [
// Avatar with online indicator
Stack(
children: [
ClipOval(
child: avatarUrl.isNotEmpty
? CachedNetworkImage(
imageUrl: avatarUrl,
width: 52,
height: 52,
fit: BoxFit.cover,
placeholder: (_, url) => _AvatarFallback(
letter: firstLetter,
),
errorWidget: (_, url, err) => _AvatarFallback(
letter: firstLetter,
),
)
: _AvatarFallback(letter: firstLetter),
Container(
width: 56,
height: 56,
decoration: BoxDecoration(
shape: BoxShape.circle,
border: Border.all(
color: AppColors.primaryDark.withValues(alpha: 0.15),
width: 1.5,
),
),
child: ClipOval(
child: avatarUrl.isNotEmpty
? CachedNetworkImage(
imageUrl: avatarUrl,
width: 56,
height: 56,
fit: BoxFit.cover,
placeholder: (context, url) => _AvatarFallback(
letter: firstLetter,
),
errorWidget: (context, url, err) =>
_AvatarFallback(
letter: firstLetter,
),
)
: _AvatarFallback(letter: firstLetter),
),
),
if (otherParty.isOnline)
Positioned(
right: 0,
bottom: 0,
right: 1,
bottom: 1,
child: Container(
width: 14,
height: 14,
@@ -398,31 +414,61 @@ class _ConversationTile extends StatelessWidget {
),
],
),
const SizedBox(width: 10),
const SizedBox(width: 14),
// Name and last message
Expanded(
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(
otherParty.name,
style: const TextStyle(
fontFamily: 'Fractul',
fontSize: 14,
fontWeight: FontWeight.w700,
color: AppColors.primaryDark,
),
maxLines: 1,
overflow: TextOverflow.ellipsis,
// Name row with optional unread badge
Row(
children: [
Expanded(
child: Text(
otherParty.name,
style: TextStyle(
fontFamily: 'Fractul',
fontSize: 14,
fontWeight:
unread > 0 ? FontWeight.w700 : FontWeight.w600,
color: AppColors.primaryDark,
),
maxLines: 1,
overflow: TextOverflow.ellipsis,
),
),
if (unread > 0) ...[
const SizedBox(width: 8),
Container(
padding: const EdgeInsets.symmetric(
horizontal: 7, vertical: 2),
decoration: BoxDecoration(
color: AppColors.accentOrange,
borderRadius: BorderRadius.circular(10),
),
child: Text(
'$unread',
style: const TextStyle(
fontFamily: 'Fractul',
fontSize: 11,
fontWeight: FontWeight.w700,
color: Colors.white,
),
),
),
],
],
),
const SizedBox(height: 4),
Text(
lastMessage,
style: const TextStyle(
style: TextStyle(
fontFamily: 'SourceSerif4',
fontSize: 14,
fontSize: 13,
fontWeight: FontWeight.w400,
color: AppColors.primaryDark,
color: unread > 0
? AppColors.primaryDark
: AppColors.hintText,
),
maxLines: 1,
overflow: TextOverflow.ellipsis,
@@ -430,15 +476,17 @@ class _ConversationTile extends StatelessWidget {
],
),
),
const SizedBox(width: 8),
const SizedBox(width: 10),
// Timestamp
Text(
timestamp,
style: const TextStyle(
style: TextStyle(
fontFamily: 'SourceSerif4',
fontSize: 14,
fontSize: 13,
fontWeight: FontWeight.w400,
color: AppColors.primaryDark,
color: unread > 0
? AppColors.primaryDark
: AppColors.hintText,
),
),
],
@@ -458,8 +506,8 @@ class _AvatarFallback extends StatelessWidget {
@override
Widget build(BuildContext context) {
return Container(
width: 52,
height: 52,
width: 56,
height: 56,
decoration: const BoxDecoration(
color: Color(0xFFE8E8E8),
shape: BoxShape.circle,
@@ -469,7 +517,7 @@ class _AvatarFallback extends StatelessWidget {
letter,
style: const TextStyle(
fontFamily: 'Fractul',
fontSize: 20,
fontSize: 22,
fontWeight: FontWeight.w700,
color: AppColors.primaryDark,
),
@@ -497,7 +545,7 @@ String _formatTimestamp(String? isoTimestamp) {
return 'Now';
}
// Same day: show time like "2 hours Ago"
// Same day: show relative time
if (messageDate == today) {
if (diff.inHours >= 1) {
return '${diff.inHours} hour${diff.inHours > 1 ? 's' : ''} Ago';

View File

@@ -11,7 +11,9 @@ import 'package:real_estate_mobile/features/home/presentation/screens/home_scree
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/agents/presentation/screens/agent_home_screen.dart';
import 'package:real_estate_mobile/features/contact/presentation/screens/contact_screen.dart';
import 'package:real_estate_mobile/features/about/presentation/screens/about_screen.dart';
import 'package:real_estate_mobile/features/profile/presentation/screens/profile_settings_screen.dart';
final routerProvider = Provider<GoRouter>((ref) {
@@ -26,7 +28,7 @@ final routerProvider = Provider<GoRouter>((ref) {
});
// Public routes accessible without authentication (matching web middleware)
const publicRoutes = ['/home', '/agents/search', '/agents/detail', '/faq', '/contact'];
const publicRoutes = ['/home', '/agents/search', '/agents/detail', '/faq', '/contact', '/about'];
const authRoutes = ['/login', '/signup'];
return GoRouter(
@@ -68,7 +70,7 @@ final routerProvider = Provider<GoRouter>((ref) {
),
GoRoute(
path: '/home',
builder: (context, state) => const HomeScreen(),
builder: (context, state) => const _HomeRouteWrapper(),
),
GoRoute(
path: '/agents/search',
@@ -108,6 +110,10 @@ final routerProvider = Provider<GoRouter>((ref) {
path: '/contact',
builder: (context, state) => const ContactScreen(),
),
GoRoute(
path: '/about',
builder: (context, state) => const AboutScreen(),
),
GoRoute(
path: '/profile',
builder: (context, state) => const ProfileSettingsScreen(),
@@ -116,6 +122,21 @@ final routerProvider = Provider<GoRouter>((ref) {
);
});
/// Reactively switches between HomeScreen and AgentHomeScreen based on auth.
class _HomeRouteWrapper extends ConsumerWidget {
const _HomeRouteWrapper();
@override
Widget build(BuildContext context, WidgetRef ref) {
final authState = ref.watch(authProvider);
if (authState.status == AuthStatus.authenticated &&
authState.user?.role == 'AGENT') {
return const AgentHomeScreen();
}
return const HomeScreen();
}
}
/// Notifier that triggers GoRouter refresh when auth state changes.
class _AuthRefreshNotifier extends ChangeNotifier {
void notify() {