505 lines
16 KiB
Dart
505 lines
16 KiB
Dart
import 'dart:async';
|
|
import 'package:flutter/foundation.dart';
|
|
import 'package:firebase_core/firebase_core.dart';
|
|
import 'package:firebase_auth/firebase_auth.dart' as fb;
|
|
import 'package:google_sign_in/google_sign_in.dart';
|
|
import 'auth_state.dart';
|
|
import 'auth_user.dart';
|
|
|
|
/// Clean abstraction interface over authentication.
|
|
/// The rest of the application should depend on this interface,
|
|
/// keeping Firebase implementation details completely isolated.
|
|
abstract class AuthService {
|
|
Stream<AuthState> get authStateChanges;
|
|
AuthState get currentState;
|
|
AuthUser? get currentUser;
|
|
bool get isConfigured;
|
|
bool get isEmailVerified => false;
|
|
|
|
Future<void> signInWithEmailAndPassword(String email, String password);
|
|
Future<void> createUserWithEmailAndPassword(String email, String password, {String? displayName});
|
|
Future<void> signInWithGoogle();
|
|
Future<void> signInAnonymously();
|
|
Future<void> sendPasswordResetEmail(String email);
|
|
Future<void> sendEmailVerification() async => throw UnsupportedError('Email verification is not supported by this auth service.');
|
|
Future<void> reloadCurrentUser() async => throw UnsupportedError('User reload is not supported by this auth service.');
|
|
Future<void> reauthenticate({String? password}) async => throw UnsupportedError('Reauthentication is not supported by this auth service.');
|
|
Future<void> deleteCurrentUser() async => throw UnsupportedError('Account deletion is not supported by this auth service.');
|
|
Future<String?> getIdToken([bool forceRefresh = false]);
|
|
Future<void> signOut();
|
|
void dispose();
|
|
}
|
|
|
|
/// Authoritative Firebase Authentication Service
|
|
class FirebaseAuthService implements AuthService {
|
|
fb.FirebaseAuth? _firebaseAuth;
|
|
final GoogleSignIn? _googleSignIn;
|
|
|
|
final _stateController = StreamController<AuthState>.broadcast();
|
|
AuthState _currentState = const AuthState.initial();
|
|
StreamSubscription? _fbAuthSubscription;
|
|
|
|
FirebaseAuthService({
|
|
fb.FirebaseAuth? firebaseAuth,
|
|
GoogleSignIn? googleSignIn,
|
|
}) : _firebaseAuth = firebaseAuth ?? _resolveFirebaseAuth(),
|
|
_googleSignIn = googleSignIn ?? GoogleSignIn() {
|
|
_init();
|
|
}
|
|
|
|
static fb.FirebaseAuth? _resolveFirebaseAuth() {
|
|
try {
|
|
if (Firebase.apps.isNotEmpty) {
|
|
return fb.FirebaseAuth.instance;
|
|
}
|
|
} catch (_) {}
|
|
return null;
|
|
}
|
|
|
|
@override
|
|
Stream<AuthState> get authStateChanges => _stateController.stream;
|
|
|
|
@override
|
|
AuthState get currentState => _currentState;
|
|
|
|
@override
|
|
AuthUser? get currentUser => _currentState.user;
|
|
|
|
@override
|
|
bool get isConfigured {
|
|
if (_firebaseAuth != null) return true;
|
|
_firebaseAuth = _resolveFirebaseAuth();
|
|
if (_firebaseAuth != null && _fbAuthSubscription == null) {
|
|
_init();
|
|
}
|
|
return _firebaseAuth != null;
|
|
}
|
|
|
|
void _init() {
|
|
final auth = _firebaseAuth;
|
|
if (auth == null) {
|
|
// Firebase is not configured or offline during startup
|
|
_updateState(const AuthState.unauthenticated(isFirebaseConfigured: false));
|
|
return;
|
|
}
|
|
|
|
try {
|
|
// Listen to native Firebase auth state stream
|
|
_fbAuthSubscription = auth.authStateChanges().listen(
|
|
(fb.User? user) {
|
|
if (user == null) {
|
|
debugPrint('[DELETE_FLOW] auth state callback signedOut');
|
|
_updateState(const AuthState.unauthenticated(isFirebaseConfigured: true));
|
|
} else {
|
|
final appUser = _mapFirebaseUser(user);
|
|
if (user.isAnonymous) {
|
|
_updateState(AuthState.anonymous(appUser, isFirebaseConfigured: true));
|
|
} else {
|
|
_updateState(AuthState.authenticated(appUser, isFirebaseConfigured: true));
|
|
}
|
|
}
|
|
},
|
|
onError: (dynamic error) {
|
|
_updateState(AuthState.error(
|
|
_formatAuthError(error),
|
|
isFirebaseConfigured: true,
|
|
));
|
|
},
|
|
);
|
|
} catch (e) {
|
|
_updateState(const AuthState.unauthenticated(isFirebaseConfigured: false));
|
|
}
|
|
}
|
|
|
|
void _updateState(AuthState state) {
|
|
_currentState = state;
|
|
if (!_stateController.isClosed) {
|
|
_stateController.add(state);
|
|
}
|
|
}
|
|
|
|
AuthUser _mapFirebaseUser(fb.User user) {
|
|
String? provider;
|
|
if (user.providerData.isNotEmpty) {
|
|
provider = user.providerData.first.providerId;
|
|
} else if (user.isAnonymous) {
|
|
provider = 'anonymous';
|
|
}
|
|
|
|
return AuthUser(
|
|
uid: user.uid,
|
|
email: user.email,
|
|
displayName: user.displayName,
|
|
photoUrl: user.photoURL,
|
|
isAnonymous: user.isAnonymous,
|
|
emailVerified: user.emailVerified,
|
|
providerId: provider,
|
|
createdAt: user.metadata.creationTime,
|
|
lastSignInTime: user.metadata.lastSignInTime,
|
|
);
|
|
}
|
|
|
|
bool get _hasAnonymousFirebaseUser =>
|
|
_firebaseAuth?.currentUser?.isAnonymous == true;
|
|
|
|
void _updateAuthenticatedUser(fb.User user) {
|
|
_updateState(AuthState.authenticated(
|
|
_mapFirebaseUser(user),
|
|
isFirebaseConfigured: true,
|
|
));
|
|
}
|
|
|
|
@override
|
|
Future<void> signInWithEmailAndPassword(String email, String password) async {
|
|
final auth = _firebaseAuth;
|
|
if (!isConfigured || auth == null) {
|
|
_updateState(AuthState.error(
|
|
'Cloud authentication is unavailable in offline/unconfigured mode. Local ride recording is active.',
|
|
user: _currentState.user,
|
|
isFirebaseConfigured: false,
|
|
));
|
|
return;
|
|
}
|
|
|
|
_updateState(AuthState.loading(
|
|
user: _currentState.user,
|
|
isFirebaseConfigured: true,
|
|
));
|
|
|
|
try {
|
|
final normalizedEmail = email.trim();
|
|
|
|
// If the current Firebase identity is anonymous, upgrade that identity
|
|
// instead of signing into a new Firebase UID. This preserves ownership
|
|
// of any cloud data already associated with the guest account.
|
|
if (_hasAnonymousFirebaseUser) {
|
|
final credential = fb.EmailAuthProvider.credential(
|
|
email: normalizedEmail,
|
|
password: password,
|
|
);
|
|
final result = await auth.currentUser!.linkWithCredential(credential);
|
|
if (result.user != null) {
|
|
_updateAuthenticatedUser(result.user!);
|
|
}
|
|
return;
|
|
}
|
|
|
|
final credential = await auth.signInWithEmailAndPassword(
|
|
email: normalizedEmail,
|
|
password: password,
|
|
);
|
|
if (credential.user != null) {
|
|
_updateAuthenticatedUser(credential.user!);
|
|
}
|
|
} catch (e) {
|
|
_updateState(AuthState.error(
|
|
_formatAuthError(e),
|
|
user: _currentState.user,
|
|
isFirebaseConfigured: true,
|
|
));
|
|
}
|
|
}
|
|
|
|
@override
|
|
Future<void> createUserWithEmailAndPassword(
|
|
String email,
|
|
String password, {
|
|
String? displayName,
|
|
}) async {
|
|
final auth = _firebaseAuth;
|
|
if (!isConfigured || auth == null) {
|
|
_updateState(AuthState.error(
|
|
'Account registration requires cloud configuration. Local riding remains fully functional.',
|
|
user: _currentState.user,
|
|
isFirebaseConfigured: false,
|
|
));
|
|
return;
|
|
}
|
|
|
|
_updateState(AuthState.loading(
|
|
user: _currentState.user,
|
|
isFirebaseConfigured: true,
|
|
));
|
|
|
|
try {
|
|
final normalizedEmail = email.trim();
|
|
final emailCredential = fb.EmailAuthProvider.credential(
|
|
email: normalizedEmail,
|
|
password: password,
|
|
);
|
|
|
|
fb.UserCredential credential;
|
|
if (_hasAnonymousFirebaseUser) {
|
|
// Upgrade the existing anonymous UID rather than creating a second
|
|
// account. This keeps prior guest-owned cloud data attached to the user.
|
|
credential = await auth.currentUser!.linkWithCredential(emailCredential);
|
|
} else {
|
|
credential = await auth.createUserWithEmailAndPassword(
|
|
email: normalizedEmail,
|
|
password: password,
|
|
);
|
|
}
|
|
|
|
if (credential.user != null) {
|
|
if (displayName != null && displayName.trim().isNotEmpty) {
|
|
await credential.user!.updateDisplayName(displayName.trim());
|
|
}
|
|
_updateAuthenticatedUser(credential.user!);
|
|
}
|
|
} catch (e) {
|
|
_updateState(AuthState.error(
|
|
_formatAuthError(e),
|
|
user: _currentState.user,
|
|
isFirebaseConfigured: true,
|
|
));
|
|
}
|
|
}
|
|
|
|
@override
|
|
Future<void> signInWithGoogle() async {
|
|
final auth = _firebaseAuth;
|
|
if (!isConfigured || auth == null) {
|
|
_updateState(AuthState.error(
|
|
'Google Sign-In is unavailable without Firebase credentials. Local ride features remain available.',
|
|
user: _currentState.user,
|
|
isFirebaseConfigured: false,
|
|
));
|
|
return;
|
|
}
|
|
|
|
_updateState(AuthState.loading(
|
|
user: _currentState.user,
|
|
isFirebaseConfigured: true,
|
|
));
|
|
|
|
try {
|
|
final googleSignIn = _googleSignIn ?? GoogleSignIn();
|
|
final GoogleSignInAccount? googleUser = await googleSignIn.signIn();
|
|
|
|
if (googleUser == null) {
|
|
// User cancelled the sign-in modal/flow
|
|
_updateState(_currentState.copyWith(
|
|
status: _currentState.user != null
|
|
? (_currentState.user!.isAnonymous
|
|
? AuthStatus.anonymous
|
|
: AuthStatus.authenticated)
|
|
: AuthStatus.unauthenticated,
|
|
clearError: true,
|
|
));
|
|
return;
|
|
}
|
|
|
|
final GoogleSignInAuthentication googleAuth = await googleUser.authentication;
|
|
final credential = fb.GoogleAuthProvider.credential(
|
|
accessToken: googleAuth.accessToken,
|
|
idToken: googleAuth.idToken,
|
|
);
|
|
|
|
fb.UserCredential result;
|
|
if (_hasAnonymousFirebaseUser) {
|
|
// Critical: link the Google provider to the existing anonymous user.
|
|
// Do not call signInWithCredential here or the guest UID can be replaced.
|
|
result = await auth.currentUser!.linkWithCredential(credential);
|
|
} else {
|
|
result = await auth.signInWithCredential(credential);
|
|
}
|
|
|
|
if (result.user != null) {
|
|
_updateAuthenticatedUser(result.user!);
|
|
}
|
|
} catch (e) {
|
|
_updateState(AuthState.error(
|
|
_formatAuthError(e),
|
|
user: _currentState.user,
|
|
isFirebaseConfigured: true,
|
|
));
|
|
}
|
|
}
|
|
|
|
@override
|
|
Future<void> signInAnonymously() async {
|
|
final auth = _firebaseAuth;
|
|
if (!isConfigured || auth == null) {
|
|
// Local fallback anonymous user representation
|
|
final localAnon = AuthUser(
|
|
uid: 'local_${DateTime.now().millisecondsSinceEpoch}',
|
|
displayName: 'Local Guest',
|
|
isAnonymous: true,
|
|
providerId: 'local',
|
|
);
|
|
_updateState(AuthState.anonymous(localAnon, isFirebaseConfigured: false));
|
|
return;
|
|
}
|
|
|
|
_updateState(AuthState.loading(
|
|
user: _currentState.user,
|
|
isFirebaseConfigured: true,
|
|
));
|
|
|
|
try {
|
|
final credential = await auth.signInAnonymously();
|
|
if (credential.user != null) {
|
|
final user = _mapFirebaseUser(credential.user!);
|
|
_updateState(AuthState.anonymous(user, isFirebaseConfigured: true));
|
|
}
|
|
} catch (e) {
|
|
_updateState(AuthState.error(
|
|
_formatAuthError(e),
|
|
user: _currentState.user,
|
|
isFirebaseConfigured: true,
|
|
));
|
|
}
|
|
}
|
|
|
|
@override
|
|
Future<void> sendPasswordResetEmail(String email) async {
|
|
final auth = _firebaseAuth;
|
|
if (!isConfigured || auth == null) {
|
|
_updateState(AuthState.error(
|
|
'Password reset is unavailable in offline mode.',
|
|
user: _currentState.user,
|
|
isFirebaseConfigured: false,
|
|
));
|
|
return;
|
|
}
|
|
|
|
try {
|
|
await auth.sendPasswordResetEmail(email: email.trim());
|
|
} catch (e) {
|
|
_updateState(AuthState.error(
|
|
_formatAuthError(e),
|
|
user: _currentState.user,
|
|
isFirebaseConfigured: true,
|
|
));
|
|
rethrow;
|
|
}
|
|
}
|
|
|
|
@override
|
|
bool get isEmailVerified => _firebaseAuth?.currentUser?.emailVerified ?? false;
|
|
|
|
@override
|
|
Future<void> sendEmailVerification() async {
|
|
final user = _firebaseAuth?.currentUser;
|
|
if (user == null || user.isAnonymous) throw StateError('A permanent account is required.');
|
|
await user.sendEmailVerification();
|
|
}
|
|
|
|
@override
|
|
Future<void> reloadCurrentUser() async {
|
|
final user = _firebaseAuth?.currentUser;
|
|
if (user == null) throw StateError('No signed-in account.');
|
|
await user.reload();
|
|
final refreshed = _firebaseAuth?.currentUser;
|
|
if (refreshed != null) {
|
|
refreshed.isAnonymous
|
|
? _updateState(AuthState.anonymous(_mapFirebaseUser(refreshed), isFirebaseConfigured: true))
|
|
: _updateAuthenticatedUser(refreshed);
|
|
}
|
|
}
|
|
|
|
@override
|
|
Future<void> reauthenticate({String? password}) async {
|
|
final auth = _firebaseAuth;
|
|
final user = auth?.currentUser;
|
|
if (auth == null || user == null || user.isAnonymous) throw StateError('A permanent account is required.');
|
|
final providers = user.providerData.map((p) => p.providerId).toSet();
|
|
fb.AuthCredential credential;
|
|
if (providers.contains('password')) {
|
|
if (password == null || password.isEmpty || user.email == null) throw StateError('Enter your current password.');
|
|
credential = fb.EmailAuthProvider.credential(email: user.email!, password: password);
|
|
} else if (providers.contains('google.com')) {
|
|
final googleUser = await (_googleSignIn ?? GoogleSignIn()).signIn();
|
|
if (googleUser == null) throw StateError('Google reauthentication was cancelled.');
|
|
final googleAuth = await googleUser.authentication;
|
|
credential = fb.GoogleAuthProvider.credential(accessToken: googleAuth.accessToken, idToken: googleAuth.idToken);
|
|
} else {
|
|
throw StateError('This account provider cannot reauthenticate here.');
|
|
}
|
|
await user.reauthenticateWithCredential(credential);
|
|
}
|
|
|
|
@override
|
|
Future<void> deleteCurrentUser() async {
|
|
final user = _firebaseAuth?.currentUser;
|
|
if (user == null || user.isAnonymous) throw StateError('A permanent account is required.');
|
|
await user.delete();
|
|
_updateState(const AuthState.unauthenticated(isFirebaseConfigured: true));
|
|
}
|
|
|
|
@override
|
|
Future<String?> getIdToken([bool forceRefresh = false]) async {
|
|
final auth = _firebaseAuth;
|
|
if (auth == null) return null;
|
|
final user = auth.currentUser;
|
|
if (user == null) return null;
|
|
try {
|
|
return await user.getIdToken(forceRefresh);
|
|
} catch (_) {
|
|
return null;
|
|
}
|
|
}
|
|
|
|
@override
|
|
Future<void> signOut() async {
|
|
_updateState(AuthState.loading(
|
|
user: _currentState.user,
|
|
isFirebaseConfigured: isConfigured,
|
|
));
|
|
|
|
try {
|
|
if (_googleSignIn != null) {
|
|
try {
|
|
await _googleSignIn.signOut();
|
|
} catch (_) {}
|
|
}
|
|
final auth = _firebaseAuth;
|
|
if (auth != null) {
|
|
await auth.signOut();
|
|
}
|
|
} catch (_) {}
|
|
|
|
_updateState(AuthState.unauthenticated(isFirebaseConfigured: isConfigured));
|
|
}
|
|
|
|
String _formatAuthError(dynamic error) {
|
|
if (error is fb.FirebaseAuthException) {
|
|
switch (error.code) {
|
|
case 'user-not-found':
|
|
return 'No account found for this email address.';
|
|
case 'wrong-password':
|
|
return 'Incorrect password. Please check and try again.';
|
|
case 'invalid-credential':
|
|
return 'Invalid credentials. Please verify your email and password.';
|
|
case 'email-already-in-use':
|
|
return 'An account already exists with this email address. Sign in to that account instead.';
|
|
case 'credential-already-in-use':
|
|
return 'This Google account is already linked to another MotorSignal account. Sign in with that account instead of replacing the current guest account.';
|
|
case 'provider-already-linked':
|
|
return 'This sign-in provider is already linked to the current account.';
|
|
case 'invalid-email':
|
|
return 'The email address format is invalid.';
|
|
case 'weak-password':
|
|
return 'Password must be at least 6 characters.';
|
|
case 'user-disabled':
|
|
return 'This account has been disabled.';
|
|
case 'operation-not-allowed':
|
|
return 'Sign-in method is currently disabled in configuration.';
|
|
case 'network-request-failed':
|
|
return 'Network unavailable. You can continue recording rides offline.';
|
|
case 'too-many-requests':
|
|
return 'Too many attempts. Please try again in a few moments.';
|
|
default:
|
|
return error.message ?? 'Authentication error (${error.code}).';
|
|
}
|
|
}
|
|
return error?.toString() ?? 'An unexpected authentication error occurred.';
|
|
}
|
|
|
|
@override
|
|
void dispose() {
|
|
_fbAuthSubscription?.cancel();
|
|
_stateController.close();
|
|
}
|
|
}
|