feat: Implement support chat functionality, add 2FA verification screen and routing, and update dependencies.

This commit is contained in:
pradeepkumar
2026-03-11 13:26:16 +05:30
parent 4780eb9a63
commit 6928697c5c
17 changed files with 1660 additions and 20 deletions

View File

@@ -1,5 +1,4 @@
<manifest xmlns:android="http://schemas.android.com/apk/res/android">
<uses-permission android:name="android.permission.CAMERA"/>
<uses-permission android:name="android.permission.READ_EXTERNAL_STORAGE" android:maxSdkVersion="32"/>
<uses-permission android:name="android.permission.READ_MEDIA_IMAGES"/>
<uses-permission android:name="android.permission.POST_NOTIFICATIONS"/>

View File

@@ -31,4 +31,11 @@ class ApiConstants {
// Messages
static const String conversations = '/messages/conversations';
static const String unreadCount = '/messages/unread-count';
// Support Chat
static const String supportChat = '/support-chat';
// 2FA
static const String twoFactorVerify = '/auth/2fa/verify';
static const String twoFactorVerifyBackup = '/auth/2fa/verify-backup';
}

View File

@@ -52,6 +52,58 @@ class AuthRepository {
}
}
/// Verify 2FA TOTP token during login
Future<AuthResponse> verifyTwoFactor({
required String tempToken,
required String token,
}) async {
try {
final response = await _dio.post(
ApiConstants.twoFactorVerify,
data: {'tempToken': tempToken, 'token': token},
);
final data = response.data['data'] as Map<String, dynamic>;
final authResponse = AuthResponse.fromJson(data);
await SecureStorage.saveTokens(
accessToken: authResponse.accessToken,
refreshToken: authResponse.refreshToken,
);
return authResponse;
} on DioException catch (e) {
if (e.error is ApiException) throw e.error as ApiException;
throw ApiException(
message: e.message ?? '2FA verification failed',
statusCode: e.response?.statusCode,
);
}
}
/// Verify backup code during login
Future<AuthResponse> verifyBackupCode({
required String tempToken,
required String backupCode,
}) async {
try {
final response = await _dio.post(
ApiConstants.twoFactorVerifyBackup,
data: {'tempToken': tempToken, 'backupCode': backupCode},
);
final data = response.data['data'] as Map<String, dynamic>;
final authResponse = AuthResponse.fromJson(data);
await SecureStorage.saveTokens(
accessToken: authResponse.accessToken,
refreshToken: authResponse.refreshToken,
);
return authResponse;
} on DioException catch (e) {
if (e.error is ApiException) throw e.error as ApiException;
throw ApiException(
message: e.message ?? 'Backup code verification failed',
statusCode: e.response?.statusCode,
);
}
}
Future<UserModel> getMe() async {
try {
final response = await _dio.get(ApiConstants.authMe);

View File

@@ -16,6 +16,9 @@ class AuthState {
final String? errorMessage;
final List<FieldError> fieldErrors;
final String registeredEmail;
final bool requiresTwoFactor;
final String? tempToken;
final String? twoFactorEmail;
const AuthState({
this.status = AuthStatus.initial,
@@ -23,6 +26,9 @@ class AuthState {
this.errorMessage,
this.fieldErrors = const [],
this.registeredEmail = '',
this.requiresTwoFactor = false,
this.tempToken,
this.twoFactorEmail,
});
AuthState copyWith({
@@ -31,6 +37,9 @@ class AuthState {
String? errorMessage,
List<FieldError>? fieldErrors,
String? registeredEmail,
bool? requiresTwoFactor,
String? tempToken,
String? twoFactorEmail,
}) {
return AuthState(
status: status ?? this.status,
@@ -38,6 +47,9 @@ class AuthState {
errorMessage: errorMessage,
fieldErrors: fieldErrors ?? this.fieldErrors,
registeredEmail: registeredEmail ?? this.registeredEmail,
requiresTwoFactor: requiresTwoFactor ?? this.requiresTwoFactor,
tempToken: tempToken ?? this.tempToken,
twoFactorEmail: twoFactorEmail ?? this.twoFactorEmail,
);
}
@@ -112,8 +124,10 @@ class AuthNotifier extends StateNotifier<AuthState> {
if (result['requiresTwoFactor'] == true) {
state = state.copyWith(
status: AuthStatus.error,
errorMessage: '2FA verification required',
status: AuthStatus.initial,
requiresTwoFactor: true,
tempToken: result['tempToken'] as String?,
twoFactorEmail: email,
);
return;
}
@@ -189,6 +203,74 @@ class AuthNotifier extends StateNotifier<AuthState> {
}
}
Future<void> verifyTwoFactor(String token) async {
final tempToken = state.tempToken;
if (tempToken == null) return;
state = state.copyWith(
status: AuthStatus.loading,
errorMessage: null,
);
try {
final authResponse = await _repository.verifyTwoFactor(
tempToken: tempToken,
token: token,
);
state = AuthState(
status: AuthStatus.authenticated,
user: authResponse.user,
);
_initPushNotifications();
} on ApiException catch (e) {
state = state.copyWith(
status: AuthStatus.initial,
errorMessage: e.message,
);
} catch (e) {
state = state.copyWith(
status: AuthStatus.initial,
errorMessage: 'Verification failed. Please try again.',
);
}
}
Future<void> verifyBackupCode(String backupCode) async {
final tempToken = state.tempToken;
if (tempToken == null) return;
state = state.copyWith(
status: AuthStatus.loading,
errorMessage: null,
);
try {
final authResponse = await _repository.verifyBackupCode(
tempToken: tempToken,
backupCode: backupCode,
);
state = AuthState(
status: AuthStatus.authenticated,
user: authResponse.user,
);
_initPushNotifications();
} on ApiException catch (e) {
state = state.copyWith(
status: AuthStatus.initial,
errorMessage: e.message,
);
} catch (e) {
state = state.copyWith(
status: AuthStatus.initial,
errorMessage: 'Verification failed. Please try again.',
);
}
}
void clearTwoFactor() {
state = const AuthState();
}
void _initPushNotifications() {
final push = PushNotificationService();
push.initialize().then((_) => push.registerAfterLogin());

View File

@@ -61,7 +61,16 @@ class _LoginScreenState extends ConsumerState<LoginScreen> {
final authState = ref.watch(authProvider);
final isLoading = authState.status == AuthStatus.loading;
// Router redirect handles navigation on auth state change
// Navigate on auth state changes
ref.listen<AuthState>(authProvider, (previous, next) {
if (next.status == AuthStatus.authenticated) {
context.go('/home');
}
// Navigate to 2FA verification screen
if (next.requiresTwoFactor && next.tempToken != null) {
context.go('/verify-2fa');
}
});
return Scaffold(
body: Container(

View File

@@ -0,0 +1,390 @@
import 'package:flutter/material.dart';
import 'package:flutter/services.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/features/auth/presentation/providers/auth_provider.dart';
class Verify2faScreen extends ConsumerStatefulWidget {
const Verify2faScreen({super.key});
@override
ConsumerState<Verify2faScreen> createState() => _Verify2faScreenState();
}
class _Verify2faScreenState extends ConsumerState<Verify2faScreen> {
final List<TextEditingController> _controllers =
List.generate(6, (_) => TextEditingController());
final List<FocusNode> _focusNodes = List.generate(6, (_) => FocusNode());
final TextEditingController _backupController = TextEditingController();
bool _useBackupCode = false;
@override
void dispose() {
for (final c in _controllers) {
c.dispose();
}
for (final f in _focusNodes) {
f.dispose();
}
_backupController.dispose();
super.dispose();
}
String get _otpCode => _controllers.map((c) => c.text).join();
void _handleOtpInput(int index, String value) {
if (value.length > 1) {
// Handle paste
final digits = value.replaceAll(RegExp(r'[^0-9]'), '');
if (digits.length >= 6) {
for (int i = 0; i < 6; i++) {
_controllers[i].text = digits[i];
}
_focusNodes[5].requestFocus();
_submit();
return;
}
_controllers[index].text = value[value.length - 1];
}
if (value.isNotEmpty && index < 5) {
_focusNodes[index + 1].requestFocus();
}
// Auto-submit when all 6 digits are entered
if (_otpCode.length == 6) {
_submit();
}
}
void _handleKeyDown(int index, KeyEvent event) {
if (event is KeyDownEvent &&
event.logicalKey == LogicalKeyboardKey.backspace) {
if (_controllers[index].text.isEmpty && index > 0) {
_focusNodes[index - 1].requestFocus();
_controllers[index - 1].clear();
}
}
}
void _submit() {
if (_useBackupCode) {
final code = _backupController.text.trim();
if (code.isEmpty) return;
ref.read(authProvider.notifier).verifyBackupCode(code);
} else {
final code = _otpCode;
if (code.length != 6) return;
ref.read(authProvider.notifier).verifyTwoFactor(code);
}
}
void _goBackToLogin() {
ref.read(authProvider.notifier).clearTwoFactor();
context.go('/login');
}
@override
Widget build(BuildContext context) {
final authState = ref.watch(authProvider);
final isLoading = authState.status == AuthStatus.loading;
// Navigate to home when authentication succeeds
ref.listen<AuthState>(authProvider, (previous, next) {
if (next.status == AuthStatus.authenticated) {
context.go('/home');
}
});
return Scaffold(
body: Container(
width: double.infinity,
height: double.infinity,
decoration: const BoxDecoration(
gradient: LinearGradient(
begin: Alignment.topCenter,
end: Alignment.bottomCenter,
colors: [AppColors.gradientStart, AppColors.gradientEnd],
),
),
child: SafeArea(
child: SingleChildScrollView(
padding: const EdgeInsets.symmetric(horizontal: 24),
child: Column(
children: [
const SizedBox(height: 40),
// Logo
Image.asset(
'assets/icons/logo.png',
width: 143,
height: 41,
),
const SizedBox(height: 32),
// Lock icon
Container(
width: 64,
height: 64,
decoration: BoxDecoration(
color: AppColors.accentOrange.withValues(alpha: 0.1),
shape: BoxShape.circle,
),
child: const Icon(
Icons.lock_outlined,
color: AppColors.accentOrange,
size: 32,
),
),
const SizedBox(height: 24),
// Title
const Text(
'Two-Factor Authentication',
style: TextStyle(
fontFamily: 'Fractul',
fontSize: 25,
fontWeight: FontWeight.w400,
color: AppColors.primaryDark,
),
),
const SizedBox(height: 12),
// Subtitle
Text(
_useBackupCode
? 'Enter one of your backup codes to sign in.'
: 'Enter the 6-digit code from your authenticator app.',
textAlign: TextAlign.center,
style: const TextStyle(
fontFamily: 'SourceSerif4',
fontSize: 14,
fontWeight: FontWeight.w500,
color: AppColors.primaryDark,
),
),
const SizedBox(height: 8),
// Email display
if (authState.twoFactorEmail != null)
Text(
authState.twoFactorEmail!,
style: TextStyle(
fontFamily: 'SourceSerif4',
fontSize: 13,
fontWeight: FontWeight.w400,
color: AppColors.primaryDark.withValues(alpha: 0.6),
),
),
const SizedBox(height: 24),
// Error banner
if (authState.errorMessage != null) ...[
Container(
width: double.infinity,
padding: const EdgeInsets.symmetric(
horizontal: 16, vertical: 12),
decoration: BoxDecoration(
color: AppColors.errorBg,
border: Border.all(color: AppColors.errorBorder),
borderRadius: BorderRadius.circular(7),
),
child: Text(
authState.errorMessage!,
style: const TextStyle(
fontFamily: 'Fractul',
fontSize: 14,
fontWeight: FontWeight.w300,
color: AppColors.error,
),
),
),
const SizedBox(height: 16),
],
// Code input
if (_useBackupCode)
_buildBackupCodeInput()
else
_buildOtpInput(),
const SizedBox(height: 24),
// Verify button
SizedBox(
width: double.infinity,
child: ElevatedButton(
onPressed: isLoading ? null : _submit,
child: isLoading
? const SizedBox(
height: 20,
width: 20,
child: CircularProgressIndicator(
strokeWidth: 2,
color: AppColors.primaryDark,
),
)
: const Text('Verify'),
),
),
const SizedBox(height: 24),
// Toggle backup code / authenticator
GestureDetector(
onTap: () {
setState(() {
_useBackupCode = !_useBackupCode;
// Clear inputs
for (final c in _controllers) {
c.clear();
}
_backupController.clear();
});
},
child: Text(
_useBackupCode
? 'Use authenticator app instead'
: 'Use a backup code instead',
style: const TextStyle(
fontFamily: 'Fractul',
fontSize: 14,
fontWeight: FontWeight.w500,
color: AppColors.accentOrange,
),
),
),
const SizedBox(height: 16),
// Back to login
GestureDetector(
onTap: _goBackToLogin,
child: const Text(
'Back to login',
style: TextStyle(
fontFamily: 'SourceSerif4',
fontSize: 14,
fontWeight: FontWeight.w400,
color: AppColors.primaryDark,
),
),
),
const SizedBox(height: 40),
],
),
),
),
),
);
}
Widget _buildOtpInput() {
return Row(
mainAxisAlignment: MainAxisAlignment.center,
children: List.generate(6, (index) {
return Container(
width: 48,
height: 56,
margin: EdgeInsets.only(
right: index < 5 ? 8 : 0,
left: index == 3 ? 8 : 0, // Extra gap after 3rd digit
),
child: KeyboardListener(
focusNode: FocusNode(),
onKeyEvent: (event) => _handleKeyDown(index, event),
child: TextField(
controller: _controllers[index],
focusNode: _focusNodes[index],
textAlign: TextAlign.center,
keyboardType: TextInputType.number,
maxLength: 1,
inputFormatters: [FilteringTextInputFormatter.digitsOnly],
onChanged: (value) => _handleOtpInput(index, value),
style: const TextStyle(
fontFamily: 'Fractul',
fontSize: 24,
fontWeight: FontWeight.w700,
color: AppColors.primaryDark,
),
decoration: InputDecoration(
counterText: '',
filled: true,
fillColor: AppColors.inputFill,
contentPadding: const EdgeInsets.symmetric(vertical: 14),
border: OutlineInputBorder(
borderRadius: BorderRadius.circular(10),
borderSide: BorderSide(
color: AppColors.primaryDark.withValues(alpha: 0.15),
),
),
enabledBorder: OutlineInputBorder(
borderRadius: BorderRadius.circular(10),
borderSide: BorderSide(
color: AppColors.primaryDark.withValues(alpha: 0.15),
),
),
focusedBorder: OutlineInputBorder(
borderRadius: BorderRadius.circular(10),
borderSide: const BorderSide(
color: AppColors.accentOrange,
width: 2,
),
),
),
),
),
);
}),
);
}
Widget _buildBackupCodeInput() {
return TextField(
controller: _backupController,
textAlign: TextAlign.center,
textInputAction: TextInputAction.done,
onSubmitted: (_) => _submit(),
style: const TextStyle(
fontFamily: 'Fractul',
fontSize: 18,
fontWeight: FontWeight.w500,
color: AppColors.primaryDark,
letterSpacing: 2,
),
decoration: InputDecoration(
hintText: 'Enter backup code',
hintStyle: TextStyle(
fontFamily: 'SourceSerif4',
fontSize: 14,
color: AppColors.primaryDark.withValues(alpha: 0.4),
letterSpacing: 0,
),
filled: true,
fillColor: AppColors.inputFill,
contentPadding: const EdgeInsets.symmetric(
horizontal: 16,
vertical: 16,
),
border: OutlineInputBorder(
borderRadius: BorderRadius.circular(10),
borderSide: BorderSide(
color: AppColors.primaryDark.withValues(alpha: 0.15),
),
),
enabledBorder: OutlineInputBorder(
borderRadius: BorderRadius.circular(10),
borderSide: BorderSide(
color: AppColors.primaryDark.withValues(alpha: 0.15),
),
),
focusedBorder: OutlineInputBorder(
borderRadius: BorderRadius.circular(10),
borderSide: const BorderSide(
color: AppColors.accentOrange,
width: 2,
),
),
),
);
}
}

View File

@@ -1,8 +1,11 @@
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:url_launcher/url_launcher.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';
// ── Data models ──
@@ -329,11 +332,11 @@ class _FaqAccordion extends StatelessWidget {
// ── Still Need Help section ──
class _StillNeedHelpSection extends StatelessWidget {
class _StillNeedHelpSection extends ConsumerWidget {
const _StillNeedHelpSection();
@override
Widget build(BuildContext context) {
Widget build(BuildContext context, WidgetRef ref) {
return Container(
margin: const EdgeInsets.symmetric(horizontal: 0),
decoration: BoxDecoration(
@@ -383,14 +386,32 @@ class _StillNeedHelpSection extends StatelessWidget {
_buildSupportButton(
icon: Icons.chat_bubble_outline,
label: 'Start Live Chat',
onTap: () {},
onTap: () {
final authState = ref.read(authProvider);
if (authState.status != AuthStatus.authenticated) {
context.push('/login');
return;
}
context.push('/support/chat');
},
),
const SizedBox(height: 12),
// Email Support
_buildSupportButton(
icon: Icons.email_outlined,
label: 'Email Support',
onTap: () {},
onTap: () async {
final uri = Uri(
scheme: 'mailto',
path: 'support@requesn.com',
queryParameters: {
'subject': 'Support Request',
},
);
if (await canLaunchUrl(uri)) {
await launchUrl(uri);
}
},
),
],
),

View File

@@ -1,6 +1,7 @@
import 'package:flutter/material.dart';
import 'package:flutter_riverpod/flutter_riverpod.dart';
import 'package:go_router/go_router.dart';
import 'package:package_info_plus/package_info_plus.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/notifications/presentation/providers/notification_provider.dart';
@@ -313,7 +314,7 @@ class _MenuDrawer extends ConsumerWidget {
// Bottom section: Log Out or Login buttons
Padding(
padding:
const EdgeInsets.symmetric(horizontal: 24, vertical: 24),
const EdgeInsets.symmetric(horizontal: 24, vertical: 16),
child: isAuthenticated
? // Log Out button - filled orange
SizedBox(
@@ -419,6 +420,28 @@ class _MenuDrawer extends ConsumerWidget {
],
),
),
// Version number
FutureBuilder<PackageInfo>(
future: PackageInfo.fromPlatform(),
builder: (context, snapshot) {
final version = snapshot.hasData
? 'v${snapshot.data!.version} (${snapshot.data!.buildNumber})'
: '';
return Padding(
padding: const EdgeInsets.only(bottom: 16),
child: Text(
version,
style: const TextStyle(
fontFamily: 'SourceSerif4',
fontSize: 12,
fontWeight: FontWeight.w400,
color: AppColors.hintText,
),
),
);
},
),
],
),
);

View File

@@ -3,6 +3,7 @@ import 'package:logger/logger.dart';
import 'package:real_estate_mobile/config/app_config.dart';
import 'package:real_estate_mobile/core/storage/secure_storage.dart';
import 'package:real_estate_mobile/features/messaging/data/models/message.dart';
import 'package:real_estate_mobile/features/support_chat/data/models/support_chat_models.dart';
// We'll use a simple WebSocket approach for now since socket_io_client needs to be added
// This will be a placeholder that uses REST polling until socket_io_client is added
@@ -26,6 +27,9 @@ class SocketService {
final _statusController = StreamController<Map<String, dynamic>>.broadcast();
final _readController = StreamController<Map<String, dynamic>>.broadcast();
final _connectionController = StreamController<bool>.broadcast();
final _supportMessageController = StreamController<SupportMessage>.broadcast();
final _supportTypingStartController = StreamController<Map<String, dynamic>>.broadcast();
final _supportTypingStopController = StreamController<Map<String, dynamic>>.broadcast();
Stream<ChatMessage> get onNewMessage => _messageController.stream;
Stream<Map<String, dynamic>> get onTypingStart => _typingStartController.stream;
@@ -33,6 +37,9 @@ class SocketService {
Stream<Map<String, dynamic>> get onStatusChange => _statusController.stream;
Stream<Map<String, dynamic>> get onMessagesRead => _readController.stream;
Stream<bool> get onConnectionChange => _connectionController.stream;
Stream<SupportMessage> get onSupportMessage => _supportMessageController.stream;
Stream<Map<String, dynamic>> get onSupportTypingStart => _supportTypingStartController.stream;
Stream<Map<String, dynamic>> get onSupportTypingStop => _supportTypingStopController.stream;
bool get isConnected => _isConnected;
Future<void> connect() async {
@@ -113,6 +120,24 @@ class SocketService {
_readController.add(Map<String, dynamic>.from(data as Map));
});
// Support chat events
_socket!.on('support_new_message', (data) {
try {
final message = SupportMessage.fromJson(data as Map<String, dynamic>);
_supportMessageController.add(message);
} catch (e) {
_log.e('Error parsing support_new_message: $e');
}
});
_socket!.on('support_typing_start', (data) {
_supportTypingStartController.add(Map<String, dynamic>.from(data as Map));
});
_socket!.on('support_typing_stop', (data) {
_supportTypingStopController.add(Map<String, dynamic>.from(data as Map));
});
_socket!.connect();
}
@@ -188,6 +213,11 @@ class SocketService {
_socket?.emit('typing_stop', {'conversationId': conversationId});
}
/// Emit a raw event (used by support chat)
void emitRaw(String event, Map<String, dynamic> data) {
_socket?.emit(event, data);
}
void markAsRead(String conversationId) {
_socket?.emit('mark_read', {'conversationId': conversationId});
}
@@ -200,5 +230,8 @@ class SocketService {
_statusController.close();
_readController.close();
_connectionController.close();
_supportMessageController.close();
_supportTypingStartController.close();
_supportTypingStopController.close();
}
}

View File

@@ -0,0 +1,93 @@
class SupportChat {
final String id;
final String userId;
final String status; // OPEN, CLOSED
final String? lastMessageAt;
final String? lastMessageText;
final int userUnreadCount;
final int adminUnreadCount;
final String createdAt;
final String updatedAt;
const SupportChat({
required this.id,
required this.userId,
required this.status,
this.lastMessageAt,
this.lastMessageText,
required this.userUnreadCount,
required this.adminUnreadCount,
required this.createdAt,
required this.updatedAt,
});
factory SupportChat.fromJson(Map<String, dynamic> json) {
return SupportChat(
id: json['id'] as String,
userId: json['userId'] as String,
status: json['status'] as String? ?? 'OPEN',
lastMessageAt: json['lastMessageAt'] as String?,
lastMessageText: json['lastMessageText'] as String?,
userUnreadCount: json['userUnreadCount'] as int? ?? 0,
adminUnreadCount: json['adminUnreadCount'] as int? ?? 0,
createdAt: json['createdAt'] as String,
updatedAt: json['updatedAt'] as String,
);
}
bool get isClosed => status == 'CLOSED';
}
class SupportMessage {
final String id;
final String chatId;
final String senderId;
final String senderRole;
final String content;
final String createdAt;
const SupportMessage({
required this.id,
required this.chatId,
required this.senderId,
required this.senderRole,
required this.content,
required this.createdAt,
});
factory SupportMessage.fromJson(Map<String, dynamic> json) {
return SupportMessage(
id: json['id'] as String,
chatId: json['chatId'] as String,
senderId: json['senderId'] as String,
senderRole: json['senderRole'] as String? ?? 'USER',
content: json['content'] as String,
createdAt: json['createdAt'] as String,
);
}
}
class SupportPagination {
final int page;
final int limit;
final int total;
final int pages;
const SupportPagination({
required this.page,
required this.limit,
required this.total,
required this.pages,
});
factory SupportPagination.fromJson(Map<String, dynamic> json) {
return SupportPagination(
page: json['page'] as int? ?? 1,
limit: json['limit'] as int? ?? 50,
total: json['total'] as int? ?? 0,
pages: json['pages'] as int? ?? 1,
);
}
bool get hasMore => page < pages;
}

View File

@@ -0,0 +1,98 @@
import 'package:dio/dio.dart';
import 'package:flutter_riverpod/flutter_riverpod.dart';
import 'package:logger/logger.dart';
import 'package:real_estate_mobile/core/constants/api_constants.dart';
import 'package:real_estate_mobile/core/network/api_client.dart';
import 'package:real_estate_mobile/core/network/api_exceptions.dart';
import 'package:real_estate_mobile/features/support_chat/data/models/support_chat_models.dart';
final _log = Logger(printer: PrettyPrinter(methodCount: 0));
class SupportChatRepository {
final Dio _dio = ApiClient.instance.dio;
/// POST /support-chat — get or create a support chat
Future<SupportChat> getOrCreateChat() async {
try {
final response = await _dio.post(ApiConstants.supportChat);
return SupportChat.fromJson(
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 create support chat',
statusCode: e.response?.statusCode,
);
}
}
/// GET /support-chat/my — get user's existing chat
Future<SupportChat> getMyChat() async {
try {
final response = await _dio.get('${ApiConstants.supportChat}/my');
return SupportChat.fromJson(
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 support chat',
statusCode: e.response?.statusCode,
);
}
}
/// GET /support-chat/:chatId/messages
Future<({List<SupportMessage> messages, SupportPagination pagination})>
getMessages(String chatId, {int page = 1, int limit = 50}) async {
try {
final response = await _dio.get(
'${ApiConstants.supportChat}/$chatId/messages',
queryParameters: {'page': page, 'limit': limit},
);
final data = response.data['data'] as Map<String, dynamic>;
final messages = (data['messages'] as List<dynamic>)
.map((e) => SupportMessage.fromJson(e as Map<String, dynamic>))
.toList();
final pagination = SupportPagination.fromJson(
data['pagination'] as Map<String, dynamic>);
return (messages: messages, pagination: pagination);
} on DioException catch (e) {
if (e.error is ApiException) throw e.error as ApiException;
throw ApiException(
message: e.message ?? 'Failed to fetch messages',
statusCode: e.response?.statusCode,
);
}
}
/// POST /support-chat/:chatId/messages
Future<SupportMessage> sendMessage(String chatId, String content) async {
try {
final response = await _dio.post(
'${ApiConstants.supportChat}/$chatId/messages',
data: {'content': content},
);
return SupportMessage.fromJson(
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 send message',
statusCode: e.response?.statusCode,
);
}
}
/// PATCH /support-chat/:chatId/read
Future<void> markAsRead(String chatId) async {
try {
await _dio.patch('${ApiConstants.supportChat}/$chatId/read');
} on DioException catch (e) {
_log.w('Failed to mark support chat as read: ${e.message}');
}
}
}
final supportChatRepositoryProvider = Provider<SupportChatRepository>((ref) {
return SupportChatRepository();
});

View File

@@ -0,0 +1,133 @@
import 'package:flutter_riverpod/flutter_riverpod.dart';
import 'package:real_estate_mobile/features/support_chat/data/models/support_chat_models.dart';
import 'package:real_estate_mobile/features/support_chat/data/support_chat_repository.dart';
class SupportChatState {
final SupportChat? chat;
final List<SupportMessage> messages;
final bool isLoading;
final bool isSending;
final bool isLoadingMore;
final bool hasMore;
final int currentPage;
final bool isTyping;
final String? error;
const SupportChatState({
this.chat,
this.messages = const [],
this.isLoading = true,
this.isSending = false,
this.isLoadingMore = false,
this.hasMore = false,
this.currentPage = 1,
this.isTyping = false,
this.error,
});
SupportChatState copyWith({
SupportChat? chat,
List<SupportMessage>? messages,
bool? isLoading,
bool? isSending,
bool? isLoadingMore,
bool? hasMore,
int? currentPage,
bool? isTyping,
String? error,
}) {
return SupportChatState(
chat: chat ?? this.chat,
messages: messages ?? this.messages,
isLoading: isLoading ?? this.isLoading,
isSending: isSending ?? this.isSending,
isLoadingMore: isLoadingMore ?? this.isLoadingMore,
hasMore: hasMore ?? this.hasMore,
currentPage: currentPage ?? this.currentPage,
isTyping: isTyping ?? this.isTyping,
error: error,
);
}
}
class SupportChatNotifier extends StateNotifier<SupportChatState> {
final SupportChatRepository _repo;
SupportChatNotifier(this._repo) : super(const SupportChatState());
Future<void> initChat() async {
state = state.copyWith(isLoading: true, error: null);
try {
final chat = await _repo.getOrCreateChat();
final result = await _repo.getMessages(chat.id);
await _repo.markAsRead(chat.id);
state = state.copyWith(
chat: chat,
messages: result.messages,
hasMore: result.pagination.hasMore,
currentPage: result.pagination.page,
isLoading: false,
);
} catch (e) {
state = state.copyWith(
isLoading: false,
error: 'Failed to load support chat. Please try again.',
);
}
}
Future<void> sendMessage(String content) async {
final chat = state.chat;
if (chat == null || content.trim().isEmpty) return;
state = state.copyWith(isSending: true);
try {
final message = await _repo.sendMessage(chat.id, content.trim());
state = state.copyWith(
messages: [...state.messages, message],
isSending: false,
);
} catch (e) {
state = state.copyWith(isSending: false);
rethrow;
}
}
Future<void> loadMore() async {
final chat = state.chat;
if (chat == null || !state.hasMore || state.isLoadingMore) return;
state = state.copyWith(isLoadingMore: true);
try {
final nextPage = state.currentPage + 1;
final result = await _repo.getMessages(chat.id, page: nextPage);
state = state.copyWith(
messages: [...result.messages, ...state.messages],
currentPage: nextPage,
hasMore: result.pagination.hasMore,
isLoadingMore: false,
);
} catch (e) {
state = state.copyWith(isLoadingMore: false);
}
}
void addIncomingMessage(SupportMessage message) {
if (state.chat == null || message.chatId != state.chat!.id) return;
if (state.messages.any((m) => m.id == message.id)) return;
state = state.copyWith(messages: [...state.messages, message]);
_repo.markAsRead(state.chat!.id);
}
void setTyping(bool typing) {
state = state.copyWith(isTyping: typing);
}
}
final supportChatProvider =
StateNotifierProvider<SupportChatNotifier, SupportChatState>((ref) {
final repo = ref.watch(supportChatRepositoryProvider);
return SupportChatNotifier(repo);
});

View File

@@ -0,0 +1,667 @@
import 'dart:async';
import 'package:flutter/material.dart';
import 'package:flutter_riverpod/flutter_riverpod.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/messaging/data/socket_service.dart';
import 'package:real_estate_mobile/features/support_chat/data/models/support_chat_models.dart';
import 'package:real_estate_mobile/features/support_chat/presentation/providers/support_chat_provider.dart';
class SupportChatScreen extends ConsumerStatefulWidget {
const SupportChatScreen({super.key});
@override
ConsumerState<SupportChatScreen> createState() => _SupportChatScreenState();
}
class _SupportChatScreenState extends ConsumerState<SupportChatScreen> {
final TextEditingController _inputController = TextEditingController();
final ScrollController _scrollController = ScrollController();
Timer? _typingTimeout;
StreamSubscription? _messageSub;
StreamSubscription? _typingStartSub;
StreamSubscription? _typingStopSub;
@override
void initState() {
super.initState();
WidgetsBinding.instance.addPostFrameCallback((_) {
ref.read(supportChatProvider.notifier).initChat();
});
}
@override
void dispose() {
_inputController.dispose();
_scrollController.dispose();
_typingTimeout?.cancel();
_messageSub?.cancel();
_typingStartSub?.cancel();
_typingStopSub?.cancel();
_leaveRoom();
super.dispose();
}
void _setupSocketListeners(String chatId, String currentUserId) {
final socket = SocketService();
// Connect socket if needed
if (!socket.isConnected) {
socket.connect();
}
// Join support chat room
if (socket.isConnected) {
socket.emitRaw('support_join', {'chatId': chatId});
}
// Listen for new support messages
_messageSub?.cancel();
_messageSub = socket.onSupportMessage.listen((message) {
ref.read(supportChatProvider.notifier).addIncomingMessage(message);
_scrollToBottom();
});
_typingStartSub?.cancel();
_typingStartSub = socket.onSupportTypingStart.listen((data) {
if (data['userId'] != currentUserId) {
ref.read(supportChatProvider.notifier).setTyping(true);
}
});
_typingStopSub?.cancel();
_typingStopSub = socket.onSupportTypingStop.listen((data) {
if (data['userId'] != currentUserId) {
ref.read(supportChatProvider.notifier).setTyping(false);
}
});
}
void _leaveRoom() {
final chatId = ref.read(supportChatProvider).chat?.id;
if (chatId != null) {
final socket = SocketService();
socket.emitRaw('support_leave', {'chatId': chatId});
}
}
void _scrollToBottom() {
Future.delayed(const Duration(milliseconds: 100), () {
if (_scrollController.hasClients) {
_scrollController.animateTo(
_scrollController.position.maxScrollExtent,
duration: const Duration(milliseconds: 300),
curve: Curves.easeOut,
);
}
});
}
void _handleSend() async {
final text = _inputController.text.trim();
if (text.isEmpty) return;
_inputController.clear();
// Stop typing
final chatId = ref.read(supportChatProvider).chat?.id;
if (chatId != null) {
SocketService().emitRaw('support_typing_stop', {'chatId': chatId});
}
try {
await ref.read(supportChatProvider.notifier).sendMessage(text);
_scrollToBottom();
} catch (e) {
if (mounted) {
ScaffoldMessenger.of(context).showSnackBar(
const SnackBar(
content: Text('Failed to send message'),
behavior: SnackBarBehavior.floating,
),
);
_inputController.text = text;
}
}
}
void _handleInputChanged(String value) {
final chatId = ref.read(supportChatProvider).chat?.id;
if (chatId == null) return;
final socket = SocketService();
if (!socket.isConnected) return;
socket.emitRaw('support_typing_start', {'chatId': chatId});
_typingTimeout?.cancel();
_typingTimeout = Timer(const Duration(seconds: 3), () {
socket.emitRaw('support_typing_stop', {'chatId': chatId});
});
}
String _formatTime(String dateStr) {
final date = DateTime.parse(dateStr).toLocal();
final h = date.hour;
final m = date.minute.toString().padLeft(2, '0');
final period = h >= 12 ? 'PM' : 'AM';
final hour12 = h == 0 ? 12 : (h > 12 ? h - 12 : h);
return '$hour12:$m $period';
}
String _formatDate(String dateStr) {
final date = DateTime.parse(dateStr).toLocal();
final now = DateTime.now();
final yesterday = now.subtract(const Duration(days: 1));
if (date.year == now.year &&
date.month == now.month &&
date.day == now.day) {
return 'Today';
}
if (date.year == yesterday.year &&
date.month == yesterday.month &&
date.day == yesterday.day) {
return 'Yesterday';
}
const months = [
'Jan', 'Feb', 'Mar', 'Apr', 'May', 'Jun',
'Jul', 'Aug', 'Sep', 'Oct', 'Nov', 'Dec'
];
return '${months[date.month - 1]} ${date.day}, ${date.year}';
}
List<_DateGroup> _groupByDate(List<SupportMessage> messages) {
final groups = <_DateGroup>[];
for (final msg in messages) {
final dateStr = _formatDate(msg.createdAt);
if (groups.isNotEmpty && groups.last.date == dateStr) {
groups.last.messages.add(msg);
} else {
groups.add(_DateGroup(date: dateStr, messages: [msg]));
}
}
return groups;
}
@override
Widget build(BuildContext context) {
final state = ref.watch(supportChatProvider);
final authState = ref.watch(authProvider);
final currentUserId = authState.user?.id ?? '';
// Setup socket listeners once chat is loaded
ref.listen<SupportChatState>(supportChatProvider, (prev, next) {
if (prev?.chat == null && next.chat != null) {
_setupSocketListeners(next.chat!.id, currentUserId);
_scrollToBottom();
}
});
return Scaffold(
body: SafeArea(
child: Column(
children: [
// Chat header
_buildHeader(context, state.chat),
// Content
Expanded(
child: state.isLoading
? const Center(
child: CircularProgressIndicator(
color: AppColors.accentOrange,
),
)
: state.error != null
? _buildError(state.error!)
: _buildMessageArea(state, currentUserId),
),
// Input area
if (!state.isLoading && state.error == null)
_buildInputArea(state),
],
),
),
);
}
Widget _buildHeader(BuildContext context, SupportChat? chat) {
return Container(
padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 14),
decoration: const BoxDecoration(
color: AppColors.primaryDark,
borderRadius: BorderRadius.vertical(top: Radius.circular(0)),
),
child: Row(
children: [
// Back button
GestureDetector(
onTap: () => Navigator.of(context).pop(),
child: const Icon(
Icons.arrow_back_ios,
color: Colors.white,
size: 20,
),
),
const SizedBox(width: 12),
// Chat icon
Container(
width: 40,
height: 40,
decoration: BoxDecoration(
color: Colors.white.withValues(alpha: 0.15),
shape: BoxShape.circle,
),
child: const Icon(
Icons.support_agent,
color: Colors.white,
size: 22,
),
),
const SizedBox(width: 12),
// Title + subtitle
Expanded(
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
const Text(
'Support Chat',
style: TextStyle(
fontFamily: 'Fractul',
fontSize: 18,
fontWeight: FontWeight.w700,
color: Colors.white,
),
),
const SizedBox(height: 2),
Text(
chat?.isClosed == true
? 'This chat has been closed'
: 'We typically reply within a few minutes',
style: TextStyle(
fontFamily: 'SourceSerif4',
fontSize: 13,
fontWeight: FontWeight.w400,
color: Colors.white.withValues(alpha: 0.7),
),
),
],
),
),
],
),
);
}
Widget _buildError(String error) {
return Center(
child: Padding(
padding: const EdgeInsets.all(24),
child: Column(
mainAxisSize: MainAxisSize.min,
children: [
Text(
error,
textAlign: TextAlign.center,
style: const TextStyle(
fontFamily: 'SourceSerif4',
fontSize: 16,
color: AppColors.error,
),
),
const SizedBox(height: 16),
GestureDetector(
onTap: () =>
ref.read(supportChatProvider.notifier).initChat(),
child: const Text(
'Try Again',
style: TextStyle(
fontFamily: 'Fractul',
fontSize: 14,
fontWeight: FontWeight.w700,
color: AppColors.accentOrange,
),
),
),
],
),
),
);
}
Widget _buildMessageArea(SupportChatState state, String currentUserId) {
final groups = _groupByDate(state.messages);
return Container(
color: const Color(0xFFF9F9F9),
child: state.messages.isEmpty
? _buildEmptyState()
: ListView(
controller: _scrollController,
padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 12),
children: [
// Load more
if (state.hasMore)
Center(
child: GestureDetector(
onTap: () =>
ref.read(supportChatProvider.notifier).loadMore(),
child: Padding(
padding: const EdgeInsets.only(bottom: 12),
child: Text(
state.isLoadingMore
? 'Loading...'
: 'Load older messages',
style: const TextStyle(
fontFamily: 'SourceSerif4',
fontSize: 13,
color: Color(0xFF5BA4A4),
),
),
),
),
),
// Message groups
for (final group in groups) ...[
_buildDateSeparator(group.date),
for (final msg in group.messages)
_buildMessageBubble(msg, msg.senderId == currentUserId),
],
// Typing indicator
if (state.isTyping) _buildTypingIndicator(),
],
),
);
}
Widget _buildEmptyState() {
return Center(
child: Column(
mainAxisSize: MainAxisSize.min,
children: [
Icon(
Icons.chat_bubble_outline,
size: 48,
color: AppColors.primaryDark.withValues(alpha: 0.3),
),
const SizedBox(height: 16),
Text(
'Welcome to Support Chat',
style: TextStyle(
fontFamily: 'Fractul',
fontSize: 16,
fontWeight: FontWeight.w600,
color: AppColors.primaryDark.withValues(alpha: 0.6),
),
),
const SizedBox(height: 8),
Text(
'Send a message to start chatting\nwith our support team',
textAlign: TextAlign.center,
style: TextStyle(
fontFamily: 'SourceSerif4',
fontSize: 14,
color: AppColors.primaryDark.withValues(alpha: 0.4),
),
),
],
),
);
}
Widget _buildDateSeparator(String date) {
return Padding(
padding: const EdgeInsets.symmetric(vertical: 12),
child: Row(
children: [
Expanded(
child: Divider(
color: AppColors.primaryDark.withValues(alpha: 0.1),
height: 1,
),
),
Padding(
padding: const EdgeInsets.symmetric(horizontal: 12),
child: Text(
date,
style: TextStyle(
fontFamily: 'SourceSerif4',
fontSize: 12,
color: AppColors.primaryDark.withValues(alpha: 0.4),
),
),
),
Expanded(
child: Divider(
color: AppColors.primaryDark.withValues(alpha: 0.1),
height: 1,
),
),
],
),
);
}
Widget _buildMessageBubble(SupportMessage msg, bool isOwn) {
return Align(
alignment: isOwn ? Alignment.centerRight : Alignment.centerLeft,
child: Container(
margin: const EdgeInsets.only(bottom: 8),
padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 12),
constraints: BoxConstraints(
maxWidth: MediaQuery.of(context).size.width * 0.75,
),
decoration: BoxDecoration(
color: isOwn ? AppColors.primaryDark : Colors.white,
borderRadius: BorderRadius.only(
topLeft: const Radius.circular(15),
topRight: const Radius.circular(15),
bottomLeft: Radius.circular(isOwn ? 15 : 4),
bottomRight: Radius.circular(isOwn ? 4 : 15),
),
border: isOwn
? null
: Border.all(
color: AppColors.primaryDark.withValues(alpha: 0.1),
),
),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
if (!isOwn)
const Padding(
padding: EdgeInsets.only(bottom: 4),
child: Text(
'Support Team',
style: TextStyle(
fontFamily: 'Fractul',
fontSize: 12,
fontWeight: FontWeight.w600,
color: AppColors.accentOrange,
),
),
),
Text(
msg.content,
style: TextStyle(
fontFamily: 'SourceSerif4',
fontSize: 14,
color: isOwn ? Colors.white : AppColors.primaryDark,
height: 1.4,
),
),
const SizedBox(height: 4),
Align(
alignment: Alignment.bottomRight,
child: Text(
_formatTime(msg.createdAt),
style: TextStyle(
fontFamily: 'SourceSerif4',
fontSize: 11,
color: isOwn
? Colors.white.withValues(alpha: 0.5)
: AppColors.primaryDark.withValues(alpha: 0.4),
),
),
),
],
),
),
);
}
Widget _buildTypingIndicator() {
return Align(
alignment: Alignment.centerLeft,
child: Container(
margin: const EdgeInsets.only(bottom: 8),
padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 12),
decoration: BoxDecoration(
color: Colors.white,
borderRadius: const BorderRadius.only(
topLeft: Radius.circular(15),
topRight: Radius.circular(15),
bottomRight: Radius.circular(15),
bottomLeft: Radius.circular(4),
),
border: Border.all(
color: AppColors.primaryDark.withValues(alpha: 0.1),
),
),
child: Row(
mainAxisSize: MainAxisSize.min,
children: List.generate(3, (i) {
return TweenAnimationBuilder<double>(
tween: Tween(begin: 0, end: 1),
duration: Duration(milliseconds: 600 + i * 150),
builder: (context, value, child) {
return Container(
margin: EdgeInsets.only(right: i < 2 ? 4 : 0),
width: 8,
height: 8,
decoration: BoxDecoration(
shape: BoxShape.circle,
color: AppColors.primaryDark.withValues(alpha: 0.3),
),
);
},
);
}),
),
),
);
}
Widget _buildInputArea(SupportChatState state) {
if (state.chat?.isClosed == true) {
return Container(
padding: const EdgeInsets.all(16),
decoration: BoxDecoration(
color: Colors.white,
border: Border(
top: BorderSide(
color: AppColors.primaryDark.withValues(alpha: 0.1),
),
),
),
child: Text(
'This chat has been closed. Start a new chat from the FAQ page.',
textAlign: TextAlign.center,
style: TextStyle(
fontFamily: 'SourceSerif4',
fontSize: 14,
color: AppColors.primaryDark.withValues(alpha: 0.5),
),
),
);
}
return Container(
padding: const EdgeInsets.symmetric(horizontal: 12, vertical: 10),
decoration: BoxDecoration(
color: Colors.white,
border: Border(
top: BorderSide(
color: AppColors.primaryDark.withValues(alpha: 0.1),
),
),
),
child: Row(
children: [
// Text input
Expanded(
child: Container(
decoration: BoxDecoration(
color: const Color(0xFFF5F5F5),
borderRadius: BorderRadius.circular(10),
),
child: TextField(
controller: _inputController,
onChanged: _handleInputChanged,
onSubmitted: (_) => _handleSend(),
textInputAction: TextInputAction.send,
enabled: !state.isSending,
style: const TextStyle(
fontFamily: 'SourceSerif4',
fontSize: 14,
color: AppColors.primaryDark,
),
decoration: InputDecoration(
hintText: 'Type your message...',
hintStyle: TextStyle(
fontFamily: 'SourceSerif4',
fontSize: 14,
color: AppColors.primaryDark.withValues(alpha: 0.4),
),
border: InputBorder.none,
contentPadding: const EdgeInsets.symmetric(
horizontal: 16,
vertical: 12,
),
),
),
),
),
const SizedBox(width: 10),
// Send button
GestureDetector(
onTap: state.isSending ? null : _handleSend,
child: Container(
width: 44,
height: 44,
decoration: BoxDecoration(
color: AppColors.accentOrange,
borderRadius: BorderRadius.circular(10),
),
child: state.isSending
? const Padding(
padding: EdgeInsets.all(12),
child: CircularProgressIndicator(
strokeWidth: 2,
color: Colors.white,
),
)
: const Icon(
Icons.send,
color: Colors.white,
size: 20,
),
),
),
],
),
);
}
}
class _DateGroup {
final String date;
final List<SupportMessage> messages;
_DateGroup({required this.date, required this.messages});
}

View File

@@ -22,6 +22,8 @@ import 'package:real_estate_mobile/features/agents/presentation/screens/agent_ed
import 'package:real_estate_mobile/features/profile/presentation/screens/payment_success_screen.dart';
import 'package:real_estate_mobile/features/splash/presentation/screens/splash_screen.dart';
import 'package:real_estate_mobile/features/onboarding/presentation/screens/onboarding_screen.dart';
import 'package:real_estate_mobile/features/auth/presentation/screens/verify_2fa_screen.dart';
import 'package:real_estate_mobile/features/support_chat/presentation/screens/support_chat_screen.dart';
final routerProvider = Provider<GoRouter>((ref) {
final authRefreshNotifier = _AuthRefreshNotifier();
@@ -36,7 +38,7 @@ final routerProvider = Provider<GoRouter>((ref) {
// Public routes accessible without authentication (matching web middleware)
const publicRoutes = ['/home', '/agents/search', '/agents/detail', '/faq', '/contact', '/about'];
const authRoutes = ['/login', '/signup'];
const authRoutes = ['/login', '/signup', '/verify-2fa'];
return GoRouter(
initialLocation: '/splash',
@@ -52,19 +54,20 @@ final routerProvider = Provider<GoRouter>((ref) {
(route) => location == route || location.startsWith('$route/'),
);
// Never redirect away from splash or onboarding — they handle their own navigation
// Never redirect away from splash or onboarding
if (location == '/splash' || location == '/onboarding') return null;
// While checking auth status, don't redirect
if (isLoading) return null;
// If authenticated and on auth page (login/signup), go to home
// Public routes and auth routes are always accessible (no auth required)
if (isPublicRoute || isAuthRoute) {
// Only redirect: if authenticated user is on login/signup, send to home
if (isAuthenticated && isAuthRoute) return '/home';
return null;
}
// Public routes are always accessible
if (isPublicRoute || isAuthRoute) return null;
// Protected routes require authentication — redirect to login
// Protected routes require authentication
if (!isAuthenticated) return '/login';
return null;
@@ -91,6 +94,10 @@ final routerProvider = Provider<GoRouter>((ref) {
path: '/signup',
builder: (context, state) => const SignupScreen(),
),
GoRoute(
path: '/verify-2fa',
builder: (context, state) => const Verify2faScreen(),
),
// All main app routes wrapped in AppShell (shared header + bottom nav)
ShellRoute(
@@ -201,6 +208,14 @@ final routerProvider = Provider<GoRouter>((ref) {
transitionsBuilder: _fadeTransition,
),
),
GoRoute(
path: '/support/chat',
pageBuilder: (context, state) => CustomTransitionPage(
key: state.pageKey,
child: const SupportChatScreen(),
transitionsBuilder: _fadeTransition,
),
),
GoRoute(
path: '/payment-success',
pageBuilder: (context, state) {
@@ -228,9 +243,8 @@ class _HomeRouteWrapper extends ConsumerWidget {
Widget build(BuildContext context, WidgetRef ref) {
final authState = ref.watch(authProvider);
// Show loader while auth is initializing or loading
if (authState.status == AuthStatus.initial ||
authState.status == AuthStatus.loading) {
// Show loader only while auth is actively loading (checking token)
if (authState.status == AuthStatus.loading) {
return const Center(
child: CircularProgressIndicator(
color: AppColors.accentOrange,

View File

@@ -11,6 +11,7 @@ import firebase_core
import firebase_messaging
import flutter_local_notifications
import flutter_secure_storage_macos
import package_info_plus
import path_provider_foundation
import shared_preferences_foundation
import sqflite_darwin
@@ -24,6 +25,7 @@ func RegisterGeneratedPlugins(registry: FlutterPluginRegistry) {
FLTFirebaseMessagingPlugin.register(with: registry.registrar(forPlugin: "FLTFirebaseMessagingPlugin"))
FlutterLocalNotificationsPlugin.register(with: registry.registrar(forPlugin: "FlutterLocalNotificationsPlugin"))
FlutterSecureStoragePlugin.register(with: registry.registrar(forPlugin: "FlutterSecureStoragePlugin"))
FPPPackageInfoPlusPlugin.register(with: registry.registrar(forPlugin: "FPPPackageInfoPlusPlugin"))
PathProviderPlugin.register(with: registry.registrar(forPlugin: "PathProviderPlugin"))
SharedPreferencesPlugin.register(with: registry.registrar(forPlugin: "SharedPreferencesPlugin"))
SqflitePlugin.register(with: registry.registrar(forPlugin: "SqflitePlugin"))

View File

@@ -824,6 +824,22 @@ packages:
url: "https://pub.dev"
source: hosted
version: "2.2.0"
package_info_plus:
dependency: "direct main"
description:
name: package_info_plus
sha256: f69da0d3189a4b4ceaeb1a3defb0f329b3b352517f52bed4290f83d4f06bc08d
url: "https://pub.dev"
source: hosted
version: "9.0.0"
package_info_plus_platform_interface:
dependency: transitive
description:
name: package_info_plus_platform_interface
sha256: "202a487f08836a592a6bd4f901ac69b3a8f146af552bbd14407b6b41e1c3f086"
url: "https://pub.dev"
source: hosted
version: "3.2.1"
path:
dependency: transitive
description:

View File

@@ -57,6 +57,7 @@ dependencies:
flutter_dotenv: ^5.2.1
image_picker: ^1.2.1
webview_flutter: ^4.13.1
package_info_plus: ^9.0.0
dev_dependencies:
flutter_test: