Como ya habréis estudiado en Acceso a datos, el patrón Repository es ideal para abstraer el acceso a Firestore y proporcionar una interfaz limpia para interactuar con los datos. Para implementar un Repository que se conecte a Firestore seguiremos los siguientes pasos:
1. Crear la data class
Define una data class para representar la estructura de los documentos en Firestore. Estas clases deben contar con un constructor sin parámetros para que funcione la deserialización.
import com.google.firebase.firestore.DocumentId
import com.google.firebase.firestore.PropertyName
import com.google.firebase.firestore.ServerTimestamp
import java.util.Date
data class User(
@DocumentId val id: String = "",
@PropertyName("user_name") val name: String,
val email: String,
val age: Int,
@ServerTimestamp val createdAt: Date? = null // Fecha generada automáticamente
) {
// Constructor vacío necesario para la deserialización
constructor() : this(name = "", email = "", age = 0)
}
Las anotaciones que se pueden usar son:
@DocumentId
La anotación @DocumentId se utiliza para mapear el ID del documento Firestore (que no es un campo del documento) a una propiedad en tu clase de datos.
@PropertyName
La anotación @PropertyName se utiliza para mapear un nombre de campo en Firestore a una propiedad de tu clase que tiene un nombre diferente.
Esta anotación es útil si necesitas que los nombres en tu clase sean diferentes de los nombres en la base de datos.
@ServerTimestamp
La anotación @ServerTimestamp se utiliza para que Firestore asigne automáticamente una marca de tiempo del servidor (Timestamp) al campo. Si el campo está configurado con esta anotación, Firestore asignará un valor de marca de tiempo cuando se cree o actualice el documento. Es útil para rastrear cuándo se creó o actualizó un documento sin tener que hacerlo manualmente.
@Exclude
La anotación @Exclude se utiliza para evitar que una propiedad específica de tu clase sea almacenada o leída desde Firestore.
import com.google.firebase.firestore.Exclude
// Esta data class nos permite almacenar los intentos de login que ha habido en el sistema
data class LoginAttempt(
val name: String = "",
val email: String = "",
// Excluimos el password para que no quede almacenado en la base de datos
@Exclude val password: String = "" // Esta propiedad no se almacena en Firestore
)
Sirve para evitar almacenar datos sensibles (como contraseñas) y para propiedades temporales o calculadas que no deben ser parte del documento en Firestore.
2. Crear el Repositorio
El patrón repository abstrae las operaciones leer, escribir y actualizar documentos. Primero debemos crear el interface del repositorio en el paquete domain.repository
import kotlinx.coroutines.flow.Flow
interface UserRepository {
suspend fun getById(id: String): User?
fun list(): Flow<List<User>>
suspend fun save(user: User)
suspend fun delete(id: String)
}
Después creamos una implementación del repositorio basada en Firestore. Se debe crear en el paquete data.repository
import com.google.firebase.firestore.FirebaseFirestore
import com.google.firebase.firestore.Query
import kotlinx.coroutines.channels.awaitClose
import kotlinx.coroutines.flow.Flow
import kotlinx.coroutines.flow.callbackFlow
import kotlinx.coroutines.tasks.await
class UserFirestoreRepository(val firestore: FirebaseFirestore): UserRepository {
private val usersCollection = firestore.collection("users")
// Obtener un usuario por ID
override suspend fun getById(id: String): User? {
return try {
val documentSnapshot = usersCollection.document(id).get().await()
documentSnapshot.toObject(User::class.java)
} catch (e: Exception) {
e.printStackTrace()
null
}
}
override fun list(): Flow<List<User>> {
// Esta implementación crea un Flow que actualiza la lista de usuarios
// cada vez que hay un cambio en la base de datos
return queryForList(
usersCollection.orderBy("name", Query.Direction.DESCENDING),
User::class.java
)
}
// Agregar un nuevo usuario
override suspend fun save(user: User): Boolean {
return try {
usersCollection.add(user).await()
true
} catch (e: Exception) {
e.printStackTrace()
false
}
}
// Eliminar un usuario por ID
override suspend fun delete(id: String): Boolean {
return try {
usersCollection.document(id).delete().await()
true
} catch (e: Exception) {
e.printStackTrace()
false
}
}
// Este método es siempre igual para cualquier repository
private fun <T> queryForList(query: Query, clazz: Class<T>): Flow<List<T>> {
return callbackFlow {
val listener = query
.addSnapshotListener { snapshots, error ->
if (error != null) {
close(error)
return@addSnapshotListener
}
val items = snapshots?.documents?.mapNotNull { doc ->
doc.toObject(clazz)
} ?: emptyList()
trySend(items)
}
awaitClose() { listener.remove() }
}
}
// Este método es siempre igual para cualquier repository
private fun <T> queryForSingle(query: Query, clazz: Class<T>): Flow<T?> {
return callbackFlow {
val listener = query
.addSnapshotListener { snapshots, error ->
if (error != null) {
close(error)
return@addSnapshotListener
}
val item = snapshots?.documents?.firstOrNull()?.toObject(clazz)
trySend(item)
}
awaitClose() { listener.remove() }
}
}
}