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:
query LaunchDetails($id: ID!) {launch(id: $id) {idsitemission {namemissionPatch(size: LARGE)}rocket {nametype}isBooked}}
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:
@Composablefun LaunchDetails(launchId: String) {var response by remember { mutableStateOf<ApolloResponse<LaunchDetailsQuery.Data>?>(null) }LaunchedEffect(Unit) {response = apolloClient.query(LaunchDetailsQuery(launchId)).execute()}
Use the response in the UI. Here too we'll use Coil's AsyncImage
for the patch:
Column(modifier = Modifier.padding(16.dp)) {Row(verticalAlignment = Alignment.CenterVertically) {// Mission patchAsyncImage(modifier = Modifier.size(160.dp, 160.dp),model = response?.data?.launch?.mission?.missionPatch,placeholder = painterResource(R.drawable.ic_placeholder),error = painterResource(R.drawable.ic_placeholder),contentDescription = "Mission patch")Spacer(modifier = Modifier.size(16.dp))Column {// Mission nameText(style = MaterialTheme.typography.headlineMedium,text = response?.data?.launch?.mission?.name ?: "")// Rocket nameText(modifier = Modifier.padding(top = 8.dp),style = MaterialTheme.typography.headlineSmall,text = response?.data?.launch?.rocket?.name?.let { "🚀 $it" } ?: "",)// SiteText(modifier = Modifier.padding(top = 8.dp),style = MaterialTheme.typography.titleMedium,text = response?.data?.launch?.site ?: "",)}}
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:
@Composableprivate fun LaunchDetails(response: ApolloResponse<LaunchDetailsQuery.Data>) {Column(modifier = Modifier.padding(16.dp)) {
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:
@Composablefun LaunchDetails(launchId: String) {var response by remember { mutableStateOf<ApolloResponse<LaunchDetailsQuery.Data>?>(null) }LaunchedEffect(Unit) {response = apolloClient.query(LaunchDetailsQuery(launchId)).execute()}if (response == null) {Loading()} else {LaunchDetails(response!!)}}
Handle protocol errors
As you execute your query, two types of errors can happen:
- Protocol errors: connectivity issues, HTTP errors, or JSON parsing errors. In this case,
response.exception
will contain anApolloException
, andresponse.data
will be null. - Application errors. In this case,
response.errors
will contain the application errors andresponse.data
might benull
.
First let's create a LaunchDetailsState
sealed interface that will hold the possible states of the UI:
private sealed interface LaunchDetailsState {object Loading : LaunchDetailsStatedata class ProtocolError(val exception: ApolloException) : LaunchDetailsStatedata class Success(val data: LaunchDetailsQuery.Data) : LaunchDetailsState}
Then, in LaunchDetails
, examine the response returned by execute
and map it to a State
:
@Composablefun LaunchDetails(launchId: String) {var state by remember { mutableStateOf<LaunchDetailsState>(Loading) }LaunchedEffect(Unit) {val response = apolloClient.query(LaunchDetailsQuery(launchId)).execute()state = when {response.exception != null -> {ProtocolError(response.exception!!)}else -> {Success(response.data!!)}}}// Use the state
Now use the state to show the appropriate UI:
// Use the statewhen (val s = state) {Loading -> Loading()is ProtocolError -> ErrorMessage("Oh no... A protocol error happened: ${s.exception.message}")is Success -> LaunchDetails(s.data)}}
Enable airplane mode before clicking the details of a launch. You should see this:
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:
private sealed interface LaunchDetailsState {object Loading : LaunchDetailsStatedata class ProtocolError(val exception: ApolloException) : LaunchDetailsStatedata class ApplicationError(val errors: List<Error>) : LaunchDetailsStatedata class Success(val data: LaunchDetailsQuery.Data) : LaunchDetailsState}
Then, in the try
block, check for response.hasErrors()
and wrap the result in the new state:
state = when {response.exception != null -> {ProtocolError(response.exception!!)}response.hasErrors() -> {ApplicationError(response.errors!!)}else -> {Success(response.data!!)}}
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:
{"errors": [{"message": "Cannot read property 'flight_number' of undefined","locations": [{"line": 1,"column": 32}],"path": ["launch"],"extensions": {"code": "INTERNAL_SERVER_ERROR"}}],"data": {"launch": null}}
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:
@Composablefun LaunchDetails(launchId: String, navigateToLogin: () -> Unit) {
The lambda should be declared in MainActivity, where the navigation is handled:
composable(route = "${NavigationDestinations.LAUNCH_DETAILS}/{${NavigationArguments.LAUNCH_ID}}") { navBackStackEntry ->LaunchDetails(launchId = navBackStackEntry.arguments!!.getString(NavigationArguments.LAUNCH_ID)!!,navigateToLogin = {navController.navigate(NavigationDestinations.LOGIN)})}
Next, go back to LaunchDetails.kt
and replace the TODO
with a call to a function where we handle the button click:
onClick = {onBookButtonClick(launchId = data.launch?.id ?: "",isBooked = data.launch?.isBooked == true,navigateToLogin = navigateToLogin)}
private fun onBookButtonClick(launchId: String, isBooked: Boolean, navigateToLogin: () -> Unit): Boolean {if (TokenRepository.getToken() == null) {navigateToLogin()return false}if (isBooked) {// TODO Cancel booking} else {// TODO Book}return false}
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.