MotorSignal-App/lib/services/auth/auth_service.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();
}
}