Firestore in Android with Jetpack Compose

Post Avatar

Exploring Firebase Firestore in Android with Jetpack Compose and Kotlin DSL


Introduction

In modern Android app development, creating efficient and dynamic databases is essential for delivering seamless user experiences. Firebase Firestore, a cloud-hosted NoSQL database, has gained immense popularity due to its real-time capabilities, scalability, and ease of integration. Combined with Jetpack Compose—a modern UI toolkit for Android—it opens up new possibilities for building responsive and visually appealing, database-driven applications.

In this post, we will explore how to implement full CRUD (Create, Read, Update, Delete) operations using Firebase Firestore in an Android app with Kotlin, Jetpack Compose, and the Kotlin DSL for Gradle. We will also refactor the code for improved structure and maintainability.


What is Firebase Firestore?

Firebase Firestore is a cloud-based NoSQL database that allows real-time synchronization of data across multiple devices and platforms. It organizes data into collections and documents, which makes managing and retrieving information straightforward. Firestore’s real-time capabilities make it an ideal choice for collaborative and data-intensive applications.


Project Setup

Creating a Firebase Project

  1. Access Firebase Console:
    Go to Firebase Console and create a new project.
  2. Add Your Android App:
    Click on the Android icon and follow the instructions to register your app.
  3. Download the google-services.json File:
    Place the downloaded file in the root directory of your app module.

Integrating with Android Studio

  1. Open Android Studio and use the Firebase Assistant from View > Tool Windows > Firebase.
  2. Connect your project to Firebase and add the required dependencies for Firestore.

Gradle Configuration with Kotlin DSL

Modern Gradle configuration benefits from Kotlin DSL, which provides compile-time safety and a more expressive syntax. Below is an example:

//[title="build.gradle.kts"]
plugins {
    id("com.android.application")
    kotlin("android")
    kotlin("kapt")
    id("com.google.gms.google-services")
}

android {
    compileSdk = 33
    defaultConfig {
        applicationId = "com.firebase_firestore.app"
        minSdk = 21
        targetSdk = 33
        versionCode = 1
        versionName = "1.0"
        testInstrumentationRunner = "androidx.test.runner.AndroidJUnitRunner"
    }
    buildTypes {
        getByName("release") {
            isMinifyEnabled = false
            proguardFiles(getDefaultProguardFile("proguard-android-optimize.txt"), "proguard-rules.pro")
        }
    }
    compileOptions {
        sourceCompatibility = JavaVersion.VERSION_1_8
        targetCompatibility = JavaVersion.VERSION_1_8
    }
    kotlinOptions {
        jvmTarget = "1.8"
    }
    buildFeatures {
        compose = true
    }
    composeOptions {
        kotlinCompilerExtensionVersion = "1.5.0"
    }
}

dependencies {
    // Firebase Firestore (latest stable version)
    implementation("com.google.firebase:firebase-firestore-ktx:24.4.0")
    
    // Jetpack Compose
    implementation("androidx.compose.ui:ui:1.5.0")
    implementation("androidx.compose.material:material:1.5.0")
    implementation("androidx.compose.ui:ui-tooling:1.5.0")
    
    // Kotlin Coroutines for Android
    implementation("org.jetbrains.kotlinx:kotlinx-coroutines-android:1.6.4")
    
    // Additional dependencies
    implementation("androidx.lifecycle:lifecycle-runtime-ktx:2.6.1")
    implementation("androidx.activity:activity-compose:1.7.1")
}

{{note:Adjust the versions as needed for the latest updates.},{type:success}}


Firestore Database Structure

Firestore organizes data into collections and documents. For example, in a note-taking app:

  • Collection: notes
  • Document: Each note document contains fields such as id, title, and content.

This flexible structure makes it easy to scale and add new fields without complex migrations.


Implementing CRUD Operations

Below are refactored and updated examples for implementing CRUD operations.

Defining the Data Model

We create a Kotlin data class to represent each note:

//[title="src/main/java/com.firebase_firestore.app/models/Note.kt"]
package com.firebase_firestore.app.model

data class Note(
    val id: String = "",
    val title: String = "",
    val content: String = ""
)

Create Operation

A function to add a new note to Firestore:

//[title="src/main/java/com.firebase_firestore.app/data/FirestoreRepository.kt"]
package com.firebase_firestore.app.data

import com.google.firebase.firestore.FirebaseFirestore
import com.firebase_firestore.app.model.Note

object FirestoreRepository {
    private val firestore = FirebaseFirestore.getInstance()
    private val notesCollection = firestore.collection("notes")

    fun addNote(note: Note, onSuccess: () -> Unit, onFailure: (Exception) -> Unit) {
        notesCollection.add(note)
            .addOnSuccessListener { onSuccess() }
            .addOnFailureListener { exception -> onFailure(exception) }
    }
}

Read Operation

A function to fetch notes and update the UI in real time:

//[title="src/main/java/com.firebase_firestore.app/data/FirestoreRepository.kt"]
package com.firebase_firestore.app.data

import androidx.compose.runtime.mutableStateListOf
import com.google.firebase.firestore.FirebaseFirestore
import com.firebase_firestore.app.models.Note

object FirestoreRepository {
    private val firestore = FirebaseFirestore.getInstance()
    private val notesCollection = firestore.collection("notes")

    fun fetchNotes( // [!code ++]
        onNotesFetched: (List<Note>) -> Unit, // [!code ++]
        onError: (Exception) -> Unit // [!code ++]
    ) { // [!code ++]
        notesCollection.get() // [!code ++]
            .addOnSuccessListener { result -> // [!code ++]
                val notes = result.documents.mapNotNull { it.toObject(Note::class.java) } // [!code ++]
                onNotesFetched(notes)  // [!code ++]
            } // [!code ++]
            .addOnFailureListener { exception -> // [!code ++]
                onError(exception) // [!code ++]
            } // [!code ++]
    } // [!code ++]
}

Update Operation

A function to update an existing note using its document ID:

//[title="src/main/java/com.firebase_firestore.app/data/FirestoreRepository.kt"]
package com.firebase_firestore.app.data

import com.google.firebase.firestore.FirebaseFirestore
import com.firebase_firestore.app.model.Note

object FirestoreRepository {
    private val firestore = FirebaseFirestore.getInstance()
    private val notesCollection = firestore.collection("notes")

    fun updateNote(noteId: String, newNote: Note, onSuccess: () -> Unit, onFailure: (Exception) -> Unit) {  // [!code ++]
        notesCollection.document(noteId).set(newNote)  // [!code ++]
            .addOnSuccessListener { onSuccess() }  // [!code ++]
            .addOnFailureListener { exception -> onFailure(exception) }  // [!code ++]
    } // [!code ++]
}

Delete Operation

A function to delete a note from the collection:

//[title="src/main/java/com.firebase_firestore.app/data/FirestoreRepository.kt"]
package com.firebase_firestore.app.data

import com.google.firebase.firestore.FirebaseFirestore

object FirestoreRepository {
    private val firestore = FirebaseFirestore.getInstance()
    private val notesCollection = firestore.collection("notes")

    fun deleteNote(noteId: String, onSuccess: () -> Unit, onFailure: (Exception) -> Unit) { // [!code ++]
        notesCollection.document(noteId).delete() // [!code ++]
            .addOnSuccessListener { onSuccess() } // [!code ++]
            .addOnFailureListener { exception -> onFailure(exception) } // [!code ++]
    } // [!code ++]
}

Integrating Firestore with Jetpack Compose

Below is how to integrate Firestore operations with a Jetpack Compose UI. This example demonstrates a simple screen to add and display notes.

Initializing Firebase in the Application Class

It is recommended to initialize Firebase in your Application class to ensure it is ready when needed:

//[title="src/main/java/com.firebase_firestore.app/MainActivity.kt"]
package com.firebase_firestore.app

import android.app.Application
import com.google.firebase.FirebaseApp

class MainActivity : Application() {
    override fun onCreate() {
        super.onCreate()
        FirebaseApp.initializeApp(this)
    }
}

{{note:Remember to register this class in your AndroidManifest.xml},{type:warning}}

Main Screen with Jetpack Compose

The following is a main screen that allows users to add a note and view the updated list:

//[title="src/main/java/com.firebase_firestore.app/ui/presentation/NoteAppScreen.kt"]
package com.firebase_firestore.app.ui

import androidx.compose.foundation.layout.*
import androidx.compose.foundation.lazy.LazyColumn
import androidx.compose.foundation.lazy.items
import androidx.compose.material.*
import androidx.compose.runtime.*
import androidx.compose.ui.Modifier
import androidx.compose.ui.unit.dp
import com.firebase_firestore.app.data.FirestoreRepository
import com.firebase_firestore.app.model.Note

@Composable
fun NoteAppScreen() {
    var title by remember { mutableStateOf("") }
    var content by remember { mutableStateOf("") }
    val notes = remember { mutableStateListOf<Note>() }
    var errorMessage by remember { mutableStateOf("") }

    LaunchedEffect(Unit) {
        FirestoreRepository.fetchNotes(
            onNotesFetched = { fetchedNotes ->
                notes.clear()
                notes.addAll(fetchedNotes)
            },
            onError = { exception ->
                errorMessage = exception.message ?: "Error loading notes"
            }
        )
    }

    Scaffold(
        topBar = {
            TopAppBar(
                title = { Text(text = "Firestore Notes") }
            )
        },
        content = { padding ->
            Column(
                modifier = Modifier
                    .padding(padding)
                    .padding(16.dp)
            ) {
                OutlinedTextField(
                    value = title,
                    onValueChange = { title = it },
                    label = { Text("Title") },
                    modifier = Modifier.fillMaxWidth()
                )
                Spacer(modifier = Modifier.height(8.dp))
                OutlinedTextField(
                    value = content,
                    onValueChange = { content = it },
                    label = { Text("Content") },
                    modifier = Modifier.fillMaxWidth()
                )
                Spacer(modifier = Modifier.height(16.dp))
                Button(
                    onClick = {
                        val newNote = Note(title = title, content = content)
                        FirestoreRepository.addNote(
                            note = newNote,
                            onSuccess = {
                                title = ""
                                content = ""
                            },
                            onFailure = { exception ->
                                errorMessage = exception.message ?: "Error adding note"
                            }
                        )
                    },
                    modifier = Modifier.fillMaxWidth()
                ) {
                    Text("Add Note")
                }
                Spacer(modifier = Modifier.height(16.dp))
                if (errorMessage.isNotEmpty()) {
                    Text(text = errorMessage, color = MaterialTheme.colors.error)
                }
                Spacer(modifier = Modifier.height(16.dp))
                Text(text = "Notes List", style = MaterialTheme.typography.h6)
                LazyColumn {
                    items(notes) { note ->
                        Card(
                            elevation = 4.dp,
                            modifier = Modifier
                                .fillMaxWidth()
                                .padding(vertical = 4.dp)
                        ) {
                            Column(modifier = Modifier.padding(8.dp)) {
                                Text(text = note.title, style = MaterialTheme.typography.subtitle1)
                                Text(text = note.content, style = MaterialTheme.typography.body2)
                            }
                        }
                    }
                }
            }
        }
    )
}

Best Practices and Improvements

  1. Repository Pattern:
    Centralize your data access logic in a repository for reusability and easier unit testing.
  2. Recommended Architecture:
    Consider using architectural patterns like MVVM along with dependency injection frameworks such as Hilt or Dagger.
  3. Real-time Listeners:
    Instead of one-time fetch calls, use addSnapshotListener for continuous UI updates.
  4. Error Handling and Caching:
    Implement robust error handling and consider local caching to improve user experience in low connectivity scenarios.
  5. Modularization:
    Separate Firestore functions into modules or layers to keep your code clean and scalable.

Conclusion

Integrating Firebase Firestore with Jetpack Compose using Kotlin DSL allows you to build modern, efficient, and high-performance Android applications. With the approach presented in this post, you can implement complete CRUD operations, leverage Compose's reactivity, and maintain a modular, maintainable codebase.

This updated guide not only enhances the structure and readability of your code but also facilitates the integration of new technologies and best practices in Android app development.


Additional Resources


Appendix: Real-Time Listener Example

To further enhance the user experience, it's ideal to use a real-time listener that updates the UI as data changes. Below is an additional example:

//[title="src/main/java/com.firebase_firestore.app/data/FirestoreRealtimeRepository"]
package com.firebase_firestore.app.data

import androidx.compose.runtime.mutableStateListOf
import com.google.firebase.firestore.FirebaseFirestore
import com.google.firebase.firestore.ListenerRegistration
import com.firebase_firestore.app.model.Note

object FirestoreRealtimeRepository {
    private val firestore = FirebaseFirestore.getInstance()
    private val notesCollection = firestore.collection("notes")
    private var listenerRegistration: ListenerRegistration? = null

    fun startListening(onNotesChanged: (List<Note>) -> Unit, onError: (Exception) -> Unit) {
        listenerRegistration = notesCollection.addSnapshotListener { snapshot, exception ->
            if (exception != null) {
                onError(exception)
                return@addSnapshotListener
            }
            snapshot?.let {
                val notes = it.documents.mapNotNull { document -> document.toObject(Note::class.java) }
                onNotesChanged(notes)
            }
        }
    }

    fun stopListening() {
        listenerRegistration?.remove()
        listenerRegistration = null
    }
}

Complete Integrated UI Example with Listener

Combine the real-time listener with a Jetpack Compose UI for continuous data updates:

//[title="src/main/java/com.firebase_firestore.app/ui/presentation/NoteRealtimeScreen.kt"]
package com.firebase_firestore.app.ui

import androidx.compose.foundation.layout.*
import androidx.compose.foundation.lazy.LazyColumn
import androidx.compose.foundation.lazy.items
import androidx.compose.material.*
import androidx.compose.runtime.*
import androidx.compose.ui.Modifier
import androidx.compose.ui.unit.dp
import com.firebase_firestore.app.data.FirestoreRealtimeRepository
import com.firebase_firestore.app.model.Note

@Composable
fun NoteRealtimeScreen() {
    val notes = remember { mutableStateListOf<Note>() }
    var errorMessage by remember { mutableStateOf("") }

    DisposableEffect(Unit) {
        FirestoreRealtimeRepository.startListening(
            onNotesChanged = { newNotes ->
                notes.clear()
                notes.addAll(newNotes)
            },
            onError = { exception ->
                errorMessage = exception.message ?: "Error loading real-time data"
            }
        )
        onDispose {
            FirestoreRealtimeRepository.stopListening()
        }
    }

    Scaffold(
        topBar = {
            TopAppBar(title = { Text("Real-Time Notes") })
        },
        content = { padding ->
            Column(
                modifier = Modifier
                    .padding(padding)
                    .padding(16.dp)
            ) {
                if (errorMessage.isNotEmpty()) {
                    Text(text = errorMessage, color = MaterialTheme.colors.error)
                }
                Spacer(modifier = Modifier.height(16.dp))
                LazyColumn {
                    items(notes) { note ->
                        Card(
                            elevation = 4.dp,
                            modifier = Modifier
                                .fillMaxWidth()
                                .padding(vertical = 4.dp)
                        ) {
                            Column(modifier = Modifier.padding(8.dp)) {
                                Text(text = note.title, style = MaterialTheme.typography.subtitle1)
                                Text(text = note.content, style = MaterialTheme.typography.body2)
                            }
                        }
                    }
                }
            }
        }
    )
}

Final Considerations

  • Continuous Updates: Always keep your dependencies up-to-date.
  • Security: Implement proper security rules in Firestore to safeguard your data.
  • Testing: Modularize your data access logic to facilitate unit testing.
  • Performance: Consider using local caching to enhance performance in low connectivity scenarios.

Happy coding!

{{image:https://danigomez.dev/content/blog/media/firestore_in_android_with_jetpack_compose/images/meme.webp},{size:50%,100%},{position:center}}