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 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:
private sealed interface LaunchDetailsState {object Loading : LaunchDetailsStatedata class Error(val message: String) : 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.data != null -> {// Handle (potentially partial) dataLaunchDetailsState.Success(response.data!!)}else -> {LaunchDetailsState.Error("Oh no... An error happened.")}}}// Use the state
Now use the state to show the appropriate UI:
// Use the statewhen (val s = state) {Loading -> Loading()is Error -> ErrorMessage(s.message)is Success -> LaunchDetails(s.data)}}
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
:
state = when {response.errors.orEmpty().isNotEmpty() -> {// GraphQL errorLaunchDetailsState.Error(response.errors!!.first().message)}response.exception is ApolloNetworkException -> {// Network errorLaunchDetailsState.Error("Please check your network connectivity.")}response.data != null -> {// data (never partial)LaunchDetailsState.Success(response.data!!)}else -> {// Another fetch error, maybe a cache miss?// Or potentially a non-compliant server returning data: null without an errorLaunchDetailsState.Error("Oh no... An error happened.")}}
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:
{"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.