Files
2026-04-10 12:05:40 +02:00

348 lines
12 KiB
Dart

import 'dart:async';
import '../models/user_model.dart';
import '../../bootstrap/supabase_client.dart';
import '../../core/utils/unit_conversion_utils.dart';
import 'package:supabase_flutter/supabase_flutter.dart' as supabase;
import 'package:google_sign_in/google_sign_in.dart';
class AuthRepository {
final supabase.SupabaseClient? _client;
StreamSubscription<supabase.AuthState>? _authStateSubscription;
AuthRepository([supabase.SupabaseClient? client]) : _client = client;
Stream<User?> get authStateChanges {
final client = supabaseClient;
if (client == null) {
// Return a stream that never emits if Supabase is not initialized
return Stream.empty();
}
return client.auth.onAuthStateChange.map((data) {
final session = data.session;
if (session?.user != null) {
return _mapSupabaseUserToAppUser(session!.user);
}
return null;
});
}
User? get currentUser {
final client = supabaseClient;
if (client == null) return null;
final user = client.auth.currentUser;
return user != null ? _mapSupabaseUserToAppUser(user) : null;
}
bool get isAuthenticated {
final client = supabaseClient;
if (client == null) return false;
return client.auth.currentUser != null;
}
String? get currentUserId {
final client = supabaseClient;
if (client == null) return null;
return client.auth.currentUser?.id;
}
Future<bool> isSessionValid() async {
assert(_client != null, 'Client must not be null');
final session = _client!.auth.currentSession;
if (session == null) return false;
final now = DateTime.now();
final expiresAt = session.expiresAt;
if (expiresAt == null) return true;
return now.isBefore(DateTime.fromMillisecondsSinceEpoch(expiresAt * 1000));
}
Future<void> refreshSession() async {
assert(_client != null, 'Client must not be null');
try {
await _client!.auth.refreshSession();
} catch (e) {
throw Exception('Failed to refresh session: $e');
}
}
Future<supabase.Session?> getCurrentSession() async {
assert(_client != null, 'Client must not be null');
return _client!.auth.currentSession;
}
void listenToAuthStateChanges(Function(User?) callback) {
assert(_client != null, 'Client must not be null');
_authStateSubscription = _client!.auth.onAuthStateChange.listen((data) {
final session = data.session;
if (session?.user != null) {
callback(_mapSupabaseUserToAppUser(session!.user));
} else {
callback(null);
}
});
}
void dispose() {
_authStateSubscription?.cancel();
}
Future<void> signInWithEmail(String email, String password) async {
assert(_client != null, 'Client must not be null');
await _client!.auth.signInWithPassword(email: email, password: password);
}
Future<void> signUpWithEmail(String email, String password, String username, {double? heightCm, double? weightKg, int? age, Gender? gender, HeightUnit? heightUnit, WeightUnit? weightUnit}) async {
assert(_client != null, 'Client must not be null');
final response = await _client!.auth.signUp(
email: email,
password: password,
data: {'username': username},
);
if (response.user != null) {
await _createUserProfile(response.user!.id, username, email, heightCm: heightCm, weightKg: weightKg, age: age, gender: gender, heightUnit: heightUnit, weightUnit: weightUnit);
}
}
Future<void> signInWithGoogle() async {
assert(_client != null, 'Client must not be null');
try {
final GoogleSignIn googleSignIn = GoogleSignIn(
scopes: ['email', 'profile'],
);
// Check if user is already signed in
final googleUser = await googleSignIn.signInSilently();
if (googleUser != null) {
await _handleGoogleUser(googleUser);
return;
}
// Sign in interactively
final interactiveUser = await googleSignIn.signIn();
if (interactiveUser == null) {
throw Exception('Google sign-in was cancelled');
}
await _handleGoogleUser(interactiveUser);
} catch (e) {
throw Exception('Google sign-in failed: ${e.toString()}');
}
}
Future<void> _handleGoogleUser(dynamic googleUser) async {
assert(_client != null, 'Client must not be null');
try {
final googleAuth = await googleUser.authentication;
final idToken = googleAuth.idToken;
final accessToken = googleAuth.accessToken;
if (idToken == null && accessToken == null) {
throw Exception('No ID token or access token from Google sign-in');
}
final response = await _client!.auth.signInWithIdToken(
provider: supabase.OAuthProvider.google,
idToken: idToken,
accessToken: accessToken,
);
if (response.user != null) {
await _ensureUserProfileExists(response.user!.id, response.user!);
}
} catch (e) {
throw Exception('Failed to authenticate with Google: ${e.toString()}');
}
}
Future<void> signInWithGithub() async {
assert(_client != null, 'Client must not be null');
await _client!.auth.signInWithOAuth(
supabase.OAuthProvider.github,
);
}
Future<void> signOut() async {
assert(_client != null, 'Client must not be null');
await _client!.auth.signOut();
}
Future<void> resetPassword(String email) async {
assert(_client != null, 'Client must not be null');
await _client!.auth.resetPasswordForEmail(email);
}
Future<void> updateProfile({
String? username,
String? bio,
String? avatarUrl,
bool? isPublicProfile,
double? heightCm,
double? weightKg,
int? age,
Gender? gender,
HeightUnit? heightUnit,
WeightUnit? weightUnit,
}) async {
assert(_client != null, 'Client must not be null');
final userId = _client!.auth.currentUser?.id;
if (userId == null) throw Exception('User not authenticated');
final updates = <String, dynamic>{};
if (username != null) updates['username'] = username;
if (bio != null) updates['bio'] = bio;
if (avatarUrl != null) updates['avatar_url'] = avatarUrl;
if (isPublicProfile != null) updates['is_public_profile'] = isPublicProfile;
if (heightCm != null) updates['height_cm'] = heightCm;
if (weightKg != null) updates['weight_kg'] = weightKg;
if (age != null) updates['age'] = age;
if (gender != null) updates['gender'] = gender.toDatabaseString();
if (heightUnit != null) updates['height_unit'] = heightUnit.code;
if (weightUnit != null) updates['weight_unit'] = weightUnit.code;
updates['updated_at'] = DateTime.now().toIso8601String();
await _client!
.from('users')
.update(updates)
.eq('id', userId);
}
Future<User> _createUserProfile(String userId, String username, String email, {double? heightCm, double? weightKg, int? age, Gender? gender, HeightUnit? heightUnit, WeightUnit? weightUnit}) async {
assert(_client != null, 'Client must not be null');
final now = DateTime.now().toIso8601String();
try {
// First try with the regular client (might fail due to RLS)
final response = await _client!.from('users').insert({
'id': userId,
'username': username,
'email': email,
'height_cm': heightCm,
'weight_kg': weightKg,
'age': age,
'gender': gender?.toDatabaseString(),
'height_unit': heightUnit?.code ?? HeightUnit.metric.code,
'weight_unit': weightUnit?.code ?? WeightUnit.metric.code,
'created_at': now,
'updated_at': now,
}).select();
if (response.isNotEmpty) {
return _mapSupabaseDataToUser(response.first);
}
} catch (e) {
// If regular client fails due to RLS, try with service role client
try {
final serviceClient = getServiceRoleClient();
final response = await serviceClient.from('users').insert({
'id': userId,
'username': username,
'email': email,
'height_cm': heightCm,
'weight_kg': weightKg,
'age': age,
'gender': gender?.toDatabaseString(),
'height_unit': heightUnit?.code ?? HeightUnit.metric.code,
'weight_unit': weightUnit?.code ?? WeightUnit.metric.code,
'created_at': now,
'updated_at': now,
}).select();
if (response.isNotEmpty) {
return _mapSupabaseDataToUser(response.first);
}
} catch (e2) {
// If both fail, create a basic user profile from auth metadata
// This allows the app to function even without database profile creation
return User(
id: userId,
username: username,
email: email,
storedAge: age,
heightCm: heightCm,
weightKg: weightKg,
gender: gender,
heightUnit: heightUnit ?? HeightUnit.metric,
weightUnit: weightUnit ?? WeightUnit.metric,
createdAt: DateTime.parse(now),
updatedAt: DateTime.parse(now),
);
}
}
// Fallback if no response but no error
return User(
id: userId,
username: username,
email: email,
storedAge: age,
heightCm: heightCm,
weightKg: weightKg,
gender: gender,
heightUnit: heightUnit ?? HeightUnit.metric,
weightUnit: weightUnit ?? WeightUnit.metric,
createdAt: DateTime.parse(now),
updatedAt: DateTime.parse(now),
);
}
Future<void> _ensureUserProfileExists(String userId, dynamic supabaseUser) async {
assert(_client != null, 'Client must not be null');
try {
final existingProfile = await _client!
.from('users')
.select('id')
.eq('id', userId)
.maybeSingle();
if (existingProfile == null) {
final username = supabaseUser.userMetadata?['username'] ??
'user_${userId.substring(0, 8)}';
final email = supabaseUser.email ?? '';
await _createUserProfile(userId, username, email);
}
} catch (e) {
// If RLS policy prevents reading, we'll assume the profile doesn't exist
// and let the _createUserProfile method handle the creation gracefully
final username = supabaseUser.userMetadata?['username'] ??
'user_${userId.substring(0, 8)}';
final email = supabaseUser.email ?? '';
await _createUserProfile(userId, username, email);
}
}
User _mapSupabaseUserToAppUser(dynamic supabaseUser) {
return User(
id: supabaseUser.id,
username: supabaseUser.userMetadata?['username'] ?? '',
email: supabaseUser.email ?? '',
createdAt: DateTime.tryParse(supabaseUser.createdAt ?? '') ?? DateTime.now(),
updatedAt: DateTime.tryParse(supabaseUser.updatedAt ?? '') ?? DateTime.now(),
);
}
User _mapSupabaseDataToUser(Map<String, dynamic> data) {
return User(
id: data['id'],
username: data['username'],
email: data['email'],
avatarUrl: data['avatar_url'],
bio: data['bio'],
isPublicProfile: data['is_public_profile'] ?? false,
countdownStartDate: data['countdown_start_date'] != null
? DateTime.parse(data['countdown_start_date'])
: null,
countdownEndDate: data['countdown_end_date'] != null
? DateTime.parse(data['countdown_end_date'])
: null,
gender: data['gender'] != null ? Gender.fromString(data['gender']) : null,
storedAge: data['age'] as int?,
heightCm: (data['height_cm'] as num?)?.toDouble(),
weightKg: (data['weight_kg'] as num?)?.toDouble(),
createdAt: DateTime.parse(data['created_at']),
updatedAt: DateTime.parse(data['updated_at']),
);
}
}