Build a Trip Experience

This section describes the user’s trip experience when the user chooses to opt-in to the IQL program. This section comprises the following topics:

Build a Trip Experience

Build a Trip Experience

Build a Trip Experience

Build a Trip Experience

Build a Trip Experience

Build a Trip Experience

1.0. Fetch User Trips

This section describes how you can fetch user's trip information.

1.1. Integrate driverTrips API

Use the following code snippet to add the driverTripsAPI endpoint to the ApiService.

interface ApiService {
    ...
    ...
    @GET("/iql/v1/driver/{driver_id}/trips/?limit=30")
    fun getTrips(
        @Path("driver_id") driverId: String,
        @Query("cursor") cursor: String?
    ): Call<TripsListResponse>
    ...
    ...
}

class Api(private val apiService: ApiService) {
    ...
    ...
    suspend fun fetchTrips(driverId: String, cursor: String? = null): ApiResponse<TripsListResponse> =
            suspendCancellableCoroutine { cont ->
                cont.invokeOnCancellation {
                    if (!apiService.getTrips(driverId, cursor).isCanceled) {
                        apiService.getTrips(driverId, cursor).cancel()
                    }
                }
                apiService.getTrips(driverId, cursor).enqueue(
                    object : Callback<TripsListResponse> {
                        override fun onResponse(
                            call: Call<TripsListResponse>,
                            response: Response<TripsListResponse>
                        ) {
                            if (response.isSuccessful) {
                                cont.resume(ApiResponse.Success(response.body()!!))
                            } else {
                                val contentType = response.headers()["Content-Type"]
                                if (contentType != null && contentType == "application/json") {
                                    cont.resume(
                                        ApiResponse.Failure(
                                            FailureResponse(
                                                response.code().toString()
                                            )
                                        )
                                    )
                                } else {
                                    Timber.e(response.errorBody()?.string())
                                    cont.resume(
                                        ApiResponse.Failure(
                                            FailureResponse(
                                                response.code().toString()
                                            )
                                        )
                                    )
                                }
                            }
                        }

                        override fun onFailure(call: Call<TripsListResponse>, t: Throwable) {
                            cont.resume(ApiResponse.Error(t))
                        }
                    }
                )
            }
        ...
        ...
}

1.2. Fetch Recent Trips Information

Use the following code snippet to fetch the user's recent trips when the application opens, and save the data in the local database.

// Define function to fetch trips from the network and save in the database
class IQLApisRepository(
    database: IQLDatabase,
    val api: Api
) {
    ...
    ...
    suspend fun fetchTripsFromNetwork(
            context: Context,
            driverId: String,
            cursor: String?
        ): Boolean {
            return when (val apiResponse = api.fetchTrips(driverId, cursor)) {
                is ApiResponse.Success -> {
                    Timber.d("Fetch recent trips response successful ${apiResponse.data.driverId} ${apiResponse.data}")
                    apiResponse.data.trips.forEach { tripInfo ->
                        val trip = tripInfo.toTrip(driverId)
                        trip?.let {
                            val startLocation = tripInfo.startLocation
                            val endLocation = tripInfo.endLocation
                            val tripMeta = context.let {
                                Timber.d("Generating trip meta, driveId: ${trip.driveId}")
                                val meta = getTripMeta(it, startLocation, endLocation)
                                return@let meta?.let {
                                    meta
                                }
                            }
                            tripDao.insert(trip.copy(meta = tripMeta))
                        } ?: Timber.e("Error in mapping TripInfo to Trip. TripInfo details: $tripInfo")
                    }
                    true
                }
                is ApiResponse.Failure -> {
                    Timber.e("Fetch recent trips response failed for user $driverId ${apiResponse.failure}")
                    false
                }
                is ApiResponse.Error -> {
                    Timber.e(
                        apiResponse.throwable,
                        "Fetch recent trips response error for user $driverId"
                    )
                    false
                }
            }
        }
    ...
    ...
}

// Create a worker to refresh recent trip list from the network
class FetchTripListWorker(context: Context, workerParams: WorkerParameters) :
    CoroutineWorker(context, workerParams) {
    override suspend fun doWork(): Result {
        Timber.d("Start fetch trip list Work ")
        try {
            ZWLCore.loginRepository().getDriverId()?.let { driverId ->
                val context = ZWLCore.context()
                // cursor is set to null to fetch the last recent 30 trips
                ZWLCore.iqlApisRepository().fetchTripsFromNetwork(context, driverId, null)
            }
        } catch (e: Exception) {
            Timber.e(e, "Fetch trip list Work failed")
        }
        Timber.d("End Fetch trip list Work")
        return Result.success()
    }
}

// Define function to enque periodic trips refresh
object ZWLWorkManager {
    ...
    private const val FETCH_TRIP_LIST_INTERVAL = 6L
    ...
    ...
    fun enqueueFetchTripsListWorker() {
            Timber.d("Enqueue work to fetch trip list")
            val work = PeriodicWorkRequestBuilder<FetchTripListWorker>(
                FETCH_TRIP_LIST_INTERVAL,
                TimeUnit.HOURS
            ).setConstraints(Constraints.Builder().setRequiredNetworkType(NetworkType.CONNECTED).build()).build()
            WorkManager.getInstance(ZWLCore.context()).enqueueUniquePeriodicWork(
                FETCH_TRIP_LIST,
                ExistingPeriodicWorkPolicy.REPLACE,
                work
            )
        }
    ...
    ...
}

// Trigger and Schedule periodic trip refresh worker while app opens the HomeActivity class
class HomeActivity : AppBaseActivity(), PopupMenu.OnMenuItemClickListener {
   private lateinit var binding: ActivityHomeBinding
   private val viewModel: HomeViewModel by viewModels()
   ...
   ...

   override fun onResume() {
       super.onResume()
       val recentTrips = viewModel.trips.value
       if (recentTrips != null && recentTrips.isNotEmpty()) {
           val imageAvailable = viewModel.isThumbnailGeneratedForAllTrips(recentTrips)
           if (!imageAvailable) {
               viewModel.scheduleTripMapGenerateTask()
           } else {
               viewModel.shutDownScheduler()
           }
       }
       viewModel.checkAndExecuteDriverStatusAPI()
       viewModel.checkAndExecuteTripListAPI()
   }
   ...
   ...
}

class HomeViewModel(application: Application) : AndroidViewModel(application) {
   ...
   ...
   fun checkAndExecuteTripListAPI() {
       executeApiUsingTimeStamps(previousApiTimeStamp = tripListApiTimeStamp) {
           executeApi(
               calendar = it,
               onTimeChanged = {
                   tripListApiTimeStamp = it
                   Timber.d("Trip List New Time Stamp $tripListApiTimeStamp")
               },
               onExecute = {
                   ZWLWorkManager.enqueueFetchTripsListWorker()
               },
               log = "Trip List Api Called at"
           )
       }
   }
   ...
   ...
}

2.0. Presenting Data In the Trip List

Use the following UX design and code snippet to create a UI with a recycler view supporting pagination with Android’s Paging library for listing the trips.

3.0. Presenting Individual Trip Detail

This section describes how to present individual trip details on your application's user interface.

3.1. Integrate the tripDetails API

Use the following code snippets to integrate the tripDetail API to fetch details of a particular trip and update its details in the trip table.

interface ApiService {
    ...
    @GET("/iql/v1/core/driver/{driver_id}/trip/{trip_id}/")
    fun geTripDetail(
        @Path("driver_id") driverId: String,
        @Path("trip_id") tripId: String,
        @Query("fields") fields: String = "simple_path,events,info"
    ): Call<TripDetailsResponse>
    ...
}

class Api(private val apiService: ApiService) {

    ...
    ...
    suspend fun fetchTripDetails(driverId: String, tripId: String): ApiResponse<TripDetailsResponse> =
            suspendCancellableCoroutine { cont ->
                cont.invokeOnCancellation {
                    if (!apiService.geTripDetail(driverId, tripId).isCanceled) {
                        apiService.geTripDetail(driverId, tripId).cancel()
                    }
                }
                apiService.geTripDetail(driverId, tripId).enqueue(
                    object : Callback<TripDetailsResponse> {
                        override fun onResponse(
                            call: Call<TripDetailsResponse>,
                            response: Response<TripDetailsResponse>
                        ) {
                            if (response.isSuccessful) {
                                cont.resume(ApiResponse.Success(response.body()!!))
                            } else {
                                val contentType = response.headers()["Content-Type"]
                                if (contentType != null && contentType == "application/json") {
                                    cont.resume(
                                        ApiResponse.Failure(
                                            FailureResponse(
                                                response.code().toString()
                                            )
                                        )
                                    )
                                } else {
                                    Timber.e(response.errorBody()?.string())
                                    cont.resume(
                                        ApiResponse.Failure(
                                            FailureResponse(
                                                response.code().toString()
                                            )
                                        )
                                    )
                                }
                            }
                        }

                        override fun onFailure(call: Call<TripDetailsResponse>, t: Throwable) {
                            cont.resume(ApiResponse.Error(t))
                        }
                    }
                )
            }
    ...
    ...
}



// Define function in the repository to update the trip details in the local DB
class IQLApisRepository(
    database: IQLDatabase,
    val api: Api
) {
    ...
    ...
    suspend fun getTripDetailsFromNetwork(driverId: String, tripId: String): Boolean {
            return when (val apiResponse = api.fetchTripDetails(driverId, tripId)) {
                is ApiResponse.Success -> {
                    Timber.d("Fetch trip details response successful ${apiResponse.data.tripId} ${apiResponse.data}")
                    val tripDetails = apiResponse.data
                    val tripDetailEvents = tripDetails.events
                    val simplePath = tripDetails.simplePath
                    val events = mutableListOf<TripEvent>()
                    tripDetailEvents.forEach {
                        it.toTripEvent()?.let {
                            events.add(it)
                        }
                    }
                    val waypoints = mutableListOf<TripLocationPointWithTimestamp>()
                    simplePath.forEach {
                        waypoints.add(
                            TripLocationPointWithTimestamp(
                                timestampMillis = it.timeMillis!!,
                                location = TripLocationPoint(
                                    latitude = it.latitude!!,
                                    longitude = it.longitude!!
                                )
                            )
                        )
                    }
                    tripDao.updateWayAndEventPoints(tripId, waypoints, events)
                    true
                }
                is ApiResponse.Failure -> {
                    Timber.e("Fetch trip details response failed for user $driverId ${apiResponse.failure}")
                    false
                }
                is ApiResponse.Error -> {
                    Timber.e(apiResponse.throwable, "Fetch trip details response error for user")
                    false
                }
            }
        }
    ...
    ...
}

3.2. Present the Retrieved Trip Details In the Application UI

Use the following code snippet to present the trip details that you retrieved using the tripDetails API to your user.

class TripDetailActivity : AppBaseActivity(), OnMapReadyCallback, TripEventListingCallbacks {

    private var googleMap: GoogleMap? = null
    private var mapView: MapView? = null
    private var deletionInProgress = false

    companion object {
        const val TRIP_ID_BUNDLE_ID = "TRIP_ID"
        ...
        ...
    }

    private val viewModel: TripDetailViewModel by viewModels()
    private val tripsViewModel: TripsViewModel by viewModels()

    private lateinit var binding: ActivityTripDetailBinding

    override fun onCreate(savedInstanceState: Bundle?) {
        super.onCreate(savedInstanceState)
        ...
        ...
        setupContent()
        val tripId = intent.getStringExtra(TRIP_ID_BUNDLE_ID)
        fetchTripDetails(tripId)
        ...
    }

    private fun fetchTripDetails(tripId: String?) {
        if (tripId != null) {
            lifecycleScope.launch(Dispatchers.IO) {
                if (viewModel.isTripDetailsUpdated(tripId).not()) {
                    Timber.d("Trip details update")
                    viewModel.fetchTripDetailsFromNetwork(binding.root.context, tripId)
                } else {
                    Timber.d("Trip details already updated")
                }
            }
        } else {
            Timber.e("Trip id is null")
        }
    }

    private fun setupContent(user: User? = null) {
        val tripId = intent.getStringExtra(TRIP_ID_BUNDLE_ID)
        if (tripId != null) {
            viewModel.getTripData(tripId).observe(
                this,
                Observer { trip ->
                    if (trip != null) {
                        ...
                        // present trip details
                        ...
                    }
                }
            )
            ...
            ...
        }
    }

    fun getTripDetailViewModel() = viewModel

    fun getGoogleMap() = googleMap
    ...
    ...
}

4.0. Enable Users To Delete Trips

Build the trip deletion UI on the trip list and trip details page in your application. Integrate the tripFeedback API endpoint on the client side, to enable trip deletion on the server. Use the following code snippets to define the functions detailed below, to delete trips using the tripFeedback API.

4.1. Allow Trip Deletion To Trips Less Than 7 Days Old

Use the following code snippet to enable users to delete trips that are less than 7 days old.

“TripExtensions.kt”
const val VALID_DURATION_IN_DAYS = 7L
/**
* This function will validate if the current trip is a valid trip to give feedback or not.
* Note: A Trip will only remain valid for feedback for 7 days
*/
fun Trip.isValidForFeedback(): Boolean {
   val tripValidDuration = (startTimeMillis + TimeUnit.DAYS.toMillis(VALID_DURATION_IN_DAYS))
   val currentDuration = getCurrentTimeInMilli()
   val isTripValidForFeedback = tripValidDuration > currentDuration
   Timber.d("TripID $driveId with startTimeMillis $startTimeMillis will be valid till $tripValidDuration")
   Timber.d("Current duration $currentDuration")
   Timber.d("Is trip valid for feedback $isTripValidForFeedback")
   return isTripValidForFeedback
}

4.2. Enable Trip Deletion Only for Non-qualified Users

Once a user qualifies for the IQL program, they should not be allowed to delete trips, as all trip data is required while preparing an offer. Use the following code snippet to disable trip deletion for qualified users:

class TripsViewModel : ViewModel() {
   ...
   ...
   /**
    * Trip Feedback will only be available in these conditions
    *
    *  1. If the user is not qualified.
    *  2. If the trip is not older than 7 days
    *
    *  @param [trip] current trip
    *
    */


   fun isFeedbackValid(trip: Trip): Boolean {
       return trip.isValidForFeedback() && isOfferAvailable.not()
   }
   ...
   ...
}
package com.zendrive.iqlref.trips.tripdetail.presentation


...
...


class TripDetailActivity : AppBaseActivity(), OnMapReadyCallback, TripEventListingCallbacks {


   private var googleMap: GoogleMap? = null
   private var mapView: MapView? = null
   private var deletionInProgress = false


   companion object {
       const val TRIP_ID_BUNDLE_ID = "TRIP_ID"
       const val TRIP_WAYPOINTS_BUNDLE_ID = "TRIP_WAYPOINTS"
       const val TRIP_EXTRAPOLATED_WAYPOINTS_BUNDLE_ID = "TRIP_EXTRAPOLATED_WAYPOINTS"
       const val TRIP_EVENTS_BUNDLE_ID = "TRIP_EVENTS"
   }


   private val viewModel: TripDetailViewModel by viewModels()
   private val tripsViewModel: TripsViewModel by viewModels()


   private lateinit var binding: ActivityTripDetailBinding


   override fun onCreate(savedInstanceState: Bundle?) {
       super.onCreate(savedInstanceState)
       ...
       ...
       setUpTripFeedbackChip()
   }


   ...
   ...


   private fun observeTripDeletionStatus() {
       lifecycleScope.launch {
           tripsViewModel.tripFeedbackState
               .distinctUntilChanged()
               .collectLatest { tripFeedbackState ->
                   val message = if (tripFeedbackState.success != null) {
                       tripFeedbackState.success
                   } else if (tripFeedbackState.failure != null) {
                       tripFeedbackState.failure
                   } else {
                       null
                   }
                   message?.let {
                       Toast.makeText(this@TripDetailActivity, message, Toast.LENGTH_LONG).show()
                   }
                   if (tripFeedbackState.success != null) {
                       tripsViewModel.refreshDriverStatusApiByResettingTheExecutionTimer()
                       finish()
                   }
               }
       }
   }


   private fun setUpTripFeedbackChip() {
       observeTripDeletionStatus()
       setTripStates()


       with(binding) {
           showTripFeedbackChipInitial()
           action.setOnClickListener {
               val areTextValuesSame = TextUtils.compareTextValues(
                   action.text.toString(),
                   getString(R.string.delete_trip)
               )
               if (areTextValuesSame) {
                   viewModel.getTrip()?.let {
                       showDeleteTripBottomSheet(it)
                   }
               } else {
                   viewModel.updateTripDeletionStatus(TripDeletionStatus.INITIAL)
                   showTripFeedbackChipInitial()
               }
           }
           actionRetry.setOnClickListener {
               viewModel.getTrip()?.let {
                   // Please check Trip table for details about
                   // why driverId is trip.userId and tripId is trip.driveId
                   Timber.d("Retry Clicked from TripDetailActivity")
                   tripsViewModel.giveTripFeedBack(
                       driverId = it.userId,
                       tripId = it.driveId
                   )
               }
           }
       }
   }


   private fun setTripStates() {
       viewModel.trip.observe(this) {
           it?.let { trip ->
               if (tripsViewModel.isFeedbackValid(trip)) {
                   binding.tripFeedbackChip.makeVisible()
                   when (trip.tripDeletionStatus) {
                       TripDeletionStatus.INITIAL -> {
                           showTripFeedbackChipInitial()
                       }
                       TripDeletionStatus.IN_PROGRESS -> {
                           deletionInProgress = true
                           showTripFeedbackChipInProgress()
                       }
                       TripDeletionStatus.SUCCESS -> {
                           // success logic
                       }
                       TripDeletionStatus.FAILED -> {
                           deletionInProgress = false
                           showTripFeedbackChipRetry()
                       }
                   }
               } else {
                   binding.tripFeedbackChip.makeGone()
               }
           }
       }
   }


   private fun showTripFeedbackChipRetry() {
       with(binding) {
//            tintLayout.root.makeGone()
           tripDeletionProgressBar.hide()
           tripDeletionProgressBar.makeGone()
           question.setTextColor(ContextCompat.getColor(this@TripDetailActivity, R.color.error_red))
           question.text = getString(R.string.couldnt_delete_trip)
           action.text = TextUtils.buildUnderLinedText(getString(R.string.cancel))
           actionRetry.makeVisible()
           dottedDivider.makeVisible()
           actionRetry.text = TextUtils.buildUnderLinedText(getString(R.string.retry))
           WindowUtils.enableScreen(this@TripDetailActivity)
       }
   }


   private fun showTripFeedbackChipInitial() {
       with(binding) {
//            tintLayout.root.makeGone()
           tripDeletionProgressBar.hide()
           tripDeletionProgressBar.makeGone()
           question.setTextColor(ContextCompat.getColor(this@TripDetailActivity, R.color.tx_dark_grey))
           question.text = getString(R.string.not_the_driver)
           actionRetry.makeGone()
           dottedDivider.makeGone()
           action.text = TextUtils.buildUnderLinedText(getString(R.string.delete_trip))
       }
   }


   private fun showTripFeedbackChipInProgress() {
       with(binding) {
//            tintLayout.root.makeVisible()
           tripDeletionProgressBar.makeVisible()
           tripDeletionProgressBar.show()
           WindowUtils.disableScreen(this@TripDetailActivity)
       }
   }


   private fun showDeleteTripBottomSheet(trip: Trip) {
       // Please check Trip table for details about
       // why driverId is trip.userId and tripId is trip.driveId
       val bottomSheetFragment = DeleteTripBottomSheet.newInstance(
           driverId = trip.userId,
           tripId = trip.driveId
       )
       bottomSheetFragment.show(supportFragmentManager, bottomSheetFragment.tag)
   }
   ...
   ...
}

5.0. IQL Reference Application UX Design

Here is the overall design implementation for the IQL Reference Application:

6.0. Publisher Integration Testing Checklist

Use the following testing checklist to test the 'Build a test drive experience' process on your application:

  • Trips Page: Make sure that the user's trips are detailed accurately on the Trips page.

  • Pagination: If the Trips page contains pagination, load additional trips for accurate display.

  • Trips Feedback: Enable the user to delete a trip in which the user was not the driver. Once a trip is deleted, it should be removed from the Trips page. If a trip deletion fails owing to internet issues, provide a retry option to the user.

  • Deletion Logic: Ensure that the user can only delete trips that are less than 7 days old. Additionally, ensure that the delete option is unavailable once the user receives an offer.

  • Trip Detail: Ensure that when the user taps on a particular trip, the detail page opens up and all trip details are displayed accurately on the Trips page.