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(
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 ?: ""
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" } ?: "",
28                )
29
30                // Site
31                Text(
32                    modifier = Modifier.padding(top = 8.dp),
33                    style = MaterialTheme.typography.titleMedium,
34                    text = response?.data?.launch?.site ?: "",
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) {
8        Loading()
9    } else {
10        LaunchDetails(response!!)
11    }
12}

Handle errors

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

  • Fetch errors: connectivity issues, HTTP errors, JSON parsing errors, etc... In this case, response.exception contains an ApolloException. Both response.data and response.errors is null.

  • GraphQL request errors: in this case, response.errors contains the GraphQL errors. response.data is null.

  • GraphQL field errors: in this case, response.errors contains the GraphQL errors. response.data contains partial data.

Let's handle the first two for now: fetch errors and GraphQL request errors.

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 Error(val message: String) : LaunchDetailsState
4    data class Success(val data: LaunchDetailsQuery.Data) : LaunchDetailsState
5}

Then, in LaunchDetails, examine the response returned by execute and map it to 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        val response = apolloClient.query(LaunchDetailsQuery(launchId)).execute()
6        state = when {
7            response.data != null -> {
8              // Handle (potentially partial) data
9              LaunchDetailsState.Success(response.data!!)
10            }
11            else -> {
12              LaunchDetailsState.Error("Oh no... An error happened.")
13            }
14        }
15    }
16    // Use the state

Now use the state to show the appropriate UI:

Kotlin
app/src/main/java/com/example/rocketreserver/LaunchDetails.kt
1    
2    // Use the state
3    when (val s = state) {
4        Loading -> Loading()
5        is Error -> ErrorMessage(s.message)
6        is Success -> LaunchDetails(s.data)
7    }
8}

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

Oh no

This is good!

This method handles fetch and GraphQL request errors but ignores GraphQL field errors.

If a GraphQL field error happens, the corresponding Kotlin property is null and an error is present in response.errors: your response contains partial data!

Handling this partial data in your UI code can be complicated. You can handle them earlier by looking at response.errors.

Handle partial data

To handle GraphQL field errors globally and make sure the returned data is not partial, use response.errors:

Kotlin
app/src/main/java/com/example/rocketreserver/LaunchDetails.kt
1state = when {
2  response.errors.orEmpty().isNotEmpty() -> {
3    // GraphQL error
4    LaunchDetailsState.Error(response.errors!!.first().message)
5  }
6  response.exception is ApolloNetworkException -> {
7    // Network error
8    LaunchDetailsState.Error("Please check your network connectivity.")
9  }
10  response.data != null -> {
11    // data (never partial)
12    LaunchDetailsState.Success(response.data!!)
13  }
14  else -> {
15    // Another fetch error, maybe a cache miss?
16    // Or potentially a non-compliant server returning data: null without an error
17    LaunchDetailsState.Error("Oh no... An error happened.")
18  }
19}

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 a GraphQL field 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) {

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 = {
5          navController.navigate(NavigationDestinations.LOGIN)
6        }
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.

Feedback

Edit on GitHub

Forums