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:
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:
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:
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:
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:
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 anApolloException
. Bothresponse.data
andresponse.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:
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
:
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:
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:
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
:
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:
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}
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:
1@Composable
2fun LaunchDetails(launchId: String, navigateToLogin: () -> Unit) {
The lambda should be declared in MainActivity, where the navigation is handled:
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:
1onClick = {
2 onBookButtonClick(
3 launchId = data.launch?.id ?: "",
4 isBooked = data.launch?.isBooked == true,
5 navigateToLogin = navigateToLogin
6 )
7}
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:
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.