8. Add a details view
In this section, you'll write a second GraphQL query that requests details about a single launch.
Open the details fragment from the list
In LaunchListAdapter.kt
, add a click listener:
1 var onEndOfListReached: (() -> Unit)? = null
2 var onItemClicked: ((LaunchListQuery.Launch) -> Unit)? = null
3
4 override fun onBindViewHolder(holder: ViewHolder, position: Int) {
5
6 // ...
7
8 holder.binding.root.setOnClickListener {
9 onItemClicked?.invoke(launch)
10 }
11 }
In LaunchListFragment.kt
, register a click listener and navigate to the details screen:
1 override fun onViewCreated(view: View, savedInstanceState: Bundle?) {
2
3 // ...
4
5 adapter.onItemClicked = { launch ->
6 findNavController().navigate(
7 LaunchListFragmentDirections.openLaunchDetails(launchId = launch.id)
8 )
9 }
10 }
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 GraphQL Playground and type Ctrl + Space
to show a list of fields that are available.
Show a loading ProgressBar
In LaunchDetailsFragment.kt
, override onViewCreated
and launch a new coroutine
This time, display a progressBar while the query executes:
1 override fun onViewCreated(view: View, savedInstanceState: Bundle?) {
2 super.onViewCreated(view, savedInstanceState)
3
4 lifecycleScope.launchWhenResumed {
5 binding.bookButton.visibility = View.GONE
6 binding.bookProgressBar.visibility = View.GONE
7 binding.progressBar.visibility = View.VISIBLE
8 binding.error.visibility = View.GONE
9
10 val response = apolloClient.query(LaunchDetailsQuery(id = args.launchId)).await()
Handle protocol errors
As you execute your query, two types of errors can happen:
Protocol errors. HTTP errors or JSON parsing errors will throw a
ApolloException
.Application errors. In this case,
response.errors
will contain the application errors andresponse.data
might benull
.
For protocol errors, Apollo Android will throw an ApolloException
, so you'll need to wrap the call in a try/catch block:
1 val response = try {
2 apolloClient.query(LaunchDetailsQuery(id = args.launchId)).await()
3 } catch (e: ApolloException) {
4 binding.progressBar.visibility = View.GONE
5 binding.error.text = "Oh no... A protocol error happened"
6 binding.error.visibility = View.VISIBLE
7 return@launchWhenResumed
8 }
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
:
1 val launch = response.data?.launch
2 if (launch == null || response.hasErrors()) {
3 binding.progressBar.visibility = View.GONE
4 binding.error.text = response.errors?.get(0)?.message
5 binding.error.visibility = View.VISIBLE
6 return@launchWhenResumed
7 }
response.errors
contains details about any errors that occurred. Note that this code also checks for response.data == null
. 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(id = args.launchId)
with LaunchDetailsQuery(id = "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 error
field to add more advanced error management.
Restore the correct launch ID: LaunchDetailsQuery(id = args.launchId)
before displaying details.
Display details
If no errors occurred, hide the progressBar and display the detailed information:
1 binding.progressBar.visibility = View.GONE
2
3 binding.missionPatch.load(launch.mission?.missionPatch) {
4 placeholder(R.drawable.ic_placeholder)
5 }
6 binding.site.text = launch.site
7 binding.missionName.text = launch.mission?.name
8 val rocket = launch.rocket
9 binding.rocketName.text = "🚀 ${rocket?.name} ${rocket?.type}"
10 }
11 }
Handle the Book now button
Add a configureButton
method that redirects to the login page:
1 override fun onViewCreated(view: View, savedInstanceState: Bundle?) {
2 super.onViewCreated(view, savedInstanceState)
3
4 lifecycleScope.launchWhenResumed {
5
6 // ...
7
8 configureButton(launch.isBooked)
9 }
10 }
11
12 private fun configureButton(isBooked: Boolean) {
13 binding.bookButton.visibility = View.VISIBLE
14 binding.bookProgressBar.visibility = View.GONE
15
16 binding.bookButton.text = if (isBooked) {
17 getString(R.string.cancel)
18 } else {
19 getString(R.string.book_now)
20 }
21
22 binding.bookButton.setOnClickListener {
23 if (User.getToken(requireContext()) == null) {
24 findNavController().navigate(
25 R.id.open_login
26 )
27 return@setOnClickListener
28 }
29 }
30 }
Test your query
Hit Run. Your screen should look like this:
Right now, you aren't logged in, so isBooked
will always be false
and you won't be able to book a trip.
Next, you will write your first mutation to log in to the backend.