MotorSignal-App/lib/services/auth/auth_service.dart

387 lines
11 KiB
Dart

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<AuthState> get authStateChanges;
AuthState get currentState;
AuthUser? get currentUser;
bool get isConfigured;
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<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) {
_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,
providerId: provider,
createdAt: user.metadata.creationTime,
lastSignInTime: user.metadata.lastSignInTime,
);
}
@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 credential = await auth.signInWithEmailAndPassword(
email: email.trim(),
password: password,
);
if (credential.user != null) {
final user = _mapFirebaseUser(credential.user!);
_updateState(AuthState.authenticated(user, isFirebaseConfigured: true));
}
} 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 credential = await auth.createUserWithEmailAndPassword(
email: email.trim(),
password: password,
);
if (credential.user != null) {
if (displayName != null && displayName.trim().isNotEmpty) {
await credential.user!.updateDisplayName(displayName.trim());
}
final user = _mapFirebaseUser(credential.user!);
_updateState(AuthState.authenticated(user, isFirebaseConfigured: true));
}
} 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 ? AuthStatus.authenticated : AuthStatus.unauthenticated, clearError: true));
return;
}
final GoogleSignInAuthentication googleAuth = await googleUser.authentication;
final credential = fb.GoogleAuthProvider.credential(
accessToken: googleAuth.accessToken,
idToken: googleAuth.idToken,
);
final result = await auth.signInWithCredential(credential);
if (result.user != null) {
final user = _mapFirebaseUser(result.user!);
_updateState(AuthState.authenticated(user, isFirebaseConfigured: true));
}
} 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,
));
}
}
@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.';
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();
}
}