feat: Implement support chat functionality, add 2FA verification screen and routing, and update dependencies.
This commit is contained in:
@@ -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';
|
||||
}
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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());
|
||||
|
||||
@@ -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(
|
||||
|
||||
390
lib/features/auth/presentation/screens/verify_2fa_screen.dart
Normal file
390
lib/features/auth/presentation/screens/verify_2fa_screen.dart
Normal 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,
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
},
|
||||
),
|
||||
],
|
||||
),
|
||||
|
||||
@@ -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,
|
||||
),
|
||||
),
|
||||
);
|
||||
},
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
|
||||
@@ -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();
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
98
lib/features/support_chat/data/support_chat_repository.dart
Normal file
98
lib/features/support_chat/data/support_chat_repository.dart
Normal 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();
|
||||
});
|
||||
@@ -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);
|
||||
});
|
||||
@@ -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});
|
||||
}
|
||||
@@ -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
|
||||
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<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,
|
||||
|
||||
Reference in New Issue
Block a user