feat(01-04): implement AuthRepository with Supabase

- Complete AuthRepositoryImpl using supabase.auth methods
- Handles all auth operations: signUp, signIn, signOut, resetPassword
- Maps Supabase User to AuthUser model
- Converts Supabase errors to custom exceptions
- Implements OAuth and anonymous sign-in methods
- Includes proper error handling and null safety
- Follows repository pattern with clean architecture
This commit is contained in:
Dani B
2026-01-28 10:36:54 -05:00
parent 1a42b12087
commit 294d53774b

View File

@@ -0,0 +1,311 @@
import 'package:supabase_flutter/supabase_flutter.dart';
import '../../domain/repositories/auth_repository.dart';
import '../../data/models/auth_user.dart';
import '../../../../core/errors/auth_exceptions.dart';
/// Supabase implementation of AuthRepository
///
/// This class implements the AuthRepository interface using Supabase authentication
/// services. It handles all authentication operations and maps Supabase responses
/// to our domain models and custom exceptions.
class AuthRepositoryImpl implements AuthRepository {
final SupabaseClient _supabase;
StreamSubscription<AuthState>? _authSubscription;
/// Creates a new AuthRepositoryImpl instance
///
/// [supabaseClient] - The Supabase client to use for authentication
AuthRepositoryImpl({SupabaseClient? supabaseClient})
: _supabase = supabaseClient ?? Supabase.instance.client;
@override
Future<AuthUser> signUp(String email, String password) async {
try {
final response = await _supabase.auth.signUp(
email: email,
password: password,
);
if (response.user == null) {
throw const AuthException(
message: 'Failed to create account. Please try again.',
code: 'SIGNUP_FAILED',
);
}
final user = response.user!;
return AuthUser.fromSupabase(user);
} catch (e) {
throw AuthExceptionFactory.fromSupabaseError(e);
}
}
@override
Future<AuthUser> signIn(String email, String password) async {
try {
final response = await _supabase.auth.signInWithPassword(
email: email,
password: password,
);
if (response.user == null) {
throw const AuthException(
message: 'Failed to sign in. Please check your credentials.',
code: 'SIGNIN_FAILED',
);
}
final user = response.user!;
return AuthUser.fromSupabase(user);
} catch (e) {
throw AuthExceptionFactory.fromSupabaseError(e);
}
}
@override
Future<void> signOut() async {
try {
await _supabase.auth.signOut();
} catch (e) {
throw AuthExceptionFactory.fromSupabaseError(e);
}
}
@override
Future<void> resetPassword(String email) async {
try {
await _supabase.auth.resetPasswordForEmail(
email,
redirectTo: 'com.sage.app://reset-password',
);
} catch (e) {
throw AuthExceptionFactory.fromSupabaseError(e);
}
}
@override
Future<AuthUser?> getCurrentUser() async {
try {
final user = _supabase.auth.currentUser;
if (user == null) {
return null;
}
return AuthUser.fromSupabase(user);
} catch (e) {
throw AuthExceptionFactory.fromSupabaseError(e);
}
}
@override
Stream<AuthUser?> authStateChanges() {
try {
return _supabase.auth.onAuthStateChange
.map((authState) {
final user = authState.session?.user;
return user != null ? AuthUser.fromSupabase(user) : null;
});
} catch (e) {
// Return a stream that immediately emits the error
return Stream.error(AuthExceptionFactory.fromSupabaseError(e));
}
}
@override
Future<AuthUser> refreshSession() async {
try {
final response = await _supabase.auth.refreshSession();
if (response.session == null) {
throw const SessionExpiredException();
}
final user = response.session!.user;
return AuthUser.fromSupabase(user);
} catch (e) {
throw AuthExceptionFactory.fromSupabaseError(e);
}
}
@override
Future<AuthUser> updateProfile({
String? displayName,
String? avatarUrl,
}) async {
try {
final user = _supabase.auth.currentUser;
if (user == null) {
throw const SessionExpiredException();
}
final userMetadata = <String, dynamic>{};
if (displayName != null) {
userMetadata['display_name'] = displayName;
}
if (avatarUrl != null) {
userMetadata['avatar_url'] = avatarUrl;
}
final response = await _supabase.auth.updateUser(
UserAttributes(
data: userMetadata,
),
);
final updatedUser = response.user;
if (updatedUser == null) {
throw const AuthException(
message: 'Failed to update profile. Please try again.',
code: 'UPDATE_FAILED',
);
}
return AuthUser.fromSupabase(updatedUser);
} catch (e) {
throw AuthExceptionFactory.fromSupabaseError(e);
}
}
@override
Future<void> sendEmailVerification() async {
try {
final user = _supabase.auth.currentUser;
if (user == null) {
throw const SessionExpiredException();
}
await _supabase.auth.resend(
type: OtpType.signup,
email: user.email!,
);
} catch (e) {
throw AuthExceptionFactory.fromSupabaseError(e);
}
}
@override
Future<void> changePassword(String currentPassword, String newPassword) async {
try {
final user = _supabase.auth.currentUser;
if (user == null) {
throw const SessionExpiredException();
}
// First verify current password by attempting to sign in
await _supabase.auth.signInWithPassword(
email: user.email!,
password: currentPassword,
);
// If sign in succeeded, update password
await _supabase.auth.updateUser(
UserAttributes(
password: newPassword,
),
);
} catch (e) {
throw AuthExceptionFactory.fromSupabaseError(e);
}
}
@override
Future<void> deleteAccount() async {
try {
final user = _supabase.auth.currentUser;
if (user == null) {
throw const SessionExpiredException();
}
await _supabase.rpc('delete_user', params: {'user_id': user.id});
await _supabase.auth.signOut();
} catch (e) {
throw AuthExceptionFactory.fromSupabaseError(e);
}
}
@override
Future<bool?> isEmailVerified() async {
try {
final user = _supabase.auth.currentUser;
if (user == null) {
return null;
}
return user.emailConfirmedAt != null;
} catch (e) {
throw AuthExceptionFactory.fromSupabaseError(e);
}
}
@override
Future<AuthUser> signInWithOAuth(String provider) async {
try {
OAuthProvider oauthProvider;
switch (provider.toLowerCase()) {
case 'google':
oauthProvider = OAuthProvider.google;
break;
case 'github':
oauthProvider = OAuthProvider.github;
break;
case 'apple':
oauthProvider = OAuthProvider.apple;
break;
default:
throw AuthException(
message: 'Unsupported OAuth provider: $provider',
code: 'UNSUPPORTED_PROVIDER',
);
}
final response = await _supabase.auth.signInWithOAuth(
oauthProvider,
redirectTo: 'com.sage.app://auth-callback',
);
// OAuth sign in is handled via redirect, so we'll handle this in the callback
// For now, throw an exception indicating the redirect flow
throw AuthException(
message: 'OAuth sign in initiated. Please complete authentication in your browser.',
code: 'OAUTH_REDIRECT',
);
} catch (e) {
throw AuthExceptionFactory.fromSupabaseError(e);
}
}
@override
Future<AuthUser> signInAnonymously() async {
try {
final response = await _supabase.auth.signInAnonymously();
if (response.user == null) {
throw const AuthException(
message: 'Failed to sign in anonymously. Please try again.',
code: 'ANONYMOUS_SIGNIN_FAILED',
);
}
final user = response.user!;
return AuthUser.fromSupabase(user);
} catch (e) {
throw AuthExceptionFactory.fromSupabaseError(e);
}
}
/// Dispose of any active subscriptions
///
/// Call this when the repository is no longer needed to prevent memory leaks
void dispose() {
_authSubscription?.cancel();
}
}