Adding complex Firestore queries to a Jetpack Compose app

This is the tenth part of a series of articles that dive into creating a new Android application from scratch using Jetpack Compose for the user interface and some other tools offered by Google, like Firebase Authentication, Crashlytics, Cloud Firestore, Performance Monitoring, Remote Config, Firebase Extensions, Firebase Cloud Messaging and Hilt. If you want to catch up on the series from the beginning, check out these articles:

  1. Overview of how this app is structured.
  2. Implement login and sign up flows using Firebase Authentication.
  3. Add Firebase Crashlytics to handle errors.
  4. Store data in the cloud using Cloud Firestore.
  5. Add Kotlin Coroutines and Kotlin Flow.
  6. Improve the app’s quality with Firebase Performance Monitoring.
  7. Control app behavior and appearance remotely using Firebase Remote Config.
  8. Add new features to your app with little effort with Firebase Extensions.
  9. Send push notifications with Firebase Cloud Messaging.

In this article you will learn how to build more complex queries to retrieve documents stored in Cloud Firestore. You will make some changes to the existing queries and build new ones to populate a new screen in Make it So: the Statistics Screen. This screen will show some statistics related to tasks that have already been completed or are yet to be completed. You will learn how to use where, or, and , in, count and orderBy operators to easily filter and order documents that meet a specific criteria.

Recap

Before diving into the queries, let’s recap the data model of the app, and the query used to retrieve the user’s tasks. This will help you to better understand the updates you’re going to make to the existing code.

Current data model

Make it So stores all documents in a collection called tasks, and each task has the following fields:

data class Task (
  @DocumentId val id: String = "",
  val title: String = "",
  val priority: String = "",
  val dueDate: String = "",
  val dueTime: String = "",
  val description: String = "",
  val url: String = "",
  val flag: Boolean = false,
  val completed: Boolean = false,
  val userId: String = ""
)

The values stored in these fields are used to populate TasksScreen (where users can see all their tasks) and EditTaskScreen (where users can make changes to each of these fields).

Retrieving user’s tasks

The first screen that users see when they open the Make it So app is the TasksScreen, which displays a list of Tasks maintained by TasksViewModel. If you open TasksViewModel, you will notice that it’s collecting the list of tasks from this Flow in StorageServiceImpl:

override val tasks: Flow<List<Task>>
    get() =
      auth.currentUser.flatMapLatest { user ->
        firestore
          .collection(TASK_COLLECTION)
          .whereEqualTo(USER_ID_FIELD, user.id)
          .dataObjects()
      }

This code snippet retrieves all documents from the tasks collection that belong to the currently logged in user. There’s just one problem with that: the documents will be retrieved according to Firestore’s default sorting order, which is based on the document ID. Since the document ID typically is a random string, this will result in the items appearing in a random order. You will learn how to use orderBy to show the most recent items at the top of the list later in this article.

Adding a Statistics screen

In addition to changing the current ordering of the tasks list, you will work on a new screen called Statistics. This screen will contain the following three sections:

  • The number of tasks that the signed in user has already completed;
  • The number of important tasks that the user has already completed (important tasks meaning any tasks with a flag or with high priority);
  • The number of medium and high priority tasks that the user hasn’t completed yet.

As you probably noticed, each of these sections displays some piece of information that requires aggregating the user’s data in some way. In the following sections, you will learn how to use Firestore’s aggregation functions to compute this data.

To implement the new statistics screen, add the following files to your codebase: StatsScreen, StatsUiModel and StatsViewModel.

StatsScreen

This is where you create the composable functions that build the UI. This screen has only a single Column, with a Toolbar and three Cards. Each Card is represented by a composable function called StatsItem. Each StatsItem has a Row where you can find two Texts: one with the card title, and the other with the number that represents the statistic.

You also need to add a statistics icon to the main screen’s Toolbar, so that the user can navigate to the new statistics screen by clicking on it:

New icon and new screen
New icon and new screen

StatsUiModel

The UI model for this screen is simple, and only contains the fields that represent the statistic to be shown on each card:

data class StatsUiState(
  val completedTasksCount: Int = 0,
  val importantCompletedTasksCount: Int = 0,
  val mediumHighTasksToCompleteCount: Int = 0
)

StatsViewModel

The StatsViewModel is responsible for calling StorageService as soon as it is initialized. As all the statistics will be retrieved in this class, you need to wrap the results returned by the StorageService functions in an object of the type StatsUiModel, and make it available for the StatsScreen composable through a MutableState:

val uiState = mutableStateOf(StatsUiState())

init {
  launchCatching { loadStats() }
}

private suspend fun loadStats() {
  val updatedUiState = StatsUiState(
    completedTasksCount = storageService.getCompletedTasksCount(),
    importantCompletedTasksCount = storageService.getImportantCompletedTasksCount(),
    mediumHighTasksToCompleteCount = storageService.getMediumHighTasksToCompleteCount()
  )

  uiState.value = updatedUiState
}

Storage service

Now that you’ve implemented the UI, and that the StatsViewModel is calling the StorageService functions, you need to implement the new methods in StorageServiceImpl with calls to the Cloud Firestore API.

Tasks Collection

Since all of the three aggregation queries operate on the same set of data (the signed in user’s data), it’s a good idea to extract this into a reusable query instance:

private val collection get() = firestore.collection(TASK_COLLECTION)
    .whereEqualTo(USER_ID_FIELD, auth.currentUserId)

Completed tasks

To find out how many tasks have already been completed by the user, you need to search for all tasks where the completed field contains the value true, and then use the count aggregator to find out how many documents meet this criteria:

override suspend fun getCompletedTasksCount(): Int {
  val query = collection.whereEqualTo(COMPLETED_FIELD, true).count()
  return query.get(AggregateSource.SERVER).await().count.toInt()
}

To compute the aggregation value, use query.get(AggregateSource.SERVER).await().count. This will compute the result on the server, which is a lot more efficient than doing this on the client. For example, this means that you won’t have to transfer all the included task items to the client.

Important completed tasks

To find out how many of these tasks are important (i.e. tasks marked as high priority, or that are flagged), run the following query:

override suspend fun getImportantCompletedTasksCount(): Int {
val query = collection.where(
  Filter.and(
    Filter.equalTo(COMPLETED_FIELD, true),
    Filter.or(
      Filter.equalTo(PRIORITY_FIELD, Priority.High.name),
      Filter.equalTo(FLAG_FIELD, true)
    )
  )
)

return query.count().get(AggregateSource.SERVER).await().count.toInt()
}

Here you are looking for all tasks where the completed field contains the value true, then using the and operator to add another condition. The second condition filters the tasks in which the priority field is equal to high or the flag field is equal to true, by applying the or operator. Finally, run count to get the number of documents that meet the criteria.

Medium and high priority tasks yet to be completed

To filter which medium and high priority tasks the user still needs to complete, you can use the in operator available in Firestore. This operator allows you to check if the value stored in a field is present in an array of values that you provide. In this case, you will filter all tasks where the value of the priority field is present in an array with medium and high values:

override suspend fun getMediumHighTasksToCompleteCount(): Int {
  val query = collection
    .whereEqualTo(COMPLETED_FIELD, false)
    .whereIn(PRIORITY_FIELD, listOf(Priority.Medium.name, Priority.High.name)).count()

  return query.get(AggregateSource.SERVER).await().count.toInt()
}

Ordering documents

Now that your new statistics screen is ready, let’s see what you need to do to present the user’s tasks in the order they were created.

Updating the data model

To change the ordering of the tasks list, you need to update the current data model, and include a timestamp that you can use for ordering the tasks in descending order. This will result in the most recent items being shown at the top of the list. Since Firestore is a NoSQL database, there is no schema that you would need to update on the database. Instead, you just need to add this field to your Kotlin data class:

data class Task(
  @DocumentId val id: String = "",
  @ServerTimestamp val createdAt: Date = Date(), //New field
  val title: String = "",
  val priority: String = "",
  val dueDate: String = "",
  val dueTime: String = "",
  val description: String = "",
  val url: String = "",
  val flag: Boolean = false,
  val completed: Boolean = false,
  val userId: String = ""
)

Note that the new createdAt field is annotated with the @ServerTimestamp annotation. This instructs Firestore to treat this field specially when writing or updating documents, so you don’t need to insert or update the date manually. If the createdAt field is null when storing it in Firestore, Firestore will automatically insert the current time on the server. In case the field is already filled with a timestamp, Firestore will not update the field when you update the document. Using Firestore’s timestamp is important because it stores the current time at the time the write hits the server, so you can be sure that this value will not vary depending on the location and timezone of your user’s device.

Updating the query

The only thing left to do now is update the query to order the user’s tasks according to the new createdAt field. To do so, update the query that retrieves the list of tasks in StorageServiceImpl to look like this:

override val tasks: Flow<List<Task>>
    get() =
      auth.currentUser.flatMapLatest { user ->
        firestore
          .collection(TASK_COLLECTION)
          .whereEqualTo(USER_ID_FIELD, user.id)
          .orderBy(CREATED_AT_FIELD, Query.Direction.DESCENDING) //New line
          .dataObjects()
      }

You need to use orderBy after you add all filters to your query, so it will order only the documents that match the criteria you specified. In this case you are filtering all documents in the tasks collection that belong to the currently logged in user, then ordering them according to the createdAt field in descending order (from latest to earliest timestamps).

Final result

Congratulations - you’ve implemented a shiny new statistics screen that will hopefully motivate your users to complete more tasks on time! And you also made it easier for them to navigate the list of tasks by ordering them according to their timestamp. Now it’s time to run the app and test the new features! For the example below, I created a few tasks (some are flagged, others have high, medium, or low priority) and marked some of them as completed:

Final result
Final result

What’s next

You can clone the source code on the Github repository, the code above is available in the final folder. If you’re also interested in iOS/Mac development, the same application is available for these platforms as well. The iOS/Mac version uses SwiftUI for building the user interface, which also follows the declarative UI model, similarly to how we build UIs in Compose. You can find the source code in this Github repository.

If you have any questions or suggestions, feel free to reach out to us on the discussions page of the repository.