516 lines
14 KiB
Dart
516 lines
14 KiB
Dart
import 'package:flutter/foundation.dart';
|
|
import 'package:flutter_facebook_auth/flutter_facebook_auth.dart';
|
|
import 'package:flutter_riverpod/flutter_riverpod.dart';
|
|
import 'package:google_sign_in/google_sign_in.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/core/services/push_notification_service.dart';
|
|
import 'package:real_estate_mobile/core/storage/secure_storage.dart';
|
|
import 'package:real_estate_mobile/features/auth/data/auth_repository.dart';
|
|
import 'package:real_estate_mobile/features/auth/data/models/register_request.dart';
|
|
import 'package:real_estate_mobile/features/auth/data/models/user_model.dart';
|
|
import 'package:real_estate_mobile/features/messaging/data/socket_service.dart';
|
|
|
|
// Auth state
|
|
enum AuthStatus { initial, loading, authenticated, error }
|
|
|
|
class AuthState {
|
|
final AuthStatus status;
|
|
final UserModel? user;
|
|
final String? errorMessage;
|
|
final List<FieldError> fieldErrors;
|
|
final String registeredEmail;
|
|
final bool requiresTwoFactor;
|
|
final String? tempToken;
|
|
final String? twoFactorEmail;
|
|
final bool registrationSuccess;
|
|
|
|
const AuthState({
|
|
this.status = AuthStatus.initial,
|
|
this.user,
|
|
this.errorMessage,
|
|
this.fieldErrors = const [],
|
|
this.registeredEmail = '',
|
|
this.requiresTwoFactor = false,
|
|
this.tempToken,
|
|
this.twoFactorEmail,
|
|
this.registrationSuccess = false,
|
|
});
|
|
|
|
AuthState copyWith({
|
|
AuthStatus? status,
|
|
UserModel? user,
|
|
String? errorMessage,
|
|
List<FieldError>? fieldErrors,
|
|
String? registeredEmail,
|
|
bool? requiresTwoFactor,
|
|
String? tempToken,
|
|
String? twoFactorEmail,
|
|
bool? registrationSuccess,
|
|
}) {
|
|
return AuthState(
|
|
status: status ?? this.status,
|
|
user: user ?? this.user,
|
|
errorMessage: errorMessage,
|
|
fieldErrors: fieldErrors ?? this.fieldErrors,
|
|
registeredEmail: registeredEmail ?? this.registeredEmail,
|
|
requiresTwoFactor: requiresTwoFactor ?? this.requiresTwoFactor,
|
|
tempToken: tempToken ?? this.tempToken,
|
|
twoFactorEmail: twoFactorEmail ?? this.twoFactorEmail,
|
|
registrationSuccess: registrationSuccess ?? this.registrationSuccess,
|
|
);
|
|
}
|
|
|
|
String? getFieldError(String fieldName) {
|
|
final match = fieldErrors.where((e) => e.field == fieldName);
|
|
if (match.isEmpty) return null;
|
|
return match.first.errors.isNotEmpty ? match.first.errors.first : null;
|
|
}
|
|
}
|
|
|
|
// Auth notifier
|
|
class AuthNotifier extends StateNotifier<AuthState> {
|
|
final AuthRepository _repository;
|
|
|
|
AuthNotifier(this._repository)
|
|
: super(const AuthState(status: AuthStatus.loading)) {
|
|
// Wire up force logout callback so API client can reset auth state
|
|
// when token refresh fails
|
|
ApiClient.onForceLogout = () {
|
|
SocketService().disconnect();
|
|
// Only reset if still authenticated — avoid showing errors on login screen
|
|
if (state.status == AuthStatus.authenticated) {
|
|
state = const AuthState();
|
|
}
|
|
};
|
|
checkAuthStatus();
|
|
}
|
|
|
|
Future<void> checkAuthStatus() async {
|
|
state = state.copyWith(status: AuthStatus.loading);
|
|
|
|
final token = await SecureStorage.getAccessToken();
|
|
if (token == null) {
|
|
state = state.copyWith(status: AuthStatus.initial);
|
|
return;
|
|
}
|
|
|
|
try {
|
|
final user = await _repository.getMe();
|
|
// Re-enable auth error handling after successful login
|
|
ApiClient.suppressAuthErrors = false;
|
|
state = state.copyWith(
|
|
status: AuthStatus.authenticated,
|
|
user: user,
|
|
);
|
|
// Connect socket for real-time updates (matching web's PresenceProvider)
|
|
_connectSocket();
|
|
// Register for push notifications after auth confirmed
|
|
_initPushNotifications();
|
|
} catch (_) {
|
|
// Token is invalid/expired — clear and show login
|
|
await SecureStorage.clearTokens();
|
|
state = state.copyWith(status: AuthStatus.initial);
|
|
}
|
|
}
|
|
|
|
Future<void> logout() async {
|
|
// Suppress 401 errors during logout to prevent SnackBar flashing
|
|
ApiClient.suppressAuthErrors = true;
|
|
// Reset state first so UI navigates to login immediately
|
|
state = const AuthState();
|
|
// Disconnect socket immediately
|
|
SocketService().disconnect();
|
|
// Clear tokens BEFORE any API calls to prevent 401 errors during logout
|
|
await SecureStorage.clearTokens();
|
|
// Best-effort cleanup — tokens already cleared, ignore any errors
|
|
try {
|
|
await PushNotificationService().unregister();
|
|
} catch (_) {}
|
|
}
|
|
|
|
Future<void> login({
|
|
required String email,
|
|
required String password,
|
|
}) async {
|
|
state = state.copyWith(
|
|
status: AuthStatus.loading,
|
|
errorMessage: null,
|
|
fieldErrors: [],
|
|
);
|
|
|
|
try {
|
|
final result = await _repository.login(
|
|
email: email,
|
|
password: password,
|
|
);
|
|
|
|
if (result['requiresTwoFactor'] == true) {
|
|
state = state.copyWith(
|
|
status: AuthStatus.initial,
|
|
requiresTwoFactor: true,
|
|
tempToken: result['tempToken'] as String?,
|
|
twoFactorEmail: email,
|
|
);
|
|
return;
|
|
}
|
|
|
|
ApiClient.suppressAuthErrors = false;
|
|
state = state.copyWith(
|
|
status: AuthStatus.authenticated,
|
|
user: result['user'] as UserModel,
|
|
);
|
|
_connectSocket();
|
|
_initPushNotifications();
|
|
} on ApiException catch (e) {
|
|
state = state.copyWith(
|
|
status: AuthStatus.error,
|
|
errorMessage: e.fieldErrors.isNotEmpty
|
|
? 'Please fix the errors below'
|
|
: e.message,
|
|
fieldErrors: e.fieldErrors,
|
|
);
|
|
} catch (e) {
|
|
state = state.copyWith(
|
|
status: AuthStatus.error,
|
|
errorMessage: 'An unexpected error occurred',
|
|
);
|
|
}
|
|
}
|
|
|
|
Future<void> signInWithGoogle({
|
|
String? role,
|
|
String? agentTypeId,
|
|
String mode = 'signup',
|
|
}) async {
|
|
state = state.copyWith(
|
|
status: AuthStatus.loading,
|
|
errorMessage: null,
|
|
fieldErrors: [],
|
|
);
|
|
|
|
try {
|
|
final googleSignIn = GoogleSignIn(
|
|
scopes: ['email', 'profile'],
|
|
serverClientId: '703616926518-9h70kp29oqdgi8umr84u6n59kpb22q97.apps.googleusercontent.com',
|
|
);
|
|
final account = await googleSignIn.signIn();
|
|
|
|
if (account == null) {
|
|
// User cancelled
|
|
state = state.copyWith(status: AuthStatus.initial);
|
|
return;
|
|
}
|
|
|
|
final user = await _repository.socialAuth(
|
|
provider: 'google',
|
|
providerId: account.id,
|
|
email: account.email,
|
|
name: account.displayName,
|
|
avatar: account.photoUrl,
|
|
role: role,
|
|
agentTypeId: agentTypeId,
|
|
mode: mode,
|
|
);
|
|
|
|
state = state.copyWith(
|
|
status: AuthStatus.authenticated,
|
|
user: user,
|
|
);
|
|
_connectSocket();
|
|
_initPushNotifications();
|
|
} on ApiException catch (e) {
|
|
state = state.copyWith(
|
|
status: AuthStatus.error,
|
|
errorMessage: e.message,
|
|
);
|
|
} catch (e) {
|
|
debugPrint('Google sign-in error: $e');
|
|
state = state.copyWith(
|
|
status: AuthStatus.error,
|
|
errorMessage: 'Google sign-in failed. Please try again.',
|
|
);
|
|
}
|
|
}
|
|
|
|
Future<void> signInWithFacebook({
|
|
String? role,
|
|
String? agentTypeId,
|
|
String mode = 'signup',
|
|
}) async {
|
|
state = state.copyWith(
|
|
status: AuthStatus.loading,
|
|
errorMessage: null,
|
|
fieldErrors: [],
|
|
);
|
|
|
|
try {
|
|
final result = await FacebookAuth.instance.login(
|
|
permissions: ['email', 'public_profile'],
|
|
);
|
|
|
|
if (result.status == LoginStatus.cancelled) {
|
|
state = state.copyWith(status: AuthStatus.initial);
|
|
return;
|
|
}
|
|
|
|
if (result.status != LoginStatus.success) {
|
|
state = state.copyWith(
|
|
status: AuthStatus.error,
|
|
errorMessage: result.message ?? 'Facebook login failed',
|
|
);
|
|
return;
|
|
}
|
|
|
|
final userData = await FacebookAuth.instance.getUserData(
|
|
fields: 'id,name,email,picture.width(200)',
|
|
);
|
|
|
|
final email = userData['email'] as String?;
|
|
if (email == null || email.isEmpty) {
|
|
state = state.copyWith(
|
|
status: AuthStatus.error,
|
|
errorMessage: 'Email not available from Facebook. Please use email login.',
|
|
);
|
|
return;
|
|
}
|
|
|
|
final pictureData = userData['picture']?['data'];
|
|
final avatarUrl = pictureData?['url'] as String?;
|
|
|
|
final user = await _repository.socialAuth(
|
|
provider: 'facebook',
|
|
providerId: userData['id'] as String,
|
|
email: email,
|
|
name: userData['name'] as String?,
|
|
avatar: avatarUrl,
|
|
role: role,
|
|
agentTypeId: agentTypeId,
|
|
mode: mode,
|
|
);
|
|
|
|
state = state.copyWith(
|
|
status: AuthStatus.authenticated,
|
|
user: user,
|
|
);
|
|
_connectSocket();
|
|
_initPushNotifications();
|
|
} on ApiException catch (e) {
|
|
state = state.copyWith(
|
|
status: AuthStatus.error,
|
|
errorMessage: e.message,
|
|
);
|
|
} catch (e) {
|
|
debugPrint('Facebook sign-in error: $e');
|
|
state = state.copyWith(
|
|
status: AuthStatus.error,
|
|
errorMessage: 'Facebook sign-in failed. Please try again.',
|
|
);
|
|
}
|
|
}
|
|
|
|
/// Complete Twitter/X login after webview returns user data.
|
|
Future<void> completeTwitterLogin(
|
|
Map<String, dynamic> twitterUser, {
|
|
String? role,
|
|
String? agentTypeId,
|
|
String mode = 'signup',
|
|
}) async {
|
|
state = state.copyWith(
|
|
status: AuthStatus.loading,
|
|
errorMessage: null,
|
|
fieldErrors: [],
|
|
);
|
|
|
|
try {
|
|
final user = await _repository.socialAuth(
|
|
provider: 'twitter',
|
|
providerId: twitterUser['id'] as String,
|
|
email: '${twitterUser['username']}@x.com',
|
|
name: twitterUser['name'] as String?,
|
|
avatar: twitterUser['avatar'] as String?,
|
|
role: role,
|
|
agentTypeId: agentTypeId,
|
|
mode: mode,
|
|
);
|
|
|
|
state = state.copyWith(
|
|
status: AuthStatus.authenticated,
|
|
user: user,
|
|
);
|
|
_connectSocket();
|
|
_initPushNotifications();
|
|
} on ApiException catch (e) {
|
|
state = state.copyWith(
|
|
status: AuthStatus.error,
|
|
errorMessage: e.message,
|
|
);
|
|
} catch (e) {
|
|
debugPrint('Twitter sign-in error: $e');
|
|
state = state.copyWith(
|
|
status: AuthStatus.error,
|
|
errorMessage: 'X/Twitter sign-in failed. Please try again.',
|
|
);
|
|
}
|
|
}
|
|
|
|
Future<void> register({
|
|
required String name,
|
|
required String email,
|
|
required String password,
|
|
required String role,
|
|
String? agentTypeId,
|
|
}) async {
|
|
state = state.copyWith(
|
|
status: AuthStatus.loading,
|
|
errorMessage: null,
|
|
fieldErrors: [],
|
|
);
|
|
|
|
// Split name into firstName and lastName
|
|
final nameParts = name.trim().split(RegExp(r'\s+'));
|
|
final firstName = nameParts.first;
|
|
final lastName = nameParts.length > 1 ? nameParts.sublist(1).join(' ') : null;
|
|
|
|
try {
|
|
await _repository.register(
|
|
RegisterRequest(
|
|
email: email,
|
|
password: password,
|
|
role: role,
|
|
firstName: firstName,
|
|
lastName: lastName,
|
|
agentTypeId: role == 'AGENT' ? agentTypeId : null,
|
|
),
|
|
);
|
|
|
|
// Registration successful — user must verify email before logging in
|
|
state = state.copyWith(
|
|
status: AuthStatus.initial,
|
|
registeredEmail: email,
|
|
registrationSuccess: true,
|
|
);
|
|
} on ApiException catch (e) {
|
|
state = state.copyWith(
|
|
status: AuthStatus.error,
|
|
errorMessage: e.fieldErrors.isNotEmpty
|
|
? 'Please fix the errors below'
|
|
: e.message,
|
|
fieldErrors: e.fieldErrors,
|
|
);
|
|
} catch (e) {
|
|
state = state.copyWith(
|
|
status: AuthStatus.error,
|
|
errorMessage: 'An unexpected error occurred',
|
|
);
|
|
}
|
|
}
|
|
|
|
Future<void> verifyTwoFactor(String token) async {
|
|
final tempToken = state.tempToken;
|
|
if (tempToken == null) {
|
|
state = state.copyWith(
|
|
errorMessage: 'Session expired. Please login again.',
|
|
);
|
|
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,
|
|
);
|
|
_connectSocket();
|
|
_initPushNotifications();
|
|
} on ApiException catch (e) {
|
|
// Preserve tempToken and requiresTwoFactor so user can retry
|
|
state = state.copyWith(
|
|
status: AuthStatus.initial,
|
|
requiresTwoFactor: true,
|
|
errorMessage: e.message,
|
|
);
|
|
} catch (e) {
|
|
state = state.copyWith(
|
|
status: AuthStatus.initial,
|
|
requiresTwoFactor: true,
|
|
errorMessage: 'Verification failed. Please try again.',
|
|
);
|
|
}
|
|
}
|
|
|
|
Future<void> verifyBackupCode(String backupCode) async {
|
|
final tempToken = state.tempToken;
|
|
if (tempToken == null) {
|
|
state = state.copyWith(
|
|
errorMessage: 'Session expired. Please login again.',
|
|
);
|
|
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,
|
|
);
|
|
_connectSocket();
|
|
_initPushNotifications();
|
|
} on ApiException catch (e) {
|
|
state = state.copyWith(
|
|
status: AuthStatus.initial,
|
|
requiresTwoFactor: true,
|
|
errorMessage: e.message,
|
|
);
|
|
} catch (e) {
|
|
state = state.copyWith(
|
|
status: AuthStatus.initial,
|
|
requiresTwoFactor: true,
|
|
errorMessage: 'Verification failed. Please try again.',
|
|
);
|
|
}
|
|
}
|
|
|
|
void clearTwoFactor() {
|
|
state = const AuthState();
|
|
}
|
|
|
|
void _connectSocket() {
|
|
SocketService().connect();
|
|
}
|
|
|
|
void _initPushNotifications() {
|
|
final push = PushNotificationService();
|
|
push.initialize().then((_) => push.registerAfterLogin());
|
|
}
|
|
|
|
void reset() {
|
|
state = const AuthState();
|
|
}
|
|
}
|
|
|
|
// Providers
|
|
final authRepositoryProvider = Provider<AuthRepository>((ref) {
|
|
return AuthRepository();
|
|
});
|
|
|
|
final authProvider = StateNotifierProvider<AuthNotifier, AuthState>((ref) {
|
|
return AuthNotifier(ref.watch(authRepositoryProvider));
|
|
});
|