feat: Implement support chat functionality, add 2FA verification screen and routing, and update dependencies.
This commit is contained in:
@@ -1,5 +1,4 @@
|
|||||||
<manifest xmlns:android="http://schemas.android.com/apk/res/android">
|
<manifest xmlns:android="http://schemas.android.com/apk/res/android">
|
||||||
<uses-permission android:name="android.permission.CAMERA"/>
|
|
||||||
<uses-permission android:name="android.permission.READ_EXTERNAL_STORAGE" android:maxSdkVersion="32"/>
|
<uses-permission android:name="android.permission.READ_EXTERNAL_STORAGE" android:maxSdkVersion="32"/>
|
||||||
<uses-permission android:name="android.permission.READ_MEDIA_IMAGES"/>
|
<uses-permission android:name="android.permission.READ_MEDIA_IMAGES"/>
|
||||||
<uses-permission android:name="android.permission.POST_NOTIFICATIONS"/>
|
<uses-permission android:name="android.permission.POST_NOTIFICATIONS"/>
|
||||||
|
|||||||
@@ -31,4 +31,11 @@ class ApiConstants {
|
|||||||
// Messages
|
// Messages
|
||||||
static const String conversations = '/messages/conversations';
|
static const String conversations = '/messages/conversations';
|
||||||
static const String unreadCount = '/messages/unread-count';
|
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 {
|
Future<UserModel> getMe() async {
|
||||||
try {
|
try {
|
||||||
final response = await _dio.get(ApiConstants.authMe);
|
final response = await _dio.get(ApiConstants.authMe);
|
||||||
|
|||||||
@@ -16,6 +16,9 @@ class AuthState {
|
|||||||
final String? errorMessage;
|
final String? errorMessage;
|
||||||
final List<FieldError> fieldErrors;
|
final List<FieldError> fieldErrors;
|
||||||
final String registeredEmail;
|
final String registeredEmail;
|
||||||
|
final bool requiresTwoFactor;
|
||||||
|
final String? tempToken;
|
||||||
|
final String? twoFactorEmail;
|
||||||
|
|
||||||
const AuthState({
|
const AuthState({
|
||||||
this.status = AuthStatus.initial,
|
this.status = AuthStatus.initial,
|
||||||
@@ -23,6 +26,9 @@ class AuthState {
|
|||||||
this.errorMessage,
|
this.errorMessage,
|
||||||
this.fieldErrors = const [],
|
this.fieldErrors = const [],
|
||||||
this.registeredEmail = '',
|
this.registeredEmail = '',
|
||||||
|
this.requiresTwoFactor = false,
|
||||||
|
this.tempToken,
|
||||||
|
this.twoFactorEmail,
|
||||||
});
|
});
|
||||||
|
|
||||||
AuthState copyWith({
|
AuthState copyWith({
|
||||||
@@ -31,6 +37,9 @@ class AuthState {
|
|||||||
String? errorMessage,
|
String? errorMessage,
|
||||||
List<FieldError>? fieldErrors,
|
List<FieldError>? fieldErrors,
|
||||||
String? registeredEmail,
|
String? registeredEmail,
|
||||||
|
bool? requiresTwoFactor,
|
||||||
|
String? tempToken,
|
||||||
|
String? twoFactorEmail,
|
||||||
}) {
|
}) {
|
||||||
return AuthState(
|
return AuthState(
|
||||||
status: status ?? this.status,
|
status: status ?? this.status,
|
||||||
@@ -38,6 +47,9 @@ class AuthState {
|
|||||||
errorMessage: errorMessage,
|
errorMessage: errorMessage,
|
||||||
fieldErrors: fieldErrors ?? this.fieldErrors,
|
fieldErrors: fieldErrors ?? this.fieldErrors,
|
||||||
registeredEmail: registeredEmail ?? this.registeredEmail,
|
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) {
|
if (result['requiresTwoFactor'] == true) {
|
||||||
state = state.copyWith(
|
state = state.copyWith(
|
||||||
status: AuthStatus.error,
|
status: AuthStatus.initial,
|
||||||
errorMessage: '2FA verification required',
|
requiresTwoFactor: true,
|
||||||
|
tempToken: result['tempToken'] as String?,
|
||||||
|
twoFactorEmail: email,
|
||||||
);
|
);
|
||||||
return;
|
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() {
|
void _initPushNotifications() {
|
||||||
final push = PushNotificationService();
|
final push = PushNotificationService();
|
||||||
push.initialize().then((_) => push.registerAfterLogin());
|
push.initialize().then((_) => push.registerAfterLogin());
|
||||||
|
|||||||
@@ -61,7 +61,16 @@ class _LoginScreenState extends ConsumerState<LoginScreen> {
|
|||||||
final authState = ref.watch(authProvider);
|
final authState = ref.watch(authProvider);
|
||||||
final isLoading = authState.status == AuthStatus.loading;
|
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(
|
return Scaffold(
|
||||||
body: Container(
|
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/material.dart';
|
||||||
import 'package:flutter_riverpod/flutter_riverpod.dart';
|
import 'package:flutter_riverpod/flutter_riverpod.dart';
|
||||||
import 'package:flutter_svg/flutter_svg.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/constants/app_colors.dart';
|
||||||
import 'package:real_estate_mobile/core/network/api_client.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 ──
|
// ── Data models ──
|
||||||
|
|
||||||
@@ -329,11 +332,11 @@ class _FaqAccordion extends StatelessWidget {
|
|||||||
|
|
||||||
// ── Still Need Help section ──
|
// ── Still Need Help section ──
|
||||||
|
|
||||||
class _StillNeedHelpSection extends StatelessWidget {
|
class _StillNeedHelpSection extends ConsumerWidget {
|
||||||
const _StillNeedHelpSection();
|
const _StillNeedHelpSection();
|
||||||
|
|
||||||
@override
|
@override
|
||||||
Widget build(BuildContext context) {
|
Widget build(BuildContext context, WidgetRef ref) {
|
||||||
return Container(
|
return Container(
|
||||||
margin: const EdgeInsets.symmetric(horizontal: 0),
|
margin: const EdgeInsets.symmetric(horizontal: 0),
|
||||||
decoration: BoxDecoration(
|
decoration: BoxDecoration(
|
||||||
@@ -383,14 +386,32 @@ class _StillNeedHelpSection extends StatelessWidget {
|
|||||||
_buildSupportButton(
|
_buildSupportButton(
|
||||||
icon: Icons.chat_bubble_outline,
|
icon: Icons.chat_bubble_outline,
|
||||||
label: 'Start Live Chat',
|
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),
|
const SizedBox(height: 12),
|
||||||
// Email Support
|
// Email Support
|
||||||
_buildSupportButton(
|
_buildSupportButton(
|
||||||
icon: Icons.email_outlined,
|
icon: Icons.email_outlined,
|
||||||
label: 'Email Support',
|
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/material.dart';
|
||||||
import 'package:flutter_riverpod/flutter_riverpod.dart';
|
import 'package:flutter_riverpod/flutter_riverpod.dart';
|
||||||
import 'package:go_router/go_router.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/core/constants/app_colors.dart';
|
||||||
import 'package:real_estate_mobile/features/auth/presentation/providers/auth_provider.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';
|
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
|
// Bottom section: Log Out or Login buttons
|
||||||
Padding(
|
Padding(
|
||||||
padding:
|
padding:
|
||||||
const EdgeInsets.symmetric(horizontal: 24, vertical: 24),
|
const EdgeInsets.symmetric(horizontal: 24, vertical: 16),
|
||||||
child: isAuthenticated
|
child: isAuthenticated
|
||||||
? // Log Out button - filled orange
|
? // Log Out button - filled orange
|
||||||
SizedBox(
|
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/config/app_config.dart';
|
||||||
import 'package:real_estate_mobile/core/storage/secure_storage.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/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
|
// 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
|
// 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 _statusController = StreamController<Map<String, dynamic>>.broadcast();
|
||||||
final _readController = StreamController<Map<String, dynamic>>.broadcast();
|
final _readController = StreamController<Map<String, dynamic>>.broadcast();
|
||||||
final _connectionController = StreamController<bool>.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<ChatMessage> get onNewMessage => _messageController.stream;
|
||||||
Stream<Map<String, dynamic>> get onTypingStart => _typingStartController.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 onStatusChange => _statusController.stream;
|
||||||
Stream<Map<String, dynamic>> get onMessagesRead => _readController.stream;
|
Stream<Map<String, dynamic>> get onMessagesRead => _readController.stream;
|
||||||
Stream<bool> get onConnectionChange => _connectionController.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;
|
bool get isConnected => _isConnected;
|
||||||
|
|
||||||
Future<void> connect() async {
|
Future<void> connect() async {
|
||||||
@@ -113,6 +120,24 @@ class SocketService {
|
|||||||
_readController.add(Map<String, dynamic>.from(data as Map));
|
_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();
|
_socket!.connect();
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -188,6 +213,11 @@ class SocketService {
|
|||||||
_socket?.emit('typing_stop', {'conversationId': conversationId});
|
_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) {
|
void markAsRead(String conversationId) {
|
||||||
_socket?.emit('mark_read', {'conversationId': conversationId});
|
_socket?.emit('mark_read', {'conversationId': conversationId});
|
||||||
}
|
}
|
||||||
@@ -200,5 +230,8 @@ class SocketService {
|
|||||||
_statusController.close();
|
_statusController.close();
|
||||||
_readController.close();
|
_readController.close();
|
||||||
_connectionController.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/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/splash/presentation/screens/splash_screen.dart';
|
||||||
import 'package:real_estate_mobile/features/onboarding/presentation/screens/onboarding_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 routerProvider = Provider<GoRouter>((ref) {
|
||||||
final authRefreshNotifier = _AuthRefreshNotifier();
|
final authRefreshNotifier = _AuthRefreshNotifier();
|
||||||
@@ -36,7 +38,7 @@ final routerProvider = Provider<GoRouter>((ref) {
|
|||||||
|
|
||||||
// Public routes accessible without authentication (matching web middleware)
|
// Public routes accessible without authentication (matching web middleware)
|
||||||
const publicRoutes = ['/home', '/agents/search', '/agents/detail', '/faq', '/contact', '/about'];
|
const publicRoutes = ['/home', '/agents/search', '/agents/detail', '/faq', '/contact', '/about'];
|
||||||
const authRoutes = ['/login', '/signup'];
|
const authRoutes = ['/login', '/signup', '/verify-2fa'];
|
||||||
|
|
||||||
return GoRouter(
|
return GoRouter(
|
||||||
initialLocation: '/splash',
|
initialLocation: '/splash',
|
||||||
@@ -52,19 +54,20 @@ final routerProvider = Provider<GoRouter>((ref) {
|
|||||||
(route) => location == route || location.startsWith('$route/'),
|
(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;
|
if (location == '/splash' || location == '/onboarding') return null;
|
||||||
|
|
||||||
// While checking auth status, don't redirect
|
// While checking auth status, don't redirect
|
||||||
if (isLoading) return null;
|
if (isLoading) return null;
|
||||||
|
|
||||||
// If authenticated and on auth page (login/signup), go to home
|
// Public routes and auth routes are always accessible (no auth required)
|
||||||
if (isAuthenticated && isAuthRoute) return '/home';
|
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
|
// Protected routes require authentication
|
||||||
if (isPublicRoute || isAuthRoute) return null;
|
|
||||||
|
|
||||||
// Protected routes require authentication — redirect to login
|
|
||||||
if (!isAuthenticated) return '/login';
|
if (!isAuthenticated) return '/login';
|
||||||
|
|
||||||
return null;
|
return null;
|
||||||
@@ -91,6 +94,10 @@ final routerProvider = Provider<GoRouter>((ref) {
|
|||||||
path: '/signup',
|
path: '/signup',
|
||||||
builder: (context, state) => const SignupScreen(),
|
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)
|
// All main app routes wrapped in AppShell (shared header + bottom nav)
|
||||||
ShellRoute(
|
ShellRoute(
|
||||||
@@ -201,6 +208,14 @@ final routerProvider = Provider<GoRouter>((ref) {
|
|||||||
transitionsBuilder: _fadeTransition,
|
transitionsBuilder: _fadeTransition,
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
|
GoRoute(
|
||||||
|
path: '/support/chat',
|
||||||
|
pageBuilder: (context, state) => CustomTransitionPage(
|
||||||
|
key: state.pageKey,
|
||||||
|
child: const SupportChatScreen(),
|
||||||
|
transitionsBuilder: _fadeTransition,
|
||||||
|
),
|
||||||
|
),
|
||||||
GoRoute(
|
GoRoute(
|
||||||
path: '/payment-success',
|
path: '/payment-success',
|
||||||
pageBuilder: (context, state) {
|
pageBuilder: (context, state) {
|
||||||
@@ -228,9 +243,8 @@ class _HomeRouteWrapper extends ConsumerWidget {
|
|||||||
Widget build(BuildContext context, WidgetRef ref) {
|
Widget build(BuildContext context, WidgetRef ref) {
|
||||||
final authState = ref.watch(authProvider);
|
final authState = ref.watch(authProvider);
|
||||||
|
|
||||||
// Show loader while auth is initializing or loading
|
// Show loader only while auth is actively loading (checking token)
|
||||||
if (authState.status == AuthStatus.initial ||
|
if (authState.status == AuthStatus.loading) {
|
||||||
authState.status == AuthStatus.loading) {
|
|
||||||
return const Center(
|
return const Center(
|
||||||
child: CircularProgressIndicator(
|
child: CircularProgressIndicator(
|
||||||
color: AppColors.accentOrange,
|
color: AppColors.accentOrange,
|
||||||
|
|||||||
@@ -11,6 +11,7 @@ import firebase_core
|
|||||||
import firebase_messaging
|
import firebase_messaging
|
||||||
import flutter_local_notifications
|
import flutter_local_notifications
|
||||||
import flutter_secure_storage_macos
|
import flutter_secure_storage_macos
|
||||||
|
import package_info_plus
|
||||||
import path_provider_foundation
|
import path_provider_foundation
|
||||||
import shared_preferences_foundation
|
import shared_preferences_foundation
|
||||||
import sqflite_darwin
|
import sqflite_darwin
|
||||||
@@ -24,6 +25,7 @@ func RegisterGeneratedPlugins(registry: FlutterPluginRegistry) {
|
|||||||
FLTFirebaseMessagingPlugin.register(with: registry.registrar(forPlugin: "FLTFirebaseMessagingPlugin"))
|
FLTFirebaseMessagingPlugin.register(with: registry.registrar(forPlugin: "FLTFirebaseMessagingPlugin"))
|
||||||
FlutterLocalNotificationsPlugin.register(with: registry.registrar(forPlugin: "FlutterLocalNotificationsPlugin"))
|
FlutterLocalNotificationsPlugin.register(with: registry.registrar(forPlugin: "FlutterLocalNotificationsPlugin"))
|
||||||
FlutterSecureStoragePlugin.register(with: registry.registrar(forPlugin: "FlutterSecureStoragePlugin"))
|
FlutterSecureStoragePlugin.register(with: registry.registrar(forPlugin: "FlutterSecureStoragePlugin"))
|
||||||
|
FPPPackageInfoPlusPlugin.register(with: registry.registrar(forPlugin: "FPPPackageInfoPlusPlugin"))
|
||||||
PathProviderPlugin.register(with: registry.registrar(forPlugin: "PathProviderPlugin"))
|
PathProviderPlugin.register(with: registry.registrar(forPlugin: "PathProviderPlugin"))
|
||||||
SharedPreferencesPlugin.register(with: registry.registrar(forPlugin: "SharedPreferencesPlugin"))
|
SharedPreferencesPlugin.register(with: registry.registrar(forPlugin: "SharedPreferencesPlugin"))
|
||||||
SqflitePlugin.register(with: registry.registrar(forPlugin: "SqflitePlugin"))
|
SqflitePlugin.register(with: registry.registrar(forPlugin: "SqflitePlugin"))
|
||||||
|
|||||||
16
pubspec.lock
16
pubspec.lock
@@ -824,6 +824,22 @@ packages:
|
|||||||
url: "https://pub.dev"
|
url: "https://pub.dev"
|
||||||
source: hosted
|
source: hosted
|
||||||
version: "2.2.0"
|
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:
|
path:
|
||||||
dependency: transitive
|
dependency: transitive
|
||||||
description:
|
description:
|
||||||
|
|||||||
@@ -57,6 +57,7 @@ dependencies:
|
|||||||
flutter_dotenv: ^5.2.1
|
flutter_dotenv: ^5.2.1
|
||||||
image_picker: ^1.2.1
|
image_picker: ^1.2.1
|
||||||
webview_flutter: ^4.13.1
|
webview_flutter: ^4.13.1
|
||||||
|
package_info_plus: ^9.0.0
|
||||||
|
|
||||||
dev_dependencies:
|
dev_dependencies:
|
||||||
flutter_test:
|
flutter_test:
|
||||||
|
|||||||
Reference in New Issue
Block a user