From 6928697c5c9699a5a00c4f328c6895a007e317b9 Mon Sep 17 00:00:00 2001 From: pradeepkumar Date: Wed, 11 Mar 2026 13:26:16 +0530 Subject: [PATCH] feat: Implement support chat functionality, add 2FA verification screen and routing, and update dependencies. --- android/app/src/main/AndroidManifest.xml | 1 - lib/core/constants/api_constants.dart | 7 + lib/features/auth/data/auth_repository.dart | 52 ++ .../presentation/providers/auth_provider.dart | 86 ++- .../presentation/screens/login_screen.dart | 11 +- .../screens/verify_2fa_screen.dart | 390 ++++++++++ .../faq/presentation/screens/faq_screen.dart | 29 +- .../presentation/widgets/home_header.dart | 25 +- .../messaging/data/socket_service.dart | 33 + .../data/models/support_chat_models.dart | 93 +++ .../data/support_chat_repository.dart | 98 +++ .../providers/support_chat_provider.dart | 133 ++++ .../screens/support_chat_screen.dart | 667 ++++++++++++++++++ lib/routing/app_router.dart | 36 +- macos/Flutter/GeneratedPluginRegistrant.swift | 2 + pubspec.lock | 16 + pubspec.yaml | 1 + 17 files changed, 1660 insertions(+), 20 deletions(-) create mode 100644 lib/features/auth/presentation/screens/verify_2fa_screen.dart create mode 100644 lib/features/support_chat/data/models/support_chat_models.dart create mode 100644 lib/features/support_chat/data/support_chat_repository.dart create mode 100644 lib/features/support_chat/presentation/providers/support_chat_provider.dart create mode 100644 lib/features/support_chat/presentation/screens/support_chat_screen.dart diff --git a/android/app/src/main/AndroidManifest.xml b/android/app/src/main/AndroidManifest.xml index bfab90a..3dee38b 100644 --- a/android/app/src/main/AndroidManifest.xml +++ b/android/app/src/main/AndroidManifest.xml @@ -1,5 +1,4 @@ - diff --git a/lib/core/constants/api_constants.dart b/lib/core/constants/api_constants.dart index ebe61b4..17ed2a2 100644 --- a/lib/core/constants/api_constants.dart +++ b/lib/core/constants/api_constants.dart @@ -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'; } diff --git a/lib/features/auth/data/auth_repository.dart b/lib/features/auth/data/auth_repository.dart index 5197c58..97d1ef8 100644 --- a/lib/features/auth/data/auth_repository.dart +++ b/lib/features/auth/data/auth_repository.dart @@ -52,6 +52,58 @@ class AuthRepository { } } + /// Verify 2FA TOTP token during login + Future 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; + 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 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; + 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 getMe() async { try { final response = await _dio.get(ApiConstants.authMe); diff --git a/lib/features/auth/presentation/providers/auth_provider.dart b/lib/features/auth/presentation/providers/auth_provider.dart index afa5a90..6bee8f0 100644 --- a/lib/features/auth/presentation/providers/auth_provider.dart +++ b/lib/features/auth/presentation/providers/auth_provider.dart @@ -16,6 +16,9 @@ class AuthState { final String? errorMessage; final List 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? 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 { 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 { } } + Future 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 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()); diff --git a/lib/features/auth/presentation/screens/login_screen.dart b/lib/features/auth/presentation/screens/login_screen.dart index 6fdbd2e..b5efeaf 100644 --- a/lib/features/auth/presentation/screens/login_screen.dart +++ b/lib/features/auth/presentation/screens/login_screen.dart @@ -61,7 +61,16 @@ class _LoginScreenState extends ConsumerState { 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(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( diff --git a/lib/features/auth/presentation/screens/verify_2fa_screen.dart b/lib/features/auth/presentation/screens/verify_2fa_screen.dart new file mode 100644 index 0000000..3ad548b --- /dev/null +++ b/lib/features/auth/presentation/screens/verify_2fa_screen.dart @@ -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 createState() => _Verify2faScreenState(); +} + +class _Verify2faScreenState extends ConsumerState { + final List _controllers = + List.generate(6, (_) => TextEditingController()); + final List _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(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, + ), + ), + ), + ); + } +} diff --git a/lib/features/faq/presentation/screens/faq_screen.dart b/lib/features/faq/presentation/screens/faq_screen.dart index 01d2822..c5da0a5 100644 --- a/lib/features/faq/presentation/screens/faq_screen.dart +++ b/lib/features/faq/presentation/screens/faq_screen.dart @@ -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); + } + }, ), ], ), diff --git a/lib/features/home/presentation/widgets/home_header.dart b/lib/features/home/presentation/widgets/home_header.dart index b024a05..297d9f9 100644 --- a/lib/features/home/presentation/widgets/home_header.dart +++ b/lib/features/home/presentation/widgets/home_header.dart @@ -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( + 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, + ), + ), + ); + }, + ), ], ), ); diff --git a/lib/features/messaging/data/socket_service.dart b/lib/features/messaging/data/socket_service.dart index cbc52d4..acee2e7 100644 --- a/lib/features/messaging/data/socket_service.dart +++ b/lib/features/messaging/data/socket_service.dart @@ -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>.broadcast(); final _readController = StreamController>.broadcast(); final _connectionController = StreamController.broadcast(); + final _supportMessageController = StreamController.broadcast(); + final _supportTypingStartController = StreamController>.broadcast(); + final _supportTypingStopController = StreamController>.broadcast(); Stream get onNewMessage => _messageController.stream; Stream> get onTypingStart => _typingStartController.stream; @@ -33,6 +37,9 @@ class SocketService { Stream> get onStatusChange => _statusController.stream; Stream> get onMessagesRead => _readController.stream; Stream get onConnectionChange => _connectionController.stream; + Stream get onSupportMessage => _supportMessageController.stream; + Stream> get onSupportTypingStart => _supportTypingStartController.stream; + Stream> get onSupportTypingStop => _supportTypingStopController.stream; bool get isConnected => _isConnected; Future connect() async { @@ -113,6 +120,24 @@ class SocketService { _readController.add(Map.from(data as Map)); }); + // Support chat events + _socket!.on('support_new_message', (data) { + try { + final message = SupportMessage.fromJson(data as Map); + _supportMessageController.add(message); + } catch (e) { + _log.e('Error parsing support_new_message: $e'); + } + }); + + _socket!.on('support_typing_start', (data) { + _supportTypingStartController.add(Map.from(data as Map)); + }); + + _socket!.on('support_typing_stop', (data) { + _supportTypingStopController.add(Map.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 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(); } } diff --git a/lib/features/support_chat/data/models/support_chat_models.dart b/lib/features/support_chat/data/models/support_chat_models.dart new file mode 100644 index 0000000..8950121 --- /dev/null +++ b/lib/features/support_chat/data/models/support_chat_models.dart @@ -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 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 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 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; +} diff --git a/lib/features/support_chat/data/support_chat_repository.dart b/lib/features/support_chat/data/support_chat_repository.dart new file mode 100644 index 0000000..be54c43 --- /dev/null +++ b/lib/features/support_chat/data/support_chat_repository.dart @@ -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 getOrCreateChat() async { + try { + final response = await _dio.post(ApiConstants.supportChat); + return SupportChat.fromJson( + response.data['data'] as Map); + } 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 getMyChat() async { + try { + final response = await _dio.get('${ApiConstants.supportChat}/my'); + return SupportChat.fromJson( + response.data['data'] as Map); + } 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 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; + final messages = (data['messages'] as List) + .map((e) => SupportMessage.fromJson(e as Map)) + .toList(); + final pagination = SupportPagination.fromJson( + data['pagination'] as Map); + 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 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); + } 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 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((ref) { + return SupportChatRepository(); +}); diff --git a/lib/features/support_chat/presentation/providers/support_chat_provider.dart b/lib/features/support_chat/presentation/providers/support_chat_provider.dart new file mode 100644 index 0000000..b881d48 --- /dev/null +++ b/lib/features/support_chat/presentation/providers/support_chat_provider.dart @@ -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 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? 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 { + final SupportChatRepository _repo; + + SupportChatNotifier(this._repo) : super(const SupportChatState()); + + Future 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 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 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((ref) { + final repo = ref.watch(supportChatRepositoryProvider); + return SupportChatNotifier(repo); +}); diff --git a/lib/features/support_chat/presentation/screens/support_chat_screen.dart b/lib/features/support_chat/presentation/screens/support_chat_screen.dart new file mode 100644 index 0000000..af31b02 --- /dev/null +++ b/lib/features/support_chat/presentation/screens/support_chat_screen.dart @@ -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 createState() => _SupportChatScreenState(); +} + +class _SupportChatScreenState extends ConsumerState { + 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 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(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( + 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 messages; + + _DateGroup({required this.date, required this.messages}); +} diff --git a/lib/routing/app_router.dart b/lib/routing/app_router.dart index e89469e..fbdef74 100644 --- a/lib/routing/app_router.dart +++ b/lib/routing/app_router.dart @@ -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((ref) { final authRefreshNotifier = _AuthRefreshNotifier(); @@ -36,7 +38,7 @@ final routerProvider = Provider((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((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 - if (isAuthenticated && isAuthRoute) return '/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((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((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, diff --git a/macos/Flutter/GeneratedPluginRegistrant.swift b/macos/Flutter/GeneratedPluginRegistrant.swift index 6fb495d..293b974 100644 --- a/macos/Flutter/GeneratedPluginRegistrant.swift +++ b/macos/Flutter/GeneratedPluginRegistrant.swift @@ -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")) diff --git a/pubspec.lock b/pubspec.lock index e702fc3..8e0e0ad 100644 --- a/pubspec.lock +++ b/pubspec.lock @@ -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: diff --git a/pubspec.yaml b/pubspec.yaml index bd0cae2..bbfefc7 100644 --- a/pubspec.yaml +++ b/pubspec.yaml @@ -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: