We have seen the use of SwitchMap operator in Instant Search. In this article, we are going learn FlatMap and ConcatMap operators with an example of Airliner Tickets Listing example. We also going to see how to use a Single Observable with multiple Observers using replay() operator.

Again the same example helps you improvise your knowledge on Retrofit networking.

1. Prerequisite

As always, to getting started, basic knowledge in below topics is necessary.

2. The Idea

The main agenda of this article is to combine our earlier knowledge on Map, FlatMap, ConcatMap and Retrofit networking. We also get introduced to replay() operator that solve the problem of Single Observable and Multiple observers.

In the Airline Tickets example, we need to make multiple dependent HTTP calls to render the screen. At first, all the tickets will be fetched by making single HTTP call excluding the price and available seats on each airline. The realtime price and seats availability will be fetched separately for each ticket.

Let’s say we have 20 tickets available, all the 20 tickets will be fetched in first HTTP call. Next, 20 subsequent HTTP calls will be made parallelly to fetch the price and seats information.

rxjava-observable-multiple-observers-subscribers

2.1. Single Observable – Multiple Subscribers

In RxJava, the above scenario can be done using single observable and multiple observers (subscribers).

  • Single Observable – First, we need to create an Observable that emits list of tickets. The job of this Observable is to fetch the tickets JSON only once and emit the data multiple times.
  • Multiple Observers – The first observer gets the list of tickets and renders the data in RecyclerView displaying the ticket details except price and seats. The second observer converts the list observable into single ticket emissions. On each emission of single ticket, another HTTP call is made to fetch the price and available seats.
  • We can use replay() method to make an Observable shared which means it will start emitting data on new subscription without re-executing the whole logic (HTTP call) again.

3. The REST API

For this example, I have created the REST API that simulates airline tickets and the realtime prices.

Listing Available Tickets
https://api.androidhive.info/json/airline-tickets.php?from=DEL&to=CHE
Lists the all available flights from source to destination.

[{
		"from": "DEL",
		"to": "CHE",
		"flight_number": "4K-560",
		"departure": "04:45",
		"arrival": "07:07",
		"duration": "2h 22m",
		"instructions": "Free Meals\/Snacks",
		"stops": 1,
		"airline": {
			"id": 1105,
			"name": "Jet Airways",
			"logo": "https:\/\/api.androidhive.info\/json\/images\/jetairways.png"
		}
	},
	{
		"from": "DEL",
		"to": "CHE",
		"flight_number": "AC-971",
		"departure": "08:11",
		"arrival": "10:59",
		"duration": "2h 48m",
		"instructions": "",
		"stops": 1,
		"airline": {
			"id": 1103,
			"name": "Spicejet",
			"logo": "https:\/\/api.androidhive.info\/json\/images\/spicejet.png"
		}
	}
]

Fetching Price and available Seats on each Flight
https://api.androidhive.info/json/airline-tickets-price.php?flight_number=6E-ARIfrom=DEL&to=CHE

{
	"price": 3560,
	"seats": 37,
	"currency": "INR",
	"flight_number": "6E-ARIfrom=DEL",
	"from": "",
	"to": "CHE"
}

4. Creating New Project

Now let’s begin by creating a new project in Android Studio.

> Basic App Setup
> Adding Retrofit Network Layer
> Adding the main interface (Listing the tickets using FlatMap)
> Listing the Ticket using ConcatMap

1. Create a new project in Android Studio from File ⇒ New Project and select Basic Activity from templates.

2. Open build.gradle located in root directory and add jitpack repository to download SpinKit loader library.

allprojects {
    repositories {
        google()
        jcenter()
        maven { url "https://jitpack.io" }
    }
}

3. Add RecyclerView, CardView, ButterKnife, SpinKit and Retrofit dependencies to your app/build.gradle and Sync the project.

dependencies {
    // ...

    // RecyclerView and CardView
    implementation 'com.android.support:recyclerview-v7:26.1.0'
    implementation 'com.android.support:cardview-v7:26.1.0'

    // RxJava
    implementation 'io.reactivex.rxjava2:rxjava:2.1.9'
    implementation 'io.reactivex.rxjava2:rxandroid:2.0.1'

    // ButterKnife
    implementation "com.jakewharton:butterknife:8.8.1"
    annotationProcessor "com.jakewharton:butterknife-compiler:8.8.1"

    // Retrofit and OkHttp
    // OkHttp interceptors for logging
    implementation "com.squareup.retrofit2:retrofit:2.0.0"
    implementation "com.squareup.retrofit2:converter-gson:2.0.0"
    implementation "com.jakewharton.retrofit:retrofit2-rxjava2-adapter:1.0.0"
    implementation "com.squareup.okhttp3:okhttp:3.0.1"
    implementation "com.squareup.okhttp3:okhttp-urlconnection:3.0.1"
    implementation "com.squareup.okhttp3:logging-interceptor:3.4.1"

    // glide image library
    implementation "com.github.bumptech.glide:glide:4.3.1"

    // spinner loaders library
    implementation 'com.github.ybq:Android-SpinKit:1.1.0'
}

4. Download the res.zip and add the contents to your res directory. This folder contains the Right Arrow Icon needed for this project.

5. Add the below resources to respective colors.xml, strings.xml and dimens.xml.

<?xml version="1.0" encoding="utf-8"?>
<?xml version="1.0" encoding="utf-8"?>
<resources>
    <color name="colorPrimary">#B91D44</color>
    <color name="colorPrimaryDark">#B91D44</color>
    <color name="colorAccent">#FF4081</color>
    <color name="airline_name">#515151</color>
    <color name="lbl_price">#787878</color>
    <color name="departure">#dd000000</color>
    <color name="tint_arrow">#6E6E6E</color>
    <color name="duration">#dd595959</color>
</resources>
<resources>
    <string name="app_name">Flight Tickets</string>
    <string name="action_settings">Settings</string>
    <string name="lbl_price">Price</string>
    <string name="title_activity_test">TestActivity</string>
</resources>
<resources>
    <dimen name="fab_margin">16dp</dimen>
    <dimen name="activity_padding">16dp</dimen>
    <dimen name="dimen_10">10dp</dimen>
    <dimen name="logo_width">20dp</dimen>
    <dimen name="airline_name">16sp</dimen>
    <dimen name="no_of_stops">14sp</dimen>
    <dimen name="lbl_price">12sp</dimen>
    <dimen name="departure">20sp</dimen>
    <dimen name="price">16sp</dimen>
    <dimen name="duration">11sp</dimen>
</resources>

6. Open AndroidManifest.xml and add INTERNET permission as we are going to make HTTP calls.

<uses-permission android:name="android.permission.INTERNET" />

7. As always, create few necessary packages named app, network, network/model and view in your project.

Below is the final project structure we are looking for.

rxjava-flight-tickets-app-project-structure-min

4.1 Adding Retrofit Network Layer

As the project setup is done, let’s quickly add the classes required for Retrofit.

8. Create a class named Const.java under app package. In this class, we define the base URL of the REST API.

public class Const {
    // To fetch the tickets
    // https://api.androidhive.info/json/airline-tickets.php

    // To fetch individual ticket price
    // https://api.androidhive.info/json/airline-tickets-price.php
    public static final String BASE_URL = "https://api.androidhive.info/json/";
}

9. Under network/model package, create three POJO classes named Airline.java, Price.java and Ticket.java. These classes will be useful in serializing the JSON. You can notice the Ticket model is dependent on both Airline and Price classes.

public class Airline {
    int id;
    String name;
    String logo;

    public int getId() {
        return id;
    }

    public String getName() {
        return name;
    }

    public String getLogo() {
        return logo;
    }
}
import com.google.gson.annotations.SerializedName;

public class Price {
    float price;
    String seats;
    String currency;

    @SerializedName("flight_number")
    String flightNumber;

    String from;
    String to;

    public float getPrice() {
        return price;
    }

    public String getSeats() {
        return seats;
    }

    public String getCurrency() {
        return currency;
    }

    public String getFlightNumber() {
        return flightNumber;
    }

    public String getFrom() {
        return from;
    }

    public String getTo() {
        return to;
    }
}

In Ticket model, we have overridden the equal() method in order to make the indexOf method work. Each ticket is uniquely identified by flight number.

import com.google.gson.annotations.SerializedName;

public class Ticket {

    String from;
    String to;

    @SerializedName("flight_number")
    String flightNumber;

    String departure;
    String arrival;
    String duration;
    String instructions;

    @SerializedName("stops")
    int numberOfStops;

    Airline airline;

    Price price;

    public String getFrom() {
        return from;
    }

    public String getTo() {
        return to;
    }

    public String getFlightNumber() {
        return flightNumber;
    }

    public String getDeparture() {
        return departure;
    }

    public String getArrival() {
        return arrival;
    }

    public String getDuration() {
        return duration;
    }

    public String getInstructions() {
        return instructions;
    }

    public int getNumberOfStops() {
        return numberOfStops;
    }

    public Airline getAirline() {
        return airline;
    }

    public Price getPrice() {
        return price;
    }

    public void setPrice(Price price) {
        this.price = price;
    }

    @Override
    public boolean equals(Object obj) {
        if (obj == this) {
            return true;
        }

        if (!(obj instanceof Ticket)) {
            return false;
        }

        return flightNumber.equalsIgnoreCase(((Ticket) obj).getFlightNumber());
    }

    @Override
    public int hashCode() {
        int hash = 3;
        hash = 53 * hash + (this.flightNumber != null ? this.flightNumber.hashCode() : 0);
        return hash;
    }
}

10. Create a class named ApiClient.java. This class initializes the Retrofit library with necessary configuration.

import com.jakewharton.retrofit2.adapter.rxjava2.RxJava2CallAdapterFactory;

import java.io.IOException;
import java.util.concurrent.TimeUnit;

import info.androidhive.flighttickets.app.Const;
import okhttp3.Interceptor;
import okhttp3.OkHttpClient;
import okhttp3.Request;
import okhttp3.Response;
import okhttp3.logging.HttpLoggingInterceptor;
import retrofit2.Retrofit;
import retrofit2.converter.gson.GsonConverterFactory;

/**
 * Created by ravi on 02/03/18.
 */

public class ApiClient {
    private static String TAG = ApiClient.class.getSimpleName();
    private static Retrofit retrofit = null;
    private static int REQUEST_TIMEOUT = 60;
    private static OkHttpClient okHttpClient;


    public static Retrofit getClient() {

        if (okHttpClient == null)
            initOkHttp();

        if (retrofit == null) {
            retrofit = new Retrofit.Builder()
                    .baseUrl(Const.BASE_URL)
                    .client(okHttpClient)
                    .addCallAdapterFactory(RxJava2CallAdapterFactory.create())
                    .addConverterFactory(GsonConverterFactory.create())
                    .build();
        }
        return retrofit;
    }

    private static void initOkHttp() {
        OkHttpClient.Builder httpClient = new OkHttpClient().newBuilder()
                .connectTimeout(REQUEST_TIMEOUT, TimeUnit.SECONDS)
                .readTimeout(REQUEST_TIMEOUT, TimeUnit.SECONDS)
                .writeTimeout(REQUEST_TIMEOUT, TimeUnit.SECONDS);

        HttpLoggingInterceptor interceptor = new HttpLoggingInterceptor();
        interceptor.setLevel(HttpLoggingInterceptor.Level.BODY);

        httpClient.addInterceptor(interceptor);

        httpClient.addInterceptor(new Interceptor() {
            @Override
            public Response intercept(Chain chain) throws IOException {
                Request original = chain.request();
                Request.Builder requestBuilder = original.newBuilder()
                        .addHeader("Accept", "application/json")
                        .addHeader("Request-Type", "Android")
                        .addHeader("Content-Type", "application/json");

                Request request = requestBuilder.build();
                return chain.proceed(request);
            }
        });

        okHttpClient = httpClient.build();
    }

    public static void resetApiClient() {
        retrofit = null;
        okHttpClient = null;
    }
}

11. Create another class named ApiService.java. This class holds the interface methods of HTTP calls by defining the Observable type and query parameters.

  • searchTickets() fetches the list of tickets and Single Observable is used. It accepts the parameters `from` and `to` to search the tickets between source and destination.
  • getPrice() fetches the price and available seats of each flight. It takes flight_number as query param that received in searchTickets() call.
import java.util.List;

import info.androidhive.flighttickets.network.model.Price;
import info.androidhive.flighttickets.network.model.Ticket;
import io.reactivex.Single;
import retrofit2.http.GET;
import retrofit2.http.Query;

public interface ApiService {

    @GET("airline-tickets.php")
    Single<List<Ticket>> searchTickets(@Query("from") String from, @Query("to") String to);

    @GET("airline-tickets-price.php")
    Single<Price> getPrice(@Query("flight_number") String flightNumber, @Query("from") String from, @Query("to") String to);
}

4.2 Adding the main interface

12. Create a layout named ticket_row.xml This layout holds the design of single row item.

<?xml version="1.0" encoding="utf-8"?>
<LinearLayout xmlns:android="http://schemas.android.com/apk/res/android"
    xmlns:app="http://schemas.android.com/apk/res-auto"
    android:layout_width="match_parent"
    android:layout_height="wrap_content"
    android:orientation="vertical">

    <android.support.v7.widget.CardView
        android:id="@+id/card_view"
        android:layout_width="match_parent"
        android:layout_height="wrap_content"
        android:layout_gravity="center"
        android:layout_margin="5dp"
        app:cardCornerRadius="2dp">

        <LinearLayout
            android:layout_width="match_parent"
            android:layout_height="wrap_content"
            android:orientation="vertical"
            android:padding="@dimen/dimen_10">

            <LinearLayout
                android:layout_width="match_parent"
                android:layout_height="wrap_content"
                android:orientation="horizontal"
                android:paddingBottom="@dimen/dimen_10"
                android:weightSum="3">

                <LinearLayout
                    android:layout_width="0dp"
                    android:layout_height="wrap_content"
                    android:layout_weight="1"
                    android:orientation="horizontal">

                    <ImageView
                        android:id="@+id/logo"
                        android:layout_width="@dimen/logo_width"
                        android:layout_height="@dimen/logo_width"
                        android:layout_gravity="center_vertical"
                        android:layout_marginRight="@dimen/dimen_10" />

                    <TextView
                        android:id="@+id/airline_name"
                        android:layout_width="wrap_content"
                        android:layout_height="wrap_content"
                        android:ellipsize="end"
                        android:fontFamily="sans-serif-medium"
                        android:gravity="center_vertical"
                        android:maxLines="1"
                        android:textColor="@color/airline_name"
                        android:textSize="@dimen/airline_name"
                        android:textStyle="normal" />
                </LinearLayout>

                <TextView
                    android:id="@+id/number_of_stops"
                    android:layout_width="0dp"
                    android:layout_height="match_parent"
                    android:layout_weight="1"
                    android:fontFamily="sans-serif"
                    android:gravity="center"
                    android:letterSpacing="0.02"
                    android:textColor="@color/colorPrimary"
                    android:textSize="@dimen/no_of_stops"
                    android:textStyle="normal" />

                <TextView
                    android:layout_width="0dp"
                    android:layout_height="match_parent"
                    android:layout_weight="1"
                    android:fontFamily="sans-serif"
                    android:gravity="right"
                    android:letterSpacing="0.02"
                    android:text="@string/lbl_price"
                    android:textColor="@color/lbl_price"
                    android:textSize="@dimen/lbl_price"
                    android:textStyle="normal" />

            </LinearLayout>

            <LinearLayout
                android:layout_width="match_parent"
                android:layout_height="wrap_content"
                android:orientation="horizontal"
                android:paddingBottom="@dimen/dimen_10"
                android:weightSum="3">

                <TextView
                    android:id="@+id/departure"
                    android:layout_width="0dp"
                    android:layout_height="wrap_content"
                    android:layout_weight="1"
                    android:ellipsize="end"
                    android:fontFamily="sans-serif-medium"
                    android:gravity="left"
                    android:maxLines="1"
                    android:textColor="@color/departure"
                    android:textSize="@dimen/departure"
                    android:textStyle="normal" />

                <ImageView
                    android:layout_width="28dp"
                    android:layout_height="match_parent"
                    android:layout_gravity="center_vertical"
                    android:paddingRight="8dp"
                    android:src="@drawable/ic_arrow_forward_black_24dp"
                    android:tint="@color/tint_arrow" />

                <TextView
                    android:id="@+id/arrival"
                    android:layout_width="0dp"
                    android:layout_height="wrap_content"
                    android:layout_weight="1"
                    android:ellipsize="end"
                    android:fontFamily="sans-serif-medium"
                    android:gravity="left"
                    android:maxLines="1"
                    android:textColor="@color/departure"
                    android:textSize="@dimen/departure"
                    android:textStyle="normal" />

                <RelativeLayout
                    android:layout_width="0dp"
                    android:gravity="right"
                    android:layout_height="match_parent"
                    android:layout_weight="1">

                    <TextView
                        android:id="@+id/price"
                        android:layout_width="wrap_content"
                        android:layout_height="wrap_content"
                        android:fontFamily="sans-serif-medium"
                        android:layout_centerVertical="true"
                        android:textColor="@color/departure"
                        android:textSize="@dimen/price" />

                    <com.github.ybq.android.spinkit.SpinKitView xmlns:app="http://schemas.android.com/apk/res-auto"
                        android:id="@+id/loader"
                        style="@style/SpinKitView.Large.Wave"
                        android:layout_width="@dimen/logo_width"
                        android:layout_height="@dimen/logo_width"
                        android:layout_centerVertical="true"
                        android:layout_gravity="right"
                        app:SpinKit_Color="@color/colorAccent" />
                </RelativeLayout>

            </LinearLayout>

            <LinearLayout
                android:layout_width="match_parent"
                android:layout_height="wrap_content"
                android:orientation="horizontal"
                android:weightSum="3">

                <TextView
                    android:id="@+id/duration"
                    android:layout_width="0dp"
                    android:layout_height="wrap_content"
                    android:layout_weight="2"
                    android:fontFamily="sans-serif"
                    android:textColor="@color/duration"
                    android:textSize="@dimen/duration"
                    android:textStyle="normal" />

                <TextView
                    android:id="@+id/number_of_seats"
                    android:layout_width="0dp"
                    android:layout_height="wrap_content"
                    android:layout_weight="1"
                    android:fontFamily="sans-serif"
                    android:gravity="right"
                    android:textColor="@color/duration"
                    android:textSize="@dimen/duration"
                    android:textStyle="normal" />
            </LinearLayout>

        </LinearLayout>
    </android.support.v7.widget.CardView>

</LinearLayout>

13. Create a class named TicketsAdapter.java. This class is usual RecyclerView adapter class that inflates ticket_row.xml with proper data.

import android.content.Context;
import android.support.v7.widget.RecyclerView;
import android.text.TextUtils;
import android.view.LayoutInflater;
import android.view.View;
import android.view.ViewGroup;
import android.widget.ImageView;
import android.widget.TextView;

import com.bumptech.glide.Glide;
import com.bumptech.glide.request.RequestOptions;
import com.github.ybq.android.spinkit.SpinKitView;

import java.util.List;

import butterknife.BindView;
import butterknife.ButterKnife;
import info.androidhive.flighttickets.R;
import info.androidhive.flighttickets.network.model.Ticket;


public class TicketsAdapter extends RecyclerView.Adapter<TicketsAdapter.MyViewHolder> {
    private Context context;
    private List<Ticket> contactList;
    private TicketsAdapterListener listener;

    public class MyViewHolder extends RecyclerView.ViewHolder {
        @BindView(R.id.airline_name)
        TextView airlineName;

        @BindView(R.id.logo)
        ImageView logo;

        @BindView(R.id.number_of_stops)
        TextView stops;

        @BindView(R.id.number_of_seats)
        TextView seats;

        @BindView(R.id.departure)
        TextView departure;

        @BindView(R.id.arrival)
        TextView arrival;

        @BindView(R.id.duration)
        TextView duration;

        @BindView(R.id.price)
        TextView price;

        @BindView(R.id.loader)
        SpinKitView loader;

        public MyViewHolder(View view) {
            super(view);
            ButterKnife.bind(this, view);

            view.setOnClickListener(new View.OnClickListener() {
                @Override
                public void onClick(View view) {
                    // send selected contact in callback
                    listener.onTicketSelected(contactList.get(getAdapterPosition()));
                }
            });
        }
    }

    public TicketsAdapter(Context context, List<Ticket> contactList, TicketsAdapterListener listener) {
        this.context = context;
        this.listener = listener;
        this.contactList = contactList;
    }

    @Override
    public MyViewHolder onCreateViewHolder(ViewGroup parent, int viewType) {
        View itemView = LayoutInflater.from(parent.getContext())
                .inflate(R.layout.ticket_row, parent, false);

        return new MyViewHolder(itemView);
    }

    @Override
    public void onBindViewHolder(MyViewHolder holder, final int position) {
        final Ticket ticket = contactList.get(position);

        Glide.with(context)
                .load(ticket.getAirline().getLogo())
                .apply(RequestOptions.circleCropTransform())
                .into(holder.logo);

        holder.airlineName.setText(ticket.getAirline().getName());

        holder.departure.setText(ticket.getDeparture() + " Dep");
        holder.arrival.setText(ticket.getArrival() + " Dest");

        holder.duration.setText(ticket.getFlightNumber());
        holder.duration.append(", " + ticket.getDuration());
        holder.stops.setText(ticket.getNumberOfStops() + " Stops");

        if (!TextUtils.isEmpty(ticket.getInstructions())) {
            holder.duration.append(", " + ticket.getInstructions());
        }

        if (ticket.getPrice() != null) {
            holder.price.setText("₹" + String.format("%.0f", ticket.getPrice().getPrice()));
            holder.seats.setText(ticket.getPrice().getSeats() + " Seats");
            holder.loader.setVisibility(View.INVISIBLE);
        } else {
            holder.loader.setVisibility(View.VISIBLE);
        }
    }

    @Override
    public int getItemCount() {
        return contactList.size();
    }

    public interface TicketsAdapterListener {
        void onTicketSelected(Ticket contact);
    }
}

14. Open the layout file main activity content_main.xml and add RecyclerView element.

<?xml version="1.0" encoding="utf-8"?>
<android.support.constraint.ConstraintLayout xmlns:android="http://schemas.android.com/apk/res/android"
    xmlns:app="http://schemas.android.com/apk/res-auto"
    xmlns:tools="http://schemas.android.com/tools"
    android:layout_width="match_parent"
    android:layout_height="match_parent"
    app:layout_behavior="@string/appbar_scrolling_view_behavior"
    tools:context="info.androidhive.flighttickets.view.MainActivity"
    tools:showIn="@layout/activity_main">

    <android.support.v7.widget.RecyclerView
        android:id="@+id/recycler_view"
        android:layout_width="match_parent"
        android:layout_height="wrap_content"
        android:clipToPadding="false"
        android:scrollbars="vertical" />

</android.support.constraint.ConstraintLayout>

15. Finally open MainActivity.java and do the below modifications.

  • CompositeDisposable is used to dispose the subscriptions in onDestroy() method.
  • getTickets() makes an HTTP call to fetch the list of tickets.
  • getPriceObservable() makes an HTTP call to get the price and number of tickets on each flight.
  • You can notice replay() operator (getTickets(from, to).replay()) is used to make an Observable emits the data on new subscriptions without re-executing the logic again. In our case, the list of tickets will be emitted without making the HTTP call again. Without the replay method, you can notice the fetch tickets HTTP call get executed multiple times.
  • In the first subscription, the list of tickets directly added to Adapter class and the RecyclerView is rendered directly without price and number of seats.
  • In the second subscription, flatMap() is used to convert list of tickets to individual ticket emissions.
                .flatMap(new Function<List<Ticket>, ObservableSource<Ticket>>() {
                                @Override
                                public ObservableSource<Ticket> apply(List<Ticket> tickets) throws Exception {
                                    return Observable.fromIterable(tickets);
                                }
                            })
    

    On the same Observable, another flatMap is chained to execute the getPriceObservable() method on each ticket emissions which fetches the price and available seats.

                .flatMap(new Function<Ticket, ObservableSource<Ticket>>() {
                                @Override
                                public ObservableSource<Ticket> apply(Ticket ticket) throws Exception {
                                    return getPriceObservable(ticket);
                                }
                            })
    
  • Once the price and seats information is received, the particular row item is updated in RecyclerView.
  • If you observe getPriceObservable(), the API call fetches Price model. But the map() operator is used to convert the return type from Price to Ticket.
  • Calling ticketsObservable.connect() will start executing the Observable
import android.content.res.Resources;
import android.graphics.Color;
import android.graphics.Rect;
import android.os.Bundle;
import android.support.design.widget.CoordinatorLayout;
import android.support.design.widget.Snackbar;
import android.support.v7.app.AppCompatActivity;
import android.support.v7.widget.DefaultItemAnimator;
import android.support.v7.widget.GridLayoutManager;
import android.support.v7.widget.RecyclerView;
import android.support.v7.widget.Toolbar;
import android.util.Log;
import android.util.TypedValue;
import android.view.Menu;
import android.view.MenuItem;
import android.view.View;
import android.widget.TextView;

import java.util.ArrayList;
import java.util.List;

import butterknife.BindView;
import butterknife.ButterKnife;
import butterknife.Unbinder;
import info.androidhive.flighttickets.R;
import info.androidhive.flighttickets.network.ApiClient;
import info.androidhive.flighttickets.network.ApiService;
import info.androidhive.flighttickets.network.model.Price;
import info.androidhive.flighttickets.network.model.Ticket;
import io.reactivex.Observable;
import io.reactivex.ObservableSource;
import io.reactivex.android.schedulers.AndroidSchedulers;
import io.reactivex.disposables.CompositeDisposable;
import io.reactivex.functions.Function;
import io.reactivex.observables.ConnectableObservable;
import io.reactivex.observers.DisposableObserver;
import io.reactivex.schedulers.Schedulers;

public class MainActivity extends AppCompatActivity implements TicketsAdapter.TicketsAdapterListener {

    private static final String TAG = MainActivity.class.getSimpleName();
    private static final String from = "DEL";
    private static final String to = "HYD";

    private CompositeDisposable disposable = new CompositeDisposable();
    private Unbinder unbinder;

    private ApiService apiService;
    private TicketsAdapter mAdapter;
    private ArrayList<Ticket> ticketsList = new ArrayList<>();

    @BindView(R.id.recycler_view)
    RecyclerView recyclerView;

    @BindView(R.id.coordinator_layout)
    CoordinatorLayout coordinatorLayout;

    @Override
    protected void onCreate(Bundle savedInstanceState) {
        super.onCreate(savedInstanceState);
        setContentView(R.layout.activity_main);
        unbinder = ButterKnife.bind(this);

        Toolbar toolbar = findViewById(R.id.toolbar);
        setSupportActionBar(toolbar);
        getSupportActionBar().setDisplayHomeAsUpEnabled(true);
        getSupportActionBar().setTitle(from + " > " + to);

        apiService = ApiClient.getClient().create(ApiService.class);

        mAdapter = new TicketsAdapter(this, ticketsList, this);

        RecyclerView.LayoutManager mLayoutManager = new GridLayoutManager(this, 1);
        recyclerView.setLayoutManager(mLayoutManager);
        recyclerView.addItemDecoration(new MainActivity.GridSpacingItemDecoration(1, dpToPx(5), true));
        recyclerView.setItemAnimator(new DefaultItemAnimator());
        recyclerView.setAdapter(mAdapter);

        ConnectableObservable<List<Ticket>> ticketsObservable = getTickets(from, to).replay();

        /**
         * Fetching all tickets first
         * Observable emits List<Ticket> at once
         * All the items will be added to RecyclerView
         * */
        disposable.add(
                ticketsObservable
                        .subscribeOn(Schedulers.io())
                        .observeOn(AndroidSchedulers.mainThread())
                        .subscribeWith(new DisposableObserver<List<Ticket>>() {

                            @Override
                            public void onNext(List<Ticket> tickets) {
                                // Refreshing list
                                ticketsList.clear();
                                ticketsList.addAll(tickets);
                                mAdapter.notifyDataSetChanged();
                            }

                            @Override
                            public void onError(Throwable e) {
                                showError(e);
                            }

                            @Override
                            public void onComplete() {

                            }
                        }));

        /**
         * Fetching individual ticket price
         * First FlatMap converts single List<Ticket> to multiple emissions
         * Second FlatMap makes HTTP call on each Ticket emission
         * */
        disposable.add(
                ticketsObservable
                        .subscribeOn(Schedulers.io())
                        .observeOn(AndroidSchedulers.mainThread())
                        /**
                         * Converting List<Ticket> emission to single Ticket emissions
                         * */
                        .flatMap(new Function<List<Ticket>, ObservableSource<Ticket>>() {
                            @Override
                            public ObservableSource<Ticket> apply(List<Ticket> tickets) throws Exception {
                                return Observable.fromIterable(tickets);
                            }
                        })
                        /**
                         * Fetching price on each Ticket emission
                         * */
                        .flatMap(new Function<Ticket, ObservableSource<Ticket>>() {
                            @Override
                            public ObservableSource<Ticket> apply(Ticket ticket) throws Exception {
                                return getPriceObservable(ticket);
                            }
                        })
                        .subscribeWith(new DisposableObserver<Ticket>() {

                            @Override
                            public void onNext(Ticket ticket) {
                                int position = ticketsList.indexOf(ticket);

                                if (position == -1) {
                                    // TODO - take action
                                    // Ticket not found in the list
                                    // This shouldn't happen
                                    return;
                                }

                                ticketsList.set(position, ticket);
                                mAdapter.notifyItemChanged(position);
                            }

                            @Override
                            public void onError(Throwable e) {
                                showError(e);
                            }

                            @Override
                            public void onComplete() {

                            }
                        }));

        // Calling connect to start emission
        ticketsObservable.connect();
    }

    /**
     * Making Retrofit call to fetch all tickets
     */
    private Observable<List<Ticket>> getTickets(String from, String to) {
        return apiService.searchTickets(from, to)
                .toObservable()
                .subscribeOn(Schedulers.io())
                .observeOn(AndroidSchedulers.mainThread());
    }

    /**
     * Making Retrofit call to get single ticket price
     * get price HTTP call returns Price object, but
     * map() operator is used to change the return type to Ticket
     */
    private Observable<Ticket> getPriceObservable(final Ticket ticket) {
        return apiService
                .getPrice(ticket.getFlightNumber(), ticket.getFrom(), ticket.getTo())
                .toObservable()
                .subscribeOn(Schedulers.io())
                .observeOn(AndroidSchedulers.mainThread())
                .map(new Function<Price, Ticket>() {
                    @Override
                    public Ticket apply(Price price) throws Exception {
                        ticket.setPrice(price);
                        return ticket;
                    }
                });
    }

    public class GridSpacingItemDecoration extends RecyclerView.ItemDecoration {

        private int spanCount;
        private int spacing;
        private boolean includeEdge;

        public GridSpacingItemDecoration(int spanCount, int spacing, boolean includeEdge) {
            this.spanCount = spanCount;
            this.spacing = spacing;
            this.includeEdge = includeEdge;
        }

        @Override
        public void getItemOffsets(Rect outRect, View view, RecyclerView parent, RecyclerView.State state) {
            int position = parent.getChildAdapterPosition(view); // item position
            int column = position % spanCount; // item column

            if (includeEdge) {
                outRect.left = spacing - column * spacing / spanCount; // spacing - column * ((1f / spanCount) * spacing)
                outRect.right = (column + 1) * spacing / spanCount; // (column + 1) * ((1f / spanCount) * spacing)

                if (position < spanCount) { // top edge
                    outRect.top = spacing;
                }
                outRect.bottom = spacing; // item bottom
            } else {
                outRect.left = column * spacing / spanCount; // column * ((1f / spanCount) * spacing)
                outRect.right = spacing - (column + 1) * spacing / spanCount; // spacing - (column + 1) * ((1f /    spanCount) * spacing)
                if (position >= spanCount) {
                    outRect.top = spacing; // item top
                }
            }
        }
    }

    private int dpToPx(int dp) {
        Resources r = getResources();
        return Math.round(TypedValue.applyDimension(TypedValue.COMPLEX_UNIT_DIP, dp, r.getDisplayMetrics()));
    }

    @Override
    public void onTicketSelected(Ticket contact) {

    }

    /**
     * Snackbar shows observer error
     */
    private void showError(Throwable e) {
        Log.e(TAG, "showError: " + e.getMessage());

        Snackbar snackbar = Snackbar
                .make(coordinatorLayout, e.getMessage(), Snackbar.LENGTH_LONG);
        View sbView = snackbar.getView();
        TextView textView = sbView.findViewById(android.support.design.R.id.snackbar_text);
        textView.setTextColor(Color.YELLOW);
        snackbar.show();
    }

    @Override
    protected void onDestroy() {
        super.onDestroy();
        disposable.dispose();
        unbinder.unbind();
    }
}

Now if you run the app, you can the prices listed by executing the HTTP calls parallelly.

4.3 Using ConcatMap operator

In the same activity, if you replace the FlatMap with ConcatMap, all the HTTP calls will be executed sequentially.

That’s all for today. We’ll catch up in another awesome article. If you have any queries, please post them in the comment section.

Happy Coding 🙂

Author

  • Farouk MOHAMED

    hi great job thanks. can you start a project like Uber ?

  • Anuj

    What happens when their are large number of tickets returned in the response and our code is fetching the ticket prices and seats in parallel. Is there a way to limit these calls (Like max 20 at a time)?

  • indrajeet jyoti

    make tutorial on dagger2 using mvp or mvvm

  • rahul khurana

    Hi ravi , thanks for the nice tutorial. i am reading these from three days . please correct me if i am wrong

    in getTickets(String from, String to) method we are writing .subscribeOn(Schedulers.io())
    .observeOn(AndroidSchedulers.mainThread()

    and again while adding
    disposable.add(
    ticketsObservable
    .subscribeOn(Schedulers.io()) \ this line again
    .observeOn(AndroidSchedulers.mainThread()) \ this line again

    It is ok to remove this line.

    • Try removing it. You might get an exception of background thread.

      • rahul khurana

        as i can see it is working, may be i am not able to reproduce exception, can you try and let me know the exact scenario if it is happening

        • rahul khurana

          i removed the lines from disposable.add