Retrofit En Android Parte 5: Radicar PQRS

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:

Este artículo se dividirá en las siguientes partes:

  1. Subir Una Imagen Con Retrofit
  2. Crear PHP REST Service Para Obtener Tipos De PQRS
  3. Crear PHP REST Service Para Crear PQRS
  4. 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:

  1. Usar @Multipart sobre la llamada Retrofit
  2. Añadir parámetros a la llamada marcados con @Part. Usar RequestBody para datos elementales y MultipartBody.Part para archivos.
  3. Usar los métodos RequestBody.create() y MultipartBody.Part.createFormData() para generar los parámetros
  4. 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:

Boceto De Screen Radicar PQRS De App SaludMock

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
  • URLhttp://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:

GET /issue-types en POSTMAN

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 PQRS
  • description: El texto con la descripción del PQRS
  • picture: 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 sentencia INSERT sobre issue.
  • El método retrieveIssue() obtiene el PQRS recién guardado a través del ID retornado por lastInsertId() en saveIssue().

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:

POST /issues en PostMan

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.

Respuesta JSON de POST /issues

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 Nameactivity_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:

Preview de layout de actividad para radicar PQRS

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:

Layout para estado de carga al subir pqrs

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.

Spinner poblado desde el servidor con retrofit

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):

Tomar Foto En App Cámara Genymotion

Y luego mostrarla en el ImageView:

Mostrar Foto En 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:

  1. Obtener los valores para cada parámetro de createIssue(). La imagen la obtenemos con el método getPicture(), el cual obtiene el archivo actual de la imagen con la ruta que ha sido guardada.
  2. Convertir cada valor en el tipo RequestBody respectivo como vimos en la introducción del articulo
  3. Configurar la llamada de createIssue()
  4. Mostrar el indicador de progreso
  5. Ocultar el action button de guardado. Esto con el fin de dirigir la atención de la app al progreso de subida.
  6. Ejecutar la petición POST. En el caso de éxito en onResponse(), terminamos la actividad actual e iniciamos AppointmentsActivity con el método showAppointmentsUi(). De lo contrario logueamos el error. En el caso de falla en onFailure(), ocultamos el progreso, mostramos el botón de guardado y mostramos un SnackBar avisándole al usuario que hubo un fallo con el método showSavingError().

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.

Llenar Formulario Para Radicar PQRS

Luego presiona el action button para guardar:

Indicador De Progreso Al Subir PQRS Al Servidor

Si todo salió bien, verás una SnackBar en AppointmentsActivity indicándole al usuario que el PQRS ha sido guardado:

SnackBar Con Mensaje De Creación Exitosa De PQRS

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.

Ú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!