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 :
- Parte I: Tutorial Retrofit En Android: Planificación Aplicación Médica
- Parte II: Crear Login En Android Con Retrofit
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.
[sociallocker id=»7121″][/sociallocker]
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:
- Citas Médicas En El Servicio REST
- Procesar petición para obtener citas más parámetros
- Procesar petición para modificar citas
- Testear web service
- Citas Médicas En La Aplicación Android
- Diseñar la interfaz de la actividad de citas
- Cargar citas en una lista
- Filtrar citas médicas con el spinner
- 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 afiliadosdoctor_id
: Vinculo con el doctor que debe ver al pacientedate_and_time
: Fecha y hora asignada a la citaservice
: 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édicosaddress
: 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":"Oftalmologu00eda", "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:
Al presionar Send y haber puesto un token correcto, podrás ver una respuesta similar a la siguiente:
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 citalist
: Obtiene todas las columnas exceptodoctor_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.
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 cabeceraContent-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:
En mi caso por ejemplo estoy modificando un cita con identificador 10000025.
Y el resultado de la petición fue:
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:
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:
Diseñar Layout De los Items De Lista
En un mock de alto nivel el diseño de cada ítem debe verse así:
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.
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:
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:
- El usuario abre por primera vez la app
- Se muestra un estado de carga
- Se piden los datos al servidor. ¿La petición fue exitosa?
- SI
- ¿Llegó al menos uno?
- Poblar adaptador
- Ocultar estado de carga
- Mostrar lista de citas médicas
- ¿No llegó nada?
- Ocultar estado de carga
- Mostrar empty state
- ¿Llegó al menos uno?
- NO
- Procesar error
- SI
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:
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:
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:
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:
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:
- Crear entidad de dominio para recibir la respuesta de la petición web (esto es opcional si has decidido no retornar nada)
- Añadir método de cancelación en
SaludMockApi
- Invocar el método dentro de la escucha de clicks del adaptador
- 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!
Ú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!