Retrofit En Android Parte 4: Crear Citas Médicas

De vuelta a la serie de tutoriales para usar la librería Retrofit en Android, esta vez trataremos el envío de datos con una petición POST.

El objetivo será tomar el caso de uso de asignar citas de la app ejemplo SaludMock.

Además de ello, si revisas los bocetos planteados, por consecuencia debemos procesar peticiones GET para:

  • Obtener los centros médicos y poblar el Spinner del formulario
  • Obtener los doctores disponibles para el horario de la cita a asignar

Y una POST para crear la cita.

Si eres nuevo, entonces ponte en contexto con el proyecto yendo a las partes previas:

Aclarando lo anterior, te comparto la lista de tareas de programación a llevar a cabo:

  1. Crear Citas Médicas En La App
  2. Elegir Un Doctor En La App
  3. Añadir Soporte De Peticiones A La API REST
  4. Consumir Servicio REST Con Retrofit

¡Sin más, comencemos a programar!

Descargar Ejemplo De Retrofit Para Android Studio

Te voy a compartir el enlace de descargar del código del proyecto en Android Studio listo para ejecutarse:

[sociallocker id=»7121″]Descargar Gratis[/sociallocker]

Al ejecutar la app podrás ver las siguientes funciones en marcha:

Paso 1. Crear Citas Médicas En La App

1.1 Crear Actividad Con Diseño De Formulario

Nuestro primer movimiento será crear la actividad para añadir las citas médicas.

Tomaremos como punto de referencia el boceto creado en el plan de la aplicación Android.

Screen asignar citas

Con ello en mente, añadamos al paquete ui presionando click derecho y yendo a New > Activity > Basic Activity.

Seguido en el asistente, usaremos estos valores:

  • Activity Name > AddAppointmentActivity
  • Layout Name > activity_add_appointment
  • Title > Asignar Cita

1.2 Diseñar UI Del Layout

Si analizamos la jerarquía del boceto notaremos que no requiere mucha fuerza de trabajo.

Consiste en agregar 3 pares de etiquetas – campos y poner al final un botón.

Adicionalmente una barra de progreso y un view para mostrar errores vendrían de utilidad en la Ux.

(Cabe resaltar que puedes personalizar el diseño con la inserción de más campos, uso de cards, más acciones en la Toolbar, etc. si así lo requiere la naturaleza de tu proyecto)

Siendo solo esto, pon la siguiente definición XML en el archivo de contenido autogenerado content_add_appointment.xml:

<?xml version="1.0" encoding="utf-8"?>
<RelativeLayout 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"
    android:paddingBottom="@dimen/activity_vertical_margin"
    android:paddingLeft="@dimen/activity_horizontal_margin"
    android:paddingRight="@dimen/activity_horizontal_margin"
    android:paddingTop="@dimen/activity_vertical_margin"
    app:layout_behavior="@string/appbar_scrolling_view_behavior"
    tools:context="com.hermosaprogramacion.blog.saludmock.ui.AddAppointmentActivity"
    tools:showIn="@layout/activity_add_appointment">

    <ProgressBar
        android:id="@+id/progress"
        android:layout_width="wrap_content"
        android:layout_height="wrap_content"
        android:layout_centerInParent="true"
        android:visibility="gone" />

    <android.support.v7.widget.CardView
        android:id="@+id/appointment_info_card"
        android:layout_width="match_parent"
        android:layout_height="wrap_content"
        app:contentPadding="16dp">

        <LinearLayout
            android:layout_width="match_parent"
            android:layout_height="match_parent"
            android:orientation="vertical">

            <TextView
                android:id="@+id/label_medical_center"
                android:layout_width="wrap_content"
                android:layout_height="wrap_content"
                android:text="@string/label_medical_center"
                android:textAppearance="@style/TextAppearance.AppCompat.Subhead"
                android:textColor="@color/colorPrimary" />

            <Spinner
                android:id="@+id/medical_center_menu"
                android:layout_width="match_parent"
                android:layout_height="wrap_content"
                tools:listitem="@android:layout/simple_list_item_1" />

            <TextView
                android:id="@+id/label_date"
                android:layout_width="wrap_content"
                android:layout_height="wrap_content"
                android:layout_marginTop="32dp"
                android:text="@string/label_date"
                android:textAppearance="@style/TextAppearance.AppCompat.Subhead"
                android:textColor="@color/colorPrimary" />

            <EditText
                android:id="@+id/date_field"
                android:layout_width="match_parent"
                android:layout_height="wrap_content"
                android:ems="10"
                android:focusable="false"
                android:inputType="none"
                tools:text="31 de Enero de 2018" />

            <TextView
                android:id="@+id/label_time_schedule"
                android:layout_width="wrap_content"
                android:layout_height="wrap_content"
                android:layout_marginTop="32dp"
                android:text="@string/label_time_schedule"
                android:textAppearance="@style/TextAppearance.AppCompat.Subhead"
                android:textColor="@color/colorPrimary" />

            <Spinner
                android:id="@+id/time_schedule_menu"
                android:layout_width="match_parent"
                android:layout_height="wrap_content"
                android:layout_marginBottom="8dp"
                android:layout_marginTop="8dp"
                android:entries="@array/entries_time_schedule"
                tools:listitem="@android:layout/simple_list_item_1" />
        </LinearLayout>
    </android.support.v7.widget.CardView>


    <android.support.v7.widget.CardView
        android:id="@+id/appointment_doctor_card"
        android:layout_width="match_parent"
        android:layout_height="wrap_content"
        android:layout_below="@id/appointment_info_card"
        android:layout_marginTop="16dp"
        app:contentPadding="16dp">

        <LinearLayout
            android:layout_width="match_parent"
            android:layout_height="match_parent"
            android:gravity="center_horizontal"
            android:orientation="vertical">

            <TextView
                android:id="@+id/empty_doctor"
                android:layout_width="wrap_content"
                android:layout_height="wrap_content"
                android:layout_gravity="center_horizontal"
                android:text="@string/message_no_doctor_schedule_picked"
                android:textSize="16sp" />

            <LinearLayout
                android:id="@+id/doctor_content"
                android:layout_width="match_parent"
                android:layout_height="wrap_content"
                android:gravity="center_horizontal"
                android:orientation="vertical"
                android:visibility="gone">

                <TextView
                    android:id="@+id/summary_doctor_name"
                    android:layout_width="wrap_content"
                    android:layout_height="wrap_content"
                    android:textAppearance="@style/TextAppearance.AppCompat.Title"
                    tools:text="@tools:sample/full_names" />

                <TextView
                    android:id="@+id/summary_doctor_time_schedule"
                    android:layout_width="wrap_content"
                    android:layout_height="wrap_content"
                    android:layout_marginTop="8dp"
                    android:textAppearance="@style/TextAppearance.AppCompat.Subhead"
                    tools:text="@tools:sample/date/hhmm" />
            </LinearLayout>

            <Button
                android:id="@+id/search_doctor_button"
                style="@style/Widget.AppCompat.Button.Borderless.Colored"
                android:layout_width="wrap_content"
                android:layout_height="wrap_content"
                android:layout_marginTop="16dp"
                android:text="@string/action_select_doctor" />
        </LinearLayout>
    </android.support.v7.widget.CardView>

    <LinearLayout
        android:id="@+id/error_container"
        android:layout_width="wrap_content"
        android:layout_height="wrap_content"
        android:layout_centerInParent="true"
        android:orientation="vertical"
        android:visibility="gone">

        <ImageView
            android:id="@+id/image_empty_state"
            android:layout_width="48dp"
            android:layout_height="48dp"
            android:layout_gravity="center"
            android:tint="#9E9E9E"
            app:srcCompat="@drawable/ic_alert_circle" />

        <TextView
            android:layout_width="wrap_content"
            android:layout_height="wrap_content"
            android:layout_gravity="center"
            android:text="Hay problemas al asignar la cita. Contacte con el administrador" />
    </LinearLayout>


</RelativeLayout>

Adicionalmente eliminamos el FloatingActionButton que Android Studio agrega al layout principal de la actividad:

<?xml version="1.0" encoding="utf-8"?>
<android.support.design.widget.CoordinatorLayout 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"
    android:fitsSystemWindows="true"
    tools:context="com.hermosaprogramacion.blog.saludmock.ui.AddAppointmentActivity">

    <android.support.design.widget.AppBarLayout
        android:layout_width="match_parent"
        android:layout_height="wrap_content"
        android:theme="@style/AppTheme.AppBarOverlay">

        <android.support.v7.widget.Toolbar
            android:id="@+id/toolbar"
            android:layout_width="match_parent"
            android:layout_height="?attr/actionBarSize"
            android:background="?attr/colorPrimary"
            app:popupTheme="@style/AppTheme.PopupOverlay" />

    </android.support.design.widget.AppBarLayout>

    <include layout="@layout/content_add_appointment" />

</android.support.design.widget.CoordinatorLayout>

Con esto realizado podremos ver la siguiente preview:

Preview Android Studio Formulario Creación De Cita

1.3 Definir Campos De La Actividad

El siguiente paso es  agregaremos las referencias de vista, dominio y datos como campos de AddAppointmentActivity.

En este caso tenemos:

  • Constantes: La etiqueta de logueo, el código de la petición por resultados al llamar la actividad de doctores y 3 para los extras que enviaremos: La jornada elegida, el id del centro médico y la fecha seleccionada. También tendremos constantes para los valores de las jornadas para la interfaz del usuario y la API.
  • Views: Todos aquellos que vamos a manipular. Ponemos como comentario el adaptador del spinner para centros médicos ya que no está construido aún.
  • Relaciones: Adaptador y API de retrofit para las peticiones
  • Variables: Sostendremos las entradas del usuario en variables globales: ID de centro médico, fecha, jornada y hora de disponibilidad del doctor.

Siendo así, la estructura base a conseguir es esta:

public class AddAppointmentActivity extends AppCompatActivity{

    private static final String TAG = AddAppointmentActivity.class.getSimpleName();

    public static final int REQUEST_PICK_DOCTOR_SCHEDULE = 1;

    public static final String EXTRA_TIME_SHEDULE_PICKED
            = "com.hermosaprogramacion.EXTRA_TIME_SCHEDULE_PICKED";
    public static final String EXTRA_MEDICAL_CENTER_ID
            = "com.hermosaprogramacion.EXTRA_MEDICAL_CENTER_ID";
    public static final String EXTRA_DATE_PICKED = "com.hermosaprogramacion.EXTRA_DATE_PICKED";

    private static final String UI_VALUE_MORNING = "Mañana";
    private static final String API_VALUE_MORNING = "morning";

    private static final String UI_VALUE_AFTERNOON = "Tarde";
    private static final String API_VALUE_AFTERNOON = "afternoon";

    private ProgressBar mProgress;
    private View mErrorView;
    private View mCard1;
    private View mCard2;
    private Spinner mMedicalCenterMenu;
    // mMedicalCenterAdapter;
    private EditText mDateField;
    private Spinner mTimeScheduleMenu;
    private View mEmptyDoctorView;
    private View mDoctorContentView;
    private TextView mDoctorName;
    private TextView mDoctorScheduleTime;
    private Button mSearchDoctorButton;

    private Retrofit mRestAdapter;
    private SaludMockApi mSaludMockApi;

    private String mMedicalCenterId;
    private Date mDatePicked;
    private String mTimeSchedule;
    private String mDoctorId;
    private String mDoctorTimeSchedule;


    @Override
    protected void onCreate(Bundle savedInstanceState) {
        super.onCreate(savedInstanceState);
        setContentView(R.layout.activity_add_appointment);
        Toolbar toolbar = (Toolbar) findViewById(R.id.toolbar);
        setSupportActionBar(toolbar);
        ActionBar ab = getSupportActionBar();

    }

   
}

1.4 Poblar Spinner De Centros Médicos

Para tener opciones en el menú de centros médicos necesitamos obtener una lista de objetos JSON desde el servidor.

En pocas palabras:

Realizar una petición GET que nos mande un array en el response body y transformarlo en una lista de centros médicos.

Aún no ejecutaremos la request con Retrofit, sin embargo crearemos la entidad de dominio para los centros médicos junto al adaptador del spinner.

Crear Modelo Para Centros Médicos

Añade una nueva clase llamada MedicalCenter dentro de data/api/model y agrega los siguientes atributos:

  • ID
  • Nombre
  • Dirección

Veamos:

public class MedicalCenter {
    private String mId;
    private String mName;
    private String mAddress;

    public MedicalCenter(String id, String name, String address) {
        mId = id;
        mName = name;
        mAddress = address;
    }

    public String getId() {
        return mId;
    }

    public void seId(String mId) {
        this.mId = mId;
    }

    public String getName() {
        return mName;
    }

    public void setName(String mName) {
        this.mName = mName;
    }

    public String getAddress() {
        return mAddress;
    }

    public void setAddress(String mAddress) {
        this.mAddress = mAddress;
    }
}

Crear Adapdator Personalizado

Para conseguir el ID del centro médico relacionado a la nueva cita médica es necesario que el adaptador de nuestro spinner nos provea dicho dato.

Como ya sabemos, esto se logra usando una lista de objetos MedicalCenter en un adaptador personalizado.

¿Cómo lo logramos?

Ok.

Agrega una nueva clase Java llamada MedicalCenterAdapter dentro del paquete ui.

¿Puntos a tener en cuenta?

  • Asegúrate hacerla heredar de ArrayAdapter
  • Tomar como parámetros en el constructor el contexto y la lista de objetos. Sálvalos en campos.
  • Sobrescribe getView() para inflar el layout android.R.layout.simple_list_item_1 y poner el nombre del centro médico en su text view android.R.id.text1
  • Sobrescribe getDropDownView() para llamar a getView() ya que cumplen la misma función.

De esta manera:

public class MedicalCenterAdapter extends ArrayAdapter<MedicalCenter> {

    private Context mContext;
    private List<MedicalCenter> mItems;

    public MedicalCenterAdapter(@NonNull Context context, @NonNull List<MedicalCenter> items) {
        super(context, 0, items);
        mContext = context;
        mItems = items;
    }

    @NonNull
    @Override
    public View getView(int position, @Nullable View convertView, @NonNull ViewGroup parent) {
        View view;
        MedicalCenter medicalCenter = mItems.get(position);

        if (convertView == null) {
            LayoutInflater inflater = LayoutInflater.from(mContext);
            view = inflater.inflate(android.R.layout.simple_list_item_1, parent, false);
        } else {
            view = convertView;
        }

        TextView textView = (TextView) view.findViewById(android.R.id.text1);
        textView.setText(medicalCenter.getName());

        return view;
    }

    @Override
    public View getDropDownView(int position, @Nullable View convertView, @NonNull ViewGroup parent) {
        return getView(position, convertView, parent);
    }
}

Crear Instancia Del Adaptador

Ahora solo nos queda poner un campo para el adaptador:

private MedicalCenterAdapter mMedicalCenterAdapter;

Y luego crear su instancia en onCreate() junto a la lectura del evento de selección, donde tomaremos el valor en la variable global:

@Override
protected void onCreate(Bundle savedInstanceState) {
    super.onCreate(savedInstanceState);
    setContentView(R.layout.activity_add_appointment);
    Toolbar toolbar = (Toolbar) findViewById(R.id.toolbar);
    setSupportActionBar(toolbar);

    mProgress = (ProgressBar) findViewById(R.id.progress);
    mErrorView = findViewById(R.id.error_container);
    mCard1 = findViewById(R.id.appointment_info_card);
    mCard2 = findViewById(R.id.appointment_doctor_card);

    // Centros médicos
    mMedicalCenterMenu = (Spinner) findViewById(R.id.medical_center_menu);
    mMedicalCenterAdapter = new MedicalCenterAdapter(this,
            new ArrayList<MedicalCenter>(0));
    mMedicalCenterMenu.setAdapter(mMedicalCenterAdapter);
    mMedicalCenterMenu.setOnItemSelectedListener(new AdapterView.OnItemSelectedListener() {
        @Override
        public void onItemSelected(AdapterView<?> adapterView, View view, int i, long l) {
            MedicalCenter medicalCenter = (MedicalCenter) adapterView.getItemAtPosition(i);
            mMedicalCenterId = medicalCenter.getId();
        }

        @Override
        public void onNothingSelected(AdapterView<?> adapterView) {

        }
    });

    // ...

}

Por el momento le pasamos una lista sin elementos ya que aún no hemos realizado la llamada asíncrona de Retrofit.

1.5 Cambiar Fecha De La Cita

A manera general, cambiar la fecha implica el disparo de un evento de click en el campo cuya reacción será mostrar un DatePickerDialog.

Veamos:

Añadir DatePickerDialog

Crea dentro del paquete ui una nueva clase que extienda de DialogFragment (como vimos en el tutorial de diálogos).

Seguido configúrala de tal forma que:

  • Sobrescriba a onCreateDialog() y este retorne un tipo DatePickerDialog
  • Condicione el DatePicker para que su fecha mínima sea el día actual.
  • Sobrescriba a onAttach() para tomar la actividad como escucha DatePickerDialog.OnDateSetListener
  • Sobrescriba a onDetach() para limpiar la referencia de la actividad

Es decir:

public class DatePickerFragment extends DialogFragment {

    private DatePickerDialog.OnDateSetListener mListener;

    @NonNull
    @Override
    public Dialog onCreateDialog(Bundle savedInstanceState) {
        final Calendar c = Calendar.getInstance();
        int year = c.get(Calendar.YEAR);
        int month = c.get(Calendar.MONTH);
        int day = c.get(Calendar.DAY_OF_MONTH);

        DatePickerDialog pickerDialog
                = new DatePickerDialog(getActivity(), mListener, year, month, day);
        pickerDialog.getDatePicker().setMinDate(c.getTimeInMillis());
        return pickerDialog;
    }

    @Override
    public void onAttach(Context context) {
        super.onAttach(context);

        try {
            mListener = (DatePickerDialog.OnDateSetListener) context;
        } catch (ClassCastException e) {
            throw new ClassCastException(context.toString()
                    + " debe implementar OnDateSetListener");

        }
    }

    @Override
    public void onDetach() {
        super.onDetach();
        mListener = null;
    }
}

Comunicar DialogFragment Con La Actividad

Para procesar el evento de la selección de fechas implementaremos a OnDateSetListener sobre la actividad:

public class AddAppointmentActivity extends AppCompatActivity
        implements DatePickerDialog.OnDateSetListener {

    ...

    @Override
    public void onDateSet(DatePicker datePicker, int year, int month, int dayOfMonth) {
        
    }
}

Dentro de onDateSet() será donde se ejecutarán las acciones de seteo.

Asignar Valor Inicial Del Campo De Fecha

Pongamos el valor por defecto de la fecha del formulario.

Para darle el formato que tenemos en el boceto crearemos un nuevo paquete llamado utils.

En su interior agrega la nueva clase de utilidad DateTimeUtils.

El objetivo es añadirle 3 métodos de clase:

  • formatDateForUi(int, int, int):Crea una fecha a partir de los valores enteros y retorna en un String con un formato ajustado al patrón deseado
  • formatDateForUi(Date): Realiza la misma función que el anterior, solo que parte de un parámetro Date
  • getCurrentDate(): Obtiene el tiempo actual

El código sería:

public class DateTimeUtils {

    private static final String UI_DATE_PATTERN = "dd 'de' MMMM 'del' yyyy";

    private DateTimeUtils() {
    }

    public static Date getCurrentDate() {
        Calendar instance = Calendar.getInstance();
        instance.set(Calendar.HOUR_OF_DAY, 0);
        instance.set(Calendar.MINUTE, 0);
        instance.set(Calendar.SECOND, 0);
        instance.set(Calendar.MILLISECOND, 0);
        return instance.getTime();
    }

    public static String formatDateForUi(int year, int month, int dayOfMonth) {
        return formatDateForUi(createDate(year, month, dayOfMonth));
    }

    public static String formatDateForUi(Date date) {
        SimpleDateFormat simpleDateFormat
                = new SimpleDateFormat(UI_DATE_PATTERN, Locale.getDefault());
        return simpleDateFormat.format(date);
    }
}

En seguida, ve a la actividad e inicializa la variable global de la fecha. Luego toma la referencia del view de la fecha y seteale la fecha actual formateada:

mDatePicked = DateTimeUtils.getCurrentDate();
mDateField = (EditText) findViewById(R.id.date_field);
mDateField.setText(DateTimeUtils.formatDateForUi(mDatePicked));

Registrar Escucha De Clicks

Recuerda que el diálogo se ejecuta al dar click en el campo, por ende asóciale una nueva escucha y muestra el picker:

mDateField.setOnClickListener(new View.OnClickListener() {
    @Override
    public void onClick(View view) {
        DatePickerFragment fragment = new DatePickerFragment();
        fragment.show(getSupportFragmentManager(), "datePicker");
    }
});

Asignar Valor Desde El DatePicker Al EditText

El siguiente movimiento es ir a onDateSet() y guardar la fecha en la variable a través de un nuevo método de utilidad llamado createDate():

public static Date createDate(int year, int month, int dayOfMonth) {
    Calendar cal = Calendar.getInstance();
    cal.set(year, month, dayOfMonth);
    cal.set(Calendar.HOUR_OF_DAY, 0);
    cal.set(Calendar.MINUTE, 0);
    cal.set(Calendar.SECOND, 0);
    cal.set(Calendar.MILLISECOND, 0);
    return cal.getTime();
}

Luego usar el método setText() del view de fecha para actualizarlo con la entrada del usuario:

@Override
public void onDateSet(DatePicker datePicker, int year, int month, int dayOfMonth) {
    mDatePicked = DateTimeUtils.createDate(year, month, dayOfMonth);
    mDateField.setText(DateTimeUtils.formatDateForUi(mDatePicked));
}

1.6 Poblar Spinner De Jornadas

Crear Array De Strings En Los Recursos

Las opciones que tenemos en la jornada son 2: Mañana y Tarde

Para proporcionar sus valores simplemente crearemos una etiqueta <string-array> dentro de strings.xml:

<string-array name="entries_time_schedule">
    <item>Mañana</item>
    <item>Tarde</item>
</string-array>

La vía que usaremos para asignarlo será el atributo android:entries del nodo <Spinner> con el ID time_schedule_menu:

<Spinner
    android:id="@+id/time_schedule_menu"
    android:layout_width="match_parent"
    android:layout_height="wrap_content"
    android:layout_marginBottom="8dp"
    android:layout_marginTop="8dp"
    android:entries="@array/entries_time_schedule"
    tools:listitem="@android:layout/simple_list_item_1" />

Adicional le setearemos una escucha para tomar el valor de la selección en la variable global:

mTimeScheduleMenu.setOnItemSelectedListener(new AdapterView.OnItemSelectedListener() {
    @Override
    public void onItemSelected(AdapterView<?> adapterView, View view, int i, long l) {
        mTimeSchedule = (String) adapterView.getItemAtPosition(i);
    }

    @Override
    public void onNothingSelected(AdapterView<?> adapterView) {

    }
});

1.7 Iniciar Actividad Con Los Turnos De Los Doctores

Registrar Escucha OnClickListener En El Botón De Búsqueda De Turnos

Tomar la referencia del botón search_doctor_button en onCreate() y luego le asignaremos la escucha de clicks.

Sin embargo, no tendremos instrucciones disponibles en onClick() por el momento hasta que creemos la actividad para los turnos:

mSearchDoctorButton = (Button)findViewById(R.id.search_doctor_button);
mSearchDoctorButton.setOnClickListener(new View.OnClickListener() {
    @Override
    public void onClick(View view) {
        // TODO: Iniciar actividad de turnos
    }
});

1.8 Abrir Asignación Desde La Lista De Citas

Ahora haremos que se ejecute AddAppointmentActivity desde AppointmentsActivity.

La forma de solucionarlo es dirigirnos al método onCreate() de la actividad de citas y buscar la obtención de referencia del FAB.

Una vez allí agregamos el inicio de la actividad de asignación de citas.

¡Pero ojo!

Usaremos startActivityForResult(), ya que necesitamos determinar si la cita fue asignada correctamente:

(Conjuntamente agrega la constante de identificación de petición REQUEST_ADD_APPOINTMENT)

FloatingActionButton fab = (FloatingActionButton) findViewById(R.id.fab);
fab.setOnClickListener(new View.OnClickListener() {
    @Override
    public void onClick(View view) {
        showAddAppointment();
    }
});

El método showAddAppointment() tendría estas instrucciones:

private void showAddAppointment() {
    Intent intent = new Intent(this, AddAppointmentActivity.class);
    startActivityForResult(intent, REQUEST_ADD_APPOINMENT);
}

Recibir Datos Desde La Otra Actividad

Sobrescribiremos el método onActivityResult() para determinar si la asignación fue exitosa.

Obviamente debes comprobar que los códigos de petición y resultado sean los correctos.

Si lo son, entonces mostramos una SnackBar para avisarle al usuario que su cita fue asignada.

@Override
protected void onActivityResult(int requestCode, int resultCode, Intent data) {
    if (REQUEST_ADD_APPOINMENT == requestCode
            && RESULT_OK == resultCode) {
        showSuccesfullySavedMessage();
    }
}

Donde showSuccessfullySavedMessage() crea el mensaje:

private void showSuccesfullySavedMessage() {
    Snackbar.make(mFab, R.string.message_appointment_succesfully_saved,
            Snackbar.LENGTH_LONG).show();
}

1.9 Añadir Action Button De Guardado A La Toolbar

El contenido principal está diseñado en su mayor parte, pero a la Toolbar aún le faltan las acciones indicadas en el boceto.

Estas son: Descarte > Back Button y Guardado > Check Action Button

¿Cómo resolver estas carencias?

Fíjate:

Crear Archivo De Menu

Nos dirigimos a res/menu y damos click derecho. Luego ejecutamos la secuencia New > Menu resource file, ponemos el nombre menu_add_appointment y confirmamos el asistente.

Añadimos una etiqueta <item> y le damos los siguientes atributos:

<?xml version="1.0" encoding="utf-8"?>
<menu xmlns:android="http://schemas.android.com/apk/res/android"
    xmlns:app="http://schemas.android.com/apk/res-auto">
    <item
        android:id="@+id/action_save_appointment"
        android:icon="@drawable/ic_check"
        android:orderInCategory="1"
        android:title="@string/action_save_appointment"
        app:showAsAction="ifRoom" />
</menu>

Inflar Recurso De Menu

Este paso ya lo sabemos. Sobrescribimos onCreateOptionsMenu() para llamar al inflater de menús:

@Override
public boolean onCreateOptionsMenu(Menu menu) {
    getMenuInflater().inflate(R.menu.menu_add_appointment, menu);
    return true;
}

Procesar Action Button Para Guardar

El paso a seguir es sobrescribir onOptionsItemSelected() en la actividad para procesar el evento de guardado.

¿Qué acciones deberíamos poner?

En primer lugar la validación de datos.

Y evaluando cada una de las entradas por parte del usuario, la única de la cual debemos asegurarnos es de la selección del turno.

Lo que quiere decir que validaremos si los datos del doctor no están vacíos antes de guardar. De lo contrario lanzamos un error.

Veamos:

@Override
public boolean onOptionsItemSelected(MenuItem item) {
    if(R.id.action_save_appointment==item.getItemId()){
        if(emptyDoctor()){
            showDoctorError();
        }else {
            saveAppointment();
        }
    }
    return super.onOptionsItemSelected(item);
}

private void saveAppointment() {
    // TODO: Guardar cita con Refrofit
}

private void showDoctorError() {
    Snackbar.make(findViewById(android.R.id.content),
            R.string.error_empty_doctor, Toast.LENGTH_LONG).show();
}

private boolean emptyDoctor() {
    return mDoctorId == null || mDoctorId.isEmpty();
}

Si analizamos el código vemos que tenemos el método emptyDoctor() para validar la existencia de un doctor asociado.

showDoctorError() para compactar la visualización del mensaje.

Y saveAppointment() para guardar la cita médica cuando tengamos el servicio web listo.

1.10 Añadir Up Button Para Descartar

Habilitar Home Button Como Up Button

Aquí tomaremos la instancia de la action bar en onCreate().

Luego de ello habilitaremos la aparición del Up Button con los métodos setDisplayHomeAsUpEnabled() y setDisplayShowHomeEnabled():

@Override
protected void onCreate(Bundle savedInstanceState) {
    super.onCreate(savedInstanceState);
    setContentView(R.layout.activity_add_appointment);
    Toolbar toolbar = (Toolbar) findViewById(R.id.toolbar);
    setSupportActionBar(toolbar);
    ActionBar ab = getSupportActionBar();
    ab.setDisplayShowHomeEnabled(true);
    ab.setDisplayHomeAsUpEnabled(true);
    ...

Procesar Evento Del Up Button

Usa el método onSupportNavigateUp() para incluir las acciones del descarte.

En nuestro caso solo finalizaremos la actividad (podemos llamar a onBackPressed() para hacerlo).

Sin embargo si lo deseas puedes crear un diálogo de confirmación para evitar perder tan repentinamente la configuración del usuario:

@Override
public boolean onSupportNavigateUp() {
    onBackPressed();
    return true;
}

Paso 2. Elegir Un Doctor En La App

2.1 Crear Clase Java Para Los Doctores Y Horarios

En lo que respecta a las reglas de negocio empresariales necesitaremos representar a los doctores con sus respectivas disponibilidades en un día determinado.

Por ende crea dentro del paquete data/api/model la clase Doctor con la siguiente estructura:

public class Doctor {
    private String mId;
    private String mName;
    private String mSpecialty;
    private String mDescription;
    private List<String> mAvailabilityTimes;

    public Doctor(String id, String name, String specialty,
                  String description, List<String> availabilityTimes) {
        mId = id;
        mName = name;
        mSpecialty = specialty;
        mDescription = description;
        mAvailabilityTimes = availabilityTimes;
    }

    public String getId() {
        return mId;
    }


    public String getName() {
        return mName;
    }


    public String getSpecialty() {
        return mSpecialty;
    }

    public String getDescription() {
        return mDescription;
    }

    public List<String> getAvailabilityTimes() {
        return mAvailabilityTimes;
    }
}

El campo mAvailabilityTimes será la lista que recibirá el contenido de la disponibilidad cuando realicemos la petición hacia la API.

2.2 Crear Actividad Para Selección De Horarios

Ahora es el turno de la selección de los horarios de los doctores disponibles.

Si vemos el boceto, tendremos una lista de los doctores y sus horarios, donde la idea es que el usuario confirme el que se adapte a su disponibilidad.

Screen Elige tu doctor

No obstante cambiaremos los botones segmentados propuestos al inicio por un spinner, ya que los slots de tiempo disponibles pueden ser muchos.

Así que para comenzar crearemos la actividad en el paquete ui con las siguientes características:

  • Activity Name > DoctorSchedulesActivity
  • Layout Name > activity_doctors_schedules
  • Title > Elige Tu Doctor

2.3 Diseñar Layout Para La Lista

Limpiar Elementos De La Plantilla

Luego de la creación automática necesitamos quitar aquellos elementos innecesarios como lo es el FAB del layout principal.

Por tanto removamoslo:

<?xml version="1.0" encoding="utf-8"?>
<android.support.design.widget.CoordinatorLayout 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"
    android:fitsSystemWindows="true"
    tools:context="com.hermosaprogramacion.blog.saludmock.ui.DoctorsSchedulesActivity">

    <android.support.design.widget.AppBarLayout
        android:layout_width="match_parent"
        android:layout_height="wrap_content"
        android:theme="@style/AppTheme.AppBarOverlay">

        <android.support.v7.widget.Toolbar
            android:id="@+id/toolbar"
            android:layout_width="match_parent"
            android:layout_height="?attr/actionBarSize"
            android:background="?attr/colorPrimary"
            app:popupTheme="@style/AppTheme.PopupOverlay" />

    </android.support.design.widget.AppBarLayout>

    <include layout="@layout/content_doctors_schedules" />

</android.support.design.widget.CoordinatorLayout>

Agregar RecyclerView Al Layout De Contenido

En esta parte abriremos content_doctors_schedules.xml y modificaremos el layout para introducir un RecyclerView junto a un view para mostrar la inexistencia de coincidencias y una barra de progreso:

<?xml version="1.0" encoding="utf-8"?>
<RelativeLayout 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:id="@+id/content_appointments"
    android:layout_width="match_parent"
    android:layout_height="match_parent"
    app:layout_behavior="@string/appbar_scrolling_view_behavior"
    tools:context="com.hermosaprogramacion.blog.saludmock.ui.DoctorsSchedulesActivity"
    tools:showIn="@layout/activity_doctors_schedules">

    <ProgressBar
        android:id="@+id/progress"
        android:layout_width="wrap_content"
        android:layout_height="wrap_content"
        android:layout_centerInParent="true"
        android:visibility="gone" />

    <android.support.v7.widget.RecyclerView
        android:id="@+id/doctors_schedules_list"
        android:layout_width="match_parent"
        android:layout_height="match_parent"
        android:paddingTop="@dimen/activity_vertical_margin"
        app:layoutManager="LinearLayoutManager" />

    <LinearLayout
        android:id="@+id/doctors_schedules_empty"
        android:layout_width="wrap_content"
        android:layout_height="wrap_content"
        android:layout_centerInParent="true"
        android:orientation="vertical"
        android:visibility="gone">

        <ImageView
            android:id="@+id/image_empty_state"
            android:layout_width="48dp"
            android:layout_height="48dp"
            android:layout_gravity="center"
            android:tint="#9E9E9E"
            app:srcCompat="@drawable/ic_medical_bag" />

        <TextView
            android:layout_width="wrap_content"
            android:layout_height="wrap_content"
            android:layout_gravity="center"
            android:text="@string/message_no_schedules" />
    </LinearLayout>
</RelativeLayout>

Diseñar Item De La Lista

Lo siguiente será crear el layout para la lista de los horarios.

Como vimos, el boceto es una card con la foto de perfil del doctor a la izquierda y a su derecha un área de contenido con sus datos esenciales.

En esta área encontramos también una sección para la disponibilidad, donde ubicaremos un spinner y un botón para confirmar.

Dicho lo dicho, crearemos un nuevo layout llamado schedule_item_list y pondremos una ConstraintLayout como raíz con la siguiente distribución:

<?xml version="1.0" encoding="utf-8"?>
<android.support.v7.widget.CardView 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="wrap_content"
    android:layout_marginBottom="@dimen/activity_vertical_margin"
    android:layout_marginLeft="@dimen/activity_horizontal_margin"
    android:layout_marginRight="@dimen/activity_horizontal_margin">

    <android.support.constraint.ConstraintLayout
        android:layout_width="match_parent"
        android:layout_height="match_parent">

        <android.support.constraint.Guideline
            android:id="@+id/guideline_vertical"
            android:layout_width="wrap_content"
            android:layout_height="wrap_content"
            android:orientation="vertical"
            app:layout_constraintGuide_percent="0.34" />


        <ImageView
            android:id="@+id/profile_image"
            android:layout_width="0dp"
            android:layout_height="0dp"
            android:tint="@android:color/darker_gray"
            app:layout_constraintBottom_toBottomOf="parent"
            app:layout_constraintEnd_toStartOf="@+id/guideline_vertical"
            app:layout_constraintStart_toStartOf="parent"
            app:layout_constraintTop_toTopOf="parent"
            app:srcCompat="@drawable/face_profile" />

        <TextView
            android:id="@+id/name_text"
            android:layout_width="wrap_content"
            android:layout_height="wrap_content"
            android:layout_marginLeft="8dp"
            android:layout_marginStart="8dp"
            android:layout_marginTop="8dp"
            android:textAppearance="@style/TextAppearance.AppCompat.Title"
            app:layout_constraintStart_toStartOf="@+id/guideline_vertical"
            app:layout_constraintTop_toTopOf="parent"
            tools:text="Carlos Gaviria" />

        <TextView
            android:id="@+id/specialty_text"
            android:layout_width="wrap_content"
            android:layout_height="wrap_content"
            android:layout_marginLeft="8dp"
            android:layout_marginStart="8dp"
            android:layout_marginTop="8dp"
            app:layout_constraintStart_toStartOf="@+id/guideline_vertical"
            app:layout_constraintTop_toBottomOf="@+id/name_text"
            tools:text="Medico General" />

        <TextView
            android:id="@+id/description_text"
            android:layout_width="0dp"
            android:layout_height="wrap_content"
            android:layout_marginEnd="8dp"
            android:layout_marginStart="8dp"
            android:layout_marginTop="8dp"
            android:ellipsize="end"
            android:maxLength="128"
            android:visibility="visible"
            app:layout_constraintEnd_toEndOf="parent"
            app:layout_constraintHorizontal_bias="0.53"
            app:layout_constraintStart_toStartOf="@+id/guideline_vertical"
            app:layout_constraintTop_toBottomOf="@+id/specialty_text"
            tools:text="Universidad Santiago, 2 años de experiencia" />

        <TextView
            android:id="@+id/label_availability"
            android:layout_width="wrap_content"
            android:layout_height="wrap_content"
            android:layout_marginStart="8dp"
            android:layout_marginTop="24dp"
            android:text="@string/label_availability"
            android:textAppearance="@style/TextAppearance.AppCompat.Body1"
            app:layout_constraintStart_toStartOf="@+id/guideline_vertical"
            app:layout_constraintTop_toBottomOf="@+id/description_text" />

        <Spinner
            android:id="@+id/time_schedules_menu"
            android:layout_width="0dp"
            android:layout_height="wrap_content"
            android:layout_marginBottom="8dp"
            android:layout_marginEnd="8dp"
            android:layout_marginStart="8dp"
            android:layout_marginTop="8dp"
            android:visibility="visible"
            app:layout_constraintEnd_toEndOf="parent"
            app:layout_constraintHorizontal_bias="0.0"
            app:layout_constraintStart_toStartOf="@+id/guideline_vertical"
            app:layout_constraintTop_toBottomOf="@+id/label_availability"
            tools:listitem="@android:layout/simple_list_item_1" />

        <Button
            android:id="@+id/booking_button"
            style="@style/Widget.AppCompat.Button.Borderless.Colored"
            android:layout_width="wrap_content"
            android:layout_height="wrap_content"
            android:layout_marginBottom="8dp"
            android:layout_marginStart="8dp"
            android:layout_marginTop="8dp"
            android:layout_weight="1"
            android:text="@string/request_button"
            app:layout_constraintBottom_toBottomOf="parent"
            app:layout_constraintStart_toStartOf="@+id/guideline_vertical"
            app:layout_constraintTop_toBottomOf="@+id/time_schedules_menu" />

    </android.support.constraint.ConstraintLayout>
</android.support.v7.widget.CardView>

En la preview tendremos una imagen similar a esta:

Preview Android Studio De Item De Lista Para Tiempos De Doctor

2.4 Crear Adaptador Personalizado Del RecyclerView

Creemos el adaptador DoctorsSchedulesAdapter dentro del paquete ui/adapters.

Las características a proporcionarle son las siguientes:

  • Extenderlo de RecyclerView.Adapter
  • Crearle una interfaz de escucha interna llamada OnItemListener con un método para capturar el click sobre el botón de reserva.
  • Implementar un constructor para recibir el contexto, los items y una instancia de la escucha
  • Sobrescribir onCreateViewHolder(), onBindViewHolder() y getItemCount()
  • Crear un ViewHolder personalizado que setee los valores y eventos a los views del ítem de lista

Estas instrucciones en código serían reflejadas así:

public class DoctorSchedulesAdapter extends 
        RecyclerView.Adapter<DoctorSchedulesAdapter.DoctorSchedulesViewHolder> {
    private final Context context;
    private List<Doctor> doctors;
    private final OnItemListener listener;

    public interface OnItemListener {
        void onBookingButtonClicked(Doctor bookedDoctor, String timeScheduleSelected);
    }

    public DoctorSchedulesAdapter(Context context, List<Doctor> doctors, OnItemListener listener) {
        this.context = context;
        this.doctors = doctors;
        this.listener = listener;
    }

    public void setDoctors(List<Doctor> doctors) {
        this.doctors = doctors;
        notifyDataSetChanged();
    }


    @Override
    public DoctorSchedulesViewHolder onCreateViewHolder(ViewGroup parent, int viewType) {
        return new DoctorSchedulesViewHolder(parent);
    }

    @Override
    public void onBindViewHolder(DoctorSchedulesViewHolder holder, int position) {
        holder.bind(doctors.get(position));
    }

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

    public class DoctorSchedulesViewHolder extends RecyclerView.ViewHolder {
        private final TextView nameView;
        private final TextView specialtyView;
        private final TextView descriptionView;
        private final Spinner scheduleView;
        private final Button bookingButton;

        public DoctorSchedulesViewHolder(ViewGroup parent) {
            super(LayoutInflater.from(parent.getContext())
                    .inflate(R.layout.schedule_item_list, parent, false));

            nameView = (TextView) itemView.findViewById(R.id.name_text);
            specialtyView = (TextView) itemView.findViewById(R.id.specialty_text);
            descriptionView = (TextView) itemView.findViewById(R.id.description_text);
            scheduleView = (Spinner) itemView.findViewById(R.id.time_schedules_menu);
            bookingButton = (Button) itemView.findViewById(R.id.booking_button);

            bookingButton.setOnClickListener(new View.OnClickListener() {
                @Override
                public void onClick(View view) {
                    int position = getAdapterPosition();
                    if (position != RecyclerView.NO_POSITION) {
                        String selectedItem = (String) scheduleView.getSelectedItem();
                        listener.onBookingButtonClicked(doctors.get(position), selectedItem);
                    }
                }
            });
        }

        public void bind(Doctor doctor) {
            // Formatos para lista de tiempos disponibles
            List<String> formatedTimes = new ArrayList<>();
            for (String time : doctor.getAvailabilityTimes()) {
                formatedTimes.add(DateTimeUtils.formatTimeForUi(time));
            }

            nameView.setText(doctor.getName());
            specialtyView.setText(doctor.getSpecialty());
            descriptionView.setText(doctor.getDescription());
            ArrayAdapter<String> adapter = new ArrayAdapter<>(context,
                    android.R.layout.simple_list_item_1, formatedTimes);
            scheduleView.setAdapter(adapter);
        }
    }
}

Cabe destacar que el método bind() del view holder nos facilita la asignación de valores a los views.

Además debemos crear el método de utilidad formatTimeForUi() para mostrar los tiempos con el patrón h:mma:

private static final String UI_TIME_PATTERN = "h:mma";
private static final String API_TIME_PATTERN = "HH:mm:ss";

public static String formatTimeForUi(String time) {
    SimpleDateFormat simpleDateFormat
            = new SimpleDateFormat(API_TIME_PATTERN, Locale.getDefault());
    try {
        Date date = simpleDateFormat.parse(time);
        simpleDateFormat.applyPattern(UI_TIME_PATTERN);
        return simpleDateFormat.format(date);
    } catch (ParseException e) {
        e.printStackTrace();
    }
    return time;
}

2.5 Definir Campos De La Actividad

Para el código Java de esta actividad incluiremos:

  • Constantes: Su tag y 3 para los extras a enviar hacia AddAppointmentActivity: el ID del doctor elegido, su nombre y la hora elegida
  • Views: La lista y su adaptador. Los views de progreso y estado vacío
  • Relaciones: De nuevo los elementos de Retrofit para realizar peticiones
  • Variables: Usaremos 3 para retener los datos enviados desde AddAppointmentActivity

Las anteriores propiedades nombradas se verían así:

public class DoctorsSchedulesActivity extends AppCompatActivity {

    private static final String TAG = DoctorsSchedulesActivity.class.getSimpleName();
    
    public static final String EXTRA_DOCTOR_ID = "com.hermosaprogramacion.EXTRA_DOCTOR_ID";
    public static final String EXTRA_DOCTOR_NAME = "com.hermosaprogramacion.EXTRA_DOCTOR_NAME";
    public static final String EXTRA_TIME_SLOT_PICKED = "com.hermosaprogramacion.EXTRA_TIME_SLOT_PICKED";

    private RecyclerView mList;
    private DoctorSchedulesAdapter mListAdapter;
    private ProgressBar mProgress;
    private View mEmptyView;

    private Retrofit mRestAdapter;
    private SaludMockApi mSaludMockApi;

    private Date mDateSchedulePicked;
    private String mMedicalCenterId;
    private String mTimeSchedule;

    @Override
    protected void onCreate(Bundle savedInstanceState) {
        super.onCreate(savedInstanceState);
        setContentView(R.layout.activity_doctors_schedules);
        Toolbar toolbar = (Toolbar) findViewById(R.id.toolbar);
        setSupportActionBar(toolbar);
    }
}

2.6 Poblar Lista De Doctores

Ubiquémonos en onCreate() para tomar la referencia del RecyclerView, crear la instancia del adaptador y relacionarlos.

Importante: pasa una lista vacía de doctores al adaptador como fuente inicial y crea una escucha anónima en el tercer parámetro:

@Override
protected void onCreate(Bundle savedInstanceState) {
    super.onCreate(savedInstanceState);
    setContentView(R.layout.activity_doctors_schedules);
    Toolbar toolbar = (Toolbar) findViewById(R.id.toolbar);
    setSupportActionBar(toolbar);

    mList = (RecyclerView) findViewById(R.id.doctors_schedules_list);
    mListAdapter = new DoctorSchedulesAdapter(this,
            new ArrayList<Doctor>(0),
            new DoctorSchedulesAdapter.OnItemListener() {
                @Override
                public void onBookingButtonClicked(Doctor bookedDoctor) {

                }
            });
}

Procesar Evento Del RecyclerView

—Si el botón de reserva es presionado en el ítem del doctor, ¿qué deberíamos hacer?

¡Exacto!, enviar el ID del doctor, su nombre y el tiempo seleccionado hacia la actividad de creación de la cita.

En ese caso crearemos un Intent de respuesta, le añadiremos los valores como extras, lo asociaremos con setResult() al código de petición y luego finalizamos la actividad:

mListAdapter = new DoctorSchedulesAdapter(this,
        new ArrayList<Doctor>(0),
        new DoctorSchedulesAdapter.OnItemListener() {
            @Override
            public void onBookingButtonClicked(Doctor bookedDoctor,
                                               String timeScheduleSelected) {
                Intent responseIntent = new Intent();
                responseIntent.putExtra(EXTRA_DOCTOR_ID, bookedDoctor.getId());
                responseIntent.putExtra(EXTRA_DOCTOR_NAME, bookedDoctor.getName());
                responseIntent.putExtra(EXTRA_TIME_SLOT_PICKED, timeScheduleSelected);
                setResult(Activity.RESULT_OK, responseIntent);
                finish();
            }
        });
mList.setAdapter(mListAdapter);

Recibir ID Y Horario Desde La Actividad De Creación

A continuación vamos a añadir el método onActivityResult() en AddAppointmentActivity con el fin de procesar los datos enviados.

La idea es que mostremos el nombre del doctor y la hora de la cita en la sección inferior:

@Override
protected void onActivityResult(int requestCode, int resultCode, Intent data) {
    if (REQUEST_PICK_DOCTOR_SCHEDULE == requestCode &&
            RESULT_OK == resultCode) {
        mDoctorId = data.getStringExtra(DoctorsSchedulesActivity.EXTRA_DOCTOR_ID);
        String doctorName = data.getStringExtra(DoctorsSchedulesActivity.EXTRA_DOCTOR_NAME);
        mTimeSlotPicked = data.getStringExtra(DoctorsSchedulesActivity.EXTRA_TIME_SLOT_PICKED);
        showDoctorScheduleSummary(doctorName, mTimeSlotPicked);
    }
}
private void showDoctorScheduleSummary(String doctorName, String doctorTime) {
    mEmptyDoctorView.setVisibility(View.GONE);
    mDoctorContentView.setVisibility(View.VISIBLE);

    mDoctorName.setText(doctorName);
    mDoctorScheduleTime.setText(doctorTime);
}

El método showDoctorScheduleSummary() nos permite aislar el cambio de visibilidades entre el view vació y el contenido. Además de setear los textos en los views.

2.7 Procesar Evento De Up Button

Habilitar Home Button Como Up

Al igual que hicimos en la actividad de asignación, habilitamos en onCreate() el Up Button:

@Override
protected void onCreate(Bundle savedInstanceState) {
    super.onCreate(savedInstanceState);
    setContentView(R.layout.activity_doctors_schedules);
    Toolbar toolbar = (Toolbar) findViewById(R.id.toolbar);
    setSupportActionBar(toolbar);
    ActionBar ab = getSupportActionBar();
    ab.setDisplayShowHomeEnabled(true);
    ab.setDisplayHomeAsUpEnabled(true);

Usar Método onSupportNavigateUp()

De forma repetida, sobrescribimos el método para la navegación superior para llamar a onBackPressed() ya que no tenemos acciones que realizar:

@Override
public boolean onSupportNavigateUp() {
    onBackPressed();
    return true;
}

Iniciar Actividad De Horarios

Finalmente ve a AddAppointmentActivity e inicia con startActivityForResult() dentro del evento del botón para reservar doctores.

No olvides pasar los  3 extras:

mSearchDoctorButton = (Button) findViewById(R.id.search_doctor_button);
mSearchDoctorButton.setOnClickListener(new View.OnClickListener() {
    @Override
    public void onClick(View view) {
        showDoctorsSchedulesUi();
    }
});
private void showDoctorsSchedulesUi() {
    Intent requestIntent = new Intent(AddAppointmentActivity.this
            , DoctorsSchedulesActivity.class);
    requestIntent.putExtra(EXTRA_DATE_PICKED, mDatePicked.getTime());
    requestIntent.putExtra(EXTRA_MEDICAL_CENTER_ID, mMedicalCenterId);
    requestIntent.putExtra(EXTRA_TIME_SHEDULE_PICKED, getTimeSchedule());
    startActivityForResult(requestIntent, REQUEST_PICK_DOCTOR_SCHEDULE);
}
private String getTimeSchedule() {
    switch (mTimeSchedule) {
        case UI_VALUE_MORNING:
            return API_VALUE_MORNING;
        case UI_VALUE_AFTERNOON:
            return API_VALUE_AFTERNOON;
        default:
            return "";
    }
}

En el caso de la jornada crearemos el método getTimeSchedule() para enviar el valor para la API correspondiente.

Paso 3. Añadir Soporte De Peticiones A La API REST

En esta sección habilitaremos las operaciones del afiliado sobre los recursos que necesitamos en la app Android para completar la asignación de citas.

Las cuales son:

  • Obtener centros médicos
  • Obtener doctores con horarios disponibles
  • Crear una cita médica

3.1 GET Request Para Obtener Centros Médicos

Diseñar URL Para El Recurso

Nuestro primer paso para habilitar el recurso es diseñar la sintaxis del path que tendrá nuestra URL.

En este caso es supremamente intuitivo para nosotros, ya que podemos usar el sustantivo medical centers y asociarlo al método GET:

GET http://localhost/saludmock/v1/medical-centers

Especificar Estructura Del Mensaje De La petición

El mensaje que debe enviar el cliente de Retrofit se compone de:

  • Método: GET
  • URI: /saludmock/v1/medical-centers
  • Cabeceras: Authorization con el token del afiliado

Un ejemplo básico sería:

GET /saludmock/v1/medical-centers HTTP/1.1
Host: localhost
Authorization: 44945899e5c49b7ff8.48092936

Diseñar Respuesta JSON Del Servicio REST

Aquí optaremos por enviar un array de objetos con la estructura tabular de los centros médicos. Dicho array lo nombraremos "results":

{
  "results": [
    {
      "id": "10001",
      "address": "7533 Carey Park",
      "name": "Clu00ednica Occidente",
      "description": "Mauris ullamcorper purus sit amet nulla."
    },
    {
      "id": "10002",
      "address": "6 Nobel Park",
      "name": "Clu00ednica Rostro y Figura",
      "description": "Integer non velit."
    },
    {
      "id": "10003",
      "address": "2 Onsgard Hill",
      "name": "Centro Medico Salud para Todos",
      "description": "Aliquam augue quam, sollicitudin vitae, consectetuer eget, rutrum at, lorem."
    },
    {
      "id": "10004",
      "address": "5 Pond Crossing",
      "name": "Hospital Carlos Carmona Montoya",
      "description": "Donec ut mauris eget massa tempor convallis."
    },
    {
      "id": "10005",
      "address": "6 Waxwing Circle",
      "name": "Hospital San Juan de Dios",
      "description": "Nulla tellus."
    }
  ]
}

Enrutar Recurso En La API REST PHP

Ahora abriremos el proyecto PHP y nos dirigiremos al archivo index.php (ya sabemos que este es el router de la API).

El objeto es añadir el segmento "medical-centers" al array de recursos $apiResources.

$apiResources = array('affiliates', 'appointments','medical-centers');

Crear Controlador Del Recurso

Lo siguiente será añadir la clase PHP correspondiente para el recurso en la carpeta v1/controllers.

Nombraremos al archivo medical_centers.php e igual la clase (agrega el require de este archivo a index.php).

(Si tienes problema con la convención de nombrado para las clases de los controladores, crear una reestructuración del string entrante en el enrutador PHP)

Y le pondremos un método get() para manejar la petición:

<?php
/**
 * Controlador de centros medicos
 */

class medical_centers
{
    public static function get(){
        
    }
}

Procesar Petición GET

Las acciones a realizar en el método get() son similares al tutorial anterior:

  • 1. Autorizar usuario
  • 2. Verificaciones, restricciones, defensas
  • 3. Invocar a la fuente de datos para retorno de citas médicas

Con esto claro, codifiquemos las acciones establecidas.

En primer lugar, la autorización podemos reutilizarla del controlador de citas médicas.

Debido a que los métodos asociados al proceso están mezclados en appointments.php, crearemos una clase singleton llamada AuthorizationManager en el folder controllers y moveremos estos métodos:

<?php
/**
 * Manejador de autorizaciones sobre recursos
 */

class AuthorizationManager
{
    private static $authManager = null;

    /**
     * AuthorizationManager constructor.
     */
    final private function __construct()
    {
    }

    public static function getInstance(){
        if(self::$authManager==null){
            self::$authManager = new self();
        }
        return self::$authManager;
    }

    final protected function __clone() {
    }

    public function authorizeAffiliate()
    {

        $authHeaderValue = apache_request_headers()['Authorization'];
        if (!isset($authHeaderValue)) {
            throw new ApiException(
                401,
                0,
                "No está autorizado para acceder a este recurso",
                "http://localhost",
                "No viene el token en la cabecera de autorización"
            );
        }

        // Consultar base de datos por afiliado
        $affiliateId = self::isAffiliateAuthorized($authHeaderValue);

        if (empty($affiliateId)) {
            throw new ApiException(
                401,
                0,
                "No está autorizado para acceder a este recurso",
                "http://localhost",
                "No hay coincidencias del token del afiliado en la base de datos"
            );
        }
        return $affiliateId;
    }

    private function isAffiliateAuthorized($token)
    {
        if (empty($token)) {
            throw new ApiException(
                405,
                0,
                "No está autorizado para acceder a este recurso",
                "http://localhost",
                "La cabecera HTTP Authorization está vacía"
            );
        }

        try {
            $pdo = MysqlManager::get()->getDb();

            // Componer sentencia SELECT
            $sentence = "SELECT id FROM affiliate WHERE token = ?";

            // Preparar sentencia
            $preStatement = $pdo->prepare($sentence);
            $preStatement->bindParam(1, $token);

            // Ejecutar sentencia
            if ($preStatement->execute()) {
                // Retornar id del afiliado autorizado
                $result = $preStatement->fetchColumn();
                return $result;

            } else {
                throw new ApiException(
                    500,
                    0,
                    "Error de base de datos en el servidor",
                    "http://localhost",
                    "Hubo un error ejecutando una sentencia SQL en la base de datos. Detalles:" . $pdo->errorInfo()[2]
                );
            }

        } catch (PDOException $e) {
            throw new ApiException(
                500,
                0,
                "Error de base de datos en el servidor",
                "http://localhost",
                "Ocurrió el siguiente error al intentar insertar el afiliado: " . $e->getMessage());
        }

    }

}

De esta forma llamamos al método authorizeAffiliate() como primer línea de get():

public static function get(){
    $affiliateId = AuthorizationManager::getInstance()->authorizeAffiliate();
}

Opcionalmente comprobaremos que no existan segmentos adicionales en la URL. Si deseas ignorarlo también es una opción procesar la petición:

if (isset($urlSegments[1])) {
    throw new ApiException(
        400,
        0,
        "El recurso está mal referenciado",
        "http://localhost",
        "El recurso $_SERVER[REQUEST_URI] no esta sujeto a resultados"
    );
}

Lo siguiente es retornar de la base de datos los centros médicos, así que crearemos un método llamado retrieveMedicalCenters().

Su bloque interno de instrucciones será preparar una sentencia SELECT para conseguir todos los centros médicos existentes.

Veamos:

private static function retrieveMedicalCenters(){
    try {
        $pdo = MysqlManager::get()->getDb();

        $query = "SELECT * FROM medical_center";

        $preStm = $pdo->prepare($query);

        if ($preStm->execute()) {
            return $preStm->fetchAll(PDO::FETCH_ASSOC);
        } else {
            throw new ApiException(
                500,
                0,
                "Error de base de datos en el servidor",
                "http://localhost",
                "Hubo un error ejecutando una sentencia SQL en la base de datos. Detalles:" . $pdo->errorInfo()[2]
            );
        }

    } catch (PDOException $e) {
        throw new ApiException(
            500,
            0,
            "Error de base de datos en el servidor",
            "http://localhost",
            "Ocurrió el siguiente error al consultar las citas médicas: " . $e->getMessage());
    }
}

Al terminarlo lo invocamos y asignamos su resultado como valor de un array asociativo con clave "results":

public static function get($urlSegments){
    $affiliateId = AuthorizationManager::getInstance()->authorizeAffiliate();

    if (isset($urlSegments[1])) {
        throw new ApiException(
            400,
            0,
            "El recurso está mal referenciado",
            "http://localhost",
            "El recurso $_SERVER[REQUEST_URI] no esta sujeto a resultados"
        );
    }

    $medicalCenters= self::retrieveMedicalCenters();

    return ["results"=>$medicalCenters];
}

Testear Petición Con Postman

Para terminar esta característica abrimos Postman y asignamos estos valores en la interfaz:

  • Método: GET
  • Request URL: http://localhost/saludmock/v1/medical-centers
  • Headers > Key: Authorization, Value: [Token de tu afiliado a testear]

La siguiente imagen representa la anterior configuración:

Valores para petición GET en Postman

Si presionamos Send, la respuesta debe verse similar a la siguiente:

Respuesta Postman de Array JSON

3.2 Obtener Lista De Doctores Con Horarios Disponibles

Analizar Diseño Lógico De La Base De Datos

Existen varios bloques de tiempo (attention_time_slot) donde un doctor puede atender pacientes.

Ejemplo: El doctor Carlos García atiende al paciente Julio Perez a las 8:30am

Para SaludMock tomaremos la jornada laboral desde 6:00am a 6:00pm, donde cada time slot tendrá duración de 30 minutos.

Lo que quiere decir que todos los doctores tendrán la posibilidad de estar relacionados con estos tiempos.

De esta relación muchos a muchos nace la tabla de horarios para doctores (doctor_schedule). La cual relaciona la fecha de laboral y la disponibilidad del slot.

Todo esto que acabamos de analizar se resume en el siguiente diagrama:

Modelo de datos para doctores y time slots

Crear Tablas En MySQL

Abre phpMyAdmin y escribe las siguientes sentencias SQL para crear las tablas doctor_schedule y attention_time_slot:

CREATE TABLE attention_time_slot (
 id int(11) NOT NULL PRIMARY KEY AUTO_INCREMENT,
 start_time time NOT NULL,
 end_time time NOT NULL
)
INSERT INTO attention_time_slot (start_time, end_time) VALUES
( '06:00:00', '06:30:00'),
( '06:30:00', '07:00:00'),
( '07:00:00', '07:30:00'),
( '07:30:00', '08:00:00'),
( '08:30:00', '09:00:00'),
( '09:00:00', '09:30:00'),
( '09:30:00', '10:00:00'),
( '10:30:00', '11:00:00'),
( '11:30:00', '12:00:00'),
( '12:30:00', '13:00:00'),
( '13:00:00', '13:30:00'),
( '13:30:00', '14:00:00'),
( '14:00:00', '14:30:00'),
( '14:30:00', '15:00:00'),
( '15:00:00', '15:30:00'),
( '15:30:00', '16:00:00');
CREATE TABLE doctor_schedule (
 doctor_id int(11) NOT NULL,
 attention_time_slot_id int(11) NOT NULL,
 available tinyint(1) NOT NULL DEFAULT '1',
 date date NOT NULL,
 PRIMARY KEY (doctor_id,attention_time_slot_id,date),
 FOREIGN KEY (doctor_id) REFERENCES doctor (id),
 FOREIGN KEY (attention_time_slot_id) REFERENCES attention_time_slot (id)
)

Aunque tenemos los datos de los slots, es necesario que agregues manualmente registros a doctor_schedule con una fecha actualizada para poder probar la interfaz de la app.

Diseñar URL Para El Recurso

Muy bien, esta vez consultaremos a los doctores que tengan al menos un slot de tiempo disponible para el día seleccionado.

Sin embargo podemos usar el adjetivo disponibilidad (en inglés availability) como segundo segmento de la URL.

Veamos:

GET http://localhost/saludmock/v1/doctors/availability

Además de ello necesitamos los siguientes parámetros:

  • date: fecha para validar los slots disponibles
  • medical-center: ID del centro médico
  • time-schedule: Jornada. Recibirá los valores morning par la mañana y afternoon para la tarde.

Especificar Estructura Del Mensaje De La petición

Al igual que la obtención de los centros médicos, requeriremos el token en la cabecera de autorización.

Ejemplo:

GET /saludmock/v1/doctors/availability?date=2018-01-20&amp;medical-center=10004&amp;time-schedule=morning HTTP/1.1
Host: localhost
Authorization: 44945899e5c49b7ff8.48092936

Diseñar Respuesta JSON

En este punto haremos uso de nuevo del array "results".

Sin embargo cada objeto doctor traerá como atributo un array llamado "times" donde listaremos todos sus slots disponibles del día.

Ejemplo:

{
  "results": [
    {
      "id": "1000001",
      "name": "Mark Cooper",
      "specialty": "Anatomu00eda Patolu00f3gica",
      "description": null,
      "times": [
        "06:00:00",
        "06:30:00",
        "08:30:00",
        "10:30:00"
      ]
    },
    {
      "id": "1000002",
      "name": "Carlos Simmons",
      "specialty": "Anestesiologu00eda y Recuperaciu00f3n",
      "description": null,
      "times": [
        "06:30:00"
      ]
    }
  ]
}

Enrutar Recurso En El Servicio Web

Seguido abrimos el index.php y añadimos el recurso al arreglo $apiResources:

$apiResources = array('affiliates', 'appointments', 'medical-centers','doctors');

Crear Controlador Para Doctores

Creamos en la carpeta controllers la clase doctors con un método get() para manejar la petición:

<?php
/**
 * Controlador de doctores
 */

class doctors
{
    public static function get($urlSegments)
    {
        
    }
}

Procesar Petición GET

Lo primero que haremos será autorizar al afiliado para que pueda visualizar la lista de doctores con sus horarios disponibles:

public static function get($urlSegments)
{
    AuthorizationManager::getInstance()->authorizeAffiliate();
}

En esta ocasión validaremos si el segmento adicional es availability, de lo contrario mandamos una excepción:

if (isset($urlSegments[0])
    && strcmp(self::AVAILABILITY_SEGMENT, $urlSegments[0]) == 0) {
    
} else {
    throw new ApiException(
        400,
        0,
        "El recurso está mal referenciado",
        "http://localhost",
        "El recurso $_SERVER[REQUEST_URI] no esta sujeto a resultados"
    );
}

Seguido a eso vamos a extraer los 3 parámetros necesarios para consultar los horarios.

Si estos no vienen en la cadena, entonces lanzamos una excepción:

if (isset($urlSegments[0])
    && strcmp(self::AVAILABILITY_SEGMENT, $urlSegments[0]) == 0) {

    $queryParams = array();

    if (isset($_SERVER['QUERY_STRING'])) {
        parse_str($_SERVER['QUERY_STRING'], $queryParams);
    }

    if (!isset($queryParams[self::PARAM_MEDICAL_CENTER])
        || empty($queryParams[self::PARAM_MEDICAL_CENTER])
        || !isset($queryParams[self::PARAM_DATE])
        || empty($queryParams[self::PARAM_DATE])
        || !isset($queryParams[self::PARAM_TIME_SCHEDULE])
        || empty($queryParams[self::PARAM_TIME_SCHEDULE])) {
        throw new ApiException(
            400,
            0,
            "Revise que los parámetros para fecha, centro médico y jornada estén especificados",
            "http://localhost",
            "Revise que estén definidos los parámetros date, medical-center y time-schedule"
        );
    }

    // ...
}

Después creamos el método retrieveDoctorsSchedules() el cual va a recibir la fecha entrante, el ID del centro médico y la jornada para consultar los médicos disponibles a través de la siguiente consulta:

SELECT id, name, specialty, description
  FROM doctor
  WHERE exists(SELECT doctor_schedule.doctor_id
  FROM doctor_schedule
  WHERE doctor_id = doctor.id AND available = TRUE 
  AND date = ? AND medical_center_id = ?

Usándola tendremos el siguiente código:

public static function retrieveDoctorsSchedules($medicalCenterId, $date, $timeSchedule)
{
    try {
        $pdo = MysqlManager::get()->getDb();
        $doctors = array();

        $query =
            "SELECT id, name, specialty, description
              FROM doctor
              WHERE exists(SELECT doctor_schedule.doctor_id
              FROM doctor_schedule
              WHERE doctor_id = doctor.id AND available = TRUE 
              AND date = ? AND medical_center_id = ?)";

        $stm = $pdo->prepare($query);
        $stm->bindParam(1, $date);
        $stm->bindParam(2, $medicalCenterId, PDO::PARAM_INT);

        if ($stm->execute()) {

            while ($doctor = $stm->fetch(PDO::FETCH_ASSOC)) {
                $doctorId = $doctor[self::COL_DOCTOR_ID];
                $times = self::retrieveTimeSlots($doctorId, $date, $timeSchedule);

                if (count($times) > 0) {
                    $doctor[self::JSON_TIMES] = $times;
                    array_push($doctors, $doctor);
                }
            }

            return $doctors;

        } else {
            throw new ApiException(
                500,
                0,
                "Error de base de datos en el servidor",
                "http://localhost",
                "Hubo un error ejecutando una sentencia SQL en la base de datos. Detalles:" . $pdo->errorInfo()[2]
            );
        }

    } catch (PDOException $e) {
        throw new ApiException(
            500,
            0,
            "Error de base de datos en el servidor",
            "http://localhost",
            "Ocurrió el siguiente error al consultar las citas médicas: " . $e->getMessage());
    }
}

Sin embargo necesitaremos crear un método retrieveTimeSlots() para consultar por cada médico las fechas disponibles de atención como vemos en esta consulta:

SELECT a.start_time
                FROM doctor
                INNER JOIN doctor_schedule b
                    ON doctor.id = b.doctor_id
                INNER JOIN attention_time_slot a
                    ON b.attention_time_slot_id = a.id
                WHERE doctor.id = ? AND b.available = TRUE AND
                      b.date = ?

Antes de ejecutar la sentencia en PDO es necesario decidir entre qué condición BETWEEN se usará dependiendo del valor del parámetro $timeSchedule.

Codifiquémoslo:

private static function retrieveTimeSlots($doctorId, $date, $timeSchedule)
{
    $pdo = MysqlManager::get()->getDb();

    $timeCondition = '';

    switch ($timeSchedule) {
        case self::VALUE_MORNING:
            $timeCondition = " AND start_time BETWEEN '06:00' AND '12:00'";
            break;
        case self::VALUE_EVENING:
            $timeCondition = " AND start_time BETWEEN '12:00' AND '18:00'";
            break;
        default:
            // TODO: Lanza una excepción para no procesar un valor diferente
    }

    $query = "SELECT a.start_time
                    FROM doctor
                    INNER JOIN doctor_schedule b
                        ON doctor.id = b.doctor_id
                    INNER JOIN attention_time_slot a
                        ON b.attention_time_slot_id = a.id
                    WHERE doctor.id = ? AND b.available = TRUE AND
                          b.date = ?";

    $query = $query . $timeCondition;

    $stm = $pdo->prepare($query);

    $stm->bindParam(1, $doctorId, PDO::PARAM_INT);
    $stm->bindParam(2, $date);
    $stm->execute();

    return $stm->fetchAll(PDO::FETCH_COLUMN);
}

Probar Petición GET Con Postman

¡Muy bien!

Abramos Postman y seteemos en la interfaz estos valores:

  • Método: GET
  • Request URL: http://localhost/saludmock/v1/doctors/availability
  • Headers > Key: Authorization, Value: [Token de tu afiliado a testear]
  • Params > {Key: dateValue: Alguna fecha que hayas habilitado}, {Key: medical-centerValue: Algún ID de centro médico}

En la interfaz se vería así:

Petición GET para obtener doctores y disponibilidad

Al enviar la petición veremos una respuesta JSON como esta:

Respuesta de petición GET para disponibilidad de doctores

3.3 Petición Para Crear Una Cita Médica

Diseñar URL Para El Recurso

La URL para citas médicas ya está diseñada, lo que cambiaremos será la acción.

Usaremos POST para crear una nueva cita:

POST http://localhost/saludmock/v1/appointments

Especificar Estructura Del Mensaje De La petición

Para la creación es necesario especificar los parámetros del cuerpo que usaremos para la creación a través de un formato JSON.

Estos son:

  • datetime
  • doctor
  • service

Un ejemplo de cómo se vería es el siguiente:

{
  "datetime": "2018-01-18 06:00:00",
  "service": "Medicina General",
  "doctor": 1000001
}

Diseñar Respuesta JSON

El resultado será un objeto básico con el mensaje de la creación (aunque también podría ser la cita completamente creada si es beneficioso para las reglas de tu aplicación):

{
  "status": 201,
  "message": "Cita creada"
}

Procesar Petición POST En El Controlador

Esta vez nos enfocaremos en el método post() de appointments.

Las líneas de código a usar tienen que seguir esta guía:

  1. Autorizar usuario
  2. Obtener body de la petición
  3. Decodificar su contenido JSON
  4. Realizar validaciones de las entradas del usuario
  5. Crear registro de cita médica con los datos entrantes
  6. Marcar horario como no disponible
  7. Retornar en objeto JSON con respuesta acertiva de creación

Codifiquemos cada instrucción:

Acción 1: La autorización es cuestión de copiar y pegar el uso de AuthorizationManager:

public static function post($urlSegments)
{
    $affiliateId = AuthorizationManager::getInstance()->authorizeAffiliate();
    
}

Acción 2: La información del body podemos encontrarla con file_get_contents() y la referencia php://input:

$requestBody = file_get_contents('php://input');

Acción 3. Aquí usaremos el método json_decode() para la conversión del JSON:

$newAppointment = json_decode($requestBody, true);

Acción 4. Incluye las validaciones que requieras.

Acción 5. Escribiremos un método llamado saveAppointment(), el cual recibirá como parámetro la info decodificada de la cita y retornará un valor booleano determinando si el registro fue insertado.

En cuanto a PDO, el camino a seguir es preparar una sentencia INSERT sobre la tabla appointment y ejecutarla:

private static function saveAppointment($affiliateId, $newAppointment)
{
    $pdo = MysqlManager::get()->getDb();

    try {
        $pdo->beginTransaction();

        $op = "INSERT INTO appointment (date_and_time, service, affiliate_id, doctor_id) 
              VALUES (?, ?, ?, ?)";


        $stm = $pdo->prepare($op);
        $stm->bindParam(1, $dateTime);
        $stm->bindParam(2, $service);
        $stm->bindParam(3, $affiliateId);
        $stm->bindParam(4, $doctorId);

        $dateTime = $newAppointment[self::JSON_DATE_TIME];
        $service = $newAppointment[self::JSON_SERVICE];
        $doctorId = $newAppointment[self::JSON_DOCTOR];

        $stm->execute();

        $explodeDateTime = explode(" ", $dateTime);
        $date = $explodeDateTime[0];
        $startTime = $explodeDateTime[1];

        self::markScheduleNotAvailable($doctorId, $startTime, $date);

        return $pdo->commit();

    } catch (PDOException $e) {
        $pdo->rollBack();

        throw new ApiException(
            500,
            0,
            "Error de base de datos en el servidor",
            "http://localhost",
            "Hubo un error ejecutando una sentencia SQL en la base de datos. Detalles:"
            . $e->getMessage()
        );
    }
}

Acción 6. Lo siguiente es crear un método llamado markScheduleNotAvailable(), el cual consultará el ID del time slot que viene (será necesario explotar la fecha entrante para conseguirlo) y se lo enviará a una operación UPDATE que modificará la columna doctor_schedule.available al valor de FALSE:

UPDATE doctor_schedule
        SET available = FALSE
        WHERE doctor_id = ? AND attention_time_slot_id = 
        (SELECT id FROM attention_time_slot WHERE start_time = ?)
         AND date = ?

Esto podemos representarlo en PDO así:

private static function markScheduleNotAvailable($doctorId, $startTime, $date)
{
    $pdo = MysqlManager::get()->getDb();

    $cmd = "UPDATE doctor_schedule
            SET available = FALSE
            WHERE doctor_id = ? AND attention_time_slot_id = 
            (SELECT id FROM attention_time_slot WHERE start_time = ?)
             AND date = ? ";

    $stm = $pdo->prepare($cmd);
    $stm->bindParam(1, $doctorId);
    $stm->bindParam(2, $startTime);
    $stm->bindParam(3, $date);

    return $stm->execute();
}

Como vemos, al interior de saveAppointment() abrimos una transacción (beginTransaction()) antes de ejecutar ambas sentencias SQL.

Una vez establecido script de inserción de la cita, explotamos la fecha entrante y se la pasamos a markScheduleNotAvailable().

Nota: Realiza el mismo procedimiento de actualización en la disponibilidad cuando las citas existentes cambien su estado.

Y final pasamos el resultado de commit(). O llamamos a rollBack() en caso de tener excepciones.

Acción 7. Llamamos a saveAppointment() y retornamos un objeto JSON con mensaje positivo o una excepción:

public static function post($urlSegments)
{
    $affiliateId = AuthorizationManager::getInstance()->authorizeAffiliate();

    $requestBody = file_get_contents('php://input');

    $newAppointment = json_decode($requestBody, true);

    if (self::saveAppointment($affiliateId, $newAppointment)) {
        return ["status" => 201, "message" => "Cita creada"];
    } else {
        throw new ApiException(
            500,
            0,
            "Error del servidor",
            "http://localhost",
            "Error en la base de datos del servidor");
    }
}

Probar Creación De Citas Con Postman

Verifiquemos la petición con estos datos:

  • Método: POST
  • Request URL: http://localhost/saludmock/v1/appointments
  • Headers > Key: Authorization, Value: [Token de tu afiliado a testear]
  • Body > raw, JSON (application/json){JSON}

Visualmente sería:

Petición POST para crear citas médicas

Y al enviarla tendríamos:

Creación de cita médica en postman

Paso 4. Consumir Servicio REST Con Retrofit

Inmediatamente después de terminar las peticiones en el servicio PHP vamos a usar Retrofit para obtener las respuestas JSON.

4.1 Obtener Información De Centros Médicos

Crear Objeto Java Para Conversión

Para satisfacer las reglas de aplicación que Retrofit requiere para la conversión de sus respuestas crearemos la clase MedicalCentersRes.

Su objetivo será mapear el atributo "results" que especificamos en el diseño de respuesta.

Veamos:

public class MedicalsCenterRes {
    private List<MedicalCenter> results;

    public MedicalsCenterRes(List<MedicalCenter> results) {
        this.results = results;
    }

    public List<MedicalCenter> getResults() { 
        return results;
    }
}

Añadir Call A La Interface De Retrofit

Abrimos la interfaz SaludMockApi y agregamos un nuevo método tipo llamado getMedicalCenters().

¿Cómo debe estar configurado?

Así:

  • Anotación @GET hacia /medical-centers
  • El tipo de retorno será Call<MedicalCentersRes>
  • El valor de la cabecera Authorization anotado con @Header

De lo anterior tendremos:

@GET("medical-centers")
Call<MedicalsCenterRes> getMedicalCenters(@Header("Authorization") String token);

Crea Implementación De La Interfaz

Iremos a AddApointmentActivity y crearemos las instancias de Retrofit al final del método onCreate():

@Override
protected void onCreate(Bundle savedInstanceState) {
    ...

    // Crear adaptador Retrofit
    Gson gson = new GsonBuilder()
            .setDateFormat("yyyy-MM-dd HH:mm:ss")
            .create();
    mRestAdapter = new Retrofit.Builder()
            .baseUrl(SaludMockApi.BASE_URL)
            .addConverterFactory(GsonConverterFactory.create(gson))
            .build();

    // Crear conexión a la API de SaludMock
    mSaludMockApi = mRestAdapter.create(SaludMockApi.class);

}

Realizar Llamada Retrofit

Luego crearemos un método llamado loadMedicalCenters().

La secuencia de acciones vendría siendo la siguiente:

  1. Mostrar indicador de carga
  2. Obtener token de afiliado
  3. Realizar llamada asíncrona con el cliente REST
    1. Respuesta obtenida
      1. Éxito: Obtener resultados del objeto de respuesta y poblar el adaptador del menú de centros médicos
      2. Falla: Mostrar error de la API o del servidor
      3. Ocultar indicador de carga
    2. Errores técnicos
      1. Mostrar mensaje de error genérico al usuario
      2. Ocultar indicador de carga

Nuestro código resultante sería este:

private void loadMedicalCenters() {
    showLoadingIndicator(true);

    String token = SessionPrefs.get(this).getToken();

    mSaludMockApi.getMedicalCenters(token).enqueue(
            new Callback<MedicalsCenterRes>() {
                @Override
                public void onResponse(Call<MedicalsCenterRes> call,
                                       Response<MedicalsCenterRes> response) {
                    if (response.isSuccessful()) {
                        MedicalsCenterRes res = response.body();
                        List<MedicalCenter> medicalCenters = res.getResults();

                        if (medicalCenters.size() > 0) {
                            showMedicalCenters(medicalCenters);
                        } else {
                            showMedicalCentersError();
                        }

                    } else {
                        String error = "Ha ocurrido un error. Contacte al administrador";

                        if (response.errorBody().contentType().subtype().equals("json")) {
                            ApiError apiError = ApiError.fromResponseBody(response.errorBody());
                            error = apiError.getMessage();
                            Log.d(TAG, apiError.getDeveloperMessage());
                        } else {
                            try {
                                Log.d(TAG, response.errorBody().string());
                            } catch (IOException e) {
                                e.printStackTrace();
                            }
                        }

                        showApiError(error);
                    }

                    showLoadingIndicator(false);
                }

                @Override
                public void onFailure(Call<MedicalsCenterRes> call, Throwable t) {
                    showLoadingIndicator(false);
                    showApiError(t.getMessage());
                }
            });
}

Donde showLoadingIndicator() muestra/oculta el progreso del layout, showMedicalCentersError() muestra el view de errores para centros médicos y showApiError() muestra errores generales de la API:

private void showMedicalCentersError() {
    mErrorView.setVisibility(View.VISIBLE);
    mProgress.setVisibility(View.GONE);
    mCard1.setVisibility(View.GONE);
    mCard2.setVisibility(View.GONE);
}

private void showLoadingIndicator(boolean show) {
    mProgress.setVisibility(show ? View.VISIBLE : View.GONE);
    mCard1.setVisibility(show ? View.GONE : View.VISIBLE);
    mCard2.setVisibility(show ? View.GONE : View.VISIBLE);
    mErrorView.setVisibility(View.GONE);
}
private void showApiError(String error) {
    Snackbar.make(findViewById(android.R.id.content),
            error, Snackbar.LENGTH_LONG).show();
}

Con este método listo vamos a onCreate() y lo ejecutamos al final:

@Override
protected void onCreate(Bundle savedInstanceState) {
    ...
    
    loadMedicalCenters();
}

El resultado sería el siguiente:

Actividad con formulario para crear cita médica Android

4.2 Mostrar Disponibilidad De Doctores

Crear Objeto Java Para Conversión

Empezaremos por el mapeo para la respuesta del /doctors/availability.

Para la cual crearemos la clase DoctorAvailabilityRes en el paquete mapping y agregaremos una lista llamada results con objetos Doctor:

public class DoctorsAvailabilityRes {
    private List<Doctor> results;

    public DoctorsAvailabilityRes(List<Doctor> results) {
        this.results = results;
    }

    public List<Doctor> getResults() {
        return results;
    }
}

Adicionalmente usaremos la anotación @SerializedName para ajustar los atributos a la respuesta JSON:

public class Doctor {
    @SerializedName("id")
    private String mId;
    @SerializedName("name")
    private String mName;
    @SerializedName("specialty")
    private String mSpecialty;
    @SerializedName("description")
    private String mDescription;
    @SerializedName("times")
    private List<String> mAvailabilityTimes;

Añadir Call A La Interface De Retrofit

Ahora abrimos SaludMockApi y agregamos la llamada para este caso.

El nombre que usaremos es getDoctorsSchedules() y la configuraremos así:

  • Anotación @GET
  • Retorno DoctorsAvailability
  • Primer parámetro la cabecera Authorization para el token
  • Segundo parámetro un mapa para los parámetros de consulta

Veamos:

@GET("doctors/availability")
Call<DoctorsAvailabilityRes> getDoctorsSchedules(@Header("Authorization") String token,
                                                 @QueryMap Map<String, Object> parameters);

Crea Implementación De La Interfaz

Nos dirigimos a la actividad DoctorsSchedulesActivity y creamos el cliente REST para comunicarnos con la API:

// Crear adaptador Retrofit
Gson gson = new GsonBuilder()
        .setDateFormat("yyyy-MM-dd HH:mm:ss")
        .create();
mRestAdapter = new Retrofit.Builder()
        .baseUrl(SaludMockApi.BASE_URL)
        .addConverterFactory(GsonConverterFactory.create(gson))
        .build();

// Crear conexión a la API de SaludMock
mSaludMockApi = mRestAdapter.create(SaludMockApi.class);

Realizar Llamada Retrofit

Después creamos el método loadDoctorsSchedules() para cargar la info de los doctores y sus tiempos.

Al igual que las otras llamadas que hemos realizado, iniciaremos el view de carga, generaremos la petición asíncrona y poblaremos los views en caso de ser una respuesta asertiva o mostraremos errores en caso contrario:

private void loadDoctorsSchedules() {
    showLoadingIndicator(true);

    String token = SessionPrefs.get(this).getToken();
    HashMap<String, Object> parameters = new HashMap<>();
    parameters.put("date", DateTimeUtils.formatDateForApi(mDateSchedulePicked));
    parameters.put("medical-center", mMedicalCenterId);
    parameters.put("time-schedule", mTimeSchedule);

    mSaludMockApi.getDoctorsSchedules(token, parameters).enqueue(
            new Callback<DoctorsAvailabilityRes>() {
                @Override
                public void onResponse(Call<DoctorsAvailabilityRes> call,
                                       Response<DoctorsAvailabilityRes> response) {
                    Log.d(TAG, call.request().toString());
                    if (response.isSuccessful()) {
                        DoctorsAvailabilityRes res = response.body();
                        List<Doctor> doctors = res.getResults();

                        if (doctors.size() > 0) {
                            showDoctors(doctors);
                        } else {
                            showEmptyView();
                        }

                    } else {
                        String error = "Ha ocurrido un error. Contacte al administrador";

                        if (response.errorBody().contentType().subtype().equals("json")) {
                            ApiError apiError = ApiError.fromResponseBody(response.errorBody());
                            error = apiError.getMessage();
                            Log.d(TAG, apiError.getDeveloperMessage());
                        } else {
                            try {
                                Log.d(TAG, response.errorBody().string());
                            } catch (IOException e) {
                                e.printStackTrace();
                            }
                        }

                        showApiError(error);
                    }

                    showLoadingIndicator(false);
                }

                @Override
                public void onFailure(Call<DoctorsAvailabilityRes> call, Throwable t) {
                    showLoadingIndicator(false);
                    showApiError(t.getMessage());
                }
            });
}

Donde formatDateForApi() es formatea la fecha para la API antes de enviarlo como parámetro:

private static final String API_DATE_PATTERN = "yyyy-MM-dd";

public static String formatDateForApi(Date date) {
        SimpleDateFormat simpleDateFormat
                = new SimpleDateFormat(API_DATE_PATTERN, Locale.getDefault());
        return simpleDateFormat.format(date);
}

Adicionalmente tenemos los métodos para los estados:

private void showApiError(String error) {
    Snackbar.make(findViewById(android.R.id.content),
            error, Snackbar.LENGTH_LONG).show();
}

private void showDoctors(List<Doctor> doctors) {
    mListAdapter.setDoctors(doctors);
    mList.setVisibility(View.VISIBLE);
    mEmptyView.setVisibility(View.GONE);
}

private void showEmptyView() {
    mEmptyView.setVisibility(View.VISIBLE);
    mProgress.setVisibility(View.GONE);
    mList.setVisibility(View.GONE);
}

private void showLoadingIndicator(boolean show) {
    mProgress.setVisibility(show ? View.VISIBLE : View.GONE);
    mList.setVisibility(show ? View.GONE : View.VISIBLE);
}

Seguido sobrescibe onResume() y llama a loadDoctorsSchedules():

@Override
protected void onResume() {
    super.onResume();
    loadDoctorsSchedules();
}

Al ejecutar la app, el resultado de nuestra actividad sería el siguiente:

Actividad con lista de doctores y disponibilidades Android

4.3 Enviar Petición Con Retrofit Para Reservar Cita

Crear Objetos Java Para Conversión

Ya que esta petición usa el método HTTP POST, es necesario que construyamos una clase Java de la cual Retrofit pueda obtener el cuerpo.

Teniendo en cuenta esto, vamos a crear una clase llamada PostAppointmentsBody en el paquete mapping.

E incluimos como parámetros los datos vistos en el diseño:

public class PostAppointmentsBody {
    private String datetime;
    private String service;
    private String doctor;

    public PostAppointmentsBody(String datetime, String service, String doctor) {
        this.datetime = datetime;
        this.service = service;
        this.doctor = doctor;
    }

    public String getDatetime() {
        return datetime;
    }

    public String getService() {
        return service;
    }


    public String getDoctor() {
        return doctor;
    }
}

Por otro lado, para la respuesta podemos usar la clase ApiMessageResponse sin ningún problema.

Añadir Call A La Interface De Retrofit

Escribimos un nuevo método en la interfaz llamado createAppointment() con estas características:

  • Anotación @POST
  • Retorno ApiMessageResponse
  • Primer parámetro la cabecera Authorization para el token
  • Segundo parámetro @Body con tipo PostAppointmentsBody
  • Cabecera Content-Type con el formato JSON

Es decir:

@Headers("Content-Type: application/json")
@POST("appointments")
Call<ApiMessageResponse> createAppointment(@Header("Authorization") String token,
                                           @Body PostAppointmentsBody body);

Realizar Llamada Retrofit

Ya antes habíamos creado el método saveAppointment() que es donde se ejecutará la llamada Retrofit.

El inicio de su cuerpo será la obtención del token y la construcción del body a partir de todos los valores conseguidos en las entradas de los usuarios.

El valor del token lo obtenemos con las preferencias de sesión:

private void saveAppointment() {
    String token = SessionPrefs.get(this).getToken();

}

La fecha y hora podemos conseguirla con el siguiente método de DateTimeUtils:

private static final String API_DATETIME_PATTERN = "yyyy-MM-dd HH:mm:ss";

public static String joinDateTime(Date datePicked, Date timeUi) {
    Calendar datePickedCal = Calendar.getInstance();
    Calendar timeUiCal = Calendar.getInstance();

    datePickedCal.setTime(datePicked);
    timeUiCal.setTime(timeUi);

    datePickedCal.add(Calendar.HOUR_OF_DAY, timeUiCal.get(Calendar.HOUR_OF_DAY));
    datePickedCal.add(Calendar.MINUTE, timeUiCal.get(Calendar.MINUTE));

    SimpleDateFormat simpleDateFormat =
            new SimpleDateFormat(API_DATETIME_PATTERN, Locale.getDefault());
    return simpleDateFormat.format(datePickedCal.getTime());
}

Y el servicio será "Medicina General" por defecto. Sin embargo puedes extender la interfaz para recibir este campo como entrada del usuario.

Una vez claro esto, solo queda lanzar la petición:

private void saveAppointment() {
    String token = SessionPrefs.get(this).getToken();

    String datetime = DateTimeUtils.joinDateTime(mDatePicked,
            DateTimeUtils.parseUiTime(mTimeSlotPicked));
    String service = "Medicina General";

    PostAppointmentsBody body =
            new PostAppointmentsBody(datetime, service, mDoctorId);

    mSaludMockApi.createAppointment(token, body).enqueue(
            new Callback<ApiMessageResponse>() {
                @Override
                public void onResponse(Call<ApiMessageResponse> call,
                                       Response<ApiMessageResponse> response) {

                    if (response.isSuccessful()) {
                        ApiMessageResponse res = response.body();
                        Log.d(TAG, res.getMessage());
                        showAppointmentsUi();

                    } else {
                        String error = "Ha ocurrido un error. Contacte al administrador";

                        if (response.errorBody().contentType().subtype().equals("json")) {
                            ApiError apiError = ApiError.fromResponseBody(response.errorBody());
                            error = apiError.getMessage();
                            Log.d(TAG, apiError.getDeveloperMessage());
                        } else {
                            try {
                                Log.d(TAG, response.errorBody().string());
                            } catch (IOException e) {
                                e.printStackTrace();
                            }
                        }

                        showApiError(error);
                    }

                    showLoadingIndicator(false);
                }

                @Override
                public void onFailure(Call<ApiMessageResponse> call, Throwable t) {
                    showLoadingIndicator(false);
                    showApiError(t.getMessage());
                }
            });

}

La carga y el error tienen los comportamientos estándar. En el caso de showAppointmentsUi() confirmamos que fue un resultado exitoso y terminamos la actividad:

private void showAppointmentsUi() {
    setResult(Activity.RESULT_OK);
    finish();
}

private void showLoadingIndicator(boolean show) {
    mProgress.setVisibility(show ? View.VISIBLE : View.GONE);
    mCard1.setVisibility(show ? View.GONE : View.VISIBLE);
    mCard2.setVisibility(show ? View.GONE : View.VISIBLE);
    mErrorView.setVisibility(View.GONE);
}

private void showApiError(String error) {
    Snackbar.make(findViewById(android.R.id.content),
            error, Snackbar.LENGTH_LONG).show();
}

Y ahora sí, el gran final de la parte 4 de este tutorial.

Ejecutamos la aplicación Android y revisamos que la creación de citas médicas sea todo un éxito a través de las actividades realizadas.

Actividad Android de creación correcta de cita médica

¿Listo Para El Siguiente Nivel?

Si andas buscando otro ejemplo completo con Retrofit que te guíe paso a paso para diseñar el servicio web y consumirlo en Android. Tengo un tutorial que te ayudará.

App Productos es uno de mis tutoriales más completos a la hora de buscar inspiración para la planeación de una App Android, el uso de diferentes fuentes de datos (caché, SQLite, servicio web) y la implementación del patrón MVP (Model-View-Presenter). El cual les ha servido a muchos lectores de Hermosa Programación.

¡Échale un vistazo!

Tutorial Android App Productos

Únete Al Discord De Develou

Si tienes problemas con el código de este tutorial, preguntas, recomendaciones o solo deseas discutir sobre desarrollo Android conmigo y otros desarrolladores, únete a la comunidad de Discord de Develou y siéntete libre de participar como gustes. ¡Te espero!