De vuelta a la serie de tutoriales para usar la librería Retrofit en Android, esta vez trataremos el envío de datos con una petición POST.
El objetivo será tomar el caso de uso de asignar citas de la app ejemplo SaludMock.
Además de ello, si revisas los bocetos planteados, por consecuencia debemos procesar peticiones GET para:
- Obtener los centros médicos y poblar el Spinner del formulario
- Obtener los doctores disponibles para el horario de la cita a asignar
Y una POST para crear la cita.
Si eres nuevo, entonces ponte en contexto con el proyecto yendo a las partes previas:
- 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
Aclarando lo anterior, te comparto la lista de tareas de programación a llevar a cabo:
- Crear Citas Médicas En La App
- Elegir Un Doctor En La App
- Añadir Soporte De Peticiones A La API REST
- Consumir Servicio REST Con Retrofit
¡Sin más, comencemos a programar!
Descargar Ejemplo De Retrofit Para Android Studio
Te voy a compartir el enlace de descargar del código del proyecto en Android Studio listo para ejecutarse:
[sociallocker id=»7121″]
Al ejecutar la app podrás ver las siguientes funciones en marcha:
Paso 1. Crear Citas Médicas En La App
1.1 Crear Actividad Con Diseño De Formulario
Nuestro primer movimiento será crear la actividad para añadir las citas médicas.
Tomaremos como punto de referencia el boceto creado en el plan de la aplicación Android.
Con ello en mente, añadamos al paquete ui presionando click derecho y yendo a New > Activity > Basic Activity.
Seguido en el asistente, usaremos estos valores:
- Activity Name > AddAppointmentActivity
- Layout Name > activity_add_appointment
- Title > Asignar Cita
1.2 Diseñar UI Del Layout
Si analizamos la jerarquía del boceto notaremos que no requiere mucha fuerza de trabajo.
Consiste en agregar 3 pares de etiquetas – campos y poner al final un botón.
Adicionalmente una barra de progreso y un view para mostrar errores vendrían de utilidad en la Ux.
(Cabe resaltar que puedes personalizar el diseño con la inserción de más campos, uso de cards, más acciones en la Toolbar, etc. si así lo requiere la naturaleza de tu proyecto)
Siendo solo esto, pon la siguiente definición XML en el archivo de contenido autogenerado content_add_appointment.xml:
<?xml version="1.0" encoding="utf-8"?> <RelativeLayout xmlns:android="http://schemas.android.com/apk/res/android" xmlns:app="http://schemas.android.com/apk/res-auto" xmlns:tools="http://schemas.android.com/tools" android:layout_width="match_parent" android:layout_height="match_parent" android:paddingBottom="@dimen/activity_vertical_margin" android:paddingLeft="@dimen/activity_horizontal_margin" android:paddingRight="@dimen/activity_horizontal_margin" android:paddingTop="@dimen/activity_vertical_margin" app:layout_behavior="@string/appbar_scrolling_view_behavior" tools:context="com.hermosaprogramacion.blog.saludmock.ui.AddAppointmentActivity" tools:showIn="@layout/activity_add_appointment"> <ProgressBar android:id="@+id/progress" android:layout_width="wrap_content" android:layout_height="wrap_content" android:layout_centerInParent="true" android:visibility="gone" /> <android.support.v7.widget.CardView android:id="@+id/appointment_info_card" android:layout_width="match_parent" android:layout_height="wrap_content" app:contentPadding="16dp"> <LinearLayout android:layout_width="match_parent" android:layout_height="match_parent" android:orientation="vertical"> <TextView android:id="@+id/label_medical_center" android:layout_width="wrap_content" android:layout_height="wrap_content" android:text="@string/label_medical_center" android:textAppearance="@style/TextAppearance.AppCompat.Subhead" android:textColor="@color/colorPrimary" /> <Spinner android:id="@+id/medical_center_menu" android:layout_width="match_parent" android:layout_height="wrap_content" tools:listitem="@android:layout/simple_list_item_1" /> <TextView android:id="@+id/label_date" android:layout_width="wrap_content" android:layout_height="wrap_content" android:layout_marginTop="32dp" android:text="@string/label_date" android:textAppearance="@style/TextAppearance.AppCompat.Subhead" android:textColor="@color/colorPrimary" /> <EditText android:id="@+id/date_field" android:layout_width="match_parent" android:layout_height="wrap_content" android:ems="10" android:focusable="false" android:inputType="none" tools:text="31 de Enero de 2018" /> <TextView android:id="@+id/label_time_schedule" android:layout_width="wrap_content" android:layout_height="wrap_content" android:layout_marginTop="32dp" android:text="@string/label_time_schedule" android:textAppearance="@style/TextAppearance.AppCompat.Subhead" android:textColor="@color/colorPrimary" /> <Spinner android:id="@+id/time_schedule_menu" android:layout_width="match_parent" android:layout_height="wrap_content" android:layout_marginBottom="8dp" android:layout_marginTop="8dp" android:entries="@array/entries_time_schedule" tools:listitem="@android:layout/simple_list_item_1" /> </LinearLayout> </android.support.v7.widget.CardView> <android.support.v7.widget.CardView android:id="@+id/appointment_doctor_card" android:layout_width="match_parent" android:layout_height="wrap_content" android:layout_below="@id/appointment_info_card" android:layout_marginTop="16dp" app:contentPadding="16dp"> <LinearLayout android:layout_width="match_parent" android:layout_height="match_parent" android:gravity="center_horizontal" android:orientation="vertical"> <TextView android:id="@+id/empty_doctor" android:layout_width="wrap_content" android:layout_height="wrap_content" android:layout_gravity="center_horizontal" android:text="@string/message_no_doctor_schedule_picked" android:textSize="16sp" /> <LinearLayout android:id="@+id/doctor_content" android:layout_width="match_parent" android:layout_height="wrap_content" android:gravity="center_horizontal" android:orientation="vertical" android:visibility="gone"> <TextView android:id="@+id/summary_doctor_name" android:layout_width="wrap_content" android:layout_height="wrap_content" android:textAppearance="@style/TextAppearance.AppCompat.Title" tools:text="@tools:sample/full_names" /> <TextView android:id="@+id/summary_doctor_time_schedule" android:layout_width="wrap_content" android:layout_height="wrap_content" android:layout_marginTop="8dp" android:textAppearance="@style/TextAppearance.AppCompat.Subhead" tools:text="@tools:sample/date/hhmm" /> </LinearLayout> <Button android:id="@+id/search_doctor_button" style="@style/Widget.AppCompat.Button.Borderless.Colored" android:layout_width="wrap_content" android:layout_height="wrap_content" android:layout_marginTop="16dp" android:text="@string/action_select_doctor" /> </LinearLayout> </android.support.v7.widget.CardView> <LinearLayout android:id="@+id/error_container" android:layout_width="wrap_content" android:layout_height="wrap_content" android:layout_centerInParent="true" android:orientation="vertical" android:visibility="gone"> <ImageView android:id="@+id/image_empty_state" android:layout_width="48dp" android:layout_height="48dp" android:layout_gravity="center" android:tint="#9E9E9E" app:srcCompat="@drawable/ic_alert_circle" /> <TextView android:layout_width="wrap_content" android:layout_height="wrap_content" android:layout_gravity="center" android:text="Hay problemas al asignar la cita. Contacte con el administrador" /> </LinearLayout> </RelativeLayout>
Adicionalmente eliminamos el FloatingActionButton
que Android Studio agrega al layout principal de la actividad:
<?xml version="1.0" encoding="utf-8"?> <android.support.design.widget.CoordinatorLayout xmlns:android="http://schemas.android.com/apk/res/android" xmlns:app="http://schemas.android.com/apk/res-auto" xmlns:tools="http://schemas.android.com/tools" android:layout_width="match_parent" android:layout_height="match_parent" android:fitsSystemWindows="true" tools:context="com.hermosaprogramacion.blog.saludmock.ui.AddAppointmentActivity"> <android.support.design.widget.AppBarLayout android:layout_width="match_parent" android:layout_height="wrap_content" android:theme="@style/AppTheme.AppBarOverlay"> <android.support.v7.widget.Toolbar android:id="@+id/toolbar" android:layout_width="match_parent" android:layout_height="?attr/actionBarSize" android:background="?attr/colorPrimary" app:popupTheme="@style/AppTheme.PopupOverlay" /> </android.support.design.widget.AppBarLayout> <include layout="@layout/content_add_appointment" /> </android.support.design.widget.CoordinatorLayout>
Con esto realizado podremos ver la siguiente preview:
1.3 Definir Campos De La Actividad
El siguiente paso es agregaremos las referencias de vista, dominio y datos como campos de AddAppointmentActivity
.
En este caso tenemos:
- Constantes: La etiqueta de logueo, el código de la petición por resultados al llamar la actividad de doctores y 3 para los extras que enviaremos: La jornada elegida, el id del centro médico y la fecha seleccionada. También tendremos constantes para los valores de las jornadas para la interfaz del usuario y la API.
- Views: Todos aquellos que vamos a manipular. Ponemos como comentario el adaptador del spinner para centros médicos ya que no está construido aún.
- Relaciones: Adaptador y API de retrofit para las peticiones
- Variables: Sostendremos las entradas del usuario en variables globales: ID de centro médico, fecha, jornada y hora de disponibilidad del doctor.
Siendo así, la estructura base a conseguir es esta:
public class AddAppointmentActivity extends AppCompatActivity{ private static final String TAG = AddAppointmentActivity.class.getSimpleName(); public static final int REQUEST_PICK_DOCTOR_SCHEDULE = 1; public static final String EXTRA_TIME_SHEDULE_PICKED = "com.hermosaprogramacion.EXTRA_TIME_SCHEDULE_PICKED"; public static final String EXTRA_MEDICAL_CENTER_ID = "com.hermosaprogramacion.EXTRA_MEDICAL_CENTER_ID"; public static final String EXTRA_DATE_PICKED = "com.hermosaprogramacion.EXTRA_DATE_PICKED"; private static final String UI_VALUE_MORNING = "Mañana"; private static final String API_VALUE_MORNING = "morning"; private static final String UI_VALUE_AFTERNOON = "Tarde"; private static final String API_VALUE_AFTERNOON = "afternoon"; private ProgressBar mProgress; private View mErrorView; private View mCard1; private View mCard2; private Spinner mMedicalCenterMenu; // mMedicalCenterAdapter; private EditText mDateField; private Spinner mTimeScheduleMenu; private View mEmptyDoctorView; private View mDoctorContentView; private TextView mDoctorName; private TextView mDoctorScheduleTime; private Button mSearchDoctorButton; private Retrofit mRestAdapter; private SaludMockApi mSaludMockApi; private String mMedicalCenterId; private Date mDatePicked; private String mTimeSchedule; private String mDoctorId; private String mDoctorTimeSchedule; @Override protected void onCreate(Bundle savedInstanceState) { super.onCreate(savedInstanceState); setContentView(R.layout.activity_add_appointment); Toolbar toolbar = (Toolbar) findViewById(R.id.toolbar); setSupportActionBar(toolbar); ActionBar ab = getSupportActionBar(); } }
1.4 Poblar Spinner De Centros Médicos
Para tener opciones en el menú de centros médicos necesitamos obtener una lista de objetos JSON desde el servidor.
En pocas palabras:
Realizar una petición GET que nos mande un array en el response body y transformarlo en una lista de centros médicos.
Aún no ejecutaremos la request con Retrofit, sin embargo crearemos la entidad de dominio para los centros médicos junto al adaptador del spinner.
Crear Modelo Para Centros Médicos
Añade una nueva clase llamada MedicalCenter
dentro de data/api/model y agrega los siguientes atributos:
- ID
- Nombre
- Dirección
Veamos:
public class MedicalCenter { private String mId; private String mName; private String mAddress; public MedicalCenter(String id, String name, String address) { mId = id; mName = name; mAddress = address; } public String getId() { return mId; } public void seId(String mId) { this.mId = mId; } public String getName() { return mName; } public void setName(String mName) { this.mName = mName; } public String getAddress() { return mAddress; } public void setAddress(String mAddress) { this.mAddress = mAddress; } }
Crear Adapdator Personalizado
Para conseguir el ID del centro médico relacionado a la nueva cita médica es necesario que el adaptador de nuestro spinner nos provea dicho dato.
Como ya sabemos, esto se logra usando una lista de objetos MedicalCenter
en un adaptador personalizado.
¿Cómo lo logramos?
Ok.
Agrega una nueva clase Java llamada MedicalCenterAdapter
dentro del paquete ui.
¿Puntos a tener en cuenta?
- Asegúrate hacerla heredar de
ArrayAdapter
- Tomar como parámetros en el constructor el contexto y la lista de objetos. Sálvalos en campos.
- Sobrescribe
getView()
para inflar el layoutandroid.R.layout.simple_list_item_1
y poner el nombre del centro médico en su text viewandroid.R.id.text1
- Sobrescribe
getDropDownView()
para llamar agetView()
ya que cumplen la misma función.
De esta manera:
public class MedicalCenterAdapter extends ArrayAdapter<MedicalCenter> { private Context mContext; private List<MedicalCenter> mItems; public MedicalCenterAdapter(@NonNull Context context, @NonNull List<MedicalCenter> items) { super(context, 0, items); mContext = context; mItems = items; } @NonNull @Override public View getView(int position, @Nullable View convertView, @NonNull ViewGroup parent) { View view; MedicalCenter medicalCenter = mItems.get(position); if (convertView == null) { LayoutInflater inflater = LayoutInflater.from(mContext); view = inflater.inflate(android.R.layout.simple_list_item_1, parent, false); } else { view = convertView; } TextView textView = (TextView) view.findViewById(android.R.id.text1); textView.setText(medicalCenter.getName()); return view; } @Override public View getDropDownView(int position, @Nullable View convertView, @NonNull ViewGroup parent) { return getView(position, convertView, parent); } }
Crear Instancia Del Adaptador
Ahora solo nos queda poner un campo para el adaptador:
private MedicalCenterAdapter mMedicalCenterAdapter;
Y luego crear su instancia en onCreate()
junto a la lectura del evento de selección, donde tomaremos el valor en la variable global:
@Override protected void onCreate(Bundle savedInstanceState) { super.onCreate(savedInstanceState); setContentView(R.layout.activity_add_appointment); Toolbar toolbar = (Toolbar) findViewById(R.id.toolbar); setSupportActionBar(toolbar); mProgress = (ProgressBar) findViewById(R.id.progress); mErrorView = findViewById(R.id.error_container); mCard1 = findViewById(R.id.appointment_info_card); mCard2 = findViewById(R.id.appointment_doctor_card); // Centros médicos mMedicalCenterMenu = (Spinner) findViewById(R.id.medical_center_menu); mMedicalCenterAdapter = new MedicalCenterAdapter(this, new ArrayList<MedicalCenter>(0)); mMedicalCenterMenu.setAdapter(mMedicalCenterAdapter); mMedicalCenterMenu.setOnItemSelectedListener(new AdapterView.OnItemSelectedListener() { @Override public void onItemSelected(AdapterView<?> adapterView, View view, int i, long l) { MedicalCenter medicalCenter = (MedicalCenter) adapterView.getItemAtPosition(i); mMedicalCenterId = medicalCenter.getId(); } @Override public void onNothingSelected(AdapterView<?> adapterView) { } }); // ... }
Por el momento le pasamos una lista sin elementos ya que aún no hemos realizado la llamada asíncrona de Retrofit.
1.5 Cambiar Fecha De La Cita
A manera general, cambiar la fecha implica el disparo de un evento de click en el campo cuya reacción será mostrar un DatePickerDialog
.
Veamos:
Añadir DatePickerDialog
Crea dentro del paquete ui una nueva clase que extienda de DialogFragment
(como vimos en el tutorial de diálogos).
Seguido configúrala de tal forma que:
- Sobrescriba a
onCreateDialog()
y este retorne un tipoDatePickerDialog
- Condicione el
DatePicker
para que su fecha mínima sea el día actual. - Sobrescriba a
onAttach()
para tomar la actividad como escuchaDatePickerDialog.OnDateSetListener
- Sobrescriba a
onDetach()
para limpiar la referencia de la actividad
Es decir:
public class DatePickerFragment extends DialogFragment { private DatePickerDialog.OnDateSetListener mListener; @NonNull @Override public Dialog onCreateDialog(Bundle savedInstanceState) { final Calendar c = Calendar.getInstance(); int year = c.get(Calendar.YEAR); int month = c.get(Calendar.MONTH); int day = c.get(Calendar.DAY_OF_MONTH); DatePickerDialog pickerDialog = new DatePickerDialog(getActivity(), mListener, year, month, day); pickerDialog.getDatePicker().setMinDate(c.getTimeInMillis()); return pickerDialog; } @Override public void onAttach(Context context) { super.onAttach(context); try { mListener = (DatePickerDialog.OnDateSetListener) context; } catch (ClassCastException e) { throw new ClassCastException(context.toString() + " debe implementar OnDateSetListener"); } } @Override public void onDetach() { super.onDetach(); mListener = null; } }
Comunicar DialogFragment Con La Actividad
Para procesar el evento de la selección de fechas implementaremos a OnDateSetListener
sobre la actividad:
public class AddAppointmentActivity extends AppCompatActivity implements DatePickerDialog.OnDateSetListener { ... @Override public void onDateSet(DatePicker datePicker, int year, int month, int dayOfMonth) { } }
Dentro de onDateSet()
será donde se ejecutarán las acciones de seteo.
Asignar Valor Inicial Del Campo De Fecha
Pongamos el valor por defecto de la fecha del formulario.
Para darle el formato que tenemos en el boceto crearemos un nuevo paquete llamado utils.
En su interior agrega la nueva clase de utilidad DateTimeUtils
.
El objetivo es añadirle 3 métodos de clase:
formatDateForUi(int, int, int):
Crea una fecha a partir de los valores enteros y retorna en unString
con un formato ajustado al patrón deseadoformatDateForUi(Date)
: Realiza la misma función que el anterior, solo que parte de un parámetroDate
getCurrentDate()
: Obtiene el tiempo actual
El código sería:
public class DateTimeUtils { private static final String UI_DATE_PATTERN = "dd 'de' MMMM 'del' yyyy"; private DateTimeUtils() { } public static Date getCurrentDate() { Calendar instance = Calendar.getInstance(); instance.set(Calendar.HOUR_OF_DAY, 0); instance.set(Calendar.MINUTE, 0); instance.set(Calendar.SECOND, 0); instance.set(Calendar.MILLISECOND, 0); return instance.getTime(); } public static String formatDateForUi(int year, int month, int dayOfMonth) { return formatDateForUi(createDate(year, month, dayOfMonth)); } public static String formatDateForUi(Date date) { SimpleDateFormat simpleDateFormat = new SimpleDateFormat(UI_DATE_PATTERN, Locale.getDefault()); return simpleDateFormat.format(date); } }
En seguida, ve a la actividad e inicializa la variable global de la fecha. Luego toma la referencia del view de la fecha y seteale la fecha actual formateada:
mDatePicked = DateTimeUtils.getCurrentDate(); mDateField = (EditText) findViewById(R.id.date_field); mDateField.setText(DateTimeUtils.formatDateForUi(mDatePicked));
Registrar Escucha De Clicks
Recuerda que el diálogo se ejecuta al dar click en el campo, por ende asóciale una nueva escucha y muestra el picker:
mDateField.setOnClickListener(new View.OnClickListener() { @Override public void onClick(View view) { DatePickerFragment fragment = new DatePickerFragment(); fragment.show(getSupportFragmentManager(), "datePicker"); } });
Asignar Valor Desde El DatePicker Al EditText
El siguiente movimiento es ir a onDateSet()
y guardar la fecha en la variable a través de un nuevo método de utilidad llamado createDate()
:
public static Date createDate(int year, int month, int dayOfMonth) { Calendar cal = Calendar.getInstance(); cal.set(year, month, dayOfMonth); cal.set(Calendar.HOUR_OF_DAY, 0); cal.set(Calendar.MINUTE, 0); cal.set(Calendar.SECOND, 0); cal.set(Calendar.MILLISECOND, 0); return cal.getTime(); }
Luego usar el método setText()
del view de fecha para actualizarlo con la entrada del usuario:
@Override public void onDateSet(DatePicker datePicker, int year, int month, int dayOfMonth) { mDatePicked = DateTimeUtils.createDate(year, month, dayOfMonth); mDateField.setText(DateTimeUtils.formatDateForUi(mDatePicked)); }
1.6 Poblar Spinner De Jornadas
Crear Array De Strings En Los Recursos
Las opciones que tenemos en la jornada son 2: Mañana y Tarde
Para proporcionar sus valores simplemente crearemos una etiqueta <string-array>
dentro de strings.xml:
<string-array name="entries_time_schedule"> <item>Mañana</item> <item>Tarde</item> </string-array>
La vía que usaremos para asignarlo será el atributo android:entries
del nodo <Spinner>
con el ID time_schedule_menu
:
<Spinner android:id="@+id/time_schedule_menu" android:layout_width="match_parent" android:layout_height="wrap_content" android:layout_marginBottom="8dp" android:layout_marginTop="8dp" android:entries="@array/entries_time_schedule" tools:listitem="@android:layout/simple_list_item_1" />
Adicional le setearemos una escucha para tomar el valor de la selección en la variable global:
mTimeScheduleMenu.setOnItemSelectedListener(new AdapterView.OnItemSelectedListener() { @Override public void onItemSelected(AdapterView<?> adapterView, View view, int i, long l) { mTimeSchedule = (String) adapterView.getItemAtPosition(i); } @Override public void onNothingSelected(AdapterView<?> adapterView) { } });
1.7 Iniciar Actividad Con Los Turnos De Los Doctores
Registrar Escucha OnClickListener En El Botón De Búsqueda De Turnos
Tomar la referencia del botón search_doctor_button
en onCreate()
y luego le asignaremos la escucha de clicks.
Sin embargo, no tendremos instrucciones disponibles en onClick()
por el momento hasta que creemos la actividad para los turnos:
mSearchDoctorButton = (Button)findViewById(R.id.search_doctor_button); mSearchDoctorButton.setOnClickListener(new View.OnClickListener() { @Override public void onClick(View view) { // TODO: Iniciar actividad de turnos } });
1.8 Abrir Asignación Desde La Lista De Citas
Ahora haremos que se ejecute AddAppointmentActivity
desde AppointmentsActivity
.
La forma de solucionarlo es dirigirnos al método onCreate()
de la actividad de citas y buscar la obtención de referencia del FAB.
Una vez allí agregamos el inicio de la actividad de asignación de citas.
¡Pero ojo!
Usaremos startActivityForResult()
, ya que necesitamos determinar si la cita fue asignada correctamente:
(Conjuntamente agrega la constante de identificación de petición REQUEST_ADD_APPOINTMENT
)
FloatingActionButton fab = (FloatingActionButton) findViewById(R.id.fab); fab.setOnClickListener(new View.OnClickListener() { @Override public void onClick(View view) { showAddAppointment(); } });
El método showAddAppointment()
tendría estas instrucciones:
private void showAddAppointment() { Intent intent = new Intent(this, AddAppointmentActivity.class); startActivityForResult(intent, REQUEST_ADD_APPOINMENT); }
Recibir Datos Desde La Otra Actividad
Sobrescribiremos el método onActivityResult()
para determinar si la asignación fue exitosa.
Obviamente debes comprobar que los códigos de petición y resultado sean los correctos.
Si lo son, entonces mostramos una SnackBar
para avisarle al usuario que su cita fue asignada.
@Override protected void onActivityResult(int requestCode, int resultCode, Intent data) { if (REQUEST_ADD_APPOINMENT == requestCode && RESULT_OK == resultCode) { showSuccesfullySavedMessage(); } }
Donde showSuccessfullySavedMessage()
crea el mensaje:
private void showSuccesfullySavedMessage() { Snackbar.make(mFab, R.string.message_appointment_succesfully_saved, Snackbar.LENGTH_LONG).show(); }
1.9 Añadir Action Button De Guardado A La Toolbar
El contenido principal está diseñado en su mayor parte, pero a la Toolbar aún le faltan las acciones indicadas en el boceto.
Estas son: Descarte > Back Button y Guardado > Check Action Button
¿Cómo resolver estas carencias?
Fíjate:
Crear Archivo De Menu
Nos dirigimos a res/menu y damos click derecho. Luego ejecutamos la secuencia New > Menu resource file, ponemos el nombre menu_add_appointment y confirmamos el asistente.
Añadimos una etiqueta <item>
y le damos los siguientes atributos:
<?xml version="1.0" encoding="utf-8"?> <menu xmlns:android="http://schemas.android.com/apk/res/android" xmlns:app="http://schemas.android.com/apk/res-auto"> <item android:id="@+id/action_save_appointment" android:icon="@drawable/ic_check" android:orderInCategory="1" android:title="@string/action_save_appointment" app:showAsAction="ifRoom" /> </menu>
Inflar Recurso De Menu
Este paso ya lo sabemos. Sobrescribimos onCreateOptionsMenu()
para llamar al inflater de menús:
@Override public boolean onCreateOptionsMenu(Menu menu) { getMenuInflater().inflate(R.menu.menu_add_appointment, menu); return true; }
Procesar Action Button Para Guardar
El paso a seguir es sobrescribir onOptionsItemSelected()
en la actividad para procesar el evento de guardado.
¿Qué acciones deberíamos poner?
En primer lugar la validación de datos.
Y evaluando cada una de las entradas por parte del usuario, la única de la cual debemos asegurarnos es de la selección del turno.
Lo que quiere decir que validaremos si los datos del doctor no están vacíos antes de guardar. De lo contrario lanzamos un error.
Veamos:
@Override public boolean onOptionsItemSelected(MenuItem item) { if(R.id.action_save_appointment==item.getItemId()){ if(emptyDoctor()){ showDoctorError(); }else { saveAppointment(); } } return super.onOptionsItemSelected(item); } private void saveAppointment() { // TODO: Guardar cita con Refrofit } private void showDoctorError() { Snackbar.make(findViewById(android.R.id.content), R.string.error_empty_doctor, Toast.LENGTH_LONG).show(); } private boolean emptyDoctor() { return mDoctorId == null || mDoctorId.isEmpty(); }
Si analizamos el código vemos que tenemos el método emptyDoctor()
para validar la existencia de un doctor asociado.
showDoctorError()
para compactar la visualización del mensaje.
Y saveAppointment()
para guardar la cita médica cuando tengamos el servicio web listo.
1.10 Añadir Up Button Para Descartar
Habilitar Home Button Como Up Button
Aquí tomaremos la instancia de la action bar en onCreate()
.
Luego de ello habilitaremos la aparición del Up Button con los métodos setDisplayHomeAsUpEnabled()
y setDisplayShowHomeEnabled()
:
@Override protected void onCreate(Bundle savedInstanceState) { super.onCreate(savedInstanceState); setContentView(R.layout.activity_add_appointment); Toolbar toolbar = (Toolbar) findViewById(R.id.toolbar); setSupportActionBar(toolbar); ActionBar ab = getSupportActionBar(); ab.setDisplayShowHomeEnabled(true); ab.setDisplayHomeAsUpEnabled(true); ...
Procesar Evento Del Up Button
Usa el método onSupportNavigateUp()
para incluir las acciones del descarte.
En nuestro caso solo finalizaremos la actividad (podemos llamar a onBackPressed()
para hacerlo).
Sin embargo si lo deseas puedes crear un diálogo de confirmación para evitar perder tan repentinamente la configuración del usuario:
@Override public boolean onSupportNavigateUp() { onBackPressed(); return true; }
Paso 2. Elegir Un Doctor En La App
2.1 Crear Clase Java Para Los Doctores Y Horarios
En lo que respecta a las reglas de negocio empresariales necesitaremos representar a los doctores con sus respectivas disponibilidades en un día determinado.
Por ende crea dentro del paquete data/api/model la clase Doctor
con la siguiente estructura:
public class Doctor { private String mId; private String mName; private String mSpecialty; private String mDescription; private List<String> mAvailabilityTimes; public Doctor(String id, String name, String specialty, String description, List<String> availabilityTimes) { mId = id; mName = name; mSpecialty = specialty; mDescription = description; mAvailabilityTimes = availabilityTimes; } public String getId() { return mId; } public String getName() { return mName; } public String getSpecialty() { return mSpecialty; } public String getDescription() { return mDescription; } public List<String> getAvailabilityTimes() { return mAvailabilityTimes; } }
El campo mAvailabilityTimes
será la lista que recibirá el contenido de la disponibilidad cuando realicemos la petición hacia la API.
2.2 Crear Actividad Para Selección De Horarios
Ahora es el turno de la selección de los horarios de los doctores disponibles.
Si vemos el boceto, tendremos una lista de los doctores y sus horarios, donde la idea es que el usuario confirme el que se adapte a su disponibilidad.
No obstante cambiaremos los botones segmentados propuestos al inicio por un spinner, ya que los slots de tiempo disponibles pueden ser muchos.
Así que para comenzar crearemos la actividad en el paquete ui con las siguientes características:
- Activity Name > DoctorSchedulesActivity
- Layout Name > activity_doctors_schedules
- Title > Elige Tu Doctor
2.3 Diseñar Layout Para La Lista
Limpiar Elementos De La Plantilla
Luego de la creación automática necesitamos quitar aquellos elementos innecesarios como lo es el FAB del layout principal.
Por tanto removamoslo:
<?xml version="1.0" encoding="utf-8"?> <android.support.design.widget.CoordinatorLayout xmlns:android="http://schemas.android.com/apk/res/android" xmlns:app="http://schemas.android.com/apk/res-auto" xmlns:tools="http://schemas.android.com/tools" android:layout_width="match_parent" android:layout_height="match_parent" android:fitsSystemWindows="true" tools:context="com.hermosaprogramacion.blog.saludmock.ui.DoctorsSchedulesActivity"> <android.support.design.widget.AppBarLayout android:layout_width="match_parent" android:layout_height="wrap_content" android:theme="@style/AppTheme.AppBarOverlay"> <android.support.v7.widget.Toolbar android:id="@+id/toolbar" android:layout_width="match_parent" android:layout_height="?attr/actionBarSize" android:background="?attr/colorPrimary" app:popupTheme="@style/AppTheme.PopupOverlay" /> </android.support.design.widget.AppBarLayout> <include layout="@layout/content_doctors_schedules" /> </android.support.design.widget.CoordinatorLayout>
Agregar RecyclerView Al Layout De Contenido
En esta parte abriremos content_doctors_schedules.xml y modificaremos el layout para introducir un RecyclerView
junto a un view para mostrar la inexistencia de coincidencias y una barra de progreso:
<?xml version="1.0" encoding="utf-8"?> <RelativeLayout xmlns:android="http://schemas.android.com/apk/res/android" xmlns:app="http://schemas.android.com/apk/res-auto" xmlns:tools="http://schemas.android.com/tools" android:id="@+id/content_appointments" android:layout_width="match_parent" android:layout_height="match_parent" app:layout_behavior="@string/appbar_scrolling_view_behavior" tools:context="com.hermosaprogramacion.blog.saludmock.ui.DoctorsSchedulesActivity" tools:showIn="@layout/activity_doctors_schedules"> <ProgressBar android:id="@+id/progress" android:layout_width="wrap_content" android:layout_height="wrap_content" android:layout_centerInParent="true" android:visibility="gone" /> <android.support.v7.widget.RecyclerView android:id="@+id/doctors_schedules_list" android:layout_width="match_parent" android:layout_height="match_parent" android:paddingTop="@dimen/activity_vertical_margin" app:layoutManager="LinearLayoutManager" /> <LinearLayout android:id="@+id/doctors_schedules_empty" android:layout_width="wrap_content" android:layout_height="wrap_content" android:layout_centerInParent="true" android:orientation="vertical" android:visibility="gone"> <ImageView android:id="@+id/image_empty_state" android:layout_width="48dp" android:layout_height="48dp" android:layout_gravity="center" android:tint="#9E9E9E" app:srcCompat="@drawable/ic_medical_bag" /> <TextView android:layout_width="wrap_content" android:layout_height="wrap_content" android:layout_gravity="center" android:text="@string/message_no_schedules" /> </LinearLayout> </RelativeLayout>
Diseñar Item De La Lista
Lo siguiente será crear el layout para la lista de los horarios.
Como vimos, el boceto es una card con la foto de perfil del doctor a la izquierda y a su derecha un área de contenido con sus datos esenciales.
En esta área encontramos también una sección para la disponibilidad, donde ubicaremos un spinner y un botón para confirmar.
Dicho lo dicho, crearemos un nuevo layout llamado schedule_item_list y pondremos una ConstraintLayout
como raíz con la siguiente distribución:
<?xml version="1.0" encoding="utf-8"?> <android.support.v7.widget.CardView xmlns:android="http://schemas.android.com/apk/res/android" xmlns:app="http://schemas.android.com/apk/res-auto" xmlns:tools="http://schemas.android.com/tools" android:layout_width="match_parent" android:layout_height="wrap_content" android:layout_marginBottom="@dimen/activity_vertical_margin" android:layout_marginLeft="@dimen/activity_horizontal_margin" android:layout_marginRight="@dimen/activity_horizontal_margin"> <android.support.constraint.ConstraintLayout android:layout_width="match_parent" android:layout_height="match_parent"> <android.support.constraint.Guideline android:id="@+id/guideline_vertical" android:layout_width="wrap_content" android:layout_height="wrap_content" android:orientation="vertical" app:layout_constraintGuide_percent="0.34" /> <ImageView android:id="@+id/profile_image" android:layout_width="0dp" android:layout_height="0dp" android:tint="@android:color/darker_gray" app:layout_constraintBottom_toBottomOf="parent" app:layout_constraintEnd_toStartOf="@+id/guideline_vertical" app:layout_constraintStart_toStartOf="parent" app:layout_constraintTop_toTopOf="parent" app:srcCompat="@drawable/face_profile" /> <TextView android:id="@+id/name_text" android:layout_width="wrap_content" android:layout_height="wrap_content" android:layout_marginLeft="8dp" android:layout_marginStart="8dp" android:layout_marginTop="8dp" android:textAppearance="@style/TextAppearance.AppCompat.Title" app:layout_constraintStart_toStartOf="@+id/guideline_vertical" app:layout_constraintTop_toTopOf="parent" tools:text="Carlos Gaviria" /> <TextView android:id="@+id/specialty_text" android:layout_width="wrap_content" android:layout_height="wrap_content" android:layout_marginLeft="8dp" android:layout_marginStart="8dp" android:layout_marginTop="8dp" app:layout_constraintStart_toStartOf="@+id/guideline_vertical" app:layout_constraintTop_toBottomOf="@+id/name_text" tools:text="Medico General" /> <TextView android:id="@+id/description_text" android:layout_width="0dp" android:layout_height="wrap_content" android:layout_marginEnd="8dp" android:layout_marginStart="8dp" android:layout_marginTop="8dp" android:ellipsize="end" android:maxLength="128" android:visibility="visible" app:layout_constraintEnd_toEndOf="parent" app:layout_constraintHorizontal_bias="0.53" app:layout_constraintStart_toStartOf="@+id/guideline_vertical" app:layout_constraintTop_toBottomOf="@+id/specialty_text" tools:text="Universidad Santiago, 2 años de experiencia" /> <TextView android:id="@+id/label_availability" android:layout_width="wrap_content" android:layout_height="wrap_content" android:layout_marginStart="8dp" android:layout_marginTop="24dp" android:text="@string/label_availability" android:textAppearance="@style/TextAppearance.AppCompat.Body1" app:layout_constraintStart_toStartOf="@+id/guideline_vertical" app:layout_constraintTop_toBottomOf="@+id/description_text" /> <Spinner android:id="@+id/time_schedules_menu" android:layout_width="0dp" android:layout_height="wrap_content" android:layout_marginBottom="8dp" android:layout_marginEnd="8dp" android:layout_marginStart="8dp" android:layout_marginTop="8dp" android:visibility="visible" app:layout_constraintEnd_toEndOf="parent" app:layout_constraintHorizontal_bias="0.0" app:layout_constraintStart_toStartOf="@+id/guideline_vertical" app:layout_constraintTop_toBottomOf="@+id/label_availability" tools:listitem="@android:layout/simple_list_item_1" /> <Button android:id="@+id/booking_button" style="@style/Widget.AppCompat.Button.Borderless.Colored" android:layout_width="wrap_content" android:layout_height="wrap_content" android:layout_marginBottom="8dp" android:layout_marginStart="8dp" android:layout_marginTop="8dp" android:layout_weight="1" android:text="@string/request_button" app:layout_constraintBottom_toBottomOf="parent" app:layout_constraintStart_toStartOf="@+id/guideline_vertical" app:layout_constraintTop_toBottomOf="@+id/time_schedules_menu" /> </android.support.constraint.ConstraintLayout> </android.support.v7.widget.CardView>
En la preview tendremos una imagen similar a esta:
2.4 Crear Adaptador Personalizado Del RecyclerView
Creemos el adaptador DoctorsSchedulesAdapter
dentro del paquete ui/adapters.
Las características a proporcionarle son las siguientes:
- Extenderlo de
RecyclerView.Adapter
- Crearle una interfaz de escucha interna llamada
OnItemListener
con un método para capturar el click sobre el botón de reserva. - Implementar un constructor para recibir el contexto, los items y una instancia de la escucha
- Sobrescribir
onCreateViewHolder()
,onBindViewHolder()
ygetItemCount()
- Crear un
ViewHolder
personalizado que setee los valores y eventos a los views del ítem de lista
Estas instrucciones en código serían reflejadas así:
public class DoctorSchedulesAdapter extends RecyclerView.Adapter<DoctorSchedulesAdapter.DoctorSchedulesViewHolder> { private final Context context; private List<Doctor> doctors; private final OnItemListener listener; public interface OnItemListener { void onBookingButtonClicked(Doctor bookedDoctor, String timeScheduleSelected); } public DoctorSchedulesAdapter(Context context, List<Doctor> doctors, OnItemListener listener) { this.context = context; this.doctors = doctors; this.listener = listener; } public void setDoctors(List<Doctor> doctors) { this.doctors = doctors; notifyDataSetChanged(); } @Override public DoctorSchedulesViewHolder onCreateViewHolder(ViewGroup parent, int viewType) { return new DoctorSchedulesViewHolder(parent); } @Override public void onBindViewHolder(DoctorSchedulesViewHolder holder, int position) { holder.bind(doctors.get(position)); } @Override public int getItemCount() { return doctors.size(); } public class DoctorSchedulesViewHolder extends RecyclerView.ViewHolder { private final TextView nameView; private final TextView specialtyView; private final TextView descriptionView; private final Spinner scheduleView; private final Button bookingButton; public DoctorSchedulesViewHolder(ViewGroup parent) { super(LayoutInflater.from(parent.getContext()) .inflate(R.layout.schedule_item_list, parent, false)); nameView = (TextView) itemView.findViewById(R.id.name_text); specialtyView = (TextView) itemView.findViewById(R.id.specialty_text); descriptionView = (TextView) itemView.findViewById(R.id.description_text); scheduleView = (Spinner) itemView.findViewById(R.id.time_schedules_menu); bookingButton = (Button) itemView.findViewById(R.id.booking_button); bookingButton.setOnClickListener(new View.OnClickListener() { @Override public void onClick(View view) { int position = getAdapterPosition(); if (position != RecyclerView.NO_POSITION) { String selectedItem = (String) scheduleView.getSelectedItem(); listener.onBookingButtonClicked(doctors.get(position), selectedItem); } } }); } public void bind(Doctor doctor) { // Formatos para lista de tiempos disponibles List<String> formatedTimes = new ArrayList<>(); for (String time : doctor.getAvailabilityTimes()) { formatedTimes.add(DateTimeUtils.formatTimeForUi(time)); } nameView.setText(doctor.getName()); specialtyView.setText(doctor.getSpecialty()); descriptionView.setText(doctor.getDescription()); ArrayAdapter<String> adapter = new ArrayAdapter<>(context, android.R.layout.simple_list_item_1, formatedTimes); scheduleView.setAdapter(adapter); } } }
Cabe destacar que el método bind()
del view holder nos facilita la asignación de valores a los views.
Además debemos crear el método de utilidad formatTimeForUi()
para mostrar los tiempos con el patrón h:mma
:
private static final String UI_TIME_PATTERN = "h:mma"; private static final String API_TIME_PATTERN = "HH:mm:ss"; public static String formatTimeForUi(String time) { SimpleDateFormat simpleDateFormat = new SimpleDateFormat(API_TIME_PATTERN, Locale.getDefault()); try { Date date = simpleDateFormat.parse(time); simpleDateFormat.applyPattern(UI_TIME_PATTERN); return simpleDateFormat.format(date); } catch (ParseException e) { e.printStackTrace(); } return time; }
2.5 Definir Campos De La Actividad
Para el código Java de esta actividad incluiremos:
- Constantes: Su tag y 3 para los extras a enviar hacia
AddAppointmentActivity
: el ID del doctor elegido, su nombre y la hora elegida - Views: La lista y su adaptador. Los views de progreso y estado vacío
- Relaciones: De nuevo los elementos de Retrofit para realizar peticiones
- Variables: Usaremos 3 para retener los datos enviados desde
AddAppointmentActivity
Las anteriores propiedades nombradas se verían así:
public class DoctorsSchedulesActivity extends AppCompatActivity { private static final String TAG = DoctorsSchedulesActivity.class.getSimpleName(); public static final String EXTRA_DOCTOR_ID = "com.hermosaprogramacion.EXTRA_DOCTOR_ID"; public static final String EXTRA_DOCTOR_NAME = "com.hermosaprogramacion.EXTRA_DOCTOR_NAME"; public static final String EXTRA_TIME_SLOT_PICKED = "com.hermosaprogramacion.EXTRA_TIME_SLOT_PICKED"; private RecyclerView mList; private DoctorSchedulesAdapter mListAdapter; private ProgressBar mProgress; private View mEmptyView; private Retrofit mRestAdapter; private SaludMockApi mSaludMockApi; private Date mDateSchedulePicked; private String mMedicalCenterId; private String mTimeSchedule; @Override protected void onCreate(Bundle savedInstanceState) { super.onCreate(savedInstanceState); setContentView(R.layout.activity_doctors_schedules); Toolbar toolbar = (Toolbar) findViewById(R.id.toolbar); setSupportActionBar(toolbar); } }
2.6 Poblar Lista De Doctores
Ubiquémonos en onCreate()
para tomar la referencia del RecyclerView, crear la instancia del adaptador y relacionarlos.
Importante: pasa una lista vacía de doctores al adaptador como fuente inicial y crea una escucha anónima en el tercer parámetro:
@Override protected void onCreate(Bundle savedInstanceState) { super.onCreate(savedInstanceState); setContentView(R.layout.activity_doctors_schedules); Toolbar toolbar = (Toolbar) findViewById(R.id.toolbar); setSupportActionBar(toolbar); mList = (RecyclerView) findViewById(R.id.doctors_schedules_list); mListAdapter = new DoctorSchedulesAdapter(this, new ArrayList<Doctor>(0), new DoctorSchedulesAdapter.OnItemListener() { @Override public void onBookingButtonClicked(Doctor bookedDoctor) { } }); }
Procesar Evento Del RecyclerView
—Si el botón de reserva es presionado en el ítem del doctor, ¿qué deberíamos hacer?
¡Exacto!, enviar el ID del doctor, su nombre y el tiempo seleccionado hacia la actividad de creación de la cita.
En ese caso crearemos un Intent de respuesta, le añadiremos los valores como extras, lo asociaremos con setResult()
al código de petición y luego finalizamos la actividad:
mListAdapter = new DoctorSchedulesAdapter(this, new ArrayList<Doctor>(0), new DoctorSchedulesAdapter.OnItemListener() { @Override public void onBookingButtonClicked(Doctor bookedDoctor, String timeScheduleSelected) { Intent responseIntent = new Intent(); responseIntent.putExtra(EXTRA_DOCTOR_ID, bookedDoctor.getId()); responseIntent.putExtra(EXTRA_DOCTOR_NAME, bookedDoctor.getName()); responseIntent.putExtra(EXTRA_TIME_SLOT_PICKED, timeScheduleSelected); setResult(Activity.RESULT_OK, responseIntent); finish(); } }); mList.setAdapter(mListAdapter);
Recibir ID Y Horario Desde La Actividad De Creación
A continuación vamos a añadir el método onActivityResult()
en AddAppointmentActivity
con el fin de procesar los datos enviados.
La idea es que mostremos el nombre del doctor y la hora de la cita en la sección inferior:
@Override protected void onActivityResult(int requestCode, int resultCode, Intent data) { if (REQUEST_PICK_DOCTOR_SCHEDULE == requestCode && RESULT_OK == resultCode) { mDoctorId = data.getStringExtra(DoctorsSchedulesActivity.EXTRA_DOCTOR_ID); String doctorName = data.getStringExtra(DoctorsSchedulesActivity.EXTRA_DOCTOR_NAME); mTimeSlotPicked = data.getStringExtra(DoctorsSchedulesActivity.EXTRA_TIME_SLOT_PICKED); showDoctorScheduleSummary(doctorName, mTimeSlotPicked); } }
private void showDoctorScheduleSummary(String doctorName, String doctorTime) { mEmptyDoctorView.setVisibility(View.GONE); mDoctorContentView.setVisibility(View.VISIBLE); mDoctorName.setText(doctorName); mDoctorScheduleTime.setText(doctorTime); }
El método showDoctorScheduleSummary()
nos permite aislar el cambio de visibilidades entre el view vació y el contenido. Además de setear los textos en los views.
2.7 Procesar Evento De Up Button
Habilitar Home Button Como Up
Al igual que hicimos en la actividad de asignación, habilitamos en onCreate()
el Up Button:
@Override protected void onCreate(Bundle savedInstanceState) { super.onCreate(savedInstanceState); setContentView(R.layout.activity_doctors_schedules); Toolbar toolbar = (Toolbar) findViewById(R.id.toolbar); setSupportActionBar(toolbar); ActionBar ab = getSupportActionBar(); ab.setDisplayShowHomeEnabled(true); ab.setDisplayHomeAsUpEnabled(true);
Usar Método onSupportNavigateUp()
De forma repetida, sobrescribimos el método para la navegación superior para llamar a onBackPressed()
ya que no tenemos acciones que realizar:
@Override public boolean onSupportNavigateUp() { onBackPressed(); return true; }
Iniciar Actividad De Horarios
Finalmente ve a AddAppointmentActivity
e inicia con startActivityForResult()
dentro del evento del botón para reservar doctores.
No olvides pasar los 3 extras:
mSearchDoctorButton = (Button) findViewById(R.id.search_doctor_button); mSearchDoctorButton.setOnClickListener(new View.OnClickListener() { @Override public void onClick(View view) { showDoctorsSchedulesUi(); } });
private void showDoctorsSchedulesUi() { Intent requestIntent = new Intent(AddAppointmentActivity.this , DoctorsSchedulesActivity.class); requestIntent.putExtra(EXTRA_DATE_PICKED, mDatePicked.getTime()); requestIntent.putExtra(EXTRA_MEDICAL_CENTER_ID, mMedicalCenterId); requestIntent.putExtra(EXTRA_TIME_SHEDULE_PICKED, getTimeSchedule()); startActivityForResult(requestIntent, REQUEST_PICK_DOCTOR_SCHEDULE); }
private String getTimeSchedule() { switch (mTimeSchedule) { case UI_VALUE_MORNING: return API_VALUE_MORNING; case UI_VALUE_AFTERNOON: return API_VALUE_AFTERNOON; default: return ""; } }
En el caso de la jornada crearemos el método getTimeSchedule()
para enviar el valor para la API correspondiente.
Paso 3. Añadir Soporte De Peticiones A La API REST
En esta sección habilitaremos las operaciones del afiliado sobre los recursos que necesitamos en la app Android para completar la asignación de citas.
Las cuales son:
- Obtener centros médicos
- Obtener doctores con horarios disponibles
- Crear una cita médica
3.1 GET Request Para Obtener Centros Médicos
Diseñar URL Para El Recurso
Nuestro primer paso para habilitar el recurso es diseñar la sintaxis del path que tendrá nuestra URL.
En este caso es supremamente intuitivo para nosotros, ya que podemos usar el sustantivo medical centers y asociarlo al método GET:
GET |
http://localhost/saludmock/v1/medical-centers |
Especificar Estructura Del Mensaje De La petición
El mensaje que debe enviar el cliente de Retrofit se compone de:
- Método:
GET
- URI:
/saludmock/v1/medical-centers
- Cabeceras:
Authorization
con el token del afiliado
Un ejemplo básico sería:
GET /saludmock/v1/medical-centers HTTP/1.1 Host: localhost Authorization: 44945899e5c49b7ff8.48092936
Diseñar Respuesta JSON Del Servicio REST
Aquí optaremos por enviar un array de objetos con la estructura tabular de los centros médicos. Dicho array lo nombraremos "results"
:
{ "results": [ { "id": "10001", "address": "7533 Carey Park", "name": "Clu00ednica Occidente", "description": "Mauris ullamcorper purus sit amet nulla." }, { "id": "10002", "address": "6 Nobel Park", "name": "Clu00ednica Rostro y Figura", "description": "Integer non velit." }, { "id": "10003", "address": "2 Onsgard Hill", "name": "Centro Medico Salud para Todos", "description": "Aliquam augue quam, sollicitudin vitae, consectetuer eget, rutrum at, lorem." }, { "id": "10004", "address": "5 Pond Crossing", "name": "Hospital Carlos Carmona Montoya", "description": "Donec ut mauris eget massa tempor convallis." }, { "id": "10005", "address": "6 Waxwing Circle", "name": "Hospital San Juan de Dios", "description": "Nulla tellus." } ] }
Enrutar Recurso En La API REST PHP
Ahora abriremos el proyecto PHP y nos dirigiremos al archivo index.php (ya sabemos que este es el router de la API).
El objeto es añadir el segmento "medical-centers"
al array de recursos $apiResources
.
$apiResources = array('affiliates', 'appointments','medical-centers');
Crear Controlador Del Recurso
Lo siguiente será añadir la clase PHP correspondiente para el recurso en la carpeta v1/controllers.
Nombraremos al archivo medical_centers.php e igual la clase (agrega el require
de este archivo a index.php).
(Si tienes problema con la convención de nombrado para las clases de los controladores, crear una reestructuración del string entrante en el enrutador PHP)
Y le pondremos un método get()
para manejar la petición:
<?php /** * Controlador de centros medicos */ class medical_centers { public static function get(){ } }
Procesar Petición GET
Las acciones a realizar en el método get()
son similares al tutorial anterior:
- 1. Autorizar usuario
- 2. Verificaciones, restricciones, defensas
- 3. Invocar a la fuente de datos para retorno de citas médicas
Con esto claro, codifiquemos las acciones establecidas.
En primer lugar, la autorización podemos reutilizarla del controlador de citas médicas.
Debido a que los métodos asociados al proceso están mezclados en appointments.php, crearemos una clase singleton llamada AuthorizationManager
en el folder controllers y moveremos estos métodos:
<?php /** * Manejador de autorizaciones sobre recursos */ class AuthorizationManager { private static $authManager = null; /** * AuthorizationManager constructor. */ final private function __construct() { } public static function getInstance(){ if(self::$authManager==null){ self::$authManager = new self(); } return self::$authManager; } final protected function __clone() { } public function authorizeAffiliate() { $authHeaderValue = apache_request_headers()['Authorization']; if (!isset($authHeaderValue)) { throw new ApiException( 401, 0, "No está autorizado para acceder a este recurso", "http://localhost", "No viene el token en la cabecera de autorización" ); } // Consultar base de datos por afiliado $affiliateId = self::isAffiliateAuthorized($authHeaderValue); if (empty($affiliateId)) { throw new ApiException( 401, 0, "No está autorizado para acceder a este recurso", "http://localhost", "No hay coincidencias del token del afiliado en la base de datos" ); } return $affiliateId; } private function isAffiliateAuthorized($token) { if (empty($token)) { throw new ApiException( 405, 0, "No está autorizado para acceder a este recurso", "http://localhost", "La cabecera HTTP Authorization está vacía" ); } try { $pdo = MysqlManager::get()->getDb(); // Componer sentencia SELECT $sentence = "SELECT id FROM affiliate WHERE token = ?"; // Preparar sentencia $preStatement = $pdo->prepare($sentence); $preStatement->bindParam(1, $token); // Ejecutar sentencia if ($preStatement->execute()) { // Retornar id del afiliado autorizado $result = $preStatement->fetchColumn(); return $result; } else { throw new ApiException( 500, 0, "Error de base de datos en el servidor", "http://localhost", "Hubo un error ejecutando una sentencia SQL en la base de datos. Detalles:" . $pdo->errorInfo()[2] ); } } catch (PDOException $e) { throw new ApiException( 500, 0, "Error de base de datos en el servidor", "http://localhost", "Ocurrió el siguiente error al intentar insertar el afiliado: " . $e->getMessage()); } } }
De esta forma llamamos al método authorizeAffiliate()
como primer línea de get()
:
public static function get(){ $affiliateId = AuthorizationManager::getInstance()->authorizeAffiliate(); }
Opcionalmente comprobaremos que no existan segmentos adicionales en la URL. Si deseas ignorarlo también es una opción procesar la petición:
if (isset($urlSegments[1])) { throw new ApiException( 400, 0, "El recurso está mal referenciado", "http://localhost", "El recurso $_SERVER[REQUEST_URI] no esta sujeto a resultados" ); }
Lo siguiente es retornar de la base de datos los centros médicos, así que crearemos un método llamado retrieveMedicalCenters()
.
Su bloque interno de instrucciones será preparar una sentencia SELECT
para conseguir todos los centros médicos existentes.
Veamos:
private static function retrieveMedicalCenters(){ try { $pdo = MysqlManager::get()->getDb(); $query = "SELECT * FROM medical_center"; $preStm = $pdo->prepare($query); if ($preStm->execute()) { return $preStm->fetchAll(PDO::FETCH_ASSOC); } else { throw new ApiException( 500, 0, "Error de base de datos en el servidor", "http://localhost", "Hubo un error ejecutando una sentencia SQL en la base de datos. Detalles:" . $pdo->errorInfo()[2] ); } } catch (PDOException $e) { throw new ApiException( 500, 0, "Error de base de datos en el servidor", "http://localhost", "Ocurrió el siguiente error al consultar las citas médicas: " . $e->getMessage()); } }
Al terminarlo lo invocamos y asignamos su resultado como valor de un array asociativo con clave "results"
:
public static function get($urlSegments){ $affiliateId = AuthorizationManager::getInstance()->authorizeAffiliate(); if (isset($urlSegments[1])) { throw new ApiException( 400, 0, "El recurso está mal referenciado", "http://localhost", "El recurso $_SERVER[REQUEST_URI] no esta sujeto a resultados" ); } $medicalCenters= self::retrieveMedicalCenters(); return ["results"=>$medicalCenters]; }
Testear Petición Con Postman
Para terminar esta característica abrimos Postman y asignamos estos valores en la interfaz:
- Método: GET
- Request URL: http://localhost/saludmock/v1/medical-centers
- Headers > Key: Authorization, Value: [Token de tu afiliado a testear]
La siguiente imagen representa la anterior configuración:
Si presionamos Send, la respuesta debe verse similar a la siguiente:
3.2 Obtener Lista De Doctores Con Horarios Disponibles
Analizar Diseño Lógico De La Base De Datos
Existen varios bloques de tiempo (attention_time_slot
) donde un doctor puede atender pacientes.
Ejemplo: El doctor Carlos García atiende al paciente Julio Perez a las 8:30am
Para SaludMock tomaremos la jornada laboral desde 6:00am a 6:00pm, donde cada time slot tendrá duración de 30 minutos.
Lo que quiere decir que todos los doctores tendrán la posibilidad de estar relacionados con estos tiempos.
De esta relación muchos a muchos nace la tabla de horarios para doctores (doctor_schedule
). La cual relaciona la fecha de laboral y la disponibilidad del slot.
Todo esto que acabamos de analizar se resume en el siguiente diagrama:
Crear Tablas En MySQL
Abre phpMyAdmin y escribe las siguientes sentencias SQL para crear las tablas doctor_schedule
y attention_time_slot
:
CREATE TABLE attention_time_slot ( id int(11) NOT NULL PRIMARY KEY AUTO_INCREMENT, start_time time NOT NULL, end_time time NOT NULL )
INSERT INTO attention_time_slot (start_time, end_time) VALUES ( '06:00:00', '06:30:00'), ( '06:30:00', '07:00:00'), ( '07:00:00', '07:30:00'), ( '07:30:00', '08:00:00'), ( '08:30:00', '09:00:00'), ( '09:00:00', '09:30:00'), ( '09:30:00', '10:00:00'), ( '10:30:00', '11:00:00'), ( '11:30:00', '12:00:00'), ( '12:30:00', '13:00:00'), ( '13:00:00', '13:30:00'), ( '13:30:00', '14:00:00'), ( '14:00:00', '14:30:00'), ( '14:30:00', '15:00:00'), ( '15:00:00', '15:30:00'), ( '15:30:00', '16:00:00');
CREATE TABLE doctor_schedule ( doctor_id int(11) NOT NULL, attention_time_slot_id int(11) NOT NULL, available tinyint(1) NOT NULL DEFAULT '1', date date NOT NULL, PRIMARY KEY (doctor_id,attention_time_slot_id,date), FOREIGN KEY (doctor_id) REFERENCES doctor (id), FOREIGN KEY (attention_time_slot_id) REFERENCES attention_time_slot (id) )
Aunque tenemos los datos de los slots, es necesario que agregues manualmente registros a doctor_schedule
con una fecha actualizada para poder probar la interfaz de la app.
Diseñar URL Para El Recurso
Muy bien, esta vez consultaremos a los doctores que tengan al menos un slot de tiempo disponible para el día seleccionado.
Sin embargo podemos usar el adjetivo disponibilidad (en inglés availability) como segundo segmento de la URL.
Veamos:
GET |
http://localhost/saludmock/v1/doctors/availability |
Además de ello necesitamos los siguientes parámetros:
date
: fecha para validar los slots disponiblesmedical-center
: ID del centro médicotime-schedule
: Jornada. Recibirá los valoresmorning
par la mañana yafternoon
para la tarde.
Especificar Estructura Del Mensaje De La petición
Al igual que la obtención de los centros médicos, requeriremos el token en la cabecera de autorización.
Ejemplo:
GET /saludmock/v1/doctors/availability?date=2018-01-20&medical-center=10004&time-schedule=morning HTTP/1.1 Host: localhost Authorization: 44945899e5c49b7ff8.48092936
Diseñar Respuesta JSON
En este punto haremos uso de nuevo del array "results"
.
Sin embargo cada objeto doctor traerá como atributo un array llamado "times"
donde listaremos todos sus slots disponibles del día.
Ejemplo:
{ "results": [ { "id": "1000001", "name": "Mark Cooper", "specialty": "Anatomu00eda Patolu00f3gica", "description": null, "times": [ "06:00:00", "06:30:00", "08:30:00", "10:30:00" ] }, { "id": "1000002", "name": "Carlos Simmons", "specialty": "Anestesiologu00eda y Recuperaciu00f3n", "description": null, "times": [ "06:30:00" ] } ] }
Enrutar Recurso En El Servicio Web
Seguido abrimos el index.php
y añadimos el recurso al arreglo $apiResources
:
$apiResources = array('affiliates', 'appointments', 'medical-centers','doctors');
Crear Controlador Para Doctores
Creamos en la carpeta controllers la clase doctors
con un método get()
para manejar la petición:
<?php /** * Controlador de doctores */ class doctors { public static function get($urlSegments) { } }
Procesar Petición GET
Lo primero que haremos será autorizar al afiliado para que pueda visualizar la lista de doctores con sus horarios disponibles:
public static function get($urlSegments) { AuthorizationManager::getInstance()->authorizeAffiliate(); }
En esta ocasión validaremos si el segmento adicional es availability
, de lo contrario mandamos una excepción:
if (isset($urlSegments[0]) && strcmp(self::AVAILABILITY_SEGMENT, $urlSegments[0]) == 0) { } else { throw new ApiException( 400, 0, "El recurso está mal referenciado", "http://localhost", "El recurso $_SERVER[REQUEST_URI] no esta sujeto a resultados" ); }
Seguido a eso vamos a extraer los 3 parámetros necesarios para consultar los horarios.
Si estos no vienen en la cadena, entonces lanzamos una excepción:
if (isset($urlSegments[0]) && strcmp(self::AVAILABILITY_SEGMENT, $urlSegments[0]) == 0) { $queryParams = array(); if (isset($_SERVER['QUERY_STRING'])) { parse_str($_SERVER['QUERY_STRING'], $queryParams); } if (!isset($queryParams[self::PARAM_MEDICAL_CENTER]) || empty($queryParams[self::PARAM_MEDICAL_CENTER]) || !isset($queryParams[self::PARAM_DATE]) || empty($queryParams[self::PARAM_DATE]) || !isset($queryParams[self::PARAM_TIME_SCHEDULE]) || empty($queryParams[self::PARAM_TIME_SCHEDULE])) { throw new ApiException( 400, 0, "Revise que los parámetros para fecha, centro médico y jornada estén especificados", "http://localhost", "Revise que estén definidos los parámetros date, medical-center y time-schedule" ); } // ... }
Después creamos el método retrieveDoctorsSchedules()
el cual va a recibir la fecha entrante, el ID del centro médico y la jornada para consultar los médicos disponibles a través de la siguiente consulta:
SELECT id, name, specialty, description FROM doctor WHERE exists(SELECT doctor_schedule.doctor_id FROM doctor_schedule WHERE doctor_id = doctor.id AND available = TRUE AND date = ? AND medical_center_id = ?
Usándola tendremos el siguiente código:
public static function retrieveDoctorsSchedules($medicalCenterId, $date, $timeSchedule) { try { $pdo = MysqlManager::get()->getDb(); $doctors = array(); $query = "SELECT id, name, specialty, description FROM doctor WHERE exists(SELECT doctor_schedule.doctor_id FROM doctor_schedule WHERE doctor_id = doctor.id AND available = TRUE AND date = ? AND medical_center_id = ?)"; $stm = $pdo->prepare($query); $stm->bindParam(1, $date); $stm->bindParam(2, $medicalCenterId, PDO::PARAM_INT); if ($stm->execute()) { while ($doctor = $stm->fetch(PDO::FETCH_ASSOC)) { $doctorId = $doctor[self::COL_DOCTOR_ID]; $times = self::retrieveTimeSlots($doctorId, $date, $timeSchedule); if (count($times) > 0) { $doctor[self::JSON_TIMES] = $times; array_push($doctors, $doctor); } } return $doctors; } else { throw new ApiException( 500, 0, "Error de base de datos en el servidor", "http://localhost", "Hubo un error ejecutando una sentencia SQL en la base de datos. Detalles:" . $pdo->errorInfo()[2] ); } } catch (PDOException $e) { throw new ApiException( 500, 0, "Error de base de datos en el servidor", "http://localhost", "Ocurrió el siguiente error al consultar las citas médicas: " . $e->getMessage()); } }
Sin embargo necesitaremos crear un método retrieveTimeSlots()
para consultar por cada médico las fechas disponibles de atención como vemos en esta consulta:
SELECT a.start_time FROM doctor INNER JOIN doctor_schedule b ON doctor.id = b.doctor_id INNER JOIN attention_time_slot a ON b.attention_time_slot_id = a.id WHERE doctor.id = ? AND b.available = TRUE AND b.date = ?
Antes de ejecutar la sentencia en PDO es necesario decidir entre qué condición BETWEEN
se usará dependiendo del valor del parámetro $timeSchedule
.
Codifiquémoslo:
private static function retrieveTimeSlots($doctorId, $date, $timeSchedule) { $pdo = MysqlManager::get()->getDb(); $timeCondition = ''; switch ($timeSchedule) { case self::VALUE_MORNING: $timeCondition = " AND start_time BETWEEN '06:00' AND '12:00'"; break; case self::VALUE_EVENING: $timeCondition = " AND start_time BETWEEN '12:00' AND '18:00'"; break; default: // TODO: Lanza una excepción para no procesar un valor diferente } $query = "SELECT a.start_time FROM doctor INNER JOIN doctor_schedule b ON doctor.id = b.doctor_id INNER JOIN attention_time_slot a ON b.attention_time_slot_id = a.id WHERE doctor.id = ? AND b.available = TRUE AND b.date = ?"; $query = $query . $timeCondition; $stm = $pdo->prepare($query); $stm->bindParam(1, $doctorId, PDO::PARAM_INT); $stm->bindParam(2, $date); $stm->execute(); return $stm->fetchAll(PDO::FETCH_COLUMN); }
Probar Petición GET Con Postman
¡Muy bien!
Abramos Postman y seteemos en la interfaz estos valores:
- Método: GET
- Request URL: http://localhost/saludmock/v1/doctors/availability
- Headers > Key: Authorization, Value: [Token de tu afiliado a testear]
- Params > {Key:
date
– Value: Alguna fecha que hayas habilitado}, {Key:medical-center
– Value: Algún ID de centro médico}
En la interfaz se vería así:
Al enviar la petición veremos una respuesta JSON como esta:
3.3 Petición Para Crear Una Cita Médica
Diseñar URL Para El Recurso
La URL para citas médicas ya está diseñada, lo que cambiaremos será la acción.
Usaremos POST para crear una nueva cita:
POST |
http://localhost/saludmock/v1/appointments |
Especificar Estructura Del Mensaje De La petición
Para la creación es necesario especificar los parámetros del cuerpo que usaremos para la creación a través de un formato JSON.
Estos son:
datetime
doctor
service
Un ejemplo de cómo se vería es el siguiente:
{ "datetime": "2018-01-18 06:00:00", "service": "Medicina General", "doctor": 1000001 }
Diseñar Respuesta JSON
El resultado será un objeto básico con el mensaje de la creación (aunque también podría ser la cita completamente creada si es beneficioso para las reglas de tu aplicación):
{ "status": 201, "message": "Cita creada" }
Procesar Petición POST En El Controlador
Esta vez nos enfocaremos en el método post()
de appointments
.
Las líneas de código a usar tienen que seguir esta guía:
- Autorizar usuario
- Obtener body de la petición
- Decodificar su contenido JSON
- Realizar validaciones de las entradas del usuario
- Crear registro de cita médica con los datos entrantes
- Marcar horario como no disponible
- Retornar en objeto JSON con respuesta acertiva de creación
Codifiquemos cada instrucción:
Acción 1: La autorización es cuestión de copiar y pegar el uso de AuthorizationManager
:
public static function post($urlSegments) { $affiliateId = AuthorizationManager::getInstance()->authorizeAffiliate(); }
Acción 2: La información del body podemos encontrarla con file_get_contents()
y la referencia php://input
:
$requestBody = file_get_contents('php://input');
Acción 3. Aquí usaremos el método json_decode()
para la conversión del JSON:
$newAppointment = json_decode($requestBody, true);
Acción 4. Incluye las validaciones que requieras.
Acción 5. Escribiremos un método llamado saveAppointment()
, el cual recibirá como parámetro la info decodificada de la cita y retornará un valor booleano determinando si el registro fue insertado.
En cuanto a PDO, el camino a seguir es preparar una sentencia INSERT
sobre la tabla appointment
y ejecutarla:
private static function saveAppointment($affiliateId, $newAppointment) { $pdo = MysqlManager::get()->getDb(); try { $pdo->beginTransaction(); $op = "INSERT INTO appointment (date_and_time, service, affiliate_id, doctor_id) VALUES (?, ?, ?, ?)"; $stm = $pdo->prepare($op); $stm->bindParam(1, $dateTime); $stm->bindParam(2, $service); $stm->bindParam(3, $affiliateId); $stm->bindParam(4, $doctorId); $dateTime = $newAppointment[self::JSON_DATE_TIME]; $service = $newAppointment[self::JSON_SERVICE]; $doctorId = $newAppointment[self::JSON_DOCTOR]; $stm->execute(); $explodeDateTime = explode(" ", $dateTime); $date = $explodeDateTime[0]; $startTime = $explodeDateTime[1]; self::markScheduleNotAvailable($doctorId, $startTime, $date); return $pdo->commit(); } catch (PDOException $e) { $pdo->rollBack(); throw new ApiException( 500, 0, "Error de base de datos en el servidor", "http://localhost", "Hubo un error ejecutando una sentencia SQL en la base de datos. Detalles:" . $e->getMessage() ); } }
Acción 6. Lo siguiente es crear un método llamado markScheduleNotAvailable()
, el cual consultará el ID del time slot que viene (será necesario explotar la fecha entrante para conseguirlo) y se lo enviará a una operación UPDATE
que modificará la columna doctor_schedule.available al valor de FALSE
:
UPDATE doctor_schedule SET available = FALSE WHERE doctor_id = ? AND attention_time_slot_id = (SELECT id FROM attention_time_slot WHERE start_time = ?) AND date = ?
Esto podemos representarlo en PDO así:
private static function markScheduleNotAvailable($doctorId, $startTime, $date) { $pdo = MysqlManager::get()->getDb(); $cmd = "UPDATE doctor_schedule SET available = FALSE WHERE doctor_id = ? AND attention_time_slot_id = (SELECT id FROM attention_time_slot WHERE start_time = ?) AND date = ? "; $stm = $pdo->prepare($cmd); $stm->bindParam(1, $doctorId); $stm->bindParam(2, $startTime); $stm->bindParam(3, $date); return $stm->execute(); }
Como vemos, al interior de saveAppointment()
abrimos una transacción (beginTransaction()
) antes de ejecutar ambas sentencias SQL.
Una vez establecido script de inserción de la cita, explotamos la fecha entrante y se la pasamos a markScheduleNotAvailable()
.
Nota: Realiza el mismo procedimiento de actualización en la disponibilidad cuando las citas existentes cambien su estado.
Y final pasamos el resultado de commit()
. O llamamos a rollBack()
en caso de tener excepciones.
Acción 7. Llamamos a saveAppointment()
y retornamos un objeto JSON con mensaje positivo o una excepción:
public static function post($urlSegments) { $affiliateId = AuthorizationManager::getInstance()->authorizeAffiliate(); $requestBody = file_get_contents('php://input'); $newAppointment = json_decode($requestBody, true); if (self::saveAppointment($affiliateId, $newAppointment)) { return ["status" => 201, "message" => "Cita creada"]; } else { throw new ApiException( 500, 0, "Error del servidor", "http://localhost", "Error en la base de datos del servidor"); } }
Probar Creación De Citas Con Postman
Verifiquemos la petición con estos datos:
- Método: POST
- Request URL: http://localhost/saludmock/v1/appointments
- Headers > Key: Authorization, Value: [Token de tu afiliado a testear]
- Body > raw, JSON (application/json): {JSON}
Visualmente sería:
Y al enviarla tendríamos:
Paso 4. Consumir Servicio REST Con Retrofit
Inmediatamente después de terminar las peticiones en el servicio PHP vamos a usar Retrofit para obtener las respuestas JSON.
4.1 Obtener Información De Centros Médicos
Crear Objeto Java Para Conversión
Para satisfacer las reglas de aplicación que Retrofit requiere para la conversión de sus respuestas crearemos la clase MedicalCentersRes
.
Su objetivo será mapear el atributo "results"
que especificamos en el diseño de respuesta.
Veamos:
public class MedicalsCenterRes { private List<MedicalCenter> results; public MedicalsCenterRes(List<MedicalCenter> results) { this.results = results; } public List<MedicalCenter> getResults() { return results; } }
Añadir Call A La Interface De Retrofit
Abrimos la interfaz SaludMockApi
y agregamos un nuevo método tipo llamado getMedicalCenters()
.
¿Cómo debe estar configurado?
Así:
- Anotación
@GET
hacia/medical-centers
- El tipo de retorno será
Call<MedicalCentersRes>
- El valor de la cabecera Authorization anotado con
@Header
De lo anterior tendremos:
@GET("medical-centers") Call<MedicalsCenterRes> getMedicalCenters(@Header("Authorization") String token);
Crea Implementación De La Interfaz
Iremos a AddApointmentActivity
y crearemos las instancias de Retrofit al final del método onCreate()
:
@Override protected void onCreate(Bundle savedInstanceState) { ... // Crear adaptador Retrofit Gson gson = new GsonBuilder() .setDateFormat("yyyy-MM-dd HH:mm:ss") .create(); mRestAdapter = new Retrofit.Builder() .baseUrl(SaludMockApi.BASE_URL) .addConverterFactory(GsonConverterFactory.create(gson)) .build(); // Crear conexión a la API de SaludMock mSaludMockApi = mRestAdapter.create(SaludMockApi.class); }
Realizar Llamada Retrofit
Luego crearemos un método llamado loadMedicalCenters()
.
La secuencia de acciones vendría siendo la siguiente:
- Mostrar indicador de carga
- Obtener token de afiliado
- Realizar llamada asíncrona con el cliente REST
- Respuesta obtenida
- Éxito: Obtener resultados del objeto de respuesta y poblar el adaptador del menú de centros médicos
- Falla: Mostrar error de la API o del servidor
- Ocultar indicador de carga
- Errores técnicos
- Mostrar mensaje de error genérico al usuario
- Ocultar indicador de carga
- Respuesta obtenida
Nuestro código resultante sería este:
private void loadMedicalCenters() { showLoadingIndicator(true); String token = SessionPrefs.get(this).getToken(); mSaludMockApi.getMedicalCenters(token).enqueue( new Callback<MedicalsCenterRes>() { @Override public void onResponse(Call<MedicalsCenterRes> call, Response<MedicalsCenterRes> response) { if (response.isSuccessful()) { MedicalsCenterRes res = response.body(); List<MedicalCenter> medicalCenters = res.getResults(); if (medicalCenters.size() > 0) { showMedicalCenters(medicalCenters); } else { showMedicalCentersError(); } } else { String error = "Ha ocurrido un error. Contacte al administrador"; if (response.errorBody().contentType().subtype().equals("json")) { ApiError apiError = ApiError.fromResponseBody(response.errorBody()); error = apiError.getMessage(); Log.d(TAG, apiError.getDeveloperMessage()); } else { try { Log.d(TAG, response.errorBody().string()); } catch (IOException e) { e.printStackTrace(); } } showApiError(error); } showLoadingIndicator(false); } @Override public void onFailure(Call<MedicalsCenterRes> call, Throwable t) { showLoadingIndicator(false); showApiError(t.getMessage()); } }); }
Donde showLoadingIndicator()
muestra/oculta el progreso del layout, showMedicalCentersError()
muestra el view de errores para centros médicos y showApiError()
muestra errores generales de la API:
private void showMedicalCentersError() { mErrorView.setVisibility(View.VISIBLE); mProgress.setVisibility(View.GONE); mCard1.setVisibility(View.GONE); mCard2.setVisibility(View.GONE); } private void showLoadingIndicator(boolean show) { mProgress.setVisibility(show ? View.VISIBLE : View.GONE); mCard1.setVisibility(show ? View.GONE : View.VISIBLE); mCard2.setVisibility(show ? View.GONE : View.VISIBLE); mErrorView.setVisibility(View.GONE); }
private void showApiError(String error) { Snackbar.make(findViewById(android.R.id.content), error, Snackbar.LENGTH_LONG).show(); }
Con este método listo vamos a onCreate()
y lo ejecutamos al final:
@Override protected void onCreate(Bundle savedInstanceState) { ... loadMedicalCenters(); }
El resultado sería el siguiente:
4.2 Mostrar Disponibilidad De Doctores
Crear Objeto Java Para Conversión
Empezaremos por el mapeo para la respuesta del /doctors/availability
.
Para la cual crearemos la clase DoctorAvailabilityRes
en el paquete mapping y agregaremos una lista llamada results
con objetos Doctor
:
public class DoctorsAvailabilityRes { private List<Doctor> results; public DoctorsAvailabilityRes(List<Doctor> results) { this.results = results; } public List<Doctor> getResults() { return results; } }
Adicionalmente usaremos la anotación @SerializedName
para ajustar los atributos a la respuesta JSON:
public class Doctor { @SerializedName("id") private String mId; @SerializedName("name") private String mName; @SerializedName("specialty") private String mSpecialty; @SerializedName("description") private String mDescription; @SerializedName("times") private List<String> mAvailabilityTimes;
Añadir Call A La Interface De Retrofit
Ahora abrimos SaludMockApi
y agregamos la llamada para este caso.
El nombre que usaremos es getDoctorsSchedules()
y la configuraremos así:
- Anotación
@GET
- Retorno
DoctorsAvailability
- Primer parámetro la cabecera Authorization para el token
- Segundo parámetro un mapa para los parámetros de consulta
Veamos:
@GET("doctors/availability") Call<DoctorsAvailabilityRes> getDoctorsSchedules(@Header("Authorization") String token, @QueryMap Map<String, Object> parameters);
Crea Implementación De La Interfaz
Nos dirigimos a la actividad DoctorsSchedulesActivity
y creamos el cliente REST para comunicarnos con la API:
// Crear adaptador Retrofit Gson gson = new GsonBuilder() .setDateFormat("yyyy-MM-dd HH:mm:ss") .create(); mRestAdapter = new Retrofit.Builder() .baseUrl(SaludMockApi.BASE_URL) .addConverterFactory(GsonConverterFactory.create(gson)) .build(); // Crear conexión a la API de SaludMock mSaludMockApi = mRestAdapter.create(SaludMockApi.class);
Realizar Llamada Retrofit
Después creamos el método loadDoctorsSchedules()
para cargar la info de los doctores y sus tiempos.
Al igual que las otras llamadas que hemos realizado, iniciaremos el view de carga, generaremos la petición asíncrona y poblaremos los views en caso de ser una respuesta asertiva o mostraremos errores en caso contrario:
private void loadDoctorsSchedules() { showLoadingIndicator(true); String token = SessionPrefs.get(this).getToken(); HashMap<String, Object> parameters = new HashMap<>(); parameters.put("date", DateTimeUtils.formatDateForApi(mDateSchedulePicked)); parameters.put("medical-center", mMedicalCenterId); parameters.put("time-schedule", mTimeSchedule); mSaludMockApi.getDoctorsSchedules(token, parameters).enqueue( new Callback<DoctorsAvailabilityRes>() { @Override public void onResponse(Call<DoctorsAvailabilityRes> call, Response<DoctorsAvailabilityRes> response) { Log.d(TAG, call.request().toString()); if (response.isSuccessful()) { DoctorsAvailabilityRes res = response.body(); List<Doctor> doctors = res.getResults(); if (doctors.size() > 0) { showDoctors(doctors); } else { showEmptyView(); } } else { String error = "Ha ocurrido un error. Contacte al administrador"; if (response.errorBody().contentType().subtype().equals("json")) { ApiError apiError = ApiError.fromResponseBody(response.errorBody()); error = apiError.getMessage(); Log.d(TAG, apiError.getDeveloperMessage()); } else { try { Log.d(TAG, response.errorBody().string()); } catch (IOException e) { e.printStackTrace(); } } showApiError(error); } showLoadingIndicator(false); } @Override public void onFailure(Call<DoctorsAvailabilityRes> call, Throwable t) { showLoadingIndicator(false); showApiError(t.getMessage()); } }); }
Donde formatDateForApi()
es formatea la fecha para la API antes de enviarlo como parámetro:
private static final String API_DATE_PATTERN = "yyyy-MM-dd"; public static String formatDateForApi(Date date) { SimpleDateFormat simpleDateFormat = new SimpleDateFormat(API_DATE_PATTERN, Locale.getDefault()); return simpleDateFormat.format(date); }
Adicionalmente tenemos los métodos para los estados:
private void showApiError(String error) { Snackbar.make(findViewById(android.R.id.content), error, Snackbar.LENGTH_LONG).show(); } private void showDoctors(List<Doctor> doctors) { mListAdapter.setDoctors(doctors); mList.setVisibility(View.VISIBLE); mEmptyView.setVisibility(View.GONE); } private void showEmptyView() { mEmptyView.setVisibility(View.VISIBLE); mProgress.setVisibility(View.GONE); mList.setVisibility(View.GONE); } private void showLoadingIndicator(boolean show) { mProgress.setVisibility(show ? View.VISIBLE : View.GONE); mList.setVisibility(show ? View.GONE : View.VISIBLE); }
Seguido sobrescibe onResume()
y llama a loadDoctorsSchedules()
:
@Override protected void onResume() { super.onResume(); loadDoctorsSchedules(); }
Al ejecutar la app, el resultado de nuestra actividad sería el siguiente:
4.3 Enviar Petición Con Retrofit Para Reservar Cita
Crear Objetos Java Para Conversión
Ya que esta petición usa el método HTTP POST, es necesario que construyamos una clase Java de la cual Retrofit pueda obtener el cuerpo.
Teniendo en cuenta esto, vamos a crear una clase llamada PostAppointmentsBody
en el paquete mapping.
E incluimos como parámetros los datos vistos en el diseño:
public class PostAppointmentsBody { private String datetime; private String service; private String doctor; public PostAppointmentsBody(String datetime, String service, String doctor) { this.datetime = datetime; this.service = service; this.doctor = doctor; } public String getDatetime() { return datetime; } public String getService() { return service; } public String getDoctor() { return doctor; } }
Por otro lado, para la respuesta podemos usar la clase ApiMessageResponse
sin ningún problema.
Añadir Call A La Interface De Retrofit
Escribimos un nuevo método en la interfaz llamado createAppointment()
con estas características:
- Anotación
@POST
- Retorno
ApiMessageResponse
- Primer parámetro la cabecera Authorization para el token
- Segundo parámetro
@Body
con tipoPostAppointmentsBody
- Cabecera Content-Type con el formato JSON
Es decir:
@Headers("Content-Type: application/json") @POST("appointments") Call<ApiMessageResponse> createAppointment(@Header("Authorization") String token, @Body PostAppointmentsBody body);
Realizar Llamada Retrofit
Ya antes habíamos creado el método saveAppointment()
que es donde se ejecutará la llamada Retrofit.
El inicio de su cuerpo será la obtención del token y la construcción del body a partir de todos los valores conseguidos en las entradas de los usuarios.
El valor del token lo obtenemos con las preferencias de sesión:
private void saveAppointment() { String token = SessionPrefs.get(this).getToken(); }
La fecha y hora podemos conseguirla con el siguiente método de DateTimeUtils
:
private static final String API_DATETIME_PATTERN = "yyyy-MM-dd HH:mm:ss"; public static String joinDateTime(Date datePicked, Date timeUi) { Calendar datePickedCal = Calendar.getInstance(); Calendar timeUiCal = Calendar.getInstance(); datePickedCal.setTime(datePicked); timeUiCal.setTime(timeUi); datePickedCal.add(Calendar.HOUR_OF_DAY, timeUiCal.get(Calendar.HOUR_OF_DAY)); datePickedCal.add(Calendar.MINUTE, timeUiCal.get(Calendar.MINUTE)); SimpleDateFormat simpleDateFormat = new SimpleDateFormat(API_DATETIME_PATTERN, Locale.getDefault()); return simpleDateFormat.format(datePickedCal.getTime()); }
Y el servicio será "Medicina General"
por defecto. Sin embargo puedes extender la interfaz para recibir este campo como entrada del usuario.
Una vez claro esto, solo queda lanzar la petición:
private void saveAppointment() { String token = SessionPrefs.get(this).getToken(); String datetime = DateTimeUtils.joinDateTime(mDatePicked, DateTimeUtils.parseUiTime(mTimeSlotPicked)); String service = "Medicina General"; PostAppointmentsBody body = new PostAppointmentsBody(datetime, service, mDoctorId); mSaludMockApi.createAppointment(token, body).enqueue( new Callback<ApiMessageResponse>() { @Override public void onResponse(Call<ApiMessageResponse> call, Response<ApiMessageResponse> response) { if (response.isSuccessful()) { ApiMessageResponse res = response.body(); Log.d(TAG, res.getMessage()); showAppointmentsUi(); } else { String error = "Ha ocurrido un error. Contacte al administrador"; if (response.errorBody().contentType().subtype().equals("json")) { ApiError apiError = ApiError.fromResponseBody(response.errorBody()); error = apiError.getMessage(); Log.d(TAG, apiError.getDeveloperMessage()); } else { try { Log.d(TAG, response.errorBody().string()); } catch (IOException e) { e.printStackTrace(); } } showApiError(error); } showLoadingIndicator(false); } @Override public void onFailure(Call<ApiMessageResponse> call, Throwable t) { showLoadingIndicator(false); showApiError(t.getMessage()); } }); }
La carga y el error tienen los comportamientos estándar. En el caso de showAppointmentsUi()
confirmamos que fue un resultado exitoso y terminamos la actividad:
private void showAppointmentsUi() { setResult(Activity.RESULT_OK); finish(); } private void showLoadingIndicator(boolean show) { mProgress.setVisibility(show ? View.VISIBLE : View.GONE); mCard1.setVisibility(show ? View.GONE : View.VISIBLE); mCard2.setVisibility(show ? View.GONE : View.VISIBLE); mErrorView.setVisibility(View.GONE); } private void showApiError(String error) { Snackbar.make(findViewById(android.R.id.content), error, Snackbar.LENGTH_LONG).show(); }
Y ahora sí, el gran final de la parte 4 de este tutorial.
Ejecutamos la aplicación Android y revisamos que la creación de citas médicas sea todo un éxito a través de las actividades realizadas.
¿Listo Para El Siguiente Nivel?
Si andas buscando otro ejemplo completo con Retrofit que te guíe paso a paso para diseñar el servicio web y consumirlo en Android. Tengo un tutorial que te ayudará.
App Productos es uno de mis tutoriales más completos a la hora de buscar inspiración para la planeación de una App Android, el uso de diferentes fuentes de datos (caché, SQLite, servicio web) y la implementación del patrón MVP (Model-View-Presenter). El cual les ha servido a muchos lectores de Hermosa Programación.
¡Échale un vistazo!