550 lines
16 KiB
Dart
550 lines
16 KiB
Dart
import 'package:firebase_core/firebase_core.dart';
|
|
import 'package:firebase_auth/firebase_auth.dart';
|
|
import 'package:cloud_firestore/cloud_firestore.dart';
|
|
import 'package:firebase_messaging/firebase_messaging.dart';
|
|
import 'package:flutter/foundation.dart';
|
|
import '../models/user_model.dart';
|
|
import '../models/recommendation_model.dart';
|
|
import '../core/constants/app_constants.dart';
|
|
import 'google_signin_wrapper.dart';
|
|
|
|
class FirebaseService {
|
|
static final FirebaseService _instance = FirebaseService._internal();
|
|
factory FirebaseService() => _instance;
|
|
|
|
// Firebase instances
|
|
final FirebaseAuth _auth = FirebaseAuth.instance;
|
|
final FirebaseFirestore _firestore = FirebaseFirestore.instance;
|
|
final FirebaseMessaging _messaging = FirebaseMessaging.instance;
|
|
|
|
// Initialize Firebase Service
|
|
FirebaseService._internal();
|
|
|
|
// Current user stream
|
|
Stream<User?> get authStateChanges => _auth.authStateChanges();
|
|
User? get currentUser => _auth.currentUser;
|
|
bool get isSignedIn => currentUser != null;
|
|
|
|
// Initialize Firebase
|
|
static Future<void> initialize() async {
|
|
try {
|
|
await Firebase.initializeApp();
|
|
debugPrint('Firebase initialized successfully');
|
|
} catch (e) {
|
|
debugPrint('Firebase initialization error: $e');
|
|
rethrow;
|
|
}
|
|
}
|
|
|
|
// Authentication Methods
|
|
Future<UserCredential?> signInWithEmail(String email, String password) async {
|
|
try {
|
|
final credential = await _auth.signInWithEmailAndPassword(
|
|
email: email,
|
|
password: password,
|
|
);
|
|
return credential;
|
|
} on FirebaseAuthException catch (e) {
|
|
debugPrint('Sign in error: ${e.message}');
|
|
throw _handleAuthException(e);
|
|
}
|
|
}
|
|
|
|
Future<UserCredential?> registerWithEmail(
|
|
String email,
|
|
String password,
|
|
) async {
|
|
try {
|
|
final credential = await _auth.createUserWithEmailAndPassword(
|
|
email: email,
|
|
password: password,
|
|
);
|
|
|
|
// Create user document in Firestore
|
|
if (credential.user != null) {
|
|
await createUserDocument(credential.user!);
|
|
}
|
|
|
|
return credential;
|
|
} on FirebaseAuthException catch (e) {
|
|
debugPrint('Registration error: ${e.message}');
|
|
throw _handleAuthException(e);
|
|
}
|
|
}
|
|
|
|
Future<UserCredential?> signInWithGoogle() async {
|
|
try {
|
|
debugPrint('🔄 Starting safe Google Sign-In process...');
|
|
|
|
// Use the crash-safe wrapper
|
|
final googleSignInWrapper = GoogleSignInWrapper.instance;
|
|
|
|
// Check if Google Sign-In is available
|
|
bool isAvailable = await googleSignInWrapper.isAvailable();
|
|
if (!isAvailable) {
|
|
throw Exception('Google Sign-In is not available on this device');
|
|
}
|
|
|
|
// Attempt sign-in using the wrapper
|
|
UserCredential? userCredential = await googleSignInWrapper.signIn();
|
|
|
|
if (userCredential == null) {
|
|
debugPrint('👤 User cancelled Google Sign-In');
|
|
return null;
|
|
}
|
|
|
|
return userCredential;
|
|
} catch (e) {
|
|
debugPrint('❌ Google Sign-In failed: $e');
|
|
throw Exception('Google Sign-In failed: $e');
|
|
}
|
|
}
|
|
|
|
Future<void> signOut() async {
|
|
try {
|
|
await Future.wait([
|
|
_auth.signOut(),
|
|
GoogleSignInWrapper.instance.signOut(),
|
|
]);
|
|
} catch (e) {
|
|
debugPrint('Sign out error: $e');
|
|
throw Exception('Sign out failed: $e');
|
|
}
|
|
}
|
|
|
|
Future<void> resetPassword(String email) async {
|
|
try {
|
|
await _auth.sendPasswordResetEmail(email: email);
|
|
} on FirebaseAuthException catch (e) {
|
|
debugPrint('Password reset error: ${e.message}');
|
|
throw _handleAuthException(e);
|
|
}
|
|
}
|
|
|
|
/// Check if email exists in Firestore (registered user)
|
|
Future<bool> checkEmailExists(String email) async {
|
|
try {
|
|
final querySnapshot = await _firestore
|
|
.collection(AppConstants.usersCollection)
|
|
.where('email', isEqualTo: email)
|
|
.limit(1)
|
|
.get();
|
|
|
|
return querySnapshot.docs.isNotEmpty;
|
|
} catch (e) {
|
|
debugPrint('Check email exists error: $e');
|
|
return false;
|
|
}
|
|
}
|
|
|
|
// Email Verification Methods
|
|
Future<UserCredential?> createUserForVerification(
|
|
String email,
|
|
String tempPassword,
|
|
) async {
|
|
try {
|
|
final credential = await _auth.createUserWithEmailAndPassword(
|
|
email: email,
|
|
password: tempPassword,
|
|
);
|
|
return credential;
|
|
} on FirebaseAuthException catch (e) {
|
|
debugPrint('Create user for verification error: ${e.message}');
|
|
throw _handleAuthException(e);
|
|
}
|
|
}
|
|
|
|
Future<void> sendEmailVerification() async {
|
|
try {
|
|
final user = _auth.currentUser;
|
|
if (user != null && !user.emailVerified) {
|
|
await user.sendEmailVerification();
|
|
debugPrint('📧 Verification email sent to ${user.email}');
|
|
}
|
|
} catch (e) {
|
|
debugPrint('Send email verification error: $e');
|
|
throw Exception('Gagal mengirim email verifikasi: $e');
|
|
}
|
|
}
|
|
|
|
Future<bool> checkEmailVerified() async {
|
|
try {
|
|
await _auth.currentUser?.reload();
|
|
return _auth.currentUser?.emailVerified ?? false;
|
|
} catch (e) {
|
|
debugPrint('Check email verified error: $e');
|
|
return false;
|
|
}
|
|
}
|
|
|
|
Future<void> updatePassword(String newPassword) async {
|
|
try {
|
|
final user = _auth.currentUser;
|
|
if (user != null) {
|
|
await user.updatePassword(newPassword);
|
|
debugPrint('🔑 Password updated successfully');
|
|
}
|
|
} catch (e) {
|
|
debugPrint('Update password error: $e');
|
|
throw Exception('Gagal memperbarui password: $e');
|
|
}
|
|
}
|
|
|
|
/// Link email/password to existing account (for Google users)
|
|
Future<UserCredential?> linkEmailPassword(
|
|
String email,
|
|
String password,
|
|
) async {
|
|
try {
|
|
final user = _auth.currentUser;
|
|
if (user == null) {
|
|
throw Exception('User tidak ditemukan');
|
|
}
|
|
|
|
// Create email/password credential
|
|
final credential = EmailAuthProvider.credential(
|
|
email: email,
|
|
password: password,
|
|
);
|
|
|
|
// Link the credential to the current user
|
|
final result = await user.linkWithCredential(credential);
|
|
debugPrint('✅ Email/password linked successfully to ${user.email}');
|
|
return result;
|
|
} on FirebaseAuthException catch (e) {
|
|
debugPrint('Link email/password error: ${e.code} - ${e.message}');
|
|
if (e.code == 'provider-already-linked') {
|
|
debugPrint('⚠️ Provider already linked, updating password to match new input...');
|
|
try {
|
|
await _auth.currentUser?.updatePassword(password);
|
|
return null;
|
|
} catch (err) {
|
|
debugPrint('Failed to update password on already linked account: $err');
|
|
}
|
|
} else if (e.code == 'credential-already-in-use') {
|
|
throw Exception('Email ini sudah digunakan akun lain');
|
|
}
|
|
throw _handleAuthException(e);
|
|
} catch (e) {
|
|
debugPrint('Link email/password error: $e');
|
|
throw Exception('Gagal menghubungkan email/password: $e');
|
|
}
|
|
}
|
|
|
|
// User Document Methods
|
|
Future<UserModel> createUserDocument(
|
|
User user, {
|
|
String? name,
|
|
String? phone,
|
|
}) async {
|
|
try {
|
|
final userDoc = _firestore
|
|
.collection(AppConstants.usersCollection)
|
|
.doc(user.uid);
|
|
// Force create/merge user document to ensure it exists
|
|
// This is crucial for self-healing if data was deleted
|
|
final userModel = UserModel(
|
|
id: user.uid,
|
|
email: user.email!,
|
|
displayName: name ?? user.displayName,
|
|
phoneNumber: phone,
|
|
photoUrl: user.photoURL,
|
|
preferences: UserPreferences(),
|
|
createdAt: DateTime.now(),
|
|
updatedAt: DateTime.now(),
|
|
isSetupComplete: false,
|
|
);
|
|
|
|
// Use SetOptions(merge: true) to safe-write
|
|
await userDoc.set(userModel.toJson(), SetOptions(merge: true));
|
|
debugPrint('✅ User document created/merged for ${user.uid}');
|
|
|
|
return userModel;
|
|
} catch (e) {
|
|
debugPrint('Create user document error: $e');
|
|
throw Exception('Failed to create user document: $e');
|
|
}
|
|
}
|
|
|
|
Future<bool> isUserRegistered(String uid) async {
|
|
try {
|
|
final doc = await _firestore
|
|
.collection(AppConstants.usersCollection)
|
|
.doc(uid)
|
|
.get();
|
|
return doc.exists;
|
|
} catch (e) {
|
|
return false;
|
|
}
|
|
}
|
|
|
|
Future<UserModel?> getUserData(String userId) async {
|
|
try {
|
|
final doc = await _firestore
|
|
.collection(AppConstants.usersCollection)
|
|
.doc(userId)
|
|
.get();
|
|
if (doc.exists && doc.data() != null) {
|
|
return UserModel.fromJson(doc.data()!);
|
|
}
|
|
return null;
|
|
} catch (e) {
|
|
debugPrint('Get user data error: $e');
|
|
throw Exception('Failed to get user data: $e');
|
|
}
|
|
}
|
|
|
|
/// Get user data by email address
|
|
Future<UserModel?> getUserDataByEmail(String email) async {
|
|
try {
|
|
final querySnapshot = await _firestore
|
|
.collection(AppConstants.usersCollection)
|
|
.where('email', isEqualTo: email)
|
|
.limit(1)
|
|
.get();
|
|
|
|
if (querySnapshot.docs.isNotEmpty) {
|
|
return UserModel.fromJson(querySnapshot.docs.first.data());
|
|
}
|
|
return null;
|
|
} catch (e) {
|
|
debugPrint('Get user data by email error: $e');
|
|
return null;
|
|
}
|
|
}
|
|
|
|
Future<void> updateUserData(UserModel user) async {
|
|
try {
|
|
await _firestore
|
|
.collection(AppConstants.usersCollection)
|
|
.doc(user.id)
|
|
.update(user.copyWith(updatedAt: DateTime.now()).toJson());
|
|
} catch (e) {
|
|
debugPrint('Update user data error: $e');
|
|
throw Exception('Failed to update user data: $e');
|
|
}
|
|
}
|
|
|
|
Future<void> updateUserPreferences(
|
|
String userId,
|
|
UserPreferences preferences,
|
|
) async {
|
|
try {
|
|
final doc = await _firestore
|
|
.collection(AppConstants.usersCollection)
|
|
.doc(userId)
|
|
.get();
|
|
|
|
if (!doc.exists) throw Exception('User not found');
|
|
|
|
final currentData = doc.data() as Map<String, dynamic>;
|
|
final dailyPreferences = Map<String, dynamic>.from(
|
|
currentData['dailyPreferences'] ?? {});
|
|
|
|
// Sinkronisasi Balik: Profil -> Daily Check-in (keinginan)
|
|
bool hasFood = preferences.favoriteCategories.any((c) =>
|
|
c.toLowerCase().contains('makanan') ||
|
|
c.toLowerCase().contains('snack') ||
|
|
c.toLowerCase().contains('dessert') ||
|
|
c.toLowerCase().contains('pastry') ||
|
|
c.toLowerCase().contains('sarapan') ||
|
|
c.toLowerCase().contains('berkuah'));
|
|
|
|
bool hasDrink = preferences.favoriteCategories.any((c) =>
|
|
c.toLowerCase().contains('minuman'));
|
|
|
|
if (hasFood && hasDrink) {
|
|
dailyPreferences['keinginan'] = 'keduanya';
|
|
} else if (hasDrink && !hasFood) {
|
|
dailyPreferences['keinginan'] = 'minuman';
|
|
} else if (hasFood && !hasDrink) {
|
|
dailyPreferences['keinginan'] = 'makanan';
|
|
}
|
|
|
|
// Sinkronkan juga budget dari profil ke daily checkin hari ini
|
|
dailyPreferences['budget'] = preferences.budgetRange;
|
|
|
|
final data = preferences.toJson();
|
|
|
|
// 🔍 DEBUG — hapus setelah debugging selesai
|
|
debugPrint('=== SAVE PREFERENCES DEBUG ===');
|
|
debugPrint('userId: $userId');
|
|
debugPrint('favoriteCategories: ${preferences.favoriteCategories}');
|
|
debugPrint('new keinginan: ${dailyPreferences['keinginan']}');
|
|
debugPrint('==============================');
|
|
|
|
await _firestore
|
|
.collection(AppConstants.usersCollection)
|
|
.doc(userId)
|
|
.update({
|
|
'preferences': data,
|
|
'dailyPreferences': dailyPreferences,
|
|
'updatedAt': DateTime.now().toIso8601String(),
|
|
});
|
|
debugPrint('✅ Preferences saved successfully (and synced to dailyPreferences)');
|
|
} catch (e) {
|
|
debugPrint('Update user preferences error: $e');
|
|
throw Exception('Failed to update user preferences: $e');
|
|
}
|
|
}
|
|
|
|
Future<void> setSetupComplete(String userId) async {
|
|
try {
|
|
await _firestore
|
|
.collection(AppConstants.usersCollection)
|
|
.doc(userId)
|
|
.update({
|
|
'isSetupComplete': true,
|
|
'updatedAt': DateTime.now().toIso8601String(),
|
|
});
|
|
} catch (e) {
|
|
debugPrint('Set setup complete error: $e');
|
|
throw Exception('Failed to set setup complete: $e');
|
|
}
|
|
}
|
|
|
|
// Recommendations Methods
|
|
Future<void> saveRecommendation(RecommendationModel recommendation) async {
|
|
try {
|
|
await _firestore
|
|
.collection(AppConstants.recommendationsCollection)
|
|
.doc(recommendation.id)
|
|
.set(recommendation.toJson());
|
|
} catch (e) {
|
|
debugPrint('Save recommendation error: $e');
|
|
throw Exception('Failed to save recommendation: $e');
|
|
}
|
|
}
|
|
|
|
Future<List<RecommendationModel>> getUserRecommendations(
|
|
String userId, {
|
|
int limit = 20,
|
|
}) async {
|
|
try {
|
|
// Simplified query to avoid index issues - just get by userId first
|
|
final query = await _firestore
|
|
.collection(AppConstants.recommendationsCollection)
|
|
.where('userId', isEqualTo: userId)
|
|
.limit(limit)
|
|
.get();
|
|
|
|
// Sort in memory to avoid composite index requirement
|
|
final recommendations = query.docs
|
|
.map((doc) => RecommendationModel.fromJson(doc.data()))
|
|
.toList();
|
|
|
|
// Sort by createdAt in descending order
|
|
recommendations.sort((a, b) => b.createdAt.compareTo(a.createdAt));
|
|
|
|
return recommendations;
|
|
} catch (e) {
|
|
debugPrint('Get user recommendations error: $e');
|
|
// Return empty list instead of throwing exception for better UX
|
|
return [];
|
|
}
|
|
}
|
|
|
|
Future<void> toggleRecommendationFavorite(
|
|
String recommendationId,
|
|
bool isFavorite,
|
|
) async {
|
|
try {
|
|
await _firestore
|
|
.collection(AppConstants.recommendationsCollection)
|
|
.doc(recommendationId)
|
|
.update({'isFavorite': isFavorite});
|
|
} catch (e) {
|
|
debugPrint('Toggle recommendation favorite error: $e');
|
|
throw Exception('Failed to update recommendation: $e');
|
|
}
|
|
}
|
|
|
|
Future<void> deleteRecommendation(String recommendationId) async {
|
|
try {
|
|
await _firestore
|
|
.collection(AppConstants.recommendationsCollection)
|
|
.doc(recommendationId)
|
|
.delete();
|
|
} catch (e) {
|
|
debugPrint('Delete recommendation error: $e');
|
|
throw Exception('Failed to delete recommendation: $e');
|
|
}
|
|
}
|
|
|
|
// Push Notifications
|
|
Future<void> initializeNotifications() async {
|
|
try {
|
|
// Request permission
|
|
NotificationSettings settings = await _messaging.requestPermission(
|
|
alert: true,
|
|
announcement: false,
|
|
badge: true,
|
|
carPlay: false,
|
|
criticalAlert: false,
|
|
provisional: false,
|
|
sound: true,
|
|
);
|
|
|
|
if (settings.authorizationStatus == AuthorizationStatus.authorized) {
|
|
debugPrint('User granted permission for notifications');
|
|
|
|
// Get FCM token
|
|
String? token = await _messaging.getToken();
|
|
if (token != null && currentUser != null) {
|
|
await _saveFCMToken(currentUser!.uid, token);
|
|
}
|
|
|
|
// Listen for token refresh
|
|
_messaging.onTokenRefresh.listen((token) {
|
|
if (currentUser != null) {
|
|
_saveFCMToken(currentUser!.uid, token);
|
|
}
|
|
});
|
|
}
|
|
} catch (e) {
|
|
debugPrint('Initialize notifications error: $e');
|
|
}
|
|
}
|
|
|
|
Future<void> _saveFCMToken(String userId, String token) async {
|
|
try {
|
|
await _firestore
|
|
.collection(AppConstants.usersCollection)
|
|
.doc(userId)
|
|
.update({
|
|
'fcmToken': token,
|
|
'updatedAt': DateTime.now().toIso8601String(),
|
|
});
|
|
} catch (e) {
|
|
debugPrint('Save FCM token error: $e');
|
|
}
|
|
}
|
|
|
|
// Error handling
|
|
String _handleAuthException(FirebaseAuthException e) {
|
|
switch (e.code) {
|
|
case 'invalid-credential':
|
|
return 'Email atau Password salah';
|
|
case 'user-not-found':
|
|
return 'Pengguna tidak ditemukan';
|
|
case 'wrong-password':
|
|
return 'Password salah';
|
|
case 'email-already-in-use':
|
|
return 'Email sudah digunakan';
|
|
case 'weak-password':
|
|
return 'Password terlalu lemah';
|
|
case 'invalid-email':
|
|
return 'Format email tidak valid';
|
|
case 'user-disabled':
|
|
return 'Akun telah dinonaktifkan';
|
|
case 'too-many-requests':
|
|
return 'Terlalu banyak percobaan, coba lagi nanti';
|
|
case 'operation-not-allowed':
|
|
return 'Operasi tidak diizinkan';
|
|
default:
|
|
return e.message ?? 'Terjadi kesalahan';
|
|
}
|
|
}
|
|
}
|
|
|