Libreria Retrofit En Android Parte 3: App De Citas Médicas

Continuando con mi serie de tutoriales de SaludMock, es el turno de parsear un array de objetos JSON con Retrofit y ponerlos en un RecyclerView.

Mi objetivo principal es mostrarte cómo usar el método GET junto a parámetros en la URL y de esta forma filtrar elementos.

¿Qué te parece :)?

Hago un paréntesis para dejarte los enlaces de las dos partes anteriores. Es importante que las leas para comprender la app de ejemplo que usamos SaludMock :

Habiendo definido eso, veamos los conocimientos necesarios para usar Retrofit en esta característica.

Descargar Servicio Web + Proyecto Android Studio

A continuación te dejo un link para descargar el código del servicio web y el proyecto en Android Studio.

Al ejecutar la app Android y habilitar los servicios del servidor Apache podrás ver un resulta así.

Hacer Petición GET Con Retrofit En Android

Para realizar una petición GET en Retrofit debemos tener las siguientes características en cuenta.

En primer lugar, el método de la interfaz del adaptador debe estar anotado con @GET y usar como expresión entre paréntesis al terminal al que apuntará.

Por ejemplo: Una petición GET hacia una API para obtener una lista de hoteles.

@GET("hoteles")

Usar Parámetros De URL

Existen varias formas para enviar parámetros en la URL.

Si el parámetro es fijo en su clave y valor, entonces puedes concatenarlo al terminal sin más.

Por ejemplo, obtener solo los hoteles activos:

@GET("hoteles?activo=1")

Ahora, si los parámetros son dinámicos, entonces usa las anotaciones @Query o @QueryMap junto a los parámetros del método de la interfaz.

Por ejemplo…

Si quieres generalizar la petición anterior, entonces mueve la clave "activo" como parámetro del método y anótalo como @Query:

@GET("hoteles")
Call<Hotel> obtenerHoteles(@Query("activo") int activo);

De esa forma, cuando se llame a obtenerHoteles(), el parámetro que reciba se pegará automáticamente a la URL con el valor que le especifiques.

Si fuese 0, entonces al llamar adaptador.obtenerHoteles(0), se produce /hoteles?activo=0.

En el caso de @QueryMap, lo usamos cuando deseamos evitar agregar muchos parámetros al método y mejor pasamos un Map con las claves y los valores.

@GET("hoteles")
Call<Hotel> obtenerHoteles(@QueryMap Map<String, String> filtros);

Para enviar los parámetros construimos un mapa previo, por ejemplo:

Map<String, String> filtros = new HashMap<String,String>;
filtros.put("activo", "1");
filtros.put("display","full");
adaptador.obtenerHoteles(filtros);

Al procesarse el mapa, la URL quedaría así /hoteles?activo=1&display=full.

¿Fácil de comprender, cierto?

Interioriza estos conceptos y memoriza las sintaxis, ya que este tutorial dependerá de ello…

Listar Citas Médicas En SaludMock

Para soportar la obtención de las citas médicas del servidor es necesario incrementar el servicio REST existente.

La parte importante es determinar la semántica de la URL para filtrar los elementos por la columna status.

Esto nos será de ayuda para cuando el usuario en la app Android seleccione una opción del Spinner que se encuentra en la toolbar.

Con todo y eso, los puntos clave para construir este sistema serán:

  1. Citas Médicas En El Servicio REST
    1. Procesar petición para obtener citas más parámetros
    2. Procesar petición para modificar citas
    3. Testear web service
  2. Citas Médicas En La Aplicación Android
    1. Diseñar la interfaz de la actividad de citas
    2. Cargar citas en una lista
    3. Filtrar citas médicas con el spinner
    4. Cancelar citas desde botón

¡Muy bien!

Manos a la obra…

Soportar Citas Médicas En El Servicio REST

Nuestra misión es clara en el servicio web:

Habilitar la fuente de datos en MySQL para permitir a nuestra app Android servirse de ella

Dicho servicio incluso permitirá la capacidad de filtro por estado, por lo que debemos encontrar una estrategia gramatical que lo permite.

Comencemos…

Crear Tabla De Citas Médicas En MySQL

Abre phpMyAdmin en tu server local Apache y sitúate en la base de datos salud_mock.

La idea es usar un comando CREATE para implementar la entidad Appointment (cita) que plasmamos en el diagrama ER.

Antes de hacerlo analicemos los atributos que posee:

  • id: Es un identificador numérico entero.
  • affiliate_id: Es la llave foránea de relación con los afiliados
  • doctor_id: Vinculo con el doctor que debe ver al paciente
  • date_and_time: Fecha y hora asignada a la cita
  • service: El tipo de cita que se prestará (general, odontológica, pediatría, etc.)
  • status: El estado de la cita. Puede tener tres valores (activa, cumplida y cancelada)

Con eso ya tenemos un esquema súper fácil a traducir.

¡Pero detente allí!

Si observas detenidamente, la tabla appointment es la tabla débil en la relación con doctor.

Por este motivo, conviene crear primero dicha tabla.

Para ello aprendamos un poco de su estructura:

  • id: El identificador de los doctores.
  • name: Nombre y apellido del doctor.
  • date_joined: La fecha en que fue contratado.
  • specialty: Es especialista en.
  • medical_center_id: Vinculo al centro médico donde atiende pacientes.

Aquí volvemos al mismo concepto, doctor es débil ante medical_center, así que antepongámosla como prioridad en la creación SQL.

La info que se desea guardar de los centros médicos sería:

  • id: El identificador de los centros médicos
  • address: La dirección donde queda (supondré que la entidad promotora de salud solo opera en una ciudad, pero si no es tu caso, entonces expande este atributo)
  • description: Descripción adicional sobre el centro médico

Entonces, en este orden generaremos los comandos para añadir las tablas a la base de datos:

CREATE TABLE medical_center (
id int(5) NOT NULL AUTO_INCREMENT,
address varchar(64) NOT NULL,
description varchar(128) DEFAULT NULL,
PRIMARY KEY (id)
);
ALTER TABLE medical_center AUTO_INCREMENT = 10000;

CREATE TABLE doctor (
 id int(7) NOT NULL AUTO_INCREMENT,
 name varchar(32) NOT NULL,
 date_joined timestamp NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP,
 specialty varchar(64) DEFAULT NULL,
 medical_center_id int(5) DEFAULT NULL,
 PRIMARY KEY (id),
 FOREIGN KEY (medical_center_id) REFERENCES medical_center (id)
);
ALTER TABLE doctor AUTO_INCREMENT = 1000000;

CREATE TABLE appointment (
 id int(8) NOT NULL AUTO_INCREMENT,
 date_and_time timestamp NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP,
 service varchar(64) NOT NULL,
 status enum('Activa','Cumplida','Cancelada') DEFAULT 'Activa',
 affiliate_id varchar(10) NOT NULL,
 doctor_id int(7) NOT NULL,
 PRIMARY KEY (id),
 FOREIGN KEY (affiliate_id) REFERENCES affiliate (id),
 FOREIGN KEY (doctor_id) REFERENCES doctor (id)
);
ALTER TABLE doctor AUTO_INCREMENT = 10000000;

Las sentencias ALTER TABLE establecen el valor inicial del AUTO_INCREMENT de cada tabla para indicar una regla de negocio hipotética de nuestros IDs en SaludMock.

Si tú tienes otras preferencias para estos identificadores, entonces eres libres de modificarlos.

Por otro lado…

Añade algunas filas de ejemplo a cada tabla para tener información de prueba.

(He añadido varias filas en el esquema de la bases de datos del ejemplo, descárgalo en la parte superior de este artículo y evita crearlas manualmente)

Diseñar URL Con Parámetro De Filtro

El terminal del web service usará la palabra plural appointments para referirnos a que allí será el origen de citas médicas desde el servidor:

GET http://localhost/blog/saludmock-iii/v1/appointments Retorna citas médicas

Lo siguiente es determinar cómo filtrar las citas por la columna del estado.

La solución será aceptar un parámetro de consulta en la URL, cuya clave sea status y el valor el estado particular del filtro.

Clave Tipo de dato Valor
status string Los estados "Activa", "Cumplida", "Cancelada" y "Todas" para no especificar un filtro como tal.

Para simplificarlo en un ejemplo, supongamos que necesitamos solo las citas canceladas, entonces agregamos el parámetro así:

http://localhost/blog/saludmock-iii/v1/appointments?status=Cancelada

¡Súper fácil!, ¿cierto?

Diseñar Requests Y Responses De Citas Médicas

Bien, el mensaje de las peticiones para obtener citas médicas deben tener a consideración:

  • El uso del método GET
  • La URL con endpoint /appointments
  • El token de usuario en la cabecera Authorization

Un ejemplo claro de petición sería:

GET /blog/saludmock-iii/v1/appointments HTTP/1.1
Host: localhost
Authorization: 155149160058419bc787cad4.60955511

Lo siguiente, es definir la respuesta del servidor.

Esta será sencilla: obtendremos un objeto JSON con un atributo con clave "results". Dicho elemento tendrá un array de citas médicas como resultado.

Fijate en un ejemplo:

{
   "results":[
      {
         "id":"3",
         "date_and_time":"2017-02-15 21:38:15",
         "service":"Oftalmolog\u00eda",
         "status":"Activa",
         "affiliate_id":"1234567890",
         "doctor_id":"1000010"
      },
      {
         "id":"4",
         "date_and_time":"2017-02-10 09:49:12",
         "service":"Medicina General",
         "status":"Activa",
         "affiliate_id":"1234567890",
         "doctor_id":"1000005"
      },
      ...
   ]
}

Enrutar Citas Médicas

Abre tu proyecto PHP del servicio web y sitúate en index.php. Recuerda que aquí actualmente estamos procesando la existencia de recursos y su enrutamiento hacia los controladores.

El paso siguiente es modificar el arreglo $apiResources para agregar el literal 'appointments':

$apiResources = array('affiliates', 'appointments');

IMPORTANTE: No olvides añadir la instrucción require para cada recurso:

require 'controllers/appointments.php';

Crear Controlador Del Recurso

El siguiente paso es crear una nueva clase PHP para el controlador de citas médicas llamada appointments.

La idea es que añadas los 4 métodos frecuentes de control de la petición HTTP (get(), post(), put() y delete()):

<?php

/**
 * Controlador del endpoint /appointments
 */
class appointments
{
    public static function get($urlSegments)
    {

    }

    public static function post($urlSegments)
    {

    }

    public static function put($urlSegments)
    {

    }

    public static function delete($urlSegments)
    {

    }
}

Procesar Petición GET Para Retornar Citas Médicas

Ya sabemos que al usar GET para el retorno del array JSON de citas médicas, el método a implementar en el controlador es get().

En este punto la pregunta que deberías hacerte es:

¿Qué pasos sigo para procesar la URL?

Si meditas, sabrás que en una situación ideal primero autorizamos al usuario, luego extraemos los parámetros de la URL, realizamos una consulta SQL con esos parámetros y al final retornamos un array.

En resumen y puliendo tendrás:

public static function get($urlSegments)
{
    // TODO: 1. Autorizar usuario
    // TODO: 2. Verificaciones, restricciones, defensas
    // TODO: 3. Extraer parámetros
    // TODO: 4. Invocar a la fuente de datos para retorno de citas médicas
}

Teniendo en cuenta esto, procedamos a autorizar al usuario.

1. Autorizar Afiliado

Crea un nuevo método llamado authorizeAffiliate() para simplificar la autorización.

¿Que debe ir en su lógica?

Por destacar tenemos que la cabecera de autorización podemos obtenerla con apache_request_headers().

Así que puedes comprobar su existencia y luego llamar un método que opere la base de datos para determinar cuál es el identificador del afiliado y así retornarlo:

private static function authorizeAffiliate()
{
    if (!isset(apache_request_headers()['Authorization'])) {
        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"
        );
    }

    $authorizationHeader = apache_request_headers()['Authorization'];

    $affiliateId = self::isAffiliateAuthorized($authorizationHeader)[0];
    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;
}

¿La pillas?

A continuación veamos cual es el objetivo de isAffiliateAuthorized()

El punto: El alcance de esta API se limita a una autorización global para un solo rol.

Ya habíamos visto que solo requerimos comprobar el token de usuario enviado a través de Authorization con el valor que existe en la tabla de afiliados.

A nivel de SQL esto es una consulta que usa una condición de comparación con este estilo:

SELECT id FROM affiliate WHERE token = "{token_entrante}"

Si existe un registro como resultado, entonces la autorización es entregada.

A partir de estos conceptos, escribe el método para generar la sentencia preparada SELECT:

private static function isAffiliateAuthorized($authorizationHeader)
{
    if (empty($authorizationHeader)) {
        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
        $preparedStatement = $pdo->prepare($sentence);
        $preparedStatement->bindParam(1, $authorizationHeader);

        // Ejecutar sentencia
        if ($preparedStatement->execute()) {

            // Retornar id del afiliado autorizado
            $row = $preparedStatement->fetchAll(PDO::FETCH_COLUMN);
            return $row;

        } 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());
    }

}

Se consulta el id de la tabla affiliate y se retorna.

Recuerda la importancia de arrojar las excepciones para determinar que está sucediendo con exactitud al pedirle recursos al server.

2. Verificar Formato De URL

La única restricción que tenemos por el momento es que no debe existir un segundo segmento luego del endpoint para citas, así que verifícalo y marca la excepción:

public static function get($urlSegments)
{
    // 1. Comprobar autorización del afiliado
    $affiliateId = self::authorizeAffiliate();

    // 2. Verificaciones, restricciones, defensas
    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"
        );
    }
}

3. Obtener Parámetros De La URL

Aquí obtendremos la cadena que contiene los parámetros de la URL en la petición.

Una forma de hacerlo es usar la clave QUERY_STRING del arreglo global $_SERVER de PHP:

public static function get($urlSegments)
{
    // ...

    $parameters = array();

    // 3. Obtener parámetros de la URL
    if (isset($_SERVER['QUERY_STRING'])) {
        parse_str($_SERVER['QUERY_STRING'], $parameters);
    }
}

4. Retornar Citas Médicas De La Base De Datos

Para construir nuestra respuesta JSON es necesario crear un array con los registros de la tabla appointment que cumplan las condiciones tomadas de la URL dentro de un nuevo método llamado retrieveAppointments().

En definitiva, esto se resume a la ejecución de un comando SELECT de dicha tabla junto a un WHERE que filtre las filas si es que el valor de status está presente (junto al procesamiento de otros parámetros si es que existen) y que esté relacionada con el paciente.

SELECT * FROM appointment WHERE status = {valor_parametro_status} AND affiliate_id = {id_afiliado}

Así que usando el administrador de Mysql creado en la parte pasada y soportandonos en sentencias preparadas, la codificación de retrieveAppointments() quedaría de esta manera:

private static function retrieveAppointments($affiliateId, $parameters)
{

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

        /* status:
        ¿Viene como parámetro en la URL y su valor no es vacío?
        ¿No está definido o su valor es "Todas"?
            SI: Usar un espacio para consultar todas las citas
            NO: Formar condición para el WHERE con la columna "status"
        */
        $isStatusDefined = isset($parameters["status"]) || !empty($parameters["status"]);
        $isAllStatus = !$isStatusDefined || strcasecmp($parameters["status"], "Todas") == 0;
        $statusSqlString = $isAllStatus ? "" : " AND status = ?";
        
        $sentence = "SELECT * FROM appointment WHERE affiliate_id = ? " . $statusSqlString;

        // Preparar sentencia
        $preparedStatement = $pdo->prepare($sentence);
        $preparedStatement->bindParam(1, $affiliateId);
        if (!$isAllStatus) {
            $preparedStatement->bindParam(2, $parameters["status"]);
        }

        // Ejecutar sentencia
        if ($preparedStatement->execute()) {
            return $preparedStatement->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 terminar este método, completa el controlador get(), donde construirás la respuesta basada en la clave "results" propuesta en el diseño:

public static function get($urlSegments)
{
    // 1. Comprobar autorización del afiliado
    $affiliateId = self::authorizeAffiliate();

    // 2. Verificaciones, restricciones, defensas
    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"
        );
    }

    $parameters = array();

    // 3. Obtener parámetros de la URL
    if (isset($_SERVER['QUERY_STRING'])) {
        parse_str($_SERVER['QUERY_STRING'], $parameters);
    }

    // 4. Invocar a la fuente de datos para retorno de citas médicas
    $appointments = self::retrieveAppointments($affiliateId, $parameters);
    return ["results" => $appointments];
}

Test De REST Service Con Postman

Por último, abre Postman y crea una nueva pestaña con las siguientes características:

  • Método: GET
  • Header Authorization: Consigue un valor de la base de datos o intenta loguear previamente un afiliado para copiarlo de los resultados de Postman.
  • URL: http://localhost/blog/saludmock-iii/v1/appointments

Al setear dichos valores tendrás algo como:

Obtener citas médicas de la API de SaludMock

Al presionar Send y haber puesto un token correcto, podrás ver una respuesta similar a la siguiente:

Respuesta JSON de obtener citas médicas

También intenta añadir el parámetro status para filtrar los resultados con los valores propuestos.

Variar La Visualización De Las Citas Médicas

En ocasiones querrás obtener diferente información con respecto a las citas.

Algunos casos serían:

  • Obtener solo la columna id de las citas
  • Obtener un resumen corto de id de afiliado y estado
  • Aunar los elementos en un solo atributo

¿Por qué te hablo de esto?

La razón es que necesitaremos los datos de las citas junto al nombre del doctor y el nombre del centro médico donde se llevará a cabo.

Y aunque hay muchas soluciones posibles para satisfacer esta necesidad.

He optado por añadir un nuevo parámetro a la URL que nos ayude.

1. Diseño De Petición Y Respuesta

Se trata del parámetro display.

/appointments?display={value}

El cual puede tomar dos valores por el momento:

  • full: Obtiene todas las columnas de la cita
  • list: Obtiene todas las columnas excepto doctor_id, ya que será reemplazado por el nombre del doctor y además se añade el nombre del centro médico.

En el caso de la respuesta full, es la que obtenemos hasta el momento.

De la otra mano, al usar list, un objeto JSON para la cita médica se vería así:

<span id="s-1" class="sBrace structure-1">{ <i class="fa fa-minus-square-o"></i> </span>
   <span id="s-2" class="sObjectK">"id"</span><span id="s-3" class="sColon">:</span><span id="s-4" class="sObjectV">1234567890</span><span id="s-5" class="sComma">,</span>
   <span id="s-6" class="sObjectK">"date_and_time"</span><span id="s-7" class="sColon">:</span><span id="s-8" class="sObjectV">"2017-02-15 21:38:15"</span><span id="s-9" class="sComma">,</span>
   <span id="s-10" class="sObjectK">"service"</span><span id="s-11" class="sColon">:</span><span id="s-12" class="sObjectV">"Oftalmología"</span><span id="s-13" class="sComma">,</span>
   <span id="s-14" class="sObjectK">"status"</span><span id="s-15" class="sColon">:</span><span id="s-16" class="sObjectV">"Activa"</span><span id="s-17" class="sComma">,</span>
   <span id="s-18" class="sObjectK">"affiliate_id"</span><span id="s-19" class="sColon">:</span><span id="s-20" class="sObjectV">"1234567890"</span><span id="s-21" class="sComma">,</span>
   <span id="s-22" class="sObjectK">"doctor"</span><span id="s-23" class="sColon">:</span><span id="s-24" class="sObjectV">"Carlos Estrada"</span><span id="s-25" class="sComma">,</span>
   <span id="s-26" class="sObjectK">"medical_center"</span><span id="s-27" class="sColon">:</span><span id="s-28" class="sObjectV">"Hospital Verde"</span>
<span id="s-29" class="sBrace structure-1">}</span>

Una vez que tengas grabado este concepto, puedes desarrollar el código…

2. Definir Sentencia SQL

¿Cómo podemos enfrentar el valor list de display?

Tú y yo estaremos de acuerdo en usar JOINs en la sentencia SQL para relacionar la cita con el doctor y el centro médico.

SELECT a.id, a.date_and_time, a.service, a.status, a.affiliate_id, b.name as doctor, c.name as medical_center 
FROM appointment as a 
INNER JOIN doctor as b ON a.doctor_id = b.id
INNER JOIN medical_center as c ON b.medical_center_id = c.id 
WHERE affiliate_id = ?

 

3. Actualizar Método De Obtención De Citas

Comprueba al interior de retrieveAppointments() la existencia del parámetro y usemos un condicional para modificar la sentencia si el valor coincide:

private static function retrieveAppointments($affiliateId, $parameters)
{

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

        /* status:
        ¿Viene como parámetro en la URL y su valor no es vacío?
        ¿No está definido o su valor es "Todas"?
            SI: Usar un espacio para consultar todas las citas
            NO: Formar condición para el WHERE con la columna "status"
        */
        $isStatusDefined = isset($parameters["status"]) || !empty($parameters["status"]);
        $isAllStatus = !$isStatusDefined || strcasecmp($parameters["status"], "Todas") == 0;
        $statusSqlString = $isAllStatus ? "" : " AND status = ?";

        // display: ¿Viene como parámetro en la URL y su valor no es vacío?
        $isDisplayDefined = isset($parameters["display"]) && !empty($parameters["display"]);

        $sentence = "SELECT * FROM appointment WHERE affiliate_id = ? " . $statusSqlString;

        if ($isDisplayDefined && $parameters["display"] == "list") {
            $sentence =
                "SELECT a.id, a.date_and_time, a.service, a.status, a.affiliate_id," .
                " b.name as doctor, c.name as medical_center " .
                "FROM appointment as a INNER JOIN doctor as b ON a.doctor_id = b.id" .
                " INNER JOIN medical_center as c ON b.medical_center_id = c.id " .
                "WHERE affiliate_id = ?" . $statusSqlString;
        }


        //...
}

REST Testing: Obtener Citas Con Parámetro display

Configura una pestaña de Postman con los siguientes datos:

  • Método: GET
  • URL: http://localhost/blog/saludmock-iii/v1/appointments?display=list
  • Content-Type: application/json
  • Authorization: Token de algún afiliado creado

Envía la petición y comprueba el resultado.

REST Testing: Obtener citas con parámetro display

Modificar Estado De Citas Médicas Desde El Web Service

Acordémonos que en la app Android permitiremos al usuario cancelar las citas que estén activas.

Es por eso que debemos crear un camino para modificar la columna status.

¿Cómo diseñar esta petición?

La siguiente tabla lo resume:

Ruta Método Autorización Descripción
/appointments/:id PATCH Se requiere el token del afiliado Modifica los campos especificados de la cita médica

Esto haría que el mensaje HTTP tenga una forma similar a esta:

PATCH /blog/saludmock-iii/v1/appointments/123 HTTP/1.1
Host: localhost
Content-Type: application/json
Authorization: 44945899e5c49b7ff8.48092936
Cache-Control: no-cache

{
 "status":"Cancelada"
}

En el contenido solo tendremos un objeto JSON con el valor nuevo del estado, en este caso será "Cancelada".

Si la modificación parcial de la cita médica es exitosa, entonces la respuesta tendrá un estado 204.

Otra alternativa de respuesta es producir un objeto JSON para indicar que el registro fue actualizado con el código 200.

{
 "status":"200",
 "message":"Cita médica modificada"
}

Con esto ya estás listo.

¡Así que vayamos a codificar!

1. Soportar Método PATCH En El Enrutador

Detrás del cambio de estado de las citas médicas está el añadir el método PATCH a la estructura switch que tenemos en index.php.

¿Cómo es eso?

Abres index.php y agregas un nuevo case con el valor 'patch'.

¡Eso es todo!

switch ($httpMethod) {
    case 'get':
    case 'post':
    case 'put':
    case 'patch':
    case 'delete':
        //...
    default:

        //...

}

2. Soportar Modificación Parcial En El Controlador De Citas

Esto es obvio.

Abrimos el controlador appointments y escribimos el nuevo controlador patch():

public static function patch($urlSegments)
{

}

3. Autorizar Afiliado

Aquí está todo lo que tienes que hacer: Copia y pega el código de autorización que usamos en get().

class appointments
{
    //...

    public static function patch($urlSegments)
    {
        // Comprobar si el afiliado está autorizado
        $affiliateId = self::authorizeAffiliate();
    }

}

4. Extraer Identificador De La Cita

Es igual de simple que en get().

Verifica que exista el segmento del identificador de la cita para proseguir:

// Extraer id de la cita
if (!isset($urlSegments[0]) || empty($urlSegments[0])) {
    throw new ApiException(
        400,
        0,
        "Se requiere id de la cita",
        "http://localhost",
        "La URL debe tener la forma /appointments/:id para aplicar el método PATCH"
    );
}
$id = $urlSegments[0];

5. Extraer Parámetros Del Cuerpo De La Petición

Lo siguiente es extraer el cuerpo de la petición, decodificar su formato JSON (si es que viene en este formato) y procesarlo en un arreglo asociativo para acceder fácilmente a su contenido:

// Extraer cuerpo de la petición
$body = file_get_contents("php://input");

$content_type = '';

if(isset($_SERVER['CONTENT_TYPE'])) {
    $content_type = $_SERVER['CONTENT_TYPE'];
}
switch($content_type) {
    case "application/json":
        $body_params = json_decode($body);
        if($body_params) {
            foreach($body_params as $param_name => $param_value) {
                $parameters[$param_name] = $param_value;
            }
        }
        break;
    default:
        throw new ApiException(
            400,
            0,
            "Formato de los datos no soportado",
            "http://localhost",
            "El cuerpo de la petición no usa el tipo application/json"
        );
}

Varios detalles a tener en cuenta si eres nuevo en PHP:

  • $_SERVER["CONTENT_TYPE"] obtiene en el servidor Apache el valor de la cabecera Content-Type.
  • El bucle foreach recorre los elementos de una array y nos provee la clave y el valor en cada iteración. Por eso no es posible conseguir los parámetros en la variable $parameters.

6. Modificar Cita Médica En La Base De Datos

Aquí usaremos el ID que viene en la URL junto a los parámetros del cuerpo de la petición con el fin de efectuar el cambio en la base de datos MySQL.

Un detalle importante: usa la sentencia UPDATE para actualizar la cita médica concatenando en la sintaxis los parámetros obtenidos.

Me refiero a que la sentencia a ejecutar sería parecida a esta:

UPDATE appointment SET status=?, p2 = ?, p3=?, ... WHERE id = ?

Averigüemos como hacerlo:

Crea un nuevo método llamado modifyAppointment() y lleva a cabo las siguientes instrucciones:

private static function modifyAppointment($parameters, $id, $affiliateId)
{
    try {
        $pdo = MysqlManager::get()->getDb();

        // Concatenar expresiones para SET
        foreach ($parameters as $key => $value) {
            $compoundSet[] = $key . "=?";
        }

        // Componer sentencia UPDATE
        $sentence = "UPDATE appointment " .
            "SET " . implode(',', $compoundSet) .
            " WHERE id = ? AND affiliate_id = ?";

        // Preparar sentencia
        $preparedStatement = $pdo->prepare($sentence);

        $i = 1;
        foreach ($parameters as $value) {
            $preparedStatement->bindParam($i, $value);
            $i++;
        }
        $preparedStatement->bindParam($i, $id);
        $preparedStatement->bindParam($i + 1, $affiliateId);

        // Ejecutar sentencia
        if ($preparedStatement->execute()) {

            $rowCount = $preparedStatement->rowCount();
            return $rowCount;

        } 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 modificar la cita: " . $e->getMessage());
    }
}

La parte diferente es el uso de ciclos para recorrer los parámetros al producir la sentencia SET y ligar los parámetros.

El método implode() nos ayuda a construir una cadena cuyos elementos estén separados por comas.

Y además usamos como retorno la cantidad de filas afectadas con rowCount().

7. Retornar Respuesta

Ahora estamos listos para invocar el a modifyAppointment() al interior de patch() y retornar la respuesta.

Así que aquí está el código:

public static function patch($urlSegments)
{
    //...

    if (empty($parameters)) {
        throw new ApiException(
            400,
            0,
            "No se especificaron atributos a modificar en la cita",
            "http://localhost",
            "El array de parámetros llegó vacío"
        );
    }

    // Modificar cita médica en la base de datos local
    $result = self::modifyAppointment($parameters, $id, $affiliateId);

    // Retornar mensaje de modificación
    if ($result > 0) {
        return ["status" => 200, "message" => "Cita médica modificada"];
    } else {
        throw new ApiException(
            409,
            0,
            "Hubo un conflicto al intentar modificar la cita",
            "http://localhost",
            "La modificación no afecta ninguna fila"
        );
    }
}

Test REST: Modificar Estado

Finalmente abre Postman y configura la petición con las características que vimos en el diseño:

Test REST: Modificar citas

En mi caso por ejemplo estoy modificando un cita con identificador 10000025.

Y el resultado de la petición fue:

Resultado de cita cancelada en POSTMAN

Consumir Lista De Citas Médicas Con Retrofit

Tan pronto el servicio web esté listo procedemos a crear la característica Android.

De forma general debemos añadir la actividad de lista de citas médicas, diseñar su layout, programar las reacciones antes los eventos del usuario y actualizar la interfaz de Retrofit para que soporte las peticiones GET hacia el terminar /appointments.

¿Estás list@?

Entonces, vamos a Android Studio…

Crear Nueva Actividad Android Para Citas Médicas

Abre el proyecto SaludMock o haz una copia de el en una carpeta llamada saludmock-iii si deseas diferenciar cada avance.

Recuerda que ya tenemos la actividad de citas médicas llamada AppointmentsActivity.

Por lo que nuestra prioridad es diseñar el layout con etiquetas XML que se acomoden de forma efectiva al boceto de la screen.

La pregunta es:

¿Que views y contenedores usar?

El mismo boceto nos lo dice. En esta normal de la lista tendremos:

  • Toolbar + Spinner
  • RecyclerView
  • Floating Action Button

Adicionalmente en los demás estados de UI habrá:

  • Estado de carga: SwipeRefreshLayout
  • Menú: MenuItem
  • Empty State: ImageView + TextView
  • Cita creada: Toast

Diseñar Layout De La Actividad

Al momento de crearse AppointmentsActivity, se añadió el layout activity_appointments.xml junto a content_appointments.xml.

Como sabes, el segundo se incluye en tiempo real automáticamente a través de la etiqueta <include> en el primero:

<?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">

    <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_appointments" />

    <android.support.design.widget.FloatingActionButton
        android:id="@+id/fab"
        android:layout_width="wrap_content"
        android:layout_height="wrap_content"
        android:layout_gravity="bottom|end"
        android:layout_margin="@dimen/fab_margin"
        app:srcCompat="@android:drawable/ic_dialog_email" />

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

Si te fijas en el diseño, ya tenemos todos los componentes excepto la lista y el spinner en la toolbar.

Pero bien…

Si traducimos las anteriores listas de elementos faltantes a pasos a seguir, tendríamos lo siguiente:

1. Añadir Spinner a la Toolbar: En el layout actual de la actividad, añade un spinner al interior de la Toolbar.

Observa:

<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">

    <Spinner
        android:id="@+id/toolbar_spinner"
        android:layout_width="wrap_content"
        android:layout_height="wrap_content"
        app:popupTheme="@style/AppTheme.PopupOverlay" />
</android.support.v7.widget.Toolbar>

2. Usar RecyclerView para la lista: Abre content_appointments.xml y reemplaza el TextView de ejemplo puesto por Android Studio por un RecyclerView de esta manera:

<?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">

    <android.support.v7.widget.RecyclerView
        android:id="@+id/list_appointments"
        android:layout_width="match_parent"
        android:layout_height="match_parent"
        android:paddingTop="8dp"
        app:layoutManager="LinearLayoutManager" />
</RelativeLayout>

3. Añadir SwipeRefreshLayout: Prosiguiendo con el diseño, el estado de carga es representado por un SwipeRefreshLayout.

Y ya sabes que este elemento debe envolver al contenido sobre el cual permitiremos el gesto Swipe Down.

Así que usa una etiqueta de este para envolver al RelativeLayout actual:

<?xml version="1.0" encoding="utf-8"?>
<android.support.v4.widget.SwipeRefreshLayout 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/refresh_layout"
    android:layout_marginLeft="16dp"
    android:layout_marginRight="16dp"
    android:layout_width="match_parent"
    android:layout_height="match_parent"
    app:layout_behavior="@string/appbar_scrolling_view_behavior">

    <RelativeLayout
        android:id="@+id/content_appointments"
        android:layout_width="match_parent"
        android:layout_height="match_parent">
<!--...-->

4. Añadir Action Button Para Radicar un nuevo PQRS: Cuando estudiamos la action bar (Toolbar), aprendimos que los action buttons son definidos en el recurso de menú (res/menu) asociado a la actividad.

Por eso, abre el archivo menu_appointments.xml y modifica el ítem que existe para que se convierta en nuestro item bocetado:

<menu 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"
    tools:context="com.hermosaprogramacion.blog.saludmock.AppointmentsActivity">
    <item
        android:id="@+id/action_add_pqrs"
        android:orderInCategory="1"
        android:title="@string/action_add_pqrs"
        app:showAsAction="never" />
</menu>

Si observas la previa, verás el siguiente resultado:

Nuevo action button para radicar PQRS

5. Añadir Empty State: El estado vacío es la composición de una imagen junto a un texto que informa que no existen citas si ese fuese el caso.

Normalmente usaremos el atributo de visibilidad programáticamente para ocultar este elemento y mostrar la lista, o viceversa. Por ende, ambos deben estar en el mismo grado por debajo del RecyclerView.

Así que crea un contenedor LinearLayout y acomoda un ImageView por encima de un TextView:

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

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

    <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/calendar_blank" />

    <TextView
        android:layout_width="wrap_content"
        android:layout_height="wrap_content"
        android:layout_gravity="center"
        android:text="No hay citas médicas" />
</LinearLayout>

IMPORTANTE: Para usar vectores en modo de compatibilidad (app:srcCompat) debes añadir la siguiente línea en tu archivo build.gradle a nivel de módulo:

android {
    compileSdkVersion 25
    buildToolsVersion "24.0.2"
    defaultConfig {
        ...
        vectorDrawables.useSupportLibrary = true
    }

Si seguiste las instrucciones tendrás una preview parecida a esta:

Empty State para citas médicas

Diseñar Layout De los Items De Lista

En un mock de alto nivel el diseño de cada ítem debe verse así:

Item de RecyclerView con Card para cita médica

El diseño no es nada del otro mundo, es muy intuitivo.

Los materiales visuales que se usan son:

  • Un view en la parte izquierda, cuyo color varía según el estado
  • Un text para la fecha (o dos si deseas separarlos)
  • Uuna línea de separación vertical (puede ser un tipo View con ancho de 1dp)
  • Una serie de texts verticales para los demás datos de la cita.
  • Y en el caso de las que están activas, un botón para cancelar.

Hay muchas formas de crear una definición XML para este layout. Y en mi caso este es el que usaré:

appointment_item_list.xml

<?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="130dp"
    android:clickable="true"
    android:foreground="?android:attr/selectableItemBackground"
    app:cardUseCompatPadding="true">

    <!-- Indicador de estado -->
    <View
        android:id="@+id/indicator_appointment_status"
        android:layout_width="8dp"
        android:layout_height="match_parent"
        android:layout_alignParentLeft="true"
        android:layout_alignParentStart="true"
        android:background="#E0E0E0" />

    <RelativeLayout
        android:layout_width="match_parent"
        android:layout_height="match_parent"
        android:paddingBottom="8dp"
        android:paddingEnd="16dp"
        android:paddingLeft="16dp"
        android:paddingRight="16dp"
        android:paddingTop="8dp">

        <TextView
            android:id="@+id/text_appointment_date"
            android:layout_width="wrap_content"
            android:layout_height="wrap_content"
            android:layout_centerVertical="true"
            android:layout_marginLeft="8dp"
            tools:text="7 Octubre 2017" />

        <View
            android:id="@+id/vertical_separator"
            android:layout_width="1dp"
            android:layout_height="match_parent"
            android:layout_alignParentTop="true"
            android:layout_marginLeft="16dp"
            android:layout_marginStart="16dp"
            android:layout_toEndOf="@+id/text_appointment_date"
            android:layout_toRightOf="@+id/text_appointment_date"
            android:background="#E0E0E0" />

        <TextView
            android:id="@+id/text_medical_service"
            style="@style/Base.TextAppearance.AppCompat.Body2"
            android:layout_width="wrap_content"
            android:layout_height="wrap_content"
            android:layout_marginLeft="16dp"
            android:layout_marginStart="16dp"
            android:layout_toEndOf="@+id/vertical_separator"
            android:layout_toRightOf="@+id/vertical_separator"
            tools:text="Consulta Medicina General" />

        <TextView
            android:id="@+id/text_doctor_name"
            android:layout_width="wrap_content"
            android:layout_height="wrap_content"
            android:layout_alignLeft="@+id/text_medical_service"
            android:layout_alignStart="@+id/text_medical_service"
            android:layout_below="@id/text_medical_service"
            tools:text="Con Jorge Ramos" />

        <Button
            android:id="@+id/button_cancel_appointment"
            style="@style/Base.Widget.AppCompat.Button.Borderless"
            android:layout_width="wrap_content"
            android:layout_height="wrap_content"
            android:layout_alignParentBottom="true"
            android:layout_marginLeft="4dp"
            android:layout_toRightOf="@id/vertical_separator"
            android:text="Cancelar"
            android:textSize="12sp" />

        <TextView
            android:id="@+id/text_medical_center"
            android:layout_width="wrap_content"
            android:layout_height="wrap_content"
            android:layout_above="@id/button_cancel_appointment"
            android:layout_alignLeft="@+id/text_doctor_name"
            android:layout_alignStart="@+id/text_doctor_name"
            android:layout_below="@+id/text_doctor_name"
            android:ellipsize="end"
            android:maxLines="1"
            tools:text="Clínica Central" />

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

Donde el RelativeLayout me ayudó a distribuir cada elemento basado en referencias relativas.

Ventana Component Tree para layout de item de lista

Recuerda que el uso de la card requiere agregar la dependencia compile 'com.android.support:cardview-v7:25.1.1'.

Ahora, si usas el atributo tools:listitem en el RecyclerView podrás observar una captura muy cercana al resultado final al ejecutar la app.

<android.support.v7.widget.RecyclerView
    android:id="@+id/list_appointments"
    android:layout_width="match_parent"
    android:layout_height="match_parent"
    app:layoutManager="LinearLayoutManager"
    tools:listitem="@layout/appointment_item_list" />

En la previa verás:

Previa de atributo tools:listitem en RecyclerView

 

Definir Campos De La Actividad

Continuando, abre AppointmentsActivity y agrega los campos necesarios para su funcionamiento.

Hablo de instancias de relaciones POO, constantes, variables globales, etc.

En nuestro caso serán las instancias UI de:

  • Lista
  • Adaptador de lista
  • Contenedor de Empty State

Así que antes de onCreate() declara dichos componentes:

public class AppointmentsActivity extends AppCompatActivity {

    private RecyclerView mAppointmentsList;
    // TODO: Agregar acceso al adaptador luego de crearlo
    private View mEmptyStateContainer;

La declaración del adaptador obviamente la expresamos como un TODO para cuando este exista.

Inicializar Los Views Y Controlar Sus Eventos

Sitúate al interior de onCreate() y obtén cada uno de los views que necesitamos.

En primer lugar será el spinner que actúa como filtro por estado. Al obtenerlo asegúrate de asignarle una escucha para escuchar el evento de click en sus ítems:

Spinner statusFilterSpinner = (Spinner) findViewById(R.id.toolbar_spinner);
statusFilterSpinner.setOnItemSelectedListener(new AdapterView.OnItemSelectedListener() {
    @Override
    public void onItemSelected(AdapterView<?> parent, View view, int position, long id) {
        // TODO: Ejecutar filtro de citas médicas
    }

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

    }
});

Seguido, inicializa la lista y deja expresada la intención de crear el adaptador cuando exista para generar la relación:

mAppointmentsList = (RecyclerView)findViewById(R.id.list_appointments);
// TODO: Inicializar adaptador y asignarlo a la lista

El contenedor que representa el estado vacío no tiene problemas:

mEmptyStateContainer = findViewById(R.id.empty_state_container);

El fab para fortuna de nosotros, ya tenía inicialización:

FloatingActionButton fab = (FloatingActionButton) findViewById(R.id.fab);
fab.setOnClickListener(new View.OnClickListener() {
    @Override
    public void onClick(View view) {
        Snackbar.make(view, "Se inicia actividad de creación de citas",
                Snackbar.LENGTH_LONG).setAction("Action", null).show();
    }
});

Y con el swipe refresh layout haz lo mismo y añade una escucha OnRefreshListener, la cual advierte en su controlador onRefresh() que la lista será recargada con información fresca:

SwipeRefreshLayout swipeRefreshLayout =
        (SwipeRefreshLayout) findViewById(R.id.refresh_layout);
swipeRefreshLayout.setOnRefreshListener(new SwipeRefreshLayout.OnRefreshListener() {
    @Override
    public void onRefresh() {
        // TODO: Pedir al servidor información reciente
    }
});

Cargar Lista De Citas Médicas En El RecyclerView

Es hora de codificar el comportamiento principal: mostrar las citas médicas en el recycler.

En esencia debemos preparar un nuevo adaptador personalizado que se alimente de una lista de POJOs.

Dicha lista será el resultado de usar el adaptador de Retrofit para ejecutar una petición GET hacia /appointments.

Con esto en mente, programemos…

1. Crear POJO De Citas Médicas

Hemos llegado a un punto determinante:

Vamos a crear una entidad de dominio llamada AppointmentDisplayList, la cual servirá como recipiente del parsing JSON de la petición de citas con parámetro display=list.

(En los objetos de citas médicas con formato JSON puedes incluir como atributo un objeto para el doctor asignado, y dentro del doctor un objeto del centro médico. Sería otra forma de realizarlo, por lo que la siguiente solución no te sería de utilidad)

Entonces…

Crea la clase en data/api/model y añade todos los atributos vistos al diseñar la base de datos. Adicionalmente genera el constructor y los métodos get*():

public class AppointmentDisplayList {

    // estados:
    public static List<String> STATES_VALUES =
            Arrays.asList("Todas", "Activas", "Cumplidas", "Canceladas");

    @SerializedName("id")
    private int mId;
    @SerializedName("dateAndtime")
    private Date mDateAndTime;
    @SerializedName("service")
    private String mService;
    @SerializedName("status")
    private String mStatus;
    @SerializedName("doctor")
    private String mDoctor;
    @SerializedName("medicalCenter")
    private String mMedicalCenter;

    public AppointmentDisplayList(int id, Date dateAndTime, String service,
                                  String status, String doctor, String medicalCenter) {
        mId = id;
        mDateAndTime = dateAndTime;
        mService = service;
        mStatus = status;
        mDoctor = doctor;
        mMedicalCenter = medicalCenter;
    }

    public int getId() {
        return mId;
    }

    public Date getDateAndTime() {
        return mDateAndTime;
    }

    public String getService() {
        return mService;
    }

    public String getStatus() {
        return mStatus;
    }

    public String getDoctor() {
        return mDoctor;
    }

    public String getMedicalCenter() {
        return mMedicalCenter;
    }

    public void setId(int mId) {
        this.mId = mId;
    }

    public void setDateAndTime(Date mDateAndTime) {
        this.mDateAndTime = mDateAndTime;
    }

    public void setService(String mService) {
        this.mService = mService;
    }

    public void setStatus(String mStatus) {
        this.mStatus = mStatus;
    }

    public void setDoctor(String mDoctor) {
        this.mDoctor = mDoctor;
    }

    public void setMedicalCenter(String mMedicalCenter) {
        this.mMedicalCenter = mMedicalCenter;
    }
}

Crear Entidad De Dominio Para La Respuesta

Se hace necesario crear una clase que permita a Retrofit realizar el parsing JSON implícito, basado en la forma de la respuesta que tiene el recurso de citas médicas.

Recuerda que la respuesta es un objeto JSON con un array interno llamado "results".

Y si interpretamos esta sintaxis en Java, entonces crearemos una nueva clase llamada ApiResponseAppointments con una lista interna (ubícala dentro de data/api/model):

public class ApiResponseAppointments {
    private List<AppointmentDisplayList> results;

    public ApiResponseAppointments(List<AppointmentDisplayList> results) {
        this.results = results;
    }

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

Al momento de realizar la petición simplemente obtenemos el contenido de results y podremos acceder a nuestras citas médicas.

2. Crear Adaptador Personalizado

Crea una nueva clase Java para el adaptador llamada AppointmentsAdapter en el paquete ui.

Extiéndela de RecyclerView.Adapter y sobrescribe los métodos onBindViewHolder(), onCreateViewHolder() y getItemCount().

public class AppointmentsAdapter extends RecyclerView.Adapter<RecyclerView.ViewHolder> {
    @Override
    public RecyclerView.ViewHolder onCreateViewHolder(ViewGroup parent, int viewType) {
        return null;
    }

    @Override
    public void onBindViewHolder(RecyclerView.ViewHolder holder, int position) {

    }

    @Override
    public int getItemCount() {
        return 0;
    }
}

En primero lugar definiremos los campos que usará el adaptador para su funcionamiento interno.

En este caso serán una lista de objetos AppointmentDisplayList como origen de datos y el contexto donde vive el adaptador.

Además declara una instancia de una escucha personalizada que detecte clicks en los ítems y clicks en el botón de cancelar:

private List<AppointmentDisplayList> mItems;

private Context mContext;

private OnItemClickListener mOnItemClickListener;

interface OnItemClickListener {

    void onItemClick(AppointmentDisplayList clickedAppointment);

    void onCancelAppointment(AppointmentDisplayList canceledAppointment, int position);

}

Lo siguiente es añadir un constructor para conseguir las instancias del contexto y la lista. Para la dependencia de la escucha usaremos métodos get y set:

public AppointmentsAdapter(Context context, List<AppointmentDisplayList> items) {
    mItems = items;
    mContext = context;
}

public OnItemClickListener getOnItemClickListener() {
    return mOnItemClickListener;
}

public void setOnItemClickListener(OnItemClickListener onItemClickListener) {
    mOnItemClickListener = onItemClickListener;
}

Ahora, añade un ViewHolder personalizado que contenga las referencias de UI existentes en el layout de ítems.

Además de ello, implementa sobre este la escucha View.OnClickListener para realizar un puente hacia nuestra escucha personalizada OnItemClickListener.

Y no olvides setear otra escucha para el botón, por si la cita puede cancelarse.

public class ViewHolder extends RecyclerView.ViewHolder implements View.OnClickListener {
    public TextView date;
    public TextView service;
    public TextView doctor;
    public TextView medicalCenter;
    public Button cancelButton;
    public View statusIndicator;

    public ViewHolder(View itemView) {
        super(itemView);

        statusIndicator = itemView.findViewById(R.id.indicator_appointment_status);
        date = (TextView) itemView.findViewById(R.id.text_appointment_date);
        service = (TextView) itemView.findViewById(R.id.text_medical_service);
        doctor = (TextView) itemView.findViewById(R.id.text_doctor_name);
        medicalCenter = (TextView) itemView.findViewById(R.id.text_medical_center);
        cancelButton = (Button) itemView.findViewById(R.id.button_cancel_appointment);

        cancelButton.setOnClickListener(new View.OnClickListener() {
            @Override
            public void onClick(View v) {
                int position = getAdapterPosition();
                if (position != RecyclerView.NO_POSITION) {
                    mOnItemClickListener.onCancelAppointment(mItems.get(position), position);
                }
            }
        });
        itemView.setOnClickListener(this);
    }

    @Override
    public void onClick(View v) {
        int position = getAdapterPosition();
        if (position != RecyclerView.NO_POSITION) {
            mOnItemClickListener.onItemClick(mItems.get(position));
        }
    }
}

Seguido vamos a definir la forma en que se inflan los elementos en onCreateViewHolder().

La idea es que obtengas una instancia del LayoutInflater con el contexto que existe como campo del adaptador y procedas a inflar el layout R.layout.appointment_item_list:

public class AppointmentsAdapter extends RecyclerView.Adapter<RecyclerView.ViewHolder> {

    //...


    @Override
    public RecyclerView.ViewHolder onCreateViewHolder(ViewGroup parent, int viewType) {
        LayoutInflater layoutInflater = LayoutInflater.from(mContext);
        View view = layoutInflater.inflate(R.layout.appointment_item_list, parent, false);
        return new ViewHolder(view);
    }

}

Posteriormente, relaciona los datos de cada cita con los views en el view holder correspondiente, de acuerdo a la posición procesada en onBindViewHolder().

Esta es la parte donde ligamos la lógica a la vista.

Uno de los aspectos a codificar es el cambio de color del view indicador de estado.

Codifica: Dependiendo del valor del item actual, usa setBackgroundResource() y asignale un color respectivo (defínelos primero en res/values/colors.xml):

@Override
public void onBindViewHolder(ViewHolder holder, int position) {
    AppointmentDisplayList appointment = mItems.get(position);

    View statusIndicator = holder.statusIndicator;

    // estado: se colorea indicador según el estado
    switch (appointment.getStatus()) {
        case "Activa":
            // mostrar botón
            holder.cancelButton.setVisibility(View.VISIBLE);
            statusIndicator.setBackgroundResource(R.color.activeStatus);
            break;
        case "Cumplida":
            // ocultar botón
            holder.cancelButton.setVisibility(View.GONE);
            statusIndicator.setBackgroundResource(R.color.completedStatus);
            break;
        case "Cancelada":
            // ocultar botón
            holder.cancelButton.setVisibility(View.GONE);
            statusIndicator.setBackgroundResource(R.color.cancelledStatus);
            break;
    }

    holder.date.setText(appointment.getDateAndTimeForList());
    holder.service.setText(appointment.getService());
    holder.doctor.setText(appointment.getDoctor());
    holder.medicalCenter.setText(appointment.getMedicalCenter());
}

Sobrescribe el método getItemCount() para que retorne la cantidad de elementos en el campo mItems:

public class AppointmentsAdapter extends RecyclerView.Adapter<AppointmentsAdapter.ViewHolder> {

    //...

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

}

Ya para terminar con este componente, crea un método personalizado para cambiar los datos de la lista llamado swapItems():

public void swapItems(List<Appointment> appointments) {
    if (appointments == null) {
        mItems = new ArrayList<>(0);
    } else {
        mItems = appointments;
    }
    notifyDataSetChanged();
}

Recuerda usar notifyDataSetChanged() o algún método notify*() cuando vayas a operar un elemento dentro del adaptador.

3. Relacionar Adaptador Con La Lista

Con todo y lo anterior, dirígete a AppointmentsActivity para crear el adaptador y relacionarlo con el recycler en onCreate().

Asegúrate haber declarado su instancia global con anterioridad:

private AppointmentsAdapter mAppointmentsAdapter;

Y luego inicializalo y setealo a la lista:

mAppointmentsList = (RecyclerView) findViewById(R.id.list_appointments);
mAppointmentsAdapter = new AppointmentsAdapter(this, new ArrayList<AppointmentDisplayList>(0));
mAppointmentsAdapter.setOnItemClickListener(new AppointmentsAdapter.OnItemClickListener() {
    @Override
    public void onItemClick(Appointment clickedAppointment) {
        // TODO: Codificar acciones de click en items
    }
});
mAppointmentsList.setAdapter(mAppointmentsAdapter);

Como puedes notar, al crear el adaptador no añadimos información alguna.

Esto es porque lo haremos desde onResume(). Pero primero…

4. Soportar Peticiones GET Al Servicio REST

Ve a la clase SaludMockApi y añade un método llamado getAppointments().

Ya sabemos que la URL base es /appointments, que tendremos dos posibles parámetros(status y display) y que se requiere en el header de autorización el token del afiliado.

Codificando…

public interface SaludMockApi {

    // ...

    @GET("appointments")
    Call<List<Appointment>> getAppointments(@Header("Authorization") String token,
                                            @QueryMap Map<String, Object> parameters);

}

5. Obtener Citas Médicas Asíncronamente

A continuación, ve a la actividad de citas médicas y añade dos instancias globales nuevas para Retrofit y SaludMockApi:

public class AppointmentsActivity extends AppCompatActivity {

    private Retrofit mRestAdapter;
    private SaludMockApi mSaludMockApi;
    //...
}

Inicializa ambos elementos al final de onCreate() de la misma forma que vimos al crear el login.

Solo que esta vez el convertidor Gson se le debe relacionar el formato de fecha que usaremos al construir su instancia con GsonBuilder y usar el método setDateFormat().

De lo contrario, Retrofit provocará una falla en el parsing:

@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);
}

Luego sobrescribe el método del ciclo de vida de la actividad onResume() y expresa la carga de datos:

@Override
protected void onResume() {
    super.onResume();
    // TODO: Cargar citas médicas
}

Muy bien, el objetivo es crear un método reusable que dispare la carga de citas médicas con Retrofit teniendo en cuenta el filtro del estado.

Además, este debe de encargarse de llamar a los estados de carga y vacío si la situación lo amerita.

El camino feliz…

Lo que esperamos que suceda idealmente es:

  1. El usuario abre por primera vez la app
  2. Se muestra un estado de carga
  3. Se piden los datos al servidor. ¿La petición fue exitosa?
    1. SI
      1. ¿Llegó al menos uno?
        1. Poblar adaptador
        2. Ocultar estado de carga
        3. Mostrar lista de citas médicas
      2. ¿No llegó nada?
        1. Ocultar estado de carga
        2. Mostrar empty state
    2. NO
      1. Procesar error

Si fuésemos a aplicar esta lógica en un nuevo método llamado loadAppointments(), sería:

@Override
protected void onResume() {
    super.onResume();
    // TODO: Obtener estado actual
    loadAppointments(status);
}

public void loadAppointments(String status) {
    // TODO: Mostrar estado de carga

    // TODO: Obtener token de usuario

    Call<List<AppointmentDisplayList>> call = mSaludMockApi.getAppointments(token, status);
    call.enqueue(new Callback<List<AppointmentDisplayList>>() {
        @Override
        public void onResponse(Call<List<AppointmentDisplayList>> call,
                               Response<List<AppointmentDisplayList>> response) {
            if (!response.isSuccessful()) {
                // TODO: Procesar error de API
                return;
            }

            List<AppointmentDisplayList> serverAppointments = response.body();

            if (serverAppointmentDisplayLists.size() > 0) {
                mAppointmentsAdapter.swapItems(serverAppointments);
                // TODO: Ocultar estado de carga
                // TODO: Mostrar lista de citas médicas
            } else {
                // TODO: Ocultar estado de carga
                // TODO: Mostrar empty state
            }
        }

        @Override
        public void onFailure(Call<List<AppointmentDisplayList>> call, Throwable t) {
            Log.d("Falla Retrofit", t.getMessage());
        }
    });
}

Poblar Spinner Con Opciones De Filtro

Empezaré por considerar a la clase AppointmentDisplayList como el origen de las opciones del filtro.

Dentro de esta crea una lista de clase para poder acceder desde la actividad. Los valores a guardar son "Todas", "Activas", "Cumplidas" y "Canceladas":

public class AppointmentDisplayList {

    // Estados:
    public static List<String> STATES_VALUES = 
            Arrays.asList("Todas", "Activas", "Cumplidas", "Canceladas");

    //...
}

Luego crea el adaptador en la actividad y asignalo al spinner:

ArrayAdapter<String> statusFilterAdapter =
        new ArrayAdapter<>(
                getApplicationContext(),
                android.R.layout.simple_spinner_item,
                AppointmentDisplayList.STATES_VALUES);
mStatusFilterSpinner.setAdapter(statusFilterAdapter);
statusFilterAdapter.setDropDownViewResource(android.R.layout.simple_spinner_dropdown_item);

Como resultado tendrás:

Filtro con Spinner en la Toolbar

Obtener Estado Actual Del Spinner

Antes de llamar al método de carga de citas, previamente se debe conseguir el estado actual.

La forma más sencilla de hacerlo es crear un método getCurrentState() que retorne un String con el texto actual del spinner:

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

private String getCurrentState() {
    String status = (String) mStatusFilterSpinner.getSelectedItem();
    return status;
}

Mostrar El Estado De Carga

Aquí mostramos la animación del indicar en el SwipeRefreshLayout.

Para hacerlo, crea un método llamado showLoadingIndicator() que reciba un parámetro booleano que active/desactive la carga.

Recuerda que para mostrar la animación se usa el método setRefreshing():

private void showLoadingIndicator(final boolean show) {
    final SwipeRefreshLayout refreshLayout =
            (SwipeRefreshLayout) findViewById(R.id.refresh_layout);
    refreshLayout.post(new Runnable() {
        @Override
        public void run() {
            refreshLayout.setRefreshing(show);
        }
    });
}

IMPORTANTE: Reemplaza los TODOs que expresen el uso del indicador de carga con la invocación de showLoadingIndicator().

Al provocar la carga podrás ver el indicador circular:

Estado de carga con SwipeRefreshLayout en RecyclerView

Obtener Token De Usuario

Con el fin de obtener el token del usuario actualmente logueado, usaremos la clase SessionPrefs.

La solución consiste en crearle un nuevo método llamado getToken(), el cual retorne el valor guardado con la clave PREF_AFFILIATE_TOKEN:

public String getToken(){
    return mPrefs.getString(PREF_AFFILIATE_TOKEN, null);
}

De esta forma, vamos a la carga de citas y complementamos la petición al servidor:

private void loadAppointments(String status) {
    // Mostrar estado de carga
    showLoadingIndicator(true);

    // Obtener token de usuario
    String token = SessionPrefs.get(this).getToken();

    Call<List<AppointmentDisplayList>> call = mSaludMockApi.getAppointments(token, status);
    //...

Procesar Errores De La API De SaludMock

La representación de errores para el usuario puede ser a través de Toasts.

Para ello crearemos un método llamado showErrorMessage() que reciba un String para poblar al Toast.

private void showErrorMessage(String error) {
    Toast.makeText(this, error, Toast.LENGTH_LONG).show();
}

Para usarlo cuando la llamada a la API produzca un error, primero es necesario decodificar la respuesta que se obtuvo.

Anteriormente teníamos a la clase ApiError para este cometido.

¿Cómo seguir?

Al igual que en el login, comprueba el resultado de errorBody() y verifica si puede interpretarse en formato JSON:

@Override
public void onResponse(Call<List<AppointmentDisplayList>> call,
                       Response<List<Appointment>> response) {
    if (!response.isSuccessful()) {
        // Procesar error de API
        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 {
                // Reportar causas de error no relacionado con la API
                Log.d(TAG, response.errorBody().string());
            } catch (IOException e) {
                e.printStackTrace();
            }
        }

        showLoadingIndicator(false);
        showErrorMessage(error);
        return;
    }
//...

Mostrar Lista De Citas Médicas

Puede que la lista haya sido escondida por no haber existido elementos anteriormente, así que diseñaremos un método que reestablezca su visión llamado showAppointments().

Este hará uso de setVisibility() para ocultar el view para el empty state y revelar el RecyclerView.

private void showAppointments(List<AppointmentDisplayList> serverAppointments) {
    mAppointmentsAdapter.swapItems(serverAppointments);

    mAppointmentsList.setVisibility(View.VISIBLE);
    mEmptyStateContainer.setVisibility(View.GONE);
}

Implementalo en loadAppointments():

@Override
public void onResponse(Call<List<AppointmentDisplayList>> call,
                       Response<List<AppointmentDisplayList>> response) {
    //...

    List<AppointmentDisplayList> serverAppointments = response.body();

    if (serverAppointments.size() > 0) {
        // Mostrar lista de citas médicas
        showAppointments(serverAppointments);
    } else {
        // TODO: Mostrar empty state
    }

    showLoadingIndicator(false);
}

De esta forma cuando la lista sea poblada con la info del servidor tendrás una scren como esta:

Lista de citas médicas app Android

Mostrar Empty State

Terminando el método de carga, mostraremos el contenedor del mensaje de ausencia de datos.

Crea un método llamado showNoAppointments() y haz lo contrario a lo que hicimos en su contraparte:

private void showNoAppointments() {
    mAppointmentsList.setVisibility(View.GONE);
    mEmptyStateContainer.setVisibility(View.VISIBLE);
}

Luego invocalo en la carga:

call.enqueue(new Callback<List<AppointmentDisplayList>>() {
    @Override
    public void onResponse(Call<List<AppointmentDisplayList>> call,
                           Response<List<AppointmentDisplayList>> response) {
        //...

        if (serverAppointments.size() > 0) {
            // Mostrar lista de citas médicas
            showAppointments(serverAppointments);
        } else {
            // Mostrar empty state
            showNoAppointments();
        }

        showLoadingIndicator(false);
    }

Cuando ejecutes podrás ver una screen como esta:

Empty State de citas médicas

Ejecutar Filtro Por Estado

Propagar la elección de un ítem en el spinner de estados, se traduce a la invocación del método loadAppointments() con el nuevo valor.

(Cabe aclarar que puedes utilizar una estrategia para salvar las citas médicas en caché y filtrarlas desde allí cuando el usuario modifique la opción del spinner, sin tener que pedir de nuevo los datos al servidor, si así te parece)

En pocas palabras, vayamos a onCreate() e invoquemos el método en la escucha del spinner:

mStatusFilterSpinner.setOnItemSelectedListener(new AdapterView.OnItemSelectedListener() {
    @Override
    public void onItemSelected(AdapterView<?> parent, View view, int position, long id) {
        // Ejecutar filtro de citas médicas
        String status = parent.getItemAtPosition(position).toString();
        loadAppointments(status);
    }

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

    }
});

Refrescar Datos Desde El Servidor

El gesto de arrastre hacia abajo activa la escucha del swipe refresh layout con el objetivo de sincronizar los datos.

Con toda razón, invoca la carga de citas en la escucha del refresh layout:

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

    SwipeRefreshLayout swipeRefreshLayout =
            (SwipeRefreshLayout) findViewById(R.id.refresh_layout);
    swipeRefreshLayout.setOnRefreshListener(new SwipeRefreshLayout.OnRefreshListener() {
        @Override
        public void onRefresh() {
            // Pedir al servidor información reciente
            mStatusFilterSpinner.setSelection(STATUS_FILTER_DEFAULT_VALUE);
            loadAppointments(getCurrentState());
        }
    });

    //...
}

Si deseas, reinicia el filtro a su estado por defecto (yo elegí la posición 0 a través de la constante que vez arriba) antes de realizar la carga para recargar todos los datos existentes.

 

Cancelar Citas Médicas

Ya tenemos preparada la escucha que procesa los clicks en el botón de cancelar de aquellas citas activas.

Lo que sigue es crear un método que envíe la petición al servidor con Retrofit para cambiar la columna status a “Cancelada”.

¿De que consta dicha interacción?

Básicamente debemos:

  1. Crear entidad de dominio para recibir la respuesta de la petición web (esto es opcional si has decidido no retornar nada)
  2. Añadir método de cancelación en SaludMockApi
  3. Invocar el método dentro de la escucha de clicks del adaptador
  4. Procesar respuesta del server. Si es correcta, recarga la lista.

¿List@?

Miremos el proceso:

1. Crear POJO Para Respuesta

Ya sabemos que la respuesta correcta consta solo del estado y un mensaje.

Esto nos facilita las cosas, así que agrega una clase llamada MessageApiResponse que presente la anterior descripción:

public class ApiMessageResponse {
    
    @SerializedName("status")
    private int mStatus;
    @SerializedName("message")
    private String mMessage;

    public ApiMessageResponse(int status, String message) {
        mStatus = status;
        mMessage = message;
    }

    public int getStatus() {
        return mStatus;
    }

    public String getMessage() {
        return mMessage;
    }
}

2. Crear Controlador Para Cancelar Citas

Esta parte es fácil.

Abrimos SaludMockApi y agregamos un método llamado cancelAppointment(), el cual siga el diseño de la petición web creada.

Es decir:

  • Cabecera fija con tipo application/json
  • Anotación @PATCH
  • Segmento dinámico para el id de la cita
  • Cabecera dinámica para autorización
  • Cuerpo HashMap para enviar el nuevo valor del estado

En código esto significa:

public interface SaludMockApi {

    //...

    @Headers("Content-Type: application/json")
    @PATCH("appointments/{id}")
    Call<ApiMessageResponse> cancelAppointment(@Path("id") int appoitmentId,
                                               @Header("Authorization") String token,
                                               @Body HashMap<String, String> statusMap);

}

Es importante que especifiques que el segmento del id será dinámico para referenciar cualquier elemento del adaptador que se quiera cancelar.

Además como solo enviaremos un nuevo valor para el campo del estado, un HashMap nos cae muy bien.

3. Invocar Método Al Clickear Botón De Cancelar

¡Sencillo!

Vuelve a la escucha anónima declarada para el adaptador y declara la invocación de un nuevo método llamado cancelAppointment() en la actividad.

Si mantenemos la congruencia a la petición, entonces este debería recibir el ID de la cita:

mAppointmentsAdapter.setOnItemClickListener(new AppointmentsAdapter.OnItemClickListener() {
    @Override
    public void onItemClick(AppointmentDisplayList clickedAppointment) {
        // TODO: Codificar acciones de click en items
    }

    @Override
    public void onCancelAppointment(AppointmentDisplayList canceledAppointment) {
        // Cancelar cita
        cancelAppointmnent(canceldAppointment.getId());
    }
});

4. Realizar Petición Y Procesar Respuesta

Dentro de cancelAppointment() enviaremos la petición HTTP con Retrofit para cancelar.

Solo realizamos el proceso convencional. Con algunas diferencias:

  • Prepara un HashMap previo a la petición. Ya sabes que este actua como el cuerpo.
  • Si la respuesta es exitosa, ordenale al adaptador recargar las citas para notar el cambio.
  • (Opcional) Muestra/oculta el view de progreso del item que se cancela mientras se realiza la petición.

Hazlo de esta forma:

private void cancelAppointmnent(int appointmentId, final int position) {
    // TODO: Mostrar estado de carga

    // Obtener token de usuario
    String token = SessionPrefs.get(this).getToken();

    // Preparar cuerpo de la petición
    HashMap<String, String> statusMap = new HashMap<>();
    statusMap.put("status", "Cancelada");

    // Enviar petición
    mSaludMockApi.cancelAppointment(appointmentId, token, statusMap).enqueue(
            new Callback<ApiMessageResponse>() {
                @Override
                public void onResponse(Call<ApiMessageResponse> call,
                                       Response<ApiMessageResponse> response) {
                    if (!response.isSuccessful()) {
                        // Procesar error de API
                        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 {
                                // Reportar causas de error no relacionado con la API
                                Log.d(TAG, response.errorBody().string());
                            } catch (IOException e) {
                                e.printStackTrace();
                            }
                        }

                        // TODO: Ocultar estado de carga
                        showErrorMessage(error);
                        return;
                    }

                    // Cancelación Exitosa
                    Log.d(TAG, response.body().getMessage());
                    loadAppointments(getCurrentState());
                    // TODO: Ocultar estado de carga
                }

                @Override
                public void onFailure(Call<ApiMessageResponse> call, Throwable t) {
                    // TODO: Ocultar estado de carga
                    Log.d(TAG, "Petición rechazada:" + t.getMessage());
                    showErrorMessage("Error de comunicación");
                }
            }
    );
}

¡Tutorial terminado!

Todos los objetivos propuestos al inicio están cumplidos :)

Así que ejecuta tu aplicación y observa que todo funcione.

Ahora es tu turno…

Deja un comentario en la parte inferior para hacerme saber que dificultades que tuviste, o las recomendaciones, errores o apreciaciones que tengas.

¡Y comparte este artículo con los tuyos!