8. Add a details view


In this section, you'll write a second GraphQL query that requests details about a single launch.

Create the details query

Create a new GraphQL query named LaunchDetails.graphql.

As you did for $cursor, add a variable named id. Notice that this variable is a non-optional type this time. You won't be able to pass null like you can for $cursor.

Because it's a details view, request the LARGE size for the missionPatch. Also request the rocket type and name:

GraphQL
app/src/main/graphql/LaunchDetails.graphql
1query LaunchDetails($id: ID!) {
2  launch(id: $id) {
3    id
4    site
5    mission {
6      name
7      missionPatch(size: LARGE)
8    }
9    rocket {
10      name
11      type
12    }
13    isBooked
14  }
15}

Remember you can always experiment in Studio Explorer and check out the left sidebar for a list of fields that are available.

Execute the query and update the UI

In LaunchDetails.kt, declare response and a LaunchedEffect to execute the query:

Kotlin
app/src/main/java/com/example/rocketreserver/LaunchDetails.kt
1@Composable
2fun LaunchDetails(launchId: String) {
3    var response by remember { mutableStateOf<ApolloResponse<LaunchDetailsQuery.Data>?>(null) }
4    LaunchedEffect(Unit) {
5        response = apolloClient.query(LaunchDetailsQuery(launchId)).execute()
6    }

Use the response in the UI. Here too we'll use Coil's AsyncImage for the patch:

Kotlin
app/src/main/java/com/example/rocketreserver/LaunchDetails.kt
1    Column(
2        modifier = Modifier.padding(16.dp)
3    ) {
4        Row(verticalAlignment = Alignment.CenterVertically) {
5            // Mission patch
6            AsyncImage( // highlight-line
7                modifier = Modifier.size(160.dp, 160.dp),
8                model = response?.data?.launch?.mission?.missionPatch,
9                placeholder = painterResource(R.drawable.ic_placeholder),
10                error = painterResource(R.drawable.ic_placeholder),
11                contentDescription = "Mission patch"
12            )
13
14            Spacer(modifier = Modifier.size(16.dp))
15
16            Column {
17                // Mission name
18                Text(
19                    style = MaterialTheme.typography.headlineMedium,
20                    text = response?.data?.launch?.mission?.name ?: "" // highlight-line
21                )
22
23                // Rocket name
24                Text(
25                    modifier = Modifier.padding(top = 8.dp),
26                    style = MaterialTheme.typography.headlineSmall,
27                    text = response?.data?.launch?.rocket?.name?.let { "🚀 $it" } ?: "", // highlight-line
28                )
29
30                // Site
31                Text(
32                    modifier = Modifier.padding(top = 8.dp),
33                    style = MaterialTheme.typography.titleMedium,
34                    text = response?.data?.launch?.site ?: "", // highlight-line
35                )
36            }
37        }
38

Show a loading state

Since response is initialized to null, you can use this as an indication that the result has not been received yet.

To structure the code a bit, extract the details UI into a separate function that takes the response as a parameter:

Kotlin
app/src/main/java/com/example/rocketreserver/LaunchDetails.kt
1@Composable
2private fun LaunchDetails(response: ApolloResponse<LaunchDetailsQuery.Data>) {
3    Column(
4        modifier = Modifier.padding(16.dp)
5    ) {

Now in the original LaunchDetails function, check if response is null and show a loading indicator if it is, otherwise call the new function with the response:

Kotlin
app/src/main/java/com/example/rocketreserver/LaunchDetails.kt
1@Composable
2fun LaunchDetails(launchId: String) {
3    var response by remember { mutableStateOf<ApolloResponse<LaunchDetailsQuery.Data>?>(null) }
4    LaunchedEffect(Unit) {
5        response = apolloClient.query(LaunchDetailsQuery(launchId)).execute()
6    }
7    if (response == null) { // highlight-line
8        Loading() // highlight-line
9    } else { // highlight-line
10        LaunchDetails(response!!) // highlight-line
11    }
12}

Handle protocol errors

As you execute your query, two types of errors can happen:

  • Protocol errors. Connectivity issues, HTTP errors, or JSON parsing errors will throw an ApolloException.

  • Application errors. In this case, response.errors will contain the application errors and response.data might be null.

Since Apollo Kotlin throws an ApolloException in case of protocol errors, you'll need to wrap the call in a try/catch block.

First let's create a LaunchDetailsState sealed interface that will hold the possible states of the UI:

Kotlin
app/src/main/java/com/example/rocketreserver/LaunchDetails.kt
1private sealed interface LaunchDetailsState {
2    object Loading : LaunchDetailsState
3    data class ProtocolError(val exception: ApolloException) : LaunchDetailsState
4    data class Success(val data: LaunchDetailsQuery.Data) : LaunchDetailsState
5}

Then, in LaunchDetails, surround the call to execute around a try/catch block, and wrap the results into a State:

Kotlin
app/src/main/java/com/example/rocketreserver/LaunchDetails.kt
1@Composable
2fun LaunchDetails(launchId: String) {
3    var state by remember { mutableStateOf<LaunchDetailsState>(Loading) }
4    LaunchedEffect(Unit) {
5        state = try {
6            Success(apolloClient.query(LaunchDetailsQuery(launchId)).execute().data!!)
7        } catch (e: ApolloException) {
8            ProtocolError(e)
9        }
10    }
11    // Use the state

Now use the state to show the appropriate UI:

Kotlin
app/src/main/java/com/example/rocketreserver/LaunchDetails.kt
1‎    // Use the state
2    when (val s = state) {
3        Loading -> Loading()
4        is ProtocolError -> ErrorMessage("Oh no... A protocol error happened: ${s.exception.message}")
5        is Success -> LaunchDetails(s.data)
6    }
7}

Enable airplane mode before clicking the details of a launch. You should see this:

Oh no

This is good! But it's not enough. Even if the request executes correctly at the protocol level, it might also contain application errors that are specific to your server.

Handle application errors

To handle application errors, you can check response.hasErrors(). First, add a new state to the sealed interface:

Kotlin
app/src/main/java/com/example/rocketreserver/LaunchDetails.kt
1private sealed interface LaunchDetailsState {
2    object Loading : LaunchDetailsState
3    data class ProtocolError(val exception: ApolloException) : LaunchDetailsState
4    data class ApplicationError(val errors: List<Error>) : LaunchDetailsState // highlight-line
5    data class Success(val data: LaunchDetailsQuery.Data) : LaunchDetailsState
6}

Then, in the try block, check for response.hasErrors() and wrap the result in the new state:

Kotlin
app/src/main/java/com/example/rocketreserver/LaunchDetails.kt
1    state = try {
2        val response = apolloClient.query(LaunchDetailsQuery(launchId)).execute()
3        if (response.hasErrors()) { // highlight-line
4            ApplicationError(response.errors!!) // highlight-line
5        } else { // highlight-line
6            Success(response.data!!) // highlight-line
7        } // highlight-line
8    } catch (e: ApolloException) {
9        ProtocolError(e)
10    }

You should also update the conditional expression to handle the ApplicationError case:

Kotlin
app/src/main/java/com/example/rocketreserver/LaunchDetails.kt
1    when (val s = state) {
2        Loading -> Loading()
3        is ApplicationError -> ErrorMessage(text = s.errors.first().message) // highlight-line
4        is ProtocolError -> ErrorMessage("Oh no... A protocol error happened: ${s.exception.message}")
5        is Success -> LaunchDetails(s.data)
6    }

response.errors contains details about any errors that occurred. Note that this code also null-checks response.data!!. In theory, a server should not set response.data == null and response.hasErrors == false at the same time, but the type system cannot guarantee this.

To trigger an error, replace LaunchDetailsQuery(launchId) with LaunchDetailsQuery("invalidId"). Disable airplane mode and select a launch. The server will send this response:

JSON
(error)
1{
2  "errors": [
3    {
4      "message": "Cannot read property 'flight_number' of undefined",
5      "locations": [
6        {
7          "line": 1,
8          "column": 32
9        }
10      ],
11      "path": [
12        "launch"
13      ],
14      "extensions": {
15        "code": "INTERNAL_SERVER_ERROR"
16      }
17    }
18  ],
19  "data": {
20    "launch": null
21  }
22}
Oh no

This is all good! You can use the errors field to add more advanced error management.

Restore the correct launch ID: LaunchDetailsQuery(launchId) before displaying details.

Handle the Book now button

To book a trip, the user must be logged in. If the user is not logged in, clicking the Book now button should open the login screen.

First let's pass a lambda to LaunchDetails to take care of navigation:

Kotlin
app/src/main/java/com/example/rocketreserver/LaunchDetails.kt
1@Composable
2fun LaunchDetails(launchId: String, navigateToLogin: () -> Unit) { // highlight-line

The lambda should be declared in MainActivity, where the navigation is handled:

Kotlin
app/src/main/java/com/example/rocketreserver/MainActivity.kt
1composable(route = "${NavigationDestinations.LAUNCH_DETAILS}/{${NavigationArguments.LAUNCH_ID}}") { navBackStackEntry ->
2    LaunchDetails(
3        launchId = navBackStackEntry.arguments!!.getString(NavigationArguments.LAUNCH_ID)!!,
4        navigateToLogin = { // highlight-line
5          navController.navigate(NavigationDestinations.LOGIN) // highlight-line
6        } // highlight-line
7    )
8}

Next, go back to LaunchDetails.kt and replace the TODO with a call to a function where we handle the button click:

Kotlin
app/src/main/java/com/example/rocketreserver/LaunchDetails.kt
1onClick = {
2    onBookButtonClick(
3        launchId = data.launch?.id ?: "",
4        isBooked = data.launch?.isBooked == true,
5        navigateToLogin = navigateToLogin
6    )
7}
Kotlin
app/src/main/java/com/example/rocketreserver/LaunchDetails.kt
1private fun onBookButtonClick(launchId: String, isBooked: Boolean, navigateToLogin: () -> Unit): Boolean {
2    if (TokenRepository.getToken() == null) {
3        navigateToLogin()
4        return false
5    }
6
7    if (isBooked) {
8        // TODO Cancel booking
9    } else {
10        // TODO Book
11    }
12    return false
13}

TokenRepository is a helper class that handles saving/retrieving a user token in EncryptedSharedPreference. We will use it to store the user token when logging in.

Returning a boolean will be useful later to update the UI depending on whether the execution happened or not.

Test the button

Hit Run. Your screen should look like this:

Details

Right now, you aren't logged in, so you won't be able to book a trip and clicking will always navigate to the login screen.

Next, you will write your first mutation to log in to the backend.

Edit on GitHub

Forums