Ejemplo de productos
En esta sección haremos un proyecto de ejemplo desde cero. Será un proyecto bastante completo incluyendo autenticación de usuarios.
Creando el proyecto
Lo primero será crear el proyecto, al que llamaremos ionic-products.
ionic start ionic-products sidemenu
Seleccionaremos Angular como framework y la modalidad standalone.
Estructura de la aplicación
En la aplicación de ejemplo vamos a tener 2 tipos de rutas. Las de autenticación (prefijo auth), y las de productos (prefijo products). Vamos a crear 2 directorios con los nombres de ambos prefijos y un archivo de rutas en cada uno de ellos. Desde el archivo principal app.routes.ts, cargaremos esas rutas hijas en función del prefijo.
Planificando la autenticación de usuarios
Las rutas que empiezan por /auth requieren no estar logueado, mientras que /products sí requieren estarlo. Para controlarlo utilizaremos guards del tipo canActivate. Las llamadas a los servicios web de autenticación los gestionará el servicio AuthService. Además, tendremos un interceptor para enviar el token y otro para añadir la url base del servidor.
Aunque Angular en la versión 17 ya crea los interceptores y guards como funciones en lugar de clases por defecto. En la versión que se ha utilizado de Ionic para documentar este ejemplo esto no es así, por lo que hay que añadir la opción --functional a los comandos.
ng g service auth/services/auth
ng g interceptor interceptors/base-url --functional
ng g interceptor interceptors/auth-token --functional
ng g guard guards/login-activate --functional
ng g guard guards/logout-activate --functional
ng g interface auth/interfaces/user
npm i @capacitor/preferences
En el servicio AuthService hay ciertas diferencias respecto a una aplicación Angular de navegador al utilizar Preferences (promesas) en lugar de LocalStorage (síncrono) para gestionar el token de autenticación:
- En el método login el servidor devuelve un token que procesaremos guardándolo en Preferences. Esto normalmente se haría en la función map, y ya en el componente nos suscribiriamos para realizar alguna acción cuando acabe esta operación. Sin embargo, al guardar el token en Preferences esto nos devuelve una promesa. Para que el observable espere a la promesa la podemos devolver, o utilizar async/await. En cualquier caso, esto nos generaría un observable del tipo Observable<Promise<void>>. Para evitar que esto ocurra, cuando tengamos que devolver un observable o promesa, usaremos switchMap en lugar de map. Esto hace que el observable original cambie su tipo de valor devuelto al que devuelve la promesa u observable que pongamos en switchMap.
- Pasa algo similar con el método isLogged, ya que tenemos que obtener el token primero (promesa) para saber si existe y llamar al servicio que comprueba el token (observable). En este caso hemos transformado la promesa de Preferences.get a observable con la función from. Utilizamos switchMap ya que al devolver la llamada al servidor esta a su vez devuelve otro observable.
Algo similar pasa con el interceptor, ya que este tiene que devolver la llamada a next (observable), pero esta llamada depende a su vez de la llamada a Preferences (promesa) para obtener el token.
Trabajando con promesas en lugar de observables
Otra opción sería en lugar de trabajar con observables, trabajar 100% con promesas. Para ello convertimos las llamadas al servidor a promesas utilizando la función firstValueFrom, que obtiene el primer valor devuelto por un observable y lo devuelve dentro de una promesa. Esto solo tiene sentido usarlo con observables que devuelven un valor, como las llamadas a servicios web. A partir de aquí ya podemos utilizar async/await para todo.
Así quedaría el código anterior tanto en el servicio como en los guards usando promesas para todo:
Los guards y resolvers no tienen problema, ya que pueden devolver el resultado dentro de una promesa u observable indistintamente. Los interceptores sin embargo, solo pueden devolver un observable (la función next que debemos llamar al final, devuelve un observable). En los componentes, en lugar de subscribe, tendríamos que cambiarlo por then o await.
Cambiando el menú lateral (AppComponent)
Vamos a quitar lo que no necesitemos y hacer unas pequeñas modificaciones en el menú lateral. El menú lateral está en app.component.html (componente ion-menu). Después quitaremos el encabezado del menú (ion-list-header, ion-note) para poner en su lugar un elemento ion-item dentro de la lista de abajo que muestre los datos del usuario logueado.
Además, podemos quitar el array de labels y la lista que los muestra, ya que no tienen ninguna funcionalidad que no sea estética. En la lista de enlaces del menú, dejaremos solo uno por ahora: /home (quita también todas las importaciones de iconos excepto la de home). Sobre los iconos, en la plantilla vamos a quitar la distinción entre Android (md) y iOS (ios) y pondremos siempre el mismo icono usando [name]. De esta manera no tenemos que importar la versión outline y sharp de cada icono que usemos.
En las páginas de login y registro (usuario no logueado) vamos a deshabilitar el menú (propiedad disabled) para que no aparezca ni haciendo el gesto con el dedo (desplazamiento a la derecha), ya que aunque no pongamos botón de menú, si este está activo, puede aparecer con este gesto. Vamos a vincular que el menú esté habilitado a tener los datos del usuario. Además, mostraremos los datos del usuario en la parte de arriba del menú.
Haremos una función effect vinculada a la signal logged del servicio AuthService, y que cada vez que cambie obtendremos los datos del usuario (si está logueado), o lo pondremos a null si se ha desconectado para que se desactive el menú.
Otra cosa que vamos a tener en cuenta es la utilización de los plugins de Capacitor para ocultar la imagen SplashScreen y cambiar el color de la barra de notificaciones nativa en cuanto la aplicación esté preparada (Platform.isReady).
npm i @capacitor/status-bar
npm i @capacitor/splash-screen
Importante: En el array de imports del componente, no viene importada la directiva IonRouterLink. Debemos añadirla para que funcione el parámetro routerDirection en el elemento ion-item de los enlaces del menú.
Creando página de login
Para crear una página para gestionar el login de usuarios, utilizaremos el siguiente comando:
ionic g page auth/login
A continuación añadimos la ruta a la página de login en el archivo de rutas auth.routes.ts:
Ionic, al menos en la versión 7.2, aunque crea el proyecto con componentes standalone, incluyendo las importaciones de Ionic, cuando creas un nuevo componente o página, importa todo el módulo de Ionic (IonicModule). Por eficiencia, vamos a cambiar esto a mano (hasta que lo arreglen) e importar los componentes, directivas y servicios de Ionic desde @ionic/angular/standalone.
Por ahora no redirigiremos a la ruta /home hasta que la implementemos.
Importante: No te olvides de importar y registrar los iconos logIn y documentText de la página de login en app.component.ts.
Creando la página de registro
A continuación crearemos una página para dar de alta nuevos usuarios. Esta página tendrá un formulario con los datos de un usuario y cuando el registro se complete con éxito, redirigirá a la página de login.
ionic g page auth/register
Después añadimos la ruta a la página de registro en el archivo de rutas auth.routes.ts:
Tendremos que hacer algo parecido a lo que hicimos en la página anterior, cambiando las importaciones de Ionic a standalone.
Para elegir la imagen de avatar, utilizaremos la cámara, por lo que instalaremos el plugin de Capacitor correspondiente (camera) junto a pwa-elements para poder probarlo desde el navegador.
npm i @capacitor/camera
npm i @ionic/pwa-elements
En esta página pondremos un formulario con la información del usuario que vamos a registrar. Crearemos también un validador para comprobar si los campos de la contraseña tienen el mismo valor. Para las directivas parece que es necesaria (todavía) la opción ionic g d validators/valueEquals --standalone para una aplicación que no utiliza módulos. Además de eso, si no queremos tener que utilizar el prefijo app en el selector, debemos dejarlo a cadena vacía tanto en el archivo angular.json (propiedad "prefix") como en la configuración de ESLint (.eslintrc.json).
ng g d validators/valueEquals --standalone
El método registerOnValidatorChange permite a Angular registrar una función que podemos llamar cada vez que cambie un valor que reciba la directiva (Input) para que vuelva a validar el input que contiene el validador. Si no, la validación solo se recalcular cuando se edita el valor del campo, por lo que si tenemos 2 contraseñas iguales y luego modificamos el primer campo para que sean diferentes, no nos marcaría el segundo campo como invalid.
Importante: No te olvides de importar y registrar los iconos arrowUndoCircle, camera, images, y checkmarkCircle de la página de registro en app.component.ts.
Creando la página de productos (Home)
Ahora vamos a crear la página inicial de la aplicación (para usuarios logueados) donde se mostrará el listado de productos que obtendremos del servidor.
ionic g page products/home
A continuación actualizaremos el archivo de rutas de productos (products.routes.ts) para que nos dirija a esta pagína con la ruta /products.
Ahora podremos activar la redirección de la página de login a la nueva página.
Servicio de productos
Necesitaremos crear también el servicio para trabajar con los productos en el servidor. En nuestro caso haremos servicios para obtener todos los productos, obtener un producto, añadir un producto, consultar los comentarios de un producto, y añadir un comentario a un producto. Sin olvidarnos de la interfaz Product que representa la información que contiene un producto, y Comment, que representa un comentario de un usuario sobre un producto.
ionic g service products/services/products
ionic g interface products/interfaces/product
ionic g interface products/interfaces/comment
Página de productos
La página de productos tendrá las siguientes características:
- Un componente IonRefresher para recargar los productos cuando el usuario estire hacia abajo con el dedo.
- Un botón flotante (IonFab) abajo a la derecha que llevará a la página de añadir producto (/products/add).
- Una lista de productos con una imagen a la izquierda (IonThumbnail) y un botón a la derecha que mostrará un menú contextual de tipo ActionSheet, con la opción de borrar un producto o ir a la página de detalle del mismo (/products/:id).
- Los productos se cargarán inicialmente en el método ionViewWillEnter ya que querremos refrescar el listado cuando naveguemos desde páginas como el detalle de un producto. Ya que en ese caso, al ser una navegación hacia adelante (apilar) y luego volver atrás (desapilar), Ionic no habrá destruido el componente de la página de productos, por lo que no volvería a crearlo y el método ngOnInit no se ejecutaría en este caso.
Importante: No te olvides de importar los iconos add, menu, trash, eye, y close en app.component.ts.
Botón de logout
Crearemos también un botón en el menú lateral para cerrar sesión. Tendrá el icono "exit" (hay que importarlo) y llamará al método logout(), que eliminará el token y nos dirigirá a la página de login.
Creando la página de añadir producto
A continuación crearemos una página que contendrá un formulario para añadir un nuevo producto:
ionic g page products/product-form
Añadiendo la ruta
A continuación actualizaremos el archivo de rutas de productos (products.routes.ts) para que nos dirija a esta pagína con la ruta /products/add.
Después, añadiremos el enlace al menú lateral en app.component.ts. Cuando seleccionemos la ruta /products/add, también marcará como activa la ruta /products en los enlaces del menú lateral. Para evitar eso añadiremos la opción [routerLinkActiveOptions]="{exact: true}" a los enlaces.
Página de añadir producto
La página tendrá un formulario muy similar al de la página de registro de usuarios. Los productos tendrán descripción, precio e imagen. La imagen podrá ser obtenida de la cámara o de la galería de imágenes del teléfono. Una vez añadido el producto, se redirigirá al usuario a la página principal (/products).
Creando la página de detalle de producto
Esta sección va a ser algo más compleja que las páginas anteriores, ya que vamos a hacer una página (product-details) con una navegación interna por pestañas (tabs). En este caso tendremos 2 pestañas:
- info (product-info) → Mostrará la información del producto (descripción, precio e imagen), así como la información de su creador.
- comments (product-comments) → Mostrará los comentarios de otros usuarios sobre el producto y permitirá añadir un nuevo comentario.
Lo primero que vamos a hacer es crear la página de detalle y las subpáginas que contendrá.
ionic g page products/product-detail
ionic g page products/product-detail/product-info
ionic g page products/product-detail/product-comments
Creando las rutas
Vamos a crear la ruta primero para cargar la página principal (product-detail). En esta ruta especificaremos tanto el componente, como las rutas anidadas que tendrá dentro. Estas rutas las vamos a poner (no es obligatorio) en un archivo aparte dentro de la carpeta de product-detail, llamado product-detail.routes.ts.
Creando las pestañas de navegación (ion-tabs)
Vamos a implementar la página que contiene las pestañas de detalle de producto (product-detail.page.ts). Esta página va a tener unas determinadas características:
- Vamos a poner una cabecera (ion-header) con la descripción del producto y un botón para volver a la página anterior (/products). En el resto de páginas internas pondremos un encabezado vacío (simplemente para que se cree un margen con el contenido) ya que cuando ponemos el encabezado en la página que contiene ion-tabs, este tapa al de las páginas internas. Además, no funcionaría el botón de volver atrás en páginas internas, ya que se crea dentro de la página que contiene las pestañas un segundo ion-router-outlet, y por lo tanto un sistema de navegación interno separado de la navegación principal (ion-router-outlet) que encontramos en app.component.
- Cada pestaña o botón (ion-tab-button) tiene una propiedad llamada tab que contiene la ruta interna a la que se navegará. Es decir, si estamos en '/products/24' y cargamos la ruta interna info, estaremos cargando '/products/24/info'.
- Necesitamos cargar la información del producto ya que vamos a poner su descripción en el título de la página. También necesitaremos la información dentro de la página interna product-info, por lo que se opta por guardar el producto en una señal para que como veremos a continuación sea relativamente simple compartir esa información con la página interna. En versiones anteriores a la 17 de Angular, al ser información asíncrona, tendríamos que haber optado por una solución diferente como un observable (ReplaySubject) para compartir ese dato y no tener que volverlo a obtener.
Importante: Para que Angular nos pase parámetros de la ruta, en este caso la id del producto en parámetros de tipo Input, debemos configurar el router de Angulara con la función withComponentInputBinding() en el archivo main.ts. Recuerda también importar los iconos informationCircle y chatboxEllipses en app.component.ts.
Importante: Por alguna razón, el parámetro de la ruta (id) a veces no está disponible desde ngOnInit. Para solucionarlo, basta con usar el método ionViewWillEnter en su lugar.
Creando la página de información
En esta página vamos a mostrar la información de un producto dentro de una card (ion-card). El producto lo podemos obtener inyectando el componente de product-detail (se puede inyectar un componente antecesor en el DOM con inject). Al ser un dato de tipo signal, cuando cambie de valor en product-detail, también lo detectará en esta página. Si fuera un dato normal, no podríamos saber cuando cambia y pasa de null a objeto (otra opción sería usar un observable).
También incluiremos la funcionalidad de borrar el producto desde aquí. Esta acción nos redirigirá a la página principal (/products).
Creando la página de comentarios
La segunda página interna de detalle de producto contendrá los comentarios de los usuarios. En este caso no necesitaremos la información del producto, únicamente su id que podemos obtener de la ruta (Input). Llamaremos al servicio de obtener comentarios y los mostraremos en una lista.
A la página añadiremos un ion-refresher para recargar los comentarios al deslizar el dedo hacia abajo. También un botón flotante (ion-fab) para añadir un nuevo comentario. El formulario para añadir comentarios se mostrará directamente en un alert (ion-alert).
Otra cosa que vamos a tener en cuenta es que añadiremos notificaciones Push a este proyecto (en otra sección del curso). La notificación informará que alguien ha comentado un producto que has creado. Al pulsar en la notificación, la aplicación cargará la página de comentarios de ese producto (y por lo tanto se obtendrán los comentarios del servidor). Sin embargo, si la aplicación está en segundo plano (pausada) en esa ruta en concreto, Angular no recargará la página y no se cargarán los comentarios otra vez (no se verá el nuevo). Para detectar eso tenemos el observable resume dentro del servicio Platform (también podríamos usar el evento appStateChange del plugin App).
Importante: Por defecto no se puede acceder desde rutas internas (hijas) a parámetros de navegación de rutas padre. Es decir, como dijimos antes, las páginas internas se cargan dentro de un ion-router-outlet interno y diferente al de app.component. Para heredar los parámetros de la ruta padre y que se pueda acceder a ellos directamente con Input, hay que habilitar la opción withRouterConfig({paramsInheritanceStrategy: 'always'}) en el archivo main.ts.
Importante: Si Angular no detecta los cambios producidos por los eventos del ion-refresher (recargar comentarios) o del alert (añadir comentario), ejecuta las modificaciones al array de productos dentro de la zona de Angular (ngZone.run) para forzar a Angular a que detecte los cambios producidos.
Generar proyecto Android
Por último, vamos a generar y configurar el proyecto de Android para poder probarlo en un dispositivo móvil. Lo primero será generar la carpeta www, que es la que se copiará en el proyecto nativo, con el código web de la app compilado y empaquetado.
ionic build
A continuación, vamos a cambiar el nombre del proyecto y la id de aplicación (debe tener un fomato de dominio inverso y debe ser único). Para ello editamos el archivo capacitor.config.ts. Si se hace alguna consulta http al servidor en lugar de https, conviene activar la opción de allowMixedContent (desaconsejada en producción).
Después, instalaremos el soporte de Android y generaremos el proyecto.
npm i @capacitor/android
npx cap add android
Para este proyecto en particular, el único plugin de Capacitor que necesita permisos específicos es el de la cámara. Así que añadiremos los siguientes permisos al archivo AndroicManifest.xml del proyecto nativo de Android. Si la aplicación se conecta por http en lugar de https a un servidor externo, también habría que habilitar la opción android:usesCleartextTraffic="true" (desaconsejada en producción):
Ya solo queda lanzar el proyecto en el dispositivo desde Android Studio:
A partir de ahora, en cada modificación de proyecto habrá que ejecutar en este orden los siguientes comandos para sincronizar el proyecto web con el de Android:
ionic build
npx cap sync