Con este artículo terminamos la serie de tutoriales para Retrofit en Android. Esta vez trataremos el caso de uso para radicar una PQRS en el sistema. La parte vital de aprendizaje será la forma en que haremos para subir una imagen al servidor, la cual será tomada desde la aplicación de cámara de Android con el fin de proporcionar evidencia fotográfica para el PQR.
En este caso, el uso del tipo multipart/form-data en la petición POST nos permitirá cumplir este objetivo.
Los requisitos para llevar a cabo este tutorial son:
- Retrofit En Android Parte 1: Planeación De Aplicación De Citas Médicas
- Retrofit En Android Parte 2: Crear Login De Usuario
- Retrofit En Android Parte 3: Obtener Citas Médicas
- Retrofit En Android Parte 4: Crear Citas Médicas
Este artículo se dividirá en las siguientes partes:
- Subir Una Imagen Con Retrofit
- Crear PHP REST Service Para Obtener Tipos De PQRS
- Crear PHP REST Service Para Crear PQRS
- Consumir PHP REST Service En Android
Descargar Proyecto Android Y REST API
El anterior video muestra el funcionamiento final de este tutorial.
Descarga el proyecto Android Studio y la API PHP desde el siguiente botón:
[sociallocker id=»7121″][/sociallocker]
Tener el código te ayudará a encontrar declaraciones de variables o recursos que por simplicidad no incluyo en la redacción.
Subir Al Servidor Una Imagen Con Retrofit
Para subir una imagen al servidor podemos seguir la siguiente receta:
- Usar
@Multipart
sobre la llamada Retrofit - Añadir parámetros a la llamada marcados con
@Part
. UsarRequestBody
para datos elementales yMultipartBody.Part
para archivos. - Usar los métodos
RequestBody.create()
yMultipartBody.Part.createFormData()
para generar los parámetros - Pasar los parámetros a la llamada y ejecutarla
Veamos:
Anotación @Multipart
Para enviar un archivo al servidor junto a otros datos haremos uso del tipo multipart/form-data
en la estructura de la petición POST para representar una creación.
Esto se logra usando la anotación @Multipart
sobre la llamada de la interfaz Retrofit y agregando parámetros de tipo @Part
:
@Multipart @POST("examples") Call<ExampleResponse> createExample(...);
Clases RequestBody y MultipartBody
Los tipos claves son RequestBody
y su subclase MultipartBody
.
Donde el primero representa el contenido a enviar para cada parte a modo de formulario.
Y el segundo contenido especial para asociar archivos elegidos por el usuario a través de la clase interna MultipartBody.Part
.
@Multipart @POST("examples") Call<ExampleResponse> createExample(@Part MultipartBody.Part image, @Part("description") RequestBody description);
El anterior ejemplo tiene como primer parámetro la imagen a subir y una descripción.
@Part
puede recibir como parámetro la clave a usar al momento de componer el dato multipart.
Crear Partes Del Cuerpo Con RequestBody.create() y MultipartBody.Part.createFormData()
Al momento previo de enviar la petición podemos usar el método RequestBody.create()
para crear una instancia asociada al tipo de contenido de la parte y su contenido:
RequestBody descriptionBody = RequestBody.create(MultipartBody.FORM, "Alguna descripción"); RequestBody imageBody = RequestBody.create(MediaType.parse("image/*"), imageFile);
Y luego adaptar los parámetros necesarios con MultipartBody.Part.createFormData()
, el cual recibe como parámetros el clave de la parte, el nombre del archivo y el objeto RequestBody
:
MultiparBody.Part imagePart = MultipartBody.Part.createFormData("picture", image.getName(), imageBody);
Nota: La clase MediaType
provee el método parse()
para producir tipos de contenido a partir de strings. En el ejemplo anterior usamos el valor MultipartBody.FORM
el cual equivale a multipart/form-data
y para la imagen pasamos el tipo image/*
con el fin de establecer un formato de imagen en esta parte.
Ejecutar Petición Retrofit
Finalmente ejecutas la llamada con los parámetros correspondientes:
Call<ExampleResponse> createExample = retrofitClient.createIssue(imagePart, descriptionBody); createExample.enqueue(new Callback<ExampleResponse>() { @Override public void onResponse(Call<ExampleResponse> call, Response<ExampleResponse> response) { } @Override public void onFailure(Call<ExampleResponse> call, Throwable t) { } });
Paso 1. Crear Servicio Web Para Obtener Tipos De PQRS
La primera petición web que vamos a manejar es la obtención del JSON con todos los tipos de PQRS que necesitaremos inflar sobre el Spinner visto en el boceto:
1.1 Crear Tabla Para Tipos De PQRS En MySQL
Abre phpMyAdmin para administrar la base de datos salud_mock
y crea la tabla issue_type
:
CREATE TABLE `issue_type` ( `id` INT(11) NOT NULL AUTO_INCREMENT, `name` VARCHAR(64) NOT NULL, PRIMARY KEY (`id`) )
Luego inserta los siguientes tipos que existirán para los PQRS de SaludMock:
INSERT INTO salud_mock.issue_type (name) VALUES ('Afiliaciones y novedades'); INSERT INTO salud_mock.issue_type (name) VALUES ('Atención al usuario'); INSERT INTO salud_mock.issue_type (name) VALUES ('Copagos y cuotas moderadoras'); INSERT INTO salud_mock.issue_type (name) VALUES ('Devolución de aportes'); INSERT INTO salud_mock.issue_type (name) VALUES ('Medicamentos');
1.2 Diseñar URL Para Tipos De PQRS
A este recurso lo nombraremos Issue Types, por lo que la palabra a usar será issue_types:
GET http://localhost/saludmock/v1/issue_types
1.3 Diseñar Respuesta Para Tipos De PQRS
El JSON que será retornado por el servicio web como respuesta será un array de objetos compuestos por los dos atributos actuales de la tabla issue_type
:
[ { "id": "1", "name": "Afiliaciones y novedades" }, { "id": "2", "name": "Atenciu00f3n al usuario" }, { "id": "3", "name": "Copagos y cuotas moderadoras" }, { "id": "4", "name": "Devoluciu00f3n de aportes" }, { "id": "5", "name": "Medicamentos" } ]
1.4 Enrutar Recurso En index.php
Abre el archivo index.php y agrega al arreglo de recursos existentes el string "issue-types"
:
$apiResources = array( 'affiliates', 'appointments', 'medical-centers', 'doctors', 'issue-types');
1.5 Crear Controlador Para Tipos De PQRS
Añade una nueva clase llamada issue_types
al interior de la carpeta controllers y pega el siguiente código:
<?php /** * Controlador de tipos de PQRS */ require_once 'AuthorizationManager.php'; class issue_types { public static function get($urlSegments){ $affiliateId = AuthorizationManager::getInstance()->authorizeAffiliate(); if (isset($urlSegments[1])) { throw new ApiException( 400, 0, "El recurso está mal referenciado", "http://localhost", "El recurso $_SERVER[REQUEST_URI] no esta sujeto a resultados" ); } $issueTypes= self::retrieveIssueTypes(); return $issueTypes; } private static function retrieveIssueTypes(){ try { $pdo = MysqlManager::get()->getDb(); $query = "SELECT * FROM issue_type"; $preStm = $pdo->prepare($query); if ($preStm->execute()) { return $preStm->fetchAll(PDO::FETCH_ASSOC); } else { throw new ApiException( 500, 0, "Error de base de datos en el servidor", "http://localhost", "Hubo un error ejecutando una sentencia SQL en la base de datos. Detalles:" . $pdo->errorInfo()[2] ); } } catch (PDOException $e) { throw new ApiException( 500, 0, "Error de base de datos en el servidor", "http://localhost", "Ocurrió el siguiente error al consultar los tipos de PQR: " . $e->getMessage()); } } }
Como ves, en el método get()
procesamos la petición para la obtención de tipos de PQRS. Al igual que en los demás recursos, autorizamos a usuario a través de la clase AuthorizationManager
.
Luego llamamos al método retrieveIssueTypes()
, el cual realiza la consulta en la base de datos para sobre la tabla issue_types
.
Al final de get()
retornamos el array asociativo que es arrojado por fetchAll()
.
Nota: Agrega la sentencia require 'controllers/issue_types.php'
en index.php.
1.5 Testear Petición Con PostMan
Abre Postman y setea las siguientes características:
- Método HTTP:
GET
- URL:
http://localhost/saludmock/v1/issue-types
- Headers: (
Authorization
,token_del_afiliado
)
Al enviar la petición podrás ver la respuesta en JSON propuesta al inicio:
Paso 2. Crear Servicio Web Para Crear PQRS
Esta petición nos permitirá insertar en la base de datos un nuevo PQRS y guardar una imagen asociada cuya existencia es opcional. La vía para procesarla será el método HTTP POST el cual interpretaremos con el tipo multipart/form-data
.
Para el almacenamiento de las imágenes crea el folder img dentro de saludmock.
2.1 Crear Tabla Issue En MySQL
Crea la tabla issue
usando el siguiente comando CREATE
en phpMyAdmin:
CREATE TABLE `issue` ( `id` INT(11) NOT NULL AUTO_INCREMENT, `affiliate_id` VARCHAR(10) NOT NULL, `type_id` INT(11) NOT NULL, `description` VARCHAR(128) NOT NULL, `created_at` TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP, `image_url` VARCHAR(256) DEFAULT NULL, PRIMARY KEY (`id`), CONSTRAINT `issue_affiliate_id_fk` FOREIGN KEY (`affiliate_id`) REFERENCES `affiliate` (`id`), CONSTRAINT `issue_issue_type_id_fk` FOREIGN KEY (`type_id`) REFERENCES `issue_type` (`id`) )
2.2 Diseñar URL Para Crear PQRS
Para representar el recurso PQRS usaremos el plural en inglés issues
. La operación de creación será a través del método POST, por lo que el endpoint podríamos escribirlo así:
POST http://localhost/saludmock/v1/issues
2.3 Diseñar Petición
Debido a que hemos elegido el uso del tipo multipart para el envío de datos del PQR es necesario usar la cabecera Content-Type
con el valor de multipart/form-data
y enviar el token en Authorization
.
La sintaxis de este tipo de contenido usa un valor llamado boundary
el cual delimita la definición de cada parte del cuerpo.
Con esto en mente, nuestra estructura HTTP para la petición con varias partes, tendría el siguiente estilo:
POST /saludmock/v1/issues HTTP/1.1 Host: localhost Content-Type: multipart/form-data; boundary=----WebKitFormBoundary7MA4YWxkTrZu0gW Authorization: 44945899e5c49b7ff8.48092936 Cache-Control: no-cache ------WebKitFormBoundary7MA4YWxkTrZu0gW Content-Disposition: form-data; name="issue_type" 1 ------WebKitFormBoundary7MA4YWxkTrZu0gW Content-Disposition: form-data; name="description" Some description. ------WebKitFormBoundary7MA4YWxkTrZu0gW Content-Disposition: form-data; name="picture"; filename="mock-picture.jpg" Content-Type: image/jpeg ------WebKitFormBoundary7MA4YWxkTrZu0gW--
Donde la cadena ----WebKitFormBoundary7MA4YWxkTrZu0gW
representa el limitante entre cada parte (este ID puede variar según el cliente).
Y las claves determinadas por el descriptor name
serían:
issue_type
: El ID para el tipo de PQRSdescription
: El texto con la descripción del PQRSpicture
: La imagen asociada como evidencia fotográfica del PQRS
Adicionalmente la cabecera Content-Disposition
describe los metadatos asociados al contenido y Content-Type
permite crear especificar un tipo agregado adicional como lo es una imagen.
Afortunadamente la construcción de este contexto es generada automáticamente por Retrofit al momento de usarlo.
2.4 Diseñar Respuesta
La respuesta será un objeto JSON que represente el esquema de un registro de la tabla issue:
{ "id": "6", "affiliate_id": "1234567890", "type_id": "1", "description": "Some description.", "created_at": "2018-02-17 14:13:55", "image_url": "http://localhost/saludmock/img/mock-picture.jpg" }
2.5 Enrutar Recurso
Agrega al arreglo de recursos permitidos en index.php el string "issues"
:
$apiResources = array( 'affiliates', 'appointments', 'medical-centers', 'doctors', 'issue-types', 'issues');
2.6 Crear Controlador De PQRs
Crea la clase issues
en el folder controllers del proyecto PHP y pega el siguiente código:
<?php /** * Controlador de PQRs */ require_once 'AuthorizationManager.php'; class issues { const FORM_DATA_ISSUE_TYPE_VALUE = 'issue_type'; const FORM_DATA_DESCRIPTION_VALUE = 'description'; const MULTIPART_IMAGE_KEY = 'picture'; public static function post($urlSegments) { $affiliateId = AuthorizationManager::getInstance()->authorizeAffiliate(); // Verificar existencia de campos obligatorios if (!isset($_POST[self::FORM_DATA_ISSUE_TYPE_VALUE]) && !isset($_POST[self::FORM_DATA_DESCRIPTION_VALUE])) { throw new ApiException( 400, 0, 'Uno o varios campos obligatorios del PQR no fueron incluidos' ); } // Extraer valores tipo formulario $issueType = $_POST[self::FORM_DATA_ISSUE_TYPE_VALUE]; $description = $_POST[self::FORM_DATA_DESCRIPTION_VALUE]; $imageUrl = null; // Extraer foto y guardarla en /img if (isset($_FILES[self::MULTIPART_IMAGE_KEY]) && $_FILES[self::MULTIPART_IMAGE_KEY]['error'] == UPLOAD_ERR_OK) { $separator = DIRECTORY_SEPARATOR; $baseDir = dirname(__FILE__, 3); // directorio saludmock $imgDir = 'img'; $imageName = basename($_FILES[self::MULTIPART_IMAGE_KEY]['name']); $imageServerPath = $baseDir . $separator . $imgDir . $separator . $imageName; if (move_uploaded_file($_FILES[self::MULTIPART_IMAGE_KEY]['tmp_name'], $imageServerPath)) { $imageUrl = 'http://localhost/saludmock/img/' . $imageName; } } // Realizar inserción en la BD $savedIssueId = self::saveIssue($affiliateId, $issueType, $description, $imageUrl); // Imprimir PQR guargado if (!empty($savedIssueId)) { return self::retrieveIssue($savedIssueId); } else { throw new ApiException( 500, 0, "Error del servidor", "http://localhost", "Ah ocurrido un error al crear el PQR"); } } private static function saveIssue($affiliateId, $issueType, $description, $imageUrl) { $pdo = MysqlManager::get()->getDb(); try { $op = 'INSERT INTO issue(affiliate_id, type_id, description, image_url) VALUES (?,?,?,?)'; $stm = $pdo->prepare($op); $stm->bindParam(1, $affiliateId); $stm->bindParam(2, $issueType); $stm->bindParam(3, $description); $stm->bindParam(4, $imageUrl); $stm->execute(); return $pdo->lastInsertId(); } catch (PDOException $exception) { 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:" . $exception->getMessage() ); } } private static function retrieveIssue($savedIssueId) { $pdo = MysqlManager::get()->getDb(); try { $query = 'SELECT * FROM issue WHERE id=?'; $stm = $pdo->prepare($query); $stm->bindParam(1, $savedIssueId); $stm->execute(); return $stm->fetch(PDO::FETCH_ASSOC); } catch (PDOException $exception) { 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:" . $exception->getMessage() ); } } }
Varios puntos a destacar:
- En
post()
extraemos los datos de la petición con los arreglos$_POST
(valores elementales clave-valor) y$_FILES
(imagen enviada). - El método
saveIssue()
toma los valores asociados a la petición y realiza una sentenciaINSERT
sobreissue
. - El método
retrieveIssue()
obtiene el PQRS recién guardado a través del ID retornado porlastInsertId()
ensaveIssue()
.
2.7 Testear Creación De PQRS Con PostMan
Abre PostMan y configura las entradas así:
- Método HTTP:
POST
- URL:
http://localhost/saludmock/v1/issues
- Cabeceras:
Content-Type
>multipart/form-data
,Authorization
>token_afiliado
En la sección Body (form-data) incorporaremos los 3 parámetros para crear el PQR:
Puedes elegir un archivo de tu PC si cambias el tipo en la casilla Key correspondiente por la opción File.
Al enviar la petición tendrás el objeto JSON con el nuevo PQRS creado.
3. Consumir Servicio REST PHP En Android
3.1 Crear Actividad Para Radicar PQRS
Abrimos Android Studio y añadimos la actividad AddIssueActivity
(usa la plantilla Basic Activity). Configura sus características principales con los siguientes datos:
- Activity Name > AddIssueActivity
- Layout Name > activity_add_issue
- Title > Radicar PQRS
Diseñar Layout De La Actividad
Abre el layout activity_add_issue.xml y pega la siguiente definición:
<?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:fitsSystemWindows="true" android:layout_height="match_parent" tools:context="com.hermosaprogramacion.blog.saludmock.ui.AddIssueActivity"> <android.support.design.widget.AppBarLayout android:layout_width="match_parent" android:layout_height="wrap_content" android:theme="@style/AppTheme.AppBarOverlay"> <android.support.v7.widget.Toolbar android:id="@+id/toolbar" android:layout_width="match_parent" android:layout_height="?attr/actionBarSize" android:background="?attr/colorPrimary" app:popupTheme="@style/AppTheme.PopupOverlay" /> </android.support.design.widget.AppBarLayout> <include layout="@layout/content_add_issue" /> <include layout="@layout/content_add_issue_upload_state" android:visibility="gone"/> </android.support.design.widget.CoordinatorLayout>
Donde content_add_issue.xml
tendrá el formulario visto en el boceto de la interfaz:
<?xml version="1.0" encoding="utf-8"?> <android.support.v4.widget.NestedScrollView xmlns:android="http://schemas.android.com/apk/res/android" android:id="@+id/add_issue_content" xmlns:app="http://schemas.android.com/apk/res-auto" xmlns:tools="http://schemas.android.com/tools" android:layout_width="match_parent" android:layout_height="match_parent" app:layout_behavior="@string/appbar_scrolling_view_behavior" tools:context="com.hermosaprogramacion.blog.saludmock.ui.AddIssueActivity" tools:showIn="@layout/activity_add_issue"> <android.support.constraint.ConstraintLayout android:layout_width="match_parent" android:layout_height="match_parent"> <TextView android:id="@+id/issue_type_label" android:layout_width="wrap_content" android:layout_height="wrap_content" android:layout_marginLeft="16dp" android:layout_marginStart="16dp" android:layout_marginTop="16dp" android:text="Motivo" android:textAppearance="@style/TextAppearance.AppCompat.Body1" android:textColor="@color/colorPrimary" app:layout_constraintStart_toStartOf="parent" app:layout_constraintTop_toTopOf="parent" /> <Spinner android:id="@+id/issue_types_menu" android:layout_width="0dp" android:layout_height="wrap_content" android:layout_marginEnd="16dp" android:layout_marginStart="16dp" android:layout_marginTop="8dp" app:layout_constraintEnd_toEndOf="parent" app:layout_constraintStart_toStartOf="parent" app:layout_constraintTop_toBottomOf="@+id/issue_type_label" tools:listitem="@android:layout/simple_list_item_1" /> <TextView android:id="@+id/description_label" android:layout_width="wrap_content" android:layout_height="wrap_content" android:layout_marginLeft="16dp" android:layout_marginStart="16dp" android:layout_marginTop="16dp" android:text="Descripción" android:textAppearance="@style/TextAppearance.AppCompat.Body1" android:textColor="@color/colorPrimary" app:layout_constraintStart_toStartOf="parent" app:layout_constraintTop_toBottomOf="@+id/issue_types_menu" /> <android.support.design.widget.TextInputLayout android:id="@+id/description_field" android:layout_width="0dp" android:layout_height="wrap_content" android:layout_marginEnd="16dp" android:layout_marginStart="16dp" android:layout_marginTop="8dp" app:hintEnabled="false" app:layout_constraintEnd_toEndOf="parent" app:layout_constraintStart_toStartOf="parent" app:layout_constraintTop_toBottomOf="@+id/description_label"> <android.support.design.widget.TextInputEditText android:layout_width="match_parent" android:layout_height="wrap_content" android:hint="Escriba lo sucedido" android:lines="3" android:maxLines="3" /> </android.support.design.widget.TextInputLayout> <TextView android:id="@+id/photo_label" android:layout_width="wrap_content" android:layout_height="wrap_content" android:layout_marginLeft="16dp" android:layout_marginStart="16dp" android:layout_marginTop="16dp" android:layout_weight="1" android:text="Evidencia Fotográfica" android:textAppearance="@style/TextAppearance.AppCompat.Body1" android:textColor="@color/colorPrimary" app:layout_constraintStart_toStartOf="parent" app:layout_constraintTop_toBottomOf="@+id/description_field" /> <Button android:id="@+id/add_photo_button" style="@style/Widget.AppCompat.Button.Borderless" android:layout_width="wrap_content" android:layout_height="wrap_content" android:layout_marginEnd="8dp" android:layout_marginStart="8dp" android:layout_marginBottom="16dp" android:layout_marginTop="8dp" android:drawableLeft="@drawable/ic_add_a_photo" android:drawablePadding="8dp" android:drawableStart="@drawable/ic_add_a_photo" android:text="Tomar una foto" app:layout_constraintEnd_toEndOf="parent" app:layout_constraintStart_toStartOf="parent" app:layout_constraintTop_toBottomOf="@+id/photo_label" /> <ImageView android:id="@+id/issue_photo" android:layout_width="0dp" android:layout_height="160dp" android:layout_marginEnd="16dp" android:layout_marginStart="16dp" android:layout_marginTop="16dp" android:visibility="gone" app:layout_constraintBottom_toBottomOf="parent" app:layout_constraintEnd_toEndOf="parent" app:layout_constraintStart_toStartOf="parent" app:layout_constraintTop_toBottomOf="@+id/add_photo_button" app:layout_constraintVertical_bias="0.0" tools:src="@color/colorPrimary" tools:visibility="visible" /> <ImageButton android:id="@+id/remove_photo_button" style="?android:attr/borderlessButtonStyle" android:layout_width="48dp" android:layout_height="48dp" android:theme="@style/AppTheme.PrimaryColorRipple" android:visibility="gone" tools:visibility="visible" app:layout_constraintBottom_toBottomOf="@+id/issue_photo" app:layout_constraintEnd_toEndOf="@+id/issue_photo" app:layout_constraintHorizontal_bias="1.0" app:layout_constraintStart_toStartOf="@+id/issue_photo" app:layout_constraintTop_toTopOf="@+id/issue_photo" app:layout_constraintVertical_bias="0.0" app:srcCompat="@drawable/ic_remove_circle_outline" /> </android.support.constraint.ConstraintLayout> </android.support.v4.widget.NestedScrollView>
El preview del layout anterior sería el siguiente:
Adicionalmente content_add_issue_upload_state.xml
representa el estado de carga al momento de subir al servidor el PQRS.
<?xml version="1.0" encoding="utf-8"?> <android.support.constraint.ConstraintLayout xmlns:android="http://schemas.android.com/apk/res/android" xmlns:app="http://schemas.android.com/apk/res-auto" xmlns:tools="http://schemas.android.com/tools" android:id="@+id/upload_progress_content" android:layout_width="match_parent" android:layout_height="match_parent"> <TextView android:id="@+id/progress_text" android:layout_width="wrap_content" android:layout_height="wrap_content" android:layout_marginBottom="8dp" android:text="Subiendo PQRS" android:textSize="24sp" app:layout_constraintBottom_toBottomOf="parent" app:layout_constraintEnd_toEndOf="parent" app:layout_constraintStart_toStartOf="parent" app:layout_constraintTop_toTopOf="parent" /> <ProgressBar android:id="@+id/progress_view" style="?android:attr/progressBarStyleHorizontal" android:layout_width="0dp" android:layout_height="wrap_content" android:layout_marginEnd="8dp" android:layout_marginStart="8dp" android:indeterminate="true" app:layout_constraintEnd_toEndOf="@+id/progress_text" app:layout_constraintStart_toStartOf="@+id/progress_text" app:layout_constraintTop_toBottomOf="@+id/progress_text" tools:progress="33" /> </android.support.constraint.ConstraintLayout>
La previa muestra como la ProgressBar
actuará como indicador de progreso del envío al servidor:
Procesar Eventos De La Toolbar
Abre la actividad para radicar PQRs y modifica su código de la siguiente forma:
public class AddIssueActivity extends AppCompatActivity { @Override protected void onCreate(Bundle savedInstanceState) { super.onCreate(savedInstanceState); setContentView(R.layout.activity_add_issue); Toolbar toolbar = findViewById(R.id.toolbar); setSupportActionBar(toolbar); ActionBar ab = getSupportActionBar(); ab.setDisplayHomeAsUpEnabled(true); ab.setDisplayShowHomeEnabled(true); } @Override public boolean onCreateOptionsMenu(Menu menu) { getMenuInflater().inflate(R.menu.menu_add_issue, menu); return true; } @Override public boolean onOptionsItemSelected(MenuItem item) { if(item.getItemId()== R.id.action_save_issue){ } return super.onOptionsItemSelected(item); } @Override public boolean onSupportNavigateUp() { onBackPressed(); return true; } }
Como ves:
- Habilitamos la navegación con el botón Up en
onCreate()
- Poblamos la toolbar a través del recurso
menu_add_issue.xml
el cual tiene un action button para guardado - Procesamos el guardado en
onOptionsItemSelected()
- Asignamos el mismo comportamiento del Back button al Up button con
onSupportNavigateUp()
Al ejecutar la app podrás navegar desde AppointmentsActivity
hasta AddIssueActivity
y viceversa.
3.2 Inflar Spinner De Tipos De PQRS Con Datos Del Servidor
Crear Entidad Para Mapear El Objeto JSON Desde El Servidor
Con el fin de recibir la lista de elementos desde el servidor, crearemos una entidad llamada IssueType
en data/api/model con los datos a obtener:
public class IssueType { @SerializedName("id") private String mId; @SerializedName("name") private String mName; public IssueType(String id, String name) { mId = id; mName = name; } public String getId() { return mId; } public String getName() { return mName; } }
Crear Adaptador Personalizado Para El Spinner
Crea un nuevo adaptador para el spinner llamado IssueTypeAdapter
y proyecta el campo del nombre sobre el texto del layout:
public class IssueTypeAdapter extends ArrayAdapter<IssueType> { private final Context mContext; private List<IssueType> mIssueTypes; public IssueTypeAdapter(@NonNull Context context, @NonNull List<IssueType> issueTypes){ super(context, 0, issueTypes); mContext = context; mIssueTypes = issueTypes; } @NonNull @Override public View getView(int position, @Nullable View convertView, @NonNull ViewGroup parent) { View view; IssueType issueType = mIssueTypes.get(position); if (convertView == null) { LayoutInflater inflater = LayoutInflater.from(mContext); view = inflater.inflate(android.R.layout.simple_list_item_1, parent, false); } else { view = convertView; } TextView textView = view.findViewById(android.R.id.text1); textView.setText(issueType.getName()); return view; } @Override public View getDropDownView(int position, @Nullable View convertView, @NonNull ViewGroup parent) { return getView(position, convertView, parent); } @Override public int getCount() { return mIssueTypes.size(); } @Nullable @Override public IssueType getItem(int position) { return mIssueTypes.get(position); } public void replaceData(List<IssueType> issueTypes) { mIssueTypes = issueTypes; notifyDataSetChanged(); } }
El layout a inflar es android.R.layout.simplet_list_item_1
, por lo que no necesitamos crear uno propio.
Además tendremos un método replaceData()
para actualizar los datos del adaptador al momento de recibir la respuesta con Retrofit.
Añadir Llamada A La Interfaz De Retrofit
Agrega un nuevo método llamado getIssueTypes()
a la interfaz SaludMockApi
para representar la petición para obtener tipos de PQRS.
@GET("issue-types") Call<List<IssueType>> getIssueTypes(@Header(HEADER_AUTHORIZATION) String token);
Como vimos al crear el servicio web, lo unico que necesitamos será el token del usuario en la cabecera de Autorización.
Ejecutar Petición GET Con El Cliente Retrofit
Antes de ejecutar la petición, crearemos una nueva clase para simplificar la creación del adaptador Retrofit.
Su nombre será RestClient y estará en data/api.
public final class RestClient { private static SaludMockApi mSaludMockApi = null; public static SaludMockApi getClient() { if (mSaludMockApi == null) { final Retrofit retrofit = new Retrofit.Builder() .addConverterFactory(GsonConverterFactory.create()) .baseUrl(SaludMockApi.BASE_URL) .build(); mSaludMockApi = retrofit.create(SaludMockApi.class); } return mSaludMockApi; } }
La idea es crear una sola instancia de la interfaz SaludMockApi
a través del método estático getClient()
.
Con esta utilidad, ya nos es posible ir a AddIssueActivity y contactar al servidor para obtener el array JSON:
@Override protected void onCreate(Bundle savedInstanceState) { //... // Inflar menú con motivos de PQRS mIssueTypesMenu = findViewById(R.id.issue_types_menu); mIssueTypeAdapter = new IssueTypeAdapter(this, new ArrayList<IssueType>(0)); mIssueTypesMenu.setAdapter(mIssueTypeAdapter); loadIssueTypes(); } private void loadIssueTypes() { String userToken = SessionPrefs.get(this).getToken(); RestClient.getClient().getIssueTypes(userToken).enqueue(new Callback<List<IssueType>>() { @Override public void onResponse(Call<List<IssueType>> call, Response<List<IssueType>> response) { if (response.isSuccessful()) { mIssueTypeAdapter.replaceData(response.body()); Log.d(TAG, "Motivos de PQRS cargados desde la API"); } else { // TODO: Manejar posibles errores Log.d(TAG, "Error Api:" + response.errorBody().toString()); } } @Override public void onFailure(Call<List<IssueType>> call, Throwable t) { Log.d(TAG, t.getMessage()); } }); }
Al llamar a getIssueTypes()
procesamos onResponse()
para que reemplace la lista del adaptador o loguee el error obtenido.
En el caso de onFailure()
también tendremos un logging del mensaje asociado.
Con estas acciones terminadas, podemos ejecutar el módulo #2 del proyecto y observar como al desplegar el Spinner tendremos la lista existente del servidor cargada.
3.3 Usar La Cámara Android Para Tomar Evidencia Fotográfica
Vale, ahora nuestro objetivo es iniciar la aplicación de cámara en Android para que el usuario tome una fotografía asociada al PQRS.
Añade Los Permisos Para La Cámara Y FileProvider
Abre el archivo AndroidManifest.xml y añade las siguientes líneas:
<uses-permission android:name="android.permission.WRITE_EXTERNAL_STORAGE" /> <uses-feature android:name="android.hardware.camera" android:required="false" />
El provider para el acceso de archivos lo declaras así:
<provider android:name="android.support.v4.content.FileProvider" android:authorities="com.hermosaprogramacion.blog.saludmock.fileprovider" android:exported="false" android:grantUriPermissions="true"> <meta-data android:name="android.support.FILE_PROVIDER_PATHS" android:resource="@xml/file_paths" /> </provider>
Donde file_paths.xml es un archivo con la ruta de acceso:
<?xml version="1.0" encoding="utf-8"?> <paths xmlns:android="http://schemas.android.com/apk/res/android"> <external-path name="my_images" path="Android/data/com.hermosaprogramacion.blog.saludmock/files/Pictures" /> </paths>
Asignar Escucha Al Botón De Tomar Fotos
El primer paso es asignar una escucha al botón que designamos para iniciar la toma de fotografías.
@Override protected void onCreate(Bundle savedInstanceState) { //... // Botón para añadir foto mAddPhotoButton = findViewById(R.id.add_photo_button); mAddPhotoButton.setOnClickListener(new View.OnClickListener() { @Override public void onClick(View view) { showCameraApp(); } }); mPhotoImage = findViewById(R.id.issue_photo); }
Iniciar App De Cámara
La idea es iniciar la actividad con un nuevo método showCameraApp()
:
private void showCameraApp() { Intent intent = new Intent(MediaStore.ACTION_IMAGE_CAPTURE); if (intent.resolveActivity(getPackageManager()) != null) { File photoFile = null; try { photoFile = createImageFile(); } catch (IOException ex) { Log.d(TAG, "Error ocurrido cuando se estaba creando el archivo de la imagen. Detalle: "+ex.toString()); } if (photoFile != null) { Uri photoURI = FileProvider.getUriForFile(this, "com.hermosaprogramacion.blog.saludmock.fileprovider", photoFile); intent.putExtra(MediaStore.EXTRA_OUTPUT, photoURI); startActivityForResult(intent, REQUEST_IMAGE_CAPTURE); } } }
El código anterior primero comprueba la existencia de la app con resolveActivity()
. Luego intenta crear un nuevo archivo en el directorio externo con createImageFile()
:
private File createImageFile() throws IOException { String timeStamp = DateTimeUtils.formatDateForFileName(new Date()); String prefix = "JPEG_" + timeStamp + "_"; File directory = getExternalFilesDir(Environment.DIRECTORY_PICTURES); File image = File.createTempFile( prefix, ".jpg", directory ); mCurrentPhotoPath = image.getAbsolutePath(); return image; }
Si todo salió bien, se le provee una URI al archivo con el FileProvider
, la cual se pasa como extra al intent que usaremos para iniciar la comunicación con startActivityForResult()
.
Mostrar Foto En El ImageView
Si la toma de la foto ha sido exitosa entonces proyectaríamos su contenido en el view correspondiente.
Para procesar este resultado sobrescribimos onActivityResult()
:
@Override protected void onActivityResult(int requestCode, int resultCode, Intent data) { if (requestCode == REQUEST_IMAGE_CAPTURE) { if (resultCode == RESULT_OK) { handleCameraPhoto(); } } }
Donde handleCameraPhoto()
se asegura de que exista una ruta actual para el archivo de la foto y así asignarlo:
private void handleCameraPhoto() { if (mCurrentPhotoPath != null) { setPic(); mCurrentPhotoPath = null; } }
La asignación se lleva a cabo con setPic()
:
private void setPic() { Glide.with(this). load(mCurrentPhotoPath). apply(RequestOptions.centerCropTransform()). into(mPhotoImage); mPhotoImage.setVisibility(View.VISIBLE); mRemoveButton.setVisibility(View.VISIBLE); }
En su interior usamos la librería Glide (ver importación en el archivo build.gradle) para asignar el contenido de la foto al view.
Luego cambiamos la visibilidad de dicho view junto al botón para remover la imagen actual.
Asignar Escucha De Clicks Al Botón De Remover Foto
Finalmente asignamos una escucha OnClickListener
para el botón que remueve la foto. Una de las formas de hacerlo es cambiar la vibilidad a GONE
del view de imagen y el mismo botón para remover:
// Botón para remover foto mRemoveButton = findViewById(R.id.remove_photo_button); mRemoveButton.setOnClickListener(new View.OnClickListener() { @Override public void onClick(View view) { mPhotoImage.setVisibility(View.GONE); mRemoveButton.setVisibility(View.GONE); } });
Con todas estas instrucciones listas podemos ejecutar la app escrita hasta el momento o el tercer módulo del código descargable para tomar la foto (Genymotion en mi caso):
Y luego mostrarla en el ImageView
:
3.4 Subir PQRS Al Servidor PHP
Por último, subiremos el PQRS creado al servidor con Retrofit, incluyendo la imagen (si es que hay una).
Manejar Click En El Action Button De Guardar
Al momento de presionar el botón para guardar ejecutaremos un método llamado saveIssue()
el cual ejecutará la petición POST al servidor:
@Override public boolean onOptionsItemSelected(MenuItem item) { if (item.getItemId() == R.id.action_save_issue) { saveIssue(); } return super.onOptionsItemSelected(item); }
Crear Entidad Para El PQRS
Recordemos que la respuesta de la petición para subir un PQRS retorna en un JSON que representa el modelo de datos.
Debido a esto, crearemos una entidad llamada Issue
que represente este elemento de dominio:
public class Issue { @SerializedName("id") private String mId; @SerializedName("affiliate_id") private String mAffiliate; @SerializedName("type_id") private String mType; @SerializedName("description") private String mDescription; @SerializedName("created_at") private String mCreatedAt; public Issue(String id, String affiliate, String type, String description, String createdAt) { mId = id; mAffiliate = affiliate; mType = type; mDescription = description; mCreatedAt = createdAt; } public String getId() { return mId; } public String getAffiliate() { return mAffiliate; } public String getType() { return mType; } public String getDescription() { return mDescription; } public String getCreatedAt() { return mCreatedAt; } @Override public String toString() { return "Issue{" + "mId='" + mId + ''' + ", mAffiliate='" + mAffiliate + ''' + ", mType='" + mType + ''' + ", mDescription='" + mDescription + ''' + ", mCreatedAt='" + mCreatedAt + ''' + '}'; } }
Crear Llamada Retrofit En La Interfaz
Abrirmos SaludMockApi
y añadimos el método para representar la petición POST que suba los datos con tipo multipart/form-data:
@Multipart @POST("issues") Call<Issue> createIssue(@Header(HEADER_AUTHORIZATION) String token, @Part("issue_type") RequestBody type, @Part("description") RequestBody description, @Part MultipartBody.Part image);
Como se muestra en el código anterior, pasamos el token para autorizar, el tipo de PQRS, la descripción y la imagen que se haya tomado como evidencia fotográfica.
Ejecutar Petición Retrofit
Ahora vamos a AddIssueActivity
y creamos el método saveIssue()
con el fin de indicarle al cliente REST que ejecute el método createIssue()
de la interfaz Retrofit.
private void saveIssue() { // Obtenemos los datos planos String userToken = SessionPrefs.get(this).getToken(); String issueType = ((IssueType) mIssueTypesMenu.getSelectedItem()).getId(); String description = mDescriptionField.getEditText().getText().toString(); File image = getPicture(); // Creamos payloads por cada dato RequestBody issueTypeBody = RequestBody.create(MultipartBody.FORM, issueType); RequestBody descriptionBody = RequestBody.create(MultipartBody.FORM, description); MultipartBody.Part imagePart = null; if (image != null) { RequestBody imageBody = RequestBody.create(MediaType.parse("image/*"), image); imagePart = MultipartBody.Part.createFormData("picture", image.getName(), imageBody); } Call<Issue> createIssue = RestClient.getClient().createIssue( userToken, issueTypeBody, descriptionBody, imagePart); showProgressIndicator(true); showSaveButton(false); createIssue.enqueue(new Callback<Issue>() { @Override public void onResponse(Call<Issue> call, Response<Issue> response) { if (response.isSuccessful()) { Log.d(TAG, "Creación de PQR correcta. Datos=" + response.body().toString()); showAppointmentsUi(); } else { // TODO: Manejar errores Log.d(TAG, "Error al crear PQR. Detalles= " + response.errorBody().toString()); } } @Override public void onFailure(Call<Issue> call, Throwable t) { showProgressIndicator(false); showSaveButton(true); showSavingError(); Log.d(TAG, "Error al crear PQR. Detalles= " + t.getMessage()); } }); }
En resumen, las instrucciones están ejecutadas para:
- Obtener los valores para cada parámetro de
createIssue()
. La imagen la obtenemos con el métodogetPicture()
, el cual obtiene el archivo actual de la imagen con la ruta que ha sido guardada. - Convertir cada valor en el tipo
RequestBody
respectivo como vimos en la introducción del articulo - Configurar la llamada de
createIssue()
- Mostrar el indicador de progreso
- Ocultar el action button de guardado. Esto con el fin de dirigir la atención de la app al progreso de subida.
- Ejecutar la petición POST. En el caso de éxito en
onResponse()
, terminamos la actividad actual e iniciamosAppointmentsActivity
con el métodoshowAppointmentsUi()
. De lo contrario logueamos el error. En el caso de falla enonFailure()
, ocultamos el progreso, mostramos el botón de guardado y mostramos unSnackBar
avisándole al usuario que hubo un fallo con el métodoshowSavingError()
.
Con esto listo ya nos es posible ejecutar la app y ver los resultados finales.
Así que terminemos corriendo el progreso hasta ahora (o ejecuta el módulo 4 del proyecto), selecciona el tipo de PQRS, ingresa la descripción y toma la foto.
Luego presiona el action button para guardar:
Si todo salió bien, verás una SnackBar
en AppointmentsActivity
indicándole al usuario que el PQRS ha sido guardado:
Conclusión
En esta quinta parte de la app SaludMock vimos cómo crear un PQRS que puede tener una imagen asociada como evidencia fotográfica.
La idea era aprender como relacionar un archivo a una petición POST con el fin de enviar un cuerpo tipo multipart/form-data
hacia el servidor.
Esto lo pudimos alcanzar a través de Retrofit y su anotación @Multipart
junto a las clases RequestBody
y MultipartBody.Part
.
Si te ha gustado el tutorial o tienes algunas recomendaciones, comenta abajo en la caja de comentarios tus impresiones.
Siguiente: Crear Una Aplicación Para Gestión De Productos, Clientes Y Facturas
Si deseas seguir aprendiendo a usar Retrofit pero esta vez añadiendo también una base de datos SQLite local a tu aplicación y usando la arquitectura CLEAN (más el patrón MVP). Tengo otro tutorial para crear una aplicación con productos, clientes y facturas que puede ayudarte con ese objetivo.