feat: Add FAQ screen and navigation, implement authentication-gated access for home screen features, and dynamically render header menu options based on authentication status.

This commit is contained in:
pradeepkumar
2026-03-07 23:28:21 +05:30
parent ef0fe593ab
commit bdb7b04fbf
6 changed files with 717 additions and 55 deletions

View File

@@ -0,0 +1,4 @@
<svg width="22" height="22" viewBox="0 0 22 22" fill="none" xmlns="http://www.w3.org/2000/svg">
<path d="M3 9.5L11 3L19 9.5V18C19 18.5304 18.7893 19.0391 18.4142 19.4142C18.0391 19.7893 17.5304 20 17 20H5C4.46957 20 3.96086 19.7893 3.58579 19.4142C3.21071 19.0391 3 18.5304 3 18V9.5Z" stroke="#E58625" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round"/>
<path d="M8 20V12H14V20" stroke="#E58625" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round"/>
</svg>

After

Width:  |  Height:  |  Size: 491 B

View File

@@ -8,6 +8,7 @@ import 'package:real_estate_mobile/features/agents/data/models/agent_profile.dar
import 'package:real_estate_mobile/features/agents/data/models/agent_type.dart';
import 'package:real_estate_mobile/features/agents/presentation/providers/search_agents_provider.dart';
import 'package:real_estate_mobile/features/agents/presentation/widgets/agent_filter_sheet.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 AgentSearchScreen extends ConsumerStatefulWidget {
@@ -390,14 +391,26 @@ class _AgentSearchScreenState extends ConsumerState<AgentSearchScreen> {
svgPath: 'assets/icons/nav_messages_icon.svg',
fallbackIcon: Icons.chat_bubble,
isSelected: false,
onTap: () {},
onTap: () {
final authState = ref.read(authProvider);
if (authState.status != AuthStatus.authenticated) {
context.push('/login');
return;
}
},
),
_buildNavItem(
index: 3,
svgPath: 'assets/icons/nav_profile_icon.svg',
fallbackIcon: Icons.person_outline,
isSelected: false,
onTap: () {},
onTap: () {
final authState = ref.read(authProvider);
if (authState.status != AuthStatus.authenticated) {
context.push('/login');
return;
}
},
),
],
),

View File

@@ -0,0 +1,545 @@
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/features/auth/presentation/providers/auth_provider.dart';
import 'package:real_estate_mobile/features/home/presentation/widgets/home_header.dart';
// ── Data models ──
class FaqItem {
final int id;
final String icon;
final String question;
final String answer;
final String category;
const FaqItem({
required this.id,
required this.icon,
required this.question,
required this.answer,
required this.category,
});
factory FaqItem.fromJson(Map<String, dynamic> json) {
return FaqItem(
id: json['id'] as int? ?? 0,
icon: json['icon'] as String? ?? '',
question: json['question'] as String? ?? '',
answer: json['answer'] as String? ?? '',
category: json['category'] as String? ?? '',
);
}
}
// ── Default data (same as web) ──
const _defaultFaqs = <FaqItem>[
FaqItem(
id: 1,
icon: 'assets/icons/home_faq_icon.svg',
question: 'How do I update my listing status?',
answer:
'To update your listing status, log in to your Agent Dashboard. Locate the property in your "Active Listings" tab, click the three-dot menu on the right, and select "Update Status." You can change it to Pending, Sold, or Temporarily Off Market. Changes are reflected immediately.',
category: 'agent',
),
FaqItem(
id: 2,
icon: 'assets/icons/home_faq_icon.svg',
question: 'What is the pre-approval process?',
answer:
'The pre-approval process involves submitting your financial documents to a lender who will review your credit history, income, assets, and debts. Once approved, you\'ll receive a pre-approval letter stating how much you can borrow, which strengthens your position when making offers on properties.',
category: 'buying',
),
FaqItem(
id: 3,
icon: 'assets/icons/home_faq_icon.svg',
question: 'How do I schedule an open house?',
answer:
'To schedule an open house, navigate to your Agent Dashboard and select the property you want to showcase. Click on "Schedule Open House" and choose your preferred date and time.',
category: 'agent',
),
FaqItem(
id: 4,
icon: 'assets/icons/home_faq_icon.svg',
question: 'How do I reset my password?',
answer:
'To reset your password, click on the "Login" button and then select "Forgot Password." Enter your registered email address and we\'ll send you a password reset link. Follow the link to create a new password.',
category: 'platform',
),
FaqItem(
id: 5,
icon: 'assets/icons/home_faq_icon.svg',
question: 'How do I contact a listing agent directly?',
answer:
'You can contact a listing agent by visiting the property page and clicking the "Contact Agent" button. You\'ll be able to send a message directly to the agent through our secure messaging system.',
category: 'buying',
),
FaqItem(
id: 6,
icon: 'assets/icons/home_faq_icon.svg',
question: 'What if I find inaccurate data on a listing?',
answer:
'If you find inaccurate information on a listing, please use the "Report Issue" button on the property page. Describe the inaccuracy and our team will review and correct it within 24-48 hours.',
category: 'platform',
),
];
// ── Provider ──
final faqProvider =
StateNotifierProvider.autoDispose<FaqNotifier, FaqState>((ref) {
return FaqNotifier();
});
class FaqState {
final List<FaqItem> faqs;
final bool isLoading;
final int? expandedId;
const FaqState({
this.faqs = const [],
this.isLoading = true,
this.expandedId,
});
FaqState copyWith({
List<FaqItem>? faqs,
bool? isLoading,
int? expandedId,
bool clearExpanded = false,
}) {
return FaqState(
faqs: faqs ?? this.faqs,
isLoading: isLoading ?? this.isLoading,
expandedId: clearExpanded ? null : (expandedId ?? this.expandedId),
);
}
}
class FaqNotifier extends StateNotifier<FaqState> {
FaqNotifier() : super(const FaqState()) {
_load();
}
Future<void> _load() async {
try {
final response = await ApiClient.instance.dio.get('/cms/page/faq');
final data = response.data['data'] as List<dynamic>?;
if (data != null) {
for (final record in data) {
final map = record as Map<String, dynamic>;
if (map['sectionKey'] == 'faqContent') {
final content = map['content'] as Map<String, dynamic>?;
if (content != null) {
final faqsList = content['faqs'] as List<dynamic>?;
if (faqsList != null && faqsList.isNotEmpty) {
final faqs = faqsList
.map((e) => FaqItem.fromJson(e as Map<String, dynamic>))
.toList();
state = state.copyWith(
faqs: faqs,
isLoading: false,
expandedId: faqs.first.id,
);
return;
}
}
}
}
}
} catch (_) {
// Fall through to defaults
}
// Use defaults
state = state.copyWith(
faqs: _defaultFaqs,
isLoading: false,
expandedId: _defaultFaqs.first.id,
);
}
void toggleFaq(int id) {
if (state.expandedId == id) {
state = state.copyWith(clearExpanded: true);
} else {
state = state.copyWith(expandedId: id);
}
}
}
// ── Screen ──
class FaqScreen extends ConsumerWidget {
const FaqScreen({super.key});
@override
Widget build(BuildContext context, WidgetRef ref) {
final state = ref.watch(faqProvider);
return Scaffold(
backgroundColor: Colors.white,
body: Column(
children: [
SafeArea(
bottom: false,
child: const HomeHeader(),
),
Expanded(
child: state.isLoading
? const Center(
child: CircularProgressIndicator(
color: AppColors.accentOrange),
)
: ListView(
padding: const EdgeInsets.fromLTRB(0, 20, 0, 30),
children: [
// Title
const Padding(
padding: EdgeInsets.symmetric(horizontal: 24),
child: Text(
'Frequently Asked Questions ?',
style: TextStyle(
fontFamily: 'Fractul',
fontSize: 24,
fontWeight: FontWeight.w700,
color: AppColors.primaryDark,
),
textAlign: TextAlign.center,
),
),
const SizedBox(height: 24),
// FAQ items
...state.faqs.map((faq) => _FaqAccordion(
faq: faq,
isExpanded: state.expandedId == faq.id,
onTap: () =>
ref.read(faqProvider.notifier).toggleFaq(faq.id),
)),
const SizedBox(height: 30),
// Still Need Help section
const _StillNeedHelpSection(),
],
),
),
],
),
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: false,
onTap: () {
final authState = ref.read(authProvider);
if (authState.status != AuthStatus.authenticated) {
context.push('/login');
return;
}
},
),
_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;
}
},
),
],
),
),
),
);
}
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,
),
),
),
);
}
}
// ── Accordion item ──
class _FaqAccordion extends StatelessWidget {
final FaqItem faq;
final bool isExpanded;
final VoidCallback onTap;
const _FaqAccordion({
required this.faq,
required this.isExpanded,
required this.onTap,
});
@override
Widget build(BuildContext context) {
return Container(
margin: const EdgeInsets.symmetric(horizontal: 0, vertical: 0),
decoration: BoxDecoration(
border: Border.all(
color: AppColors.primaryDark.withValues(alpha: 0.15),
width: 1,
),
borderRadius: BorderRadius.circular(7),
),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
// Question row
GestureDetector(
onTap: onTap,
behavior: HitTestBehavior.opaque,
child: Padding(
padding: const EdgeInsets.symmetric(horizontal: 20, vertical: 18),
child: Row(
children: [
// Icon
SvgPicture.asset(
'assets/icons/home_faq_icon.svg',
width: 22,
height: 22,
placeholderBuilder: (_) => const Icon(
Icons.home_outlined,
color: AppColors.accentOrange,
size: 22,
),
),
const SizedBox(width: 14),
// Question text
Expanded(
child: Text(
faq.question,
style: TextStyle(
fontFamily: isExpanded ? 'Fractul' : 'SourceSerif4',
fontSize: 16,
fontWeight:
isExpanded ? FontWeight.w500 : FontWeight.w600,
color: AppColors.primaryDark,
),
),
),
const SizedBox(width: 12),
// Chevron
Icon(
isExpanded
? Icons.keyboard_arrow_down
: Icons.keyboard_arrow_right,
color: AppColors.primaryDark,
size: 20,
),
],
),
),
),
// Answer (expanded)
if (isExpanded) ...[
// Divider
Padding(
padding: const EdgeInsets.symmetric(horizontal: 20),
child: Divider(
height: 1,
color: AppColors.primaryDark.withValues(alpha: 0.15),
),
),
Padding(
padding: const EdgeInsets.fromLTRB(56, 14, 20, 18),
child: Text(
faq.answer,
style: const TextStyle(
fontFamily: 'SourceSerif4',
fontSize: 14,
fontWeight: FontWeight.w400,
color: AppColors.primaryDark,
height: 1.5,
),
),
),
],
],
),
);
}
}
// ── Still Need Help section ──
class _StillNeedHelpSection extends StatelessWidget {
const _StillNeedHelpSection();
@override
Widget build(BuildContext context) {
return Container(
margin: const EdgeInsets.symmetric(horizontal: 0),
decoration: BoxDecoration(
border: Border.all(
color: AppColors.primaryDark.withValues(alpha: 0.15),
width: 1,
),
borderRadius: BorderRadius.circular(7),
),
padding: const EdgeInsets.fromLTRB(24, 24, 20, 24),
child: Row(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
// Left side: text
Expanded(
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
const Text(
'Still Need Help ?',
style: TextStyle(
fontFamily: 'Fractul',
fontSize: 14,
fontWeight: FontWeight.w700,
color: AppColors.primaryDark,
),
),
const SizedBox(height: 10),
const Text(
'Our Support Team Is Available\nMon-Fri, 9am-6pm IST',
style: TextStyle(
fontFamily: 'SourceSerif4',
fontSize: 14,
fontWeight: FontWeight.w400,
color: AppColors.primaryDark,
height: 1.4,
),
),
],
),
),
// Right side: buttons
Column(
children: [
// Start Live Chat
_buildSupportButton(
icon: Icons.chat_bubble_outline,
label: 'Start Live Chat',
onTap: () {},
),
const SizedBox(height: 12),
// Email Support
_buildSupportButton(
icon: Icons.email_outlined,
label: 'Email Support',
onTap: () {},
),
],
),
],
),
);
}
Widget _buildSupportButton({
required IconData icon,
required String label,
required VoidCallback onTap,
}) {
return GestureDetector(
onTap: onTap,
child: Container(
width: 162,
height: 38,
decoration: BoxDecoration(
borderRadius: BorderRadius.circular(7),
border: Border.all(
color: AppColors.primaryDark.withValues(alpha: 0.15),
width: 1,
),
),
child: Row(
mainAxisAlignment: MainAxisAlignment.center,
children: [
Icon(icon, size: 18, color: AppColors.primaryDark),
const SizedBox(width: 6),
Text(
label,
style: const TextStyle(
fontFamily: 'SourceSerif4',
fontSize: 14,
fontWeight: FontWeight.w400,
color: AppColors.primaryDark,
),
),
],
),
),
);
}
}

View File

@@ -1,7 +1,9 @@
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/featured_professionals_section.dart';
import 'package:real_estate_mobile/features/home/presentation/widgets/features_section.dart';
import 'package:real_estate_mobile/features/home/presentation/widgets/hero_section.dart';
@@ -10,14 +12,14 @@ import 'package:real_estate_mobile/features/home/presentation/widgets/testimonia
import 'package:real_estate_mobile/features/home/presentation/widgets/top_professionals_section.dart';
import 'package:real_estate_mobile/features/home/presentation/widgets/trust_stats_section.dart';
class HomeScreen extends StatefulWidget {
class HomeScreen extends ConsumerStatefulWidget {
const HomeScreen({super.key});
@override
State<HomeScreen> createState() => _HomeScreenState();
ConsumerState<HomeScreen> createState() => _HomeScreenState();
}
class _HomeScreenState extends State<HomeScreen> {
class _HomeScreenState extends ConsumerState<HomeScreen> {
int _selectedIndex = 0;
@override
@@ -148,6 +150,13 @@ class _HomeScreenState extends State<HomeScreen> {
context.push('/agents/search');
return;
}
if (index == 2 || index == 3) {
final authState = ref.read(authProvider);
if (authState.status != AuthStatus.authenticated) {
context.push('/login');
return;
}
}
setState(() => _selectedIndex = index);
},
behavior: HitTestBehavior.opaque,

View File

@@ -154,7 +154,10 @@ class _MenuDrawer extends ConsumerWidget {
context,
icon: Icons.help_outline,
label: "FAQ's",
onTap: () => Navigator.pop(context),
onTap: () {
Navigator.pop(context);
context.push('/faq');
},
),
_buildMenuItem(
context,
@@ -165,6 +168,8 @@ class _MenuDrawer extends ConsumerWidget {
const SizedBox(height: 16),
const Divider(color: AppColors.divider, height: 1),
const SizedBox(height: 16),
if (ref.watch(authProvider).status ==
AuthStatus.authenticated) ...[
_buildMenuItem(
context,
icon: Icons.person_outline,
@@ -203,7 +208,8 @@ class _MenuDrawer extends ConsumerWidget {
),
),
style: OutlinedButton.styleFrom(
padding: const EdgeInsets.symmetric(vertical: 16),
padding:
const EdgeInsets.symmetric(vertical: 16),
side: const BorderSide(
color: AppColors.accentOrange,
width: 1.5,
@@ -214,6 +220,76 @@ class _MenuDrawer extends ConsumerWidget {
),
),
),
] else ...[
// Login button
SizedBox(
width: double.infinity,
child: ElevatedButton.icon(
onPressed: () {
Navigator.pop(context);
context.push('/login');
},
icon: const Icon(
Icons.login,
color: Colors.white,
size: 20,
),
label: const Text(
'Log In',
style: TextStyle(
fontFamily: 'Fractul',
fontSize: 16,
fontWeight: FontWeight.w700,
color: Colors.white,
),
),
style: ElevatedButton.styleFrom(
backgroundColor: AppColors.accentOrange,
padding:
const EdgeInsets.symmetric(vertical: 16),
shape: RoundedRectangleBorder(
borderRadius: BorderRadius.circular(15),
),
),
),
),
const SizedBox(height: 12),
// Sign Up button
SizedBox(
width: double.infinity,
child: OutlinedButton.icon(
onPressed: () {
Navigator.pop(context);
context.push('/signup');
},
icon: const Icon(
Icons.person_add_outlined,
color: AppColors.accentOrange,
size: 20,
),
label: const Text(
'Sign Up',
style: TextStyle(
fontFamily: 'Fractul',
fontSize: 16,
fontWeight: FontWeight.w700,
color: AppColors.accentOrange,
),
),
style: OutlinedButton.styleFrom(
padding:
const EdgeInsets.symmetric(vertical: 16),
side: const BorderSide(
color: AppColors.accentOrange,
width: 1.5,
),
shape: RoundedRectangleBorder(
borderRadius: BorderRadius.circular(15),
),
),
),
),
],
],
),
),

View File

@@ -5,6 +5,7 @@ import 'package:real_estate_mobile/features/auth/presentation/providers/auth_pro
import 'package:real_estate_mobile/features/auth/presentation/screens/login_screen.dart';
import 'package:real_estate_mobile/features/auth/presentation/screens/signup_screen.dart';
import 'package:real_estate_mobile/features/agents/presentation/screens/agent_search_screen.dart';
import 'package:real_estate_mobile/features/faq/presentation/screens/faq_screen.dart';
import 'package:real_estate_mobile/features/home/presentation/screens/home_screen.dart';
final routerProvider = Provider<GoRouter>((ref) {
@@ -18,25 +19,35 @@ final routerProvider = Provider<GoRouter>((ref) {
authRefreshNotifier.dispose();
});
// Public routes accessible without authentication (matching web middleware)
const publicRoutes = ['/home', '/agents/search', '/faq'];
const authRoutes = ['/login', '/signup'];
return GoRouter(
initialLocation: '/login',
initialLocation: '/home',
refreshListenable: authRefreshNotifier,
redirect: (context, state) {
final authState = ref.read(authProvider);
final isAuthenticated = authState.status == AuthStatus.authenticated;
final isLoading = authState.status == AuthStatus.loading;
final isAuthRoute = state.matchedLocation == '/login' ||
state.matchedLocation == '/signup';
final location = state.matchedLocation;
final isAuthRoute = authRoutes.contains(location);
final isPublicRoute = publicRoutes.any(
(route) => location == route || location.startsWith('$route/'),
);
// While checking auth status, don't redirect
if (isLoading) return null;
// If authenticated and on auth page, go to home
// If authenticated and on auth page (login/signup), go to home
if (isAuthenticated && isAuthRoute) return '/home';
// If not authenticated and on protected page, go to login
if (!isAuthenticated && !isAuthRoute) return '/login';
// Public routes are always accessible
if (isPublicRoute || isAuthRoute) return null;
// Protected routes require authentication — redirect to login
if (!isAuthenticated) return '/login';
return null;
},
@@ -60,6 +71,10 @@ final routerProvider = Provider<GoRouter>((ref) {
return AgentSearchScreen(initialQuery: query);
},
),
GoRoute(
path: '/faq',
builder: (context, state) => const FaqScreen(),
),
],
);
});