import 'dart:async'; 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 get authStateChanges; AuthState get currentState; AuthUser? get currentUser; bool get isConfigured; bool get isEmailVerified => false; Future signInWithEmailAndPassword(String email, String password); Future createUserWithEmailAndPassword(String email, String password, {String? displayName}); Future signInWithGoogle(); Future signInAnonymously(); Future sendPasswordResetEmail(String email); Future sendEmailVerification() async => throw UnsupportedError('Email verification is not supported by this auth service.'); Future reloadCurrentUser() async => throw UnsupportedError('User reload is not supported by this auth service.'); Future reauthenticate({String? password}) async => throw UnsupportedError('Reauthentication is not supported by this auth service.'); Future deleteCurrentUser() async => throw UnsupportedError('Account deletion is not supported by this auth service.'); Future getIdToken([bool forceRefresh = false]); Future signOut(); void dispose(); } /// Authoritative Firebase Authentication Service class FirebaseAuthService implements AuthService { fb.FirebaseAuth? _firebaseAuth; final GoogleSignIn? _googleSignIn; final _stateController = StreamController.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 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) { _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 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 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 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 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 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 sendEmailVerification() async { final user = _firebaseAuth?.currentUser; if (user == null || user.isAnonymous) throw StateError('A permanent account is required.'); await user.sendEmailVerification(); } @override Future 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 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 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 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 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(); } }