Files

857 lines
27 KiB
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/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';
// ── 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 _AboutTeamCard {
final String iconPath;
final String title;
final String description;
const _AboutTeamCard({
required this.iconPath,
required this.title,
required this.description,
});
factory _AboutTeamCard.fromJson(Map<String, dynamic> json) {
return _AboutTeamCard(
iconPath: json['iconPath'] as String? ?? '',
title: json['title'] as String? ?? '',
description: json['description'] 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 ──
// Empty defaults — only CMS-provided content should render (matches web behavior)
const _defaultStats = <_AboutStatItem>[];
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 _defaultTeamCards = <_AboutTeamCard>[];
// ── 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 String teamIntro;
final List<_AboutTeamCard> teamCards;
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.teamIntro = '',
this.teamCards = _defaultTeamCards,
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,
String? teamIntro,
List<_AboutTeamCard>? teamCards,
_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,
teamIntro: teamIntro ?? this.teamIntro,
teamCards: teamCards ?? this.teamCards,
cta: cta ?? this.cta,
activeTeamIndex: activeTeamIndex ?? this.activeTeamIndex,
);
}
}
// ── Provider ──
class _AboutNotifier extends StateNotifier<_AboutState> {
_AboutNotifier() : super(const _AboutState()) {
// Defer CMS fetch so the first frame paints before the HTTP request
Future.microtask(_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 intro = content['intro'] as String?;
final cardsList = content['cards'] as List<dynamic>?;
if (title != null) state = state.copyWith(teamTitle: title);
if (subtitle != null) state = state.copyWith(teamSubtitle: subtitle);
if (intro != null) state = state.copyWith(teamIntro: intro);
if (cardsList != null && cardsList.isNotEmpty) {
state = state.copyWith(
teamCards: cardsList
.map((e) => _AboutTeamCard.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);
if (state.isLoading) {
// Full-size placeholder so slide-in transition doesn't pop blank
return Container(
color: Colors.white,
alignment: Alignment.topCenter,
padding: const EdgeInsets.only(top: 120),
child: const SizedBox(
width: 28,
height: 28,
child: CircularProgressIndicator(
color: AppColors.accentOrange,
strokeWidth: 2.5,
),
),
);
}
return SingleChildScrollView(
physics: const AlwaysScrollableScrollPhysics(),
child: Column(
children: [
// Back button
Padding(
padding: const EdgeInsets.fromLTRB(16, 8, 16, 0),
child: Align(
alignment: Alignment.centerLeft,
child: GestureDetector(
onTap: () {
if (Navigator.of(context).canPop()) {
Navigator.of(context).pop();
} else {
context.go('/home');
}
},
child: Container(
width: 36,
height: 36,
decoration: BoxDecoration(
color: AppColors.primaryDark.withValues(alpha: 0.08),
shape: BoxShape.circle,
),
child: const Icon(
Icons.arrow_back_ios_new_rounded,
color: AppColors.primaryDark,
size: 18,
),
),
),
),
),
const SizedBox(height: 10),
_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: 24),
_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: 20),
_buildTeamSection(context, ref, state),
const SizedBox(height: 24),
_buildCtaSection(context, state.cta),
const SizedBox(height: 24),
],
),
);
}
// -- 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 — matches web: title, subtitle, intro, icon cards) --
Widget _buildTeamSection(
BuildContext context,
WidgetRef ref,
_AboutState state,
) {
final cards = state.teamCards;
final hasContent = state.teamTitle.isNotEmpty ||
state.teamSubtitle.isNotEmpty ||
state.teamIntro.isNotEmpty ||
cards.isNotEmpty;
if (!hasContent) return const SizedBox.shrink();
return Column(
children: [
if (state.teamTitle.isNotEmpty)
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,
),
),
),
if (state.teamSubtitle.isNotEmpty) ...[
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,
),
),
),
],
if (state.teamIntro.isNotEmpty) ...[
const SizedBox(height: 16),
Padding(
padding: const EdgeInsets.symmetric(horizontal: 24),
child: Text(
state.teamIntro,
textAlign: TextAlign.center,
style: const TextStyle(
fontFamily: 'SourceSerif4',
fontSize: 14,
fontWeight: FontWeight.w400,
color: AppColors.primaryDark,
height: 1.5,
),
),
),
],
if (cards.isNotEmpty) ...[
const SizedBox(height: 24),
...cards.map(
(card) => Padding(
padding: const EdgeInsets.fromLTRB(24, 0, 24, 15),
child: _buildTeamCard(card),
),
),
],
],
);
}
Widget _buildTeamCard(_AboutTeamCard card) {
return Container(
width: double.infinity,
padding: const EdgeInsets.all(24),
decoration: BoxDecoration(
borderRadius: BorderRadius.circular(15),
border: Border.all(
color: AppColors.primaryDark.withValues(alpha: 0.1),
width: 1,
),
),
child: Column(
children: [
if (card.iconPath.isNotEmpty)
SizedBox(
width: 36,
height: 36,
child: card.iconPath.startsWith('http')
? Image.network(
card.iconPath,
width: 36,
height: 36,
fit: BoxFit.contain,
errorBuilder: (_, __, ___) => const SizedBox.shrink(),
)
: S3Image(
imageUrl: card.iconPath,
width: 36,
height: 36,
fit: BoxFit.contain,
placeholder: (_) => const SizedBox.shrink(),
errorWidget: (_) => const SizedBox.shrink(),
),
),
if (card.title.isNotEmpty) ...[
const SizedBox(height: 12),
Text(
card.title,
textAlign: TextAlign.center,
style: const TextStyle(
fontFamily: 'Fractul',
fontSize: 16,
fontWeight: FontWeight.w700,
color: AppColors.primaryDark,
),
),
],
if (card.description.isNotEmpty) ...[
const SizedBox(height: 8),
Text(
card.description,
textAlign: TextAlign.center,
style: const TextStyle(
fontFamily: 'SourceSerif4',
fontSize: 14,
fontWeight: FontWeight.w400,
color: AppColors.primaryDark,
height: 1.5,
),
),
],
],
),
);
}
// -- 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 — navigate based on user role
Consumer(builder: (context, ref, _) {
return GestureDetector(
onTap: () {
final authState = ref.read(authProvider);
if (authState.status == AuthStatus.authenticated &&
authState.user?.role == 'AGENT') {
context.go('/home');
} else {
context.push('/agents/search');
}
},
child: Container(
height: 50,
width: 210,
decoration: BoxDecoration(
color: AppColors.accentOrange,
borderRadius: BorderRadius.circular(25),
),
alignment: Alignment.center,
child: Text(
(ref.read(authProvider).user?.role == 'AGENT')
? 'Go to Dashboard'
: 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,
),
),
),
),
],
);
}
}