Una app muy sencilla para guardar sitios interesantes con ubicaciones y fotografías con IONIC 5+Angular+Cordova

El objetivo: Guardar ubicaciones e imágenes

En este ejercicio vamos a demostrar que puede resultar muy sencillo desarrollar una aplicación que nos permita mantener un registro de lugares interesantes, guardando la ubicación precisa y una imagen identificativa de cada uno.

Será el mismo navegador el que nos proporcione la ubicación actual de forma automática, y nos permita elegir las imágenes a utilizar, de entre las que tengamos en nuestros equipos. Además, al ejecutarla como app en nuestros móviles, podremos utilizar también la cámara en el mismo momento, pudiendo hacer una foto que se quedará guardada en nuestra aplicación. De esa forma, tendremos imágenes asociadas con la latitud y la longitud de los sitios donde hayamos hecho las fotos, pudiendo acceder además al mapa correspondiente con un solo clic.

Con menos de 100 líneas de código

Veremos que al utilizar IONIC 4 + Angular, con menos de 100 líneas de código podemos desarrollar una aplicación multiplataforma, que funcionará perfectamente en cualquier navegador, o instalada como app en nuestros dispositivos móviles. Y todo ello, con el mismo código fuente, sin cambiar ni una sola línea.

La funcionalidad de la aplicación

La aplicación dispondrá de un cuadro de texto donde podamos especificar la descripción del nuevo lugar que queramos registrar.

Si ya hemos introducido alguna descripción, se habilitará un nuevo control para poder elegir una imagen. Si estamos ejecutando la aplicación desde nuestro ordenador de escritorio, podremos elegir cualquier imagen de las que se encuentran en nuestro equipo. Si ejecutamos la aplicación desde nuestros dispositivos móviles, podremos hacer una foto en ese mismo momento.

Una vez introducida la descripción y elegida o hecha la foto, la aplicación obtendrá automáticamente la ubicación en la que nos encontremos y creará un elemento en la lista de lugares en el que aparecerá la foto y la descripción.

Al hacer clic sobre cada elemento de la lista, si estamos utilizando el navegador de nuestro ordenador de escritorio, abrirá la página web correspondiente de Google Maps para mostrarnos la ubicación precisa. Si estamos ejecutando la aplicación desde nuestros dispositivos móviles, nos permitirá elegir qué aplicación se utilizará para visualizar la ubicación.

Además, podremos reordenar los elementos de la lista activando un control por cada elemento, que se podrá ocultar o mostrar mediante un botón ubicado en la barra superior de la aplicación.

Por último, permitiremos borrar cualquier elemento de la lista utilizando un botón oculto que se mostrará al deslizar el elemento correspondiente a la derecha.

Primeros pasos

Código base de la aplicación

Para comenzar a desarrollar nuestra aplicación deberemos partir de un código base generado automáticamente por IONIC. Bastará con ejecutar el siguiente comando para generar los ficheros necesarios:

ionic start lugares blank --type=angular --cordova --no-git --no-link

Al finalizar el proceso, se habrá creado un directorio con el nombre lugares, que constituye el directorio raíz de nuestro proyecto, donde tendremos todo el código necesario para continuar con el desarrollo de nuestra aplicación. Para realizar los siguientes pasos deberemos acceder primero a dicha carpeta:

cd lugares

Vamos a instalar la versión más estable hasta el momento:

npm install @ionic/[email protected]

Y a partir de ahora ya podemos comenzar a añadir el código específico de nuestra aplicación. Puesto que toda la funcionalidad se encuentra ubicada en una única pantalla, sólo vamos a modificar dos ficheros:

  1. src/app/home/home.page.html: Código HTML.
  2. src/app/home/home.page.ts: Código TypeScript.

Probando la aplicación en el navegador

La misma herramienta de consola de IONIC se puede utilizar como servidor web, lo que nos permitirá  probar nuestra aplicación fácilmente en el navegador. Además, cada vez que realicemos cualquier cambio en el código fuente, veremos cómo se actualiza el resultado automáticamente. Bastará con ejecutar el siguiente comando desde la carpeta raíz del proyecto donde se encuentra nuestra aplicación, dejando el terminal abierto:

ionic serve

Si todo ha ido bien, se compilará la aplicación y deberíamos visualizar en el navegador algo del siguiente estilo:

(bastará con pulsar Ctrl+C en el terminal para finalizar la ejecución del comando)

Código HTML

El encabezado

Como ya viene siendo habitual, colocaremos una barra superior que contenga el título y el botón de reordenar:

<ion-header>
  ...
  <ion-title>Places!</ion-title>
  ...
  <ion-button (click)="reorder=!reorder">
  ...    
</ion-header>

En cada pulsación del botón de reordenar, activaremos o desactivaremos dicha funcionalidad cambiando simplemente el valor del atributo correspondiente.

El formulario para la descripción y la imagen

Utilizaremos un simple cuadro de texto para introducir la descripción del lugar que queramos registrar y un elemento HTML estándar de tipo fichero para elegir la imagen:

<ion-item>
  <ion-input placeholder="Enter description" [(ngModel)]="text"></ion-input>
</ion-item>
<ion-item *ngIf="text.length">
  <input type="file" accept="image/*" (change)="add($event)" />
  <ion-icon slot="end" name="camera" (click)="add()"></ion-icon>
</ion-item>

Debemos destacar de nuevo el uso de la directiva ngModel, que nos permitirá enlazar el valor del atributo text, de forma que cualquier modificación en el código HTML cambiará automáticamente el valor del atributo en el código TypeScript y viceversa.

En segundo lugar, también destacamos el uso de la directiva ngIf, que automatiza la creación del campo de tipo fichero, de forma que dicho campo no existirá hasta que se introduzca algún texto en el campo de la descripción. Además, al inicializar de nuevo la descripción desde el código TypeScript, dicho campo desaparecerá automáticamente.

Por último, al final del cuadro de elección de imagen colocaremos un icono para acceder a la cámara de nuestros dispositivos móviles, utilizando como imagen la fotografía que realicemos en ese momento. Esta funcionalidad no estará operativa cuando utilicemos la aplicación desde el navegador.

Los elementos de la lista

Cada elemento de la lista será un enlace a la página web correspondiente de Google Maps para conocer la ubicación del lugar, de forma que podamos acceder fácilmente con un simple clic en el elemento correspondiente. Para ello bastará con especificar la url mediante el atributo href del elemento <ion-item></ion-item> (más información aquí).

Además, utilizaremos el elemento <ion-thumbnail></ion-thumbnail> para mostrar una miniatura de la imagen. Por último, añadiremos dos botones, uno para reordenar (<ion-reorder></ion-reorder>) y otro para borrar (<ion-item-option></ion-item-option>):

<ion-item [href]='item.url'>
  <ion-thumbnail slot="start"><img [src]="item.img" /></ion-thumbnail>{{item.name}}
  <ion-reorder slot="end"></ion-reorder>
</ion-item>
<ion-item-options side="start">
  <ion-item-option color="danger" (click)="delete(i)">
    <ion-icon slot="icon-only" name="trash"></ion-icon>
  </ion-item-option>
</ion-item-options>

Mostrando todos los elementos

Vamos a hacer uso del potencial de Angular para mostrar la lista completa de lugares:

<ion-reorder-group [disabled]="!reorder" (ionItemReorder)="move($event.detail)">
  <ion-item-sliding *ngFor="let item of list; let i=index">
    ...
  </ion-item-sliding>
</ion-reorder-group>

En primer lugar utilizaremos la expresión [disabled]="!reorder" para activar o desactivar la opción de reordenar los elementos. Mediante los corchetes enlazamos el valor del atributo reorder de TypeScript para poder acceder a su valor directamente. El evento (ionItemReorder) nos indicará que se ha cambiado de sitio algún elemento, y por lo tanto ejecutaremos el método move(), cuya funcionalidad veremos más adelante.

Por último, sólo necesitamos realizar un bucle utilizando la directiva ngFor, que además nos proporcionará cada elemento item del array que representa la lista, así como el índice i de cada uno de ellos.

El fichero «home.page.html» completo

<ion-header>
  <ion-toolbar color="primary">
    <ion-title>Places!</ion-title>
    <ion-buttons slot="primary">
      <ion-button (click)="reorder=!reorder">
        <ion-icon slot="icon-only" name="reorder"></ion-icon>
      </ion-button>
    </ion-buttons>
  </ion-toolbar>       
</ion-header>
<ion-content>
  <ion-list #myList lines="full">
    <ion-item color="light">
      <ion-input placeholder="Enter description" [(ngModel)]="text"></ion-input>
    </ion-item>
    <ion-item color="light" *ngIf="text.length">
      <input type="file" accept="image/*" (change)="add($event)" />
      <ion-icon slot="end" name="camera" (click)="add()"></ion-icon>
    </ion-item>    
    <ion-reorder-group [disabled]="!reorder" (ionItemReorder)="move($event.detail)">
      <ion-item-sliding *ngFor="let item of list; let i=index">
        <ion-item [href]='item.url'>
          <ion-thumbnail slot="start"><img [src]="item.img" /></ion-thumbnail>{{item.name}}
          <ion-reorder slot="end"></ion-reorder>
        </ion-item>
        <ion-item-options side="start">
          <ion-item-option color="danger" (click)="delete(i)">
            <ion-icon slot="icon-only" name="trash"></ion-icon>
          </ion-item-option>
        </ion-item-options>
      </ion-item-sliding>
    </ion-reorder-group>
  </ion-list>
</ion-content>

Código TypeScript

Los atributos

En este ejercicio sólo necesitamos cuatro campos para guardar todos los datos necesarios:

@ViewChild('myList') listRef: List;
reorder: boolean;
list: any;
text: string;

El propósito de cada uno de ellos es el siguiente:

  • listRef: Referencia a la lista de html para poder cerrar todos los elementos antes de borrarlos.
  • reorder: Su valor (falso o verdadero) indicará si se encuentra habilitada o no la funcionalidad de reordenar.
  • list: Array que contendrá todos los lugares.
  • text: Descripción del nuevo lugar que queremos registrar.

Los métodos

  • constructor: Recuperará la lista guardada para que se muestre al iniciar la aplicación.
  • add: Añadirá un nuevo lugar a la lista de lugares.
  • save: Guardará en localStorage el listado de lugares para que no se pierda al cerrar la aplicación o recargar el contenido del navegador.
  • delete: Borrará un elemento de la lista.
  • move: Moverá un elemento de la lista a otra posición.

Obteniendo la ubicación

Para conocer la ubicación desde la que vamos a registrar un nuevo lugar, utilizaremos la funcionalidad de geolocalización de HTML, de forma que consigamos que el código funcione perfectamente tanto en el navegador, como en la app una vez compilada (más información aquí):

navigator.geolocation.getCurrentPosition(pos => {
  let url = "https://maps.google.com/maps?&z=15&t=k&q="+pos.coords.latitude+" "+pos.coords.longitude;
  ...
});

Además, crear un enlace a Google Maps conociendo la ubicación resulta muy sencillo. En nuestro caso especificamos un zoom razonable (z=15), el tipo de mapa satélite (t=k), y la latitud y la longitud (q=…). Se puede obtener más información en la página oficial de Google, o también por ejemplo aquí.

Indicador de ejecución en proceso

Mientras se obtiene la ubicación y se procesa la imagen, resulta conveniente bloquear la aplicación mediante algún indicador para informar al usuario que todavía no se ha completado la acción. Para ello mostraremos un indicador en pantalla utilizando la funcionalidad que nos proporciona IONIC (más información aquí):

async add(event) {
  let loading = await this.loadingController.create();
  loading.present();
  ...
  loading.dismiss();
}

Obteniendo la imagen

Utilizando simple código HTML y JavaScript, podemos obtener la imagen seleccionada en formato base 64, de forma que la podemos guardar como si se tratara de cualquier otra cadena de texto (más información aquí, o aquí):

...
<input type="file" (change)="add($event)" />
...
<img [src]="item.img" />
...
async add(event) {
  let reader = new FileReader();
  reader.onload = (data:any) => {
    item.img = data.target.result;
    ...
  }
  reader.readAsDataURL(event.target.files[0]);
}

Además, añadiremos la funcionalidad necesaria para abrir la cámara cuando ejecutemos la aplicación en nuestros dispositivos móviles (más información en la documentación de IONIC y en Cordova):

...
const options:CameraOptions = {targetWidth:100,destinationType:this.camera.DestinationType.DATA_URL};
this.camera.getPicture(options).then(data => {
  this.list.unshift({name:this.text,img:"data:image/jpeg;base64,"+data,url:url});
  this.save();         
});
...

El fichero «home.page.ts» completo

import { Component, ViewChild } from '@angular/core';
import { List, LoadingController } from '@ionic/angular';
import { Camera, CameraOptions } from '@ionic-native/camera/ngx';

@Component({
  selector: 'app-home',
  templateUrl: 'home.page.html',
  styleUrls: ['home.page.scss'],
})

export class HomePage {
  @ViewChild('myList') listRef: List;
  reorder: boolean;
  list: any;
  text: string;

  constructor(public loadingController:LoadingController,private camera:Camera) {
    this.text = "";
    this.list = localStorage.getItem('places-list');
    if (this.list) this.list = JSON.parse(this.list);
    else this.list = [];
  }

  async add(event?) {
    let loading = await this.loadingController.create({duration:15000});
    loading.present();

    navigator.geolocation.getCurrentPosition(pos => {
      let url = "https://maps.google.com/maps?&z=15&t=k&q="+pos.coords.latitude+" "+pos.coords.longitude;

      if (event) {
        let reader = new FileReader();
        reader.onload = (data:any) => {
          this.list.unshift({name:this.text,img:data.target.result,url:url});
          this.save();
          loading.dismiss();
        }
        reader.readAsDataURL(event.target.files[0]);
      }
      else {
        loading.dismiss();
        const options:CameraOptions = {targetWidth:100,destinationType:this.camera.DestinationType.DATA_URL};
        this.camera.getPicture(options).then(data => {
          this.list.unshift({name:this.text,img:"data:image/jpeg;base64,"+data,url:url});
          this.save();         
        });
      }
    });
  }

  save() {
    localStorage.setItem('places-list', JSON.stringify(this.list));
    this.text = "";
  }

  delete(item) {
    this.listRef.closeSlidingItems();
    this.list.splice(item, 1);
    this.save();
  }

  move(indexes) {
    let item = this.list[indexes.from];
    this.list.splice(indexes.from, 1);
    this.list.splice(indexes.to, 0, item);
    this.save();
  }
}

Icono y pantalla de bienvenida

Si queremos cambiar el icono o la pantalla de bienvenida, bastará con actualizar los archivos icon.png y splash.png respectivamente, colocándolos en la carpeta resources de nuestro proyecto (sobrescribiendo los que allí estuvieran, ya que normalmente IONIC nos habrá proporcionado unos por defecto). Después sólo debemos ejecutar el comando correspondiente para que ionic genere o actualice todos los archivos necesarios:

ionic cordova resources

Compilando la aplicación

Plugins de geolocalización y cámara

Para que la aplicación tenga los permisos correctos, y podamos utilizar la funcionalidad nativa para conocer la ubicación, añadiremos el plugin correspondiente (más información aquí):

ionic cordova plugin add cordova-plugin-geolocation --save

Además, para utilizar la cámara también echaremos mano de otro plugin, esta vez utilizando la API nativa que nos proporciona IONIC, ya que sólo podemos acceder a dicha funcionalidad en nuestros dispositivos móviles (más información aquí):

ionic cordova plugin add cordova-plugin-camera --save
npm install --save @ionic-native/camera@beta

Debemos recordar que toda la funcionalidad de la aplicación se encontrará disponible tanto en el navegador como en la app, exceptuando la cámara, ya que es específica del dispositivo, y no estará operativa en el navegador.

También será necesario modificar el archivo app.module.ts para poder acceder a la API de la cámara:

...
import { Camera } from '@ionic-native/camera/ngx';
...
@NgModule({
  ...
  providers: [
    ...
    Camera,
    ...
  ],
  ...
})
export class AppModule {}

El archivo «app.module.ts» completo

import { NgModule } from '@angular/core';
import { BrowserModule } from '@angular/platform-browser';
import { RouteReuseStrategy } from '@angular/router';

import { IonicModule, IonicRouteStrategy } from '@ionic/angular';
import { SplashScreen } from '@ionic-native/splash-screen/ngx';
import { StatusBar } from '@ionic-native/status-bar/ngx';
import { Camera } from '@ionic-native/camera/ngx';

import { AppComponent } from './app.component';
import { AppRoutingModule } from './app-routing.module';

@NgModule({
  declarations: [AppComponent],
  entryComponents: [],
  imports: [BrowserModule, IonicModule.forRoot(), AppRoutingModule],
  providers: [
    StatusBar,
    SplashScreen,
    Camera,
    { provide: RouteReuseStrategy, useClass: IonicRouteStrategy }
  ],
  bootstrap: [AppComponent]
})
export class AppModule {}

Generando el archivo APK

Para instalar la aplicación en nuestros dispositivos móviles, tenemos que compilar el código fuente:

ionic cordova build android --prod

O también lo podemos ejecutar directamente  en el móvil que tengamos conectado a nuestro ordenador:

ionic cordova run android --prod

El resultado

Puedes hacer clic aquí para probar la aplicación propuesta. Se puede observar que el mismo código de la app generada también puede funcionar perfectamente en el navegador como una página web.

Una app muy sencilla para guardar sitios interesantes con ubicaciones y fotografías con IONIC 5+Angular

El objetivo: Guardar ubicaciones e imágenes

En este ejercicio vamos a demostrar que puede resultar muy sencillo desarrollar una aplicación que nos permita mantener un registro de lugares interesantes, guardando la ubicación precisa y una imagen identificativa de cada uno.

Será el mismo navegador el que nos proporcione la ubicación actual de forma automática, y nos permita elegir las imágenes a utilizar, de entre las que tengamos en nuestros equipos. Además, al ejecutarla como app en nuestros móviles, podremos utilizar también la cámara en el mismo momento, pudiendo hacer una foto que se quedará guardada en nuestra aplicación. De esa forma, tendremos imágenes asociadas con la latitud y la longitud de los sitios donde hayamos hecho las fotos, pudiendo acceder además al mapa correspondiente con un solo clic.

Con sólo 100 líneas de código

Veremos que al utilizar IONIC 5 + Angular, con sólo 100 líneas de código (sumando HTML y Typescript) podemos desarrollar una aplicación multiplataforma, que funcionará perfectamente en cualquier navegador, o instalada como app en nuestros dispositivos móviles. Y todo ello, con el mismo código fuente, sin cambiar ni una sola línea.

La funcionalidad de la aplicación

La aplicación dispondrá de un cuadro de texto donde podamos especificar la descripción del nuevo lugar que queramos registrar.

Si ya hemos introducido alguna descripción, se habilitará un nuevo control para poder elegir una imagen. Si estamos ejecutando la aplicación desde nuestro ordenador de escritorio, podremos elegir cualquier imagen de las que se encuentran en nuestro equipo. Si ejecutamos la aplicación desde nuestros dispositivos móviles, podremos hacer una foto en ese mismo momento.

Una vez introducida la descripción y elegida o hecha la foto, la aplicación obtendrá automáticamente la ubicación en la que nos encontremos y creará un elemento en la lista de lugares en el que aparecerá la foto y la descripción.

Al hacer clic sobre cada elemento de la lista, si estamos utilizando el navegador de nuestro ordenador de escritorio, abrirá la página web correspondiente de Google Maps para mostrarnos la ubicación precisa. Si estamos ejecutando la aplicación desde nuestros dispositivos móviles, nos permitirá elegir qué aplicación se utilizará para visualizar la ubicación.

Además, podremos reordenar los elementos de la lista activando un control por cada elemento, que se podrá ocultar o mostrar mediante un botón ubicado en la barra superior de la aplicación.

Por último, permitiremos borrar cualquier elemento de la lista utilizando un botón oculto que se mostrará al deslizar el elemento correspondiente a la derecha.

Primeros pasos

Código base de la aplicación

Para comenzar a desarrollar nuestra aplicación deberemos partir de un código base generado automáticamente por IONIC. Bastará con ejecutar el siguiente comando para generar los ficheros necesarios:

ionic start lugares blank --type=angular --no-git --no-link

Al finalizar el proceso, se habrá creado un directorio con el nombre lugares, que constituye el directorio raíz de nuestro proyecto, donde tendremos todo el código necesario para continuar con el desarrollo de nuestra aplicación. Para realizar los siguientes pasos deberemos acceder primero a dicha carpeta:

cd lugares

Y a partir de ahora ya podemos comenzar a añadir el código específico de nuestra aplicación. Puesto que toda la funcionalidad se encuentra ubicada en una única pantalla, sólo vamos a modificar dos ficheros:

  1. src/app/home/home.page.html: Código HTML.
  2. src/app/home/home.page.ts: Código TypeScript.

Probando la aplicación en el navegador

La misma herramienta de consola de IONIC se puede utilizar como servidor web, lo que nos permitirá  probar nuestra aplicación fácilmente en el navegador. Además, cada vez que realicemos cualquier cambio en el código fuente, veremos cómo se actualiza el resultado automáticamente. Bastará con ejecutar el siguiente comando desde la carpeta raíz del proyecto donde se encuentra nuestra aplicación, dejando el terminal abierto:

ionic serve

Si todo ha ido bien, se compilará la aplicación y deberíamos visualizar en el navegador algo del siguiente estilo:

(bastará con pulsar Ctrl+C en el terminal para finalizar la ejecución del comando)

Código HTML

El encabezado

Como ya viene siendo habitual, colocaremos una barra superior que contenga el título y el botón de reordenar:

<ion-header>
  ...
  <ion-title>Places!</ion-title>
  ...
  <ion-button (click)="reorder=!reorder">
  ...    
</ion-header>

En cada pulsación del botón de reordenar, activaremos o desactivaremos dicha funcionalidad cambiando simplemente el valor del atributo correspondiente.

El formulario para la descripción y la imagen

Utilizaremos un simple cuadro de texto para introducir la descripción del lugar que queramos registrar y un elemento HTML estándar de tipo fichero para elegir la imagen:

<ion-item>
  <ion-input placeholder="Enter description" [(ngModel)]="text"></ion-input>
</ion-item>
<ion-item *ngIf="text.length">
  <input type="file" accept="image/*" (change)="add($event)" />
  <ion-icon slot="end" name="camera" (click)="add()"></ion-icon>
</ion-item>

Debemos destacar de nuevo el uso de la directiva ngModel, que nos permitirá enlazar el valor del atributo text, de forma que cualquier modificación en el código HTML cambiará automáticamente el valor del atributo en el código TypeScript y viceversa.

En segundo lugar, también destacamos el uso de la directiva ngIf, que automatiza la creación del campo de tipo fichero, de forma que dicho campo no existirá hasta que se introduzca algún texto en el campo de la descripción. Además, al inicializar de nuevo la descripción desde el código TypeScript, dicho campo desaparecerá automáticamente.

Por último, al final del cuadro de elección de imagen colocaremos un icono para acceder a la cámara de nuestros dispositivos móviles, utilizando como imagen la fotografía que realicemos en ese momento. Esta funcionalidad no estará operativa cuando utilicemos la aplicación desde el navegador.

Los elementos de la lista

Cada elemento de la lista será un enlace a la página web correspondiente de Google Maps para conocer la ubicación del lugar, de forma que podamos acceder fácilmente con un simple clic en el elemento correspondiente. Para ello bastará con especificar la url mediante el atributo href del elemento <ion-item></ion-item> (más información aquí).

Además, utilizaremos el elemento <ion-thumbnail></ion-thumbnail> para mostrar una miniatura de la imagen. Por último, añadiremos dos botones, uno para reordenar (<ion-reorder></ion-reorder>) y otro para borrar (<ion-item-option></ion-item-option>):

<ion-item [href]='item.url'>
  <ion-thumbnail slot="start"><img [src]="item.img" /></ion-thumbnail>{{item.name}}
  <ion-reorder slot="end"></ion-reorder>
</ion-item>
<ion-item-options side="start">
  <ion-item-option color="danger" (click)="delete(i)">
    <ion-icon slot="icon-only" name="trash"></ion-icon>
  </ion-item-option>
</ion-item-options>

Mostrando todos los elementos

Vamos a hacer uso del potencial de Angular para mostrar la lista completa de lugares:

<ion-reorder-group [disabled]="!reorder" (ionItemReorder)="move($event.detail)">
  <ion-item-sliding *ngFor="let item of list; let i=index">
    ...
  </ion-item-sliding>
</ion-reorder-group>

En primer lugar utilizaremos la expresión [disabled]="!reorder" para activar o desactivar la opción de reordenar los elementos. Mediante los corchetes enlazamos el valor del atributo reorder de TypeScript para poder acceder a su valor directamente. El evento (ionItemReorder) nos indicará que se ha cambiado de sitio algún elemento, y por lo tanto ejecutaremos el método move(), cuya funcionalidad veremos más adelante.

Por último, sólo necesitamos realizar un bucle utilizando la directiva ngFor, que además nos proporcionará cada elemento item del array que representa la lista, así como el índice i de cada uno de ellos.

El fichero «home.page.html» completo

<ion-header>
  <ion-toolbar color="primary">
    <ion-title>Places!</ion-title>
    <ion-buttons slot="primary">
      <ion-button (click)="reorder=!reorder">
        <ion-icon slot="icon-only" name="reorder"></ion-icon>
      </ion-button>
    </ion-buttons>
  </ion-toolbar>       
</ion-header>
<ion-content>
  <ion-list lines="full">
    <ion-item color="light">
      <ion-input placeholder="Enter description" [(ngModel)]="text"></ion-input>
    </ion-item>
    <ion-item color="light" *ngIf="text.length">
      <input type="file" accept="image/*" (change)="add($event)" />
      <ion-icon slot="end" name="camera" (click)="add()"></ion-icon>
    </ion-item>    
    <ion-reorder-group [disabled]="!reorder" (ionItemReorder)="move($event.detail)">
      <ion-item-sliding *ngFor="let item of list; let i=index">
        <ion-item [href]='item.url'>
          <ion-thumbnail slot="start"><img [src]="item.img" /></ion-thumbnail>{{item.name}}
          <ion-reorder slot="end"></ion-reorder>
        </ion-item>
        <ion-item-options side="start">
          <ion-item-option color="danger" (click)="delete(i)">
            <ion-icon slot="icon-only" name="trash"></ion-icon>
          </ion-item-option>
        </ion-item-options>
      </ion-item-sliding>
    </ion-reorder-group>
  </ion-list>
</ion-content>

Código TypeScript

Los atributos

En este ejercicio sólo necesitamos cuatro campos para guardar todos los datos necesarios:

reorder: boolean;
list: any;
text: string;

El propósito de cada uno de ellos es el siguiente:

  • reorder: Su valor (falso o verdadero) indicará si se encuentra habilitada o no la funcionalidad de reordenar.
  • list: Array que contendrá todos los lugares.
  • text: Descripción del nuevo lugar que queremos registrar.

Los métodos

  • constructor: Recuperará la lista guardada para que se muestre al iniciar la aplicación.
  • add: Añadirá un nuevo lugar a la lista de lugares.
  • save: Guardará en localStorage el listado de lugares para que no se pierda al cerrar la aplicación o recargar el contenido del navegador.
  • delete: Borrará un elemento de la lista.
  • move: Moverá un elemento de la lista a otra posición.

Obteniendo la ubicación

Para conocer la ubicación desde la que vamos a registrar un nuevo lugar, utilizaremos la funcionalidad de geolocalización de HTML, de forma que consigamos que el código funcione perfectamente tanto en el navegador, como en la app una vez compilada (más información aquí):

navigator.geolocation.getCurrentPosition(pos => {
  let url = "https://maps.google.com/maps?&z=15&t=k&q="+pos.coords.latitude+" "+pos.coords.longitude;
  ...
});

Además, crear un enlace a Google Maps conociendo la ubicación resulta muy sencillo. En nuestro caso especificamos un zoom razonable (z=15), el tipo de mapa satélite (t=k), y la latitud y la longitud (q=…). Se puede obtener más información en la página oficial de Google, o también por ejemplo aquí.

Indicador de ejecución en proceso

Mientras se obtiene la ubicación y se procesa la imagen, resulta conveniente bloquear la aplicación mediante algún indicador para informar al usuario que todavía no se ha completado la acción. Para ello mostraremos un indicador en pantalla utilizando la funcionalidad que nos proporciona IONIC (más información aquí):

async add(event) {
  let loading = await this.loadingController.create();
  loading.present();
  ...
  loading.dismiss();
}

Obteniendo la imagen

Utilizando simple código HTML y JavaScript, podemos obtener la imagen seleccionada en formato base 64, de forma que la podemos guardar como si se tratara de cualquier otra cadena de texto (más información aquí, o aquí):

...
<input type="file" (change)="add($event)" />
...
<img [src]="item.img" />
...
async add(event) {
  let reader = new FileReader();
  reader.onload = (data:any) => {
    item.img = data.target.result;
    ...
  }
  reader.readAsDataURL(event.target.files[0]);
}

Además, añadiremos la funcionalidad necesaria para abrir la cámara cuando ejecutemos la aplicación en nuestros dispositivos móviles (más información en la documentación de Cordova):

...
  navigator.camera.getPicture(function success(data) {
    list.unshift({ name:this.text, img:"data:image/jpeg;base64,"+data, url:url });
    save();     
  }, function error(msg) {
  }, { targetWidth:100, navigator.camera.DestinationType.DATA_URL });
...

El fichero «home.page.ts» completo

import { Component } from '@angular/core';
import { LoadingController } from '@ionic/angular';

@Component({
  selector: 'app-home',
  templateUrl: 'home.page.html',
  styleUrls: ['home.page.scss'],
})

export class HomePage {
  reorder: boolean;
  list: any;
  text: string;

  constructor(public loadingController:LoadingController) {
    this.reorder = false;
    this.text = "";
    this.list = localStorage.getItem('places-list');
    if (this.list!=='undefined' && this.list!==null) this.list = JSON.parse(this.list);
    else this.list = [];
  }

  async add(event?) {
    let loading = await this.loadingController.create({duration:30000});
    loading.present();

    let text = this.text, list = this.list, save = this.save;
    this.text = "";

    navigator.geolocation.getCurrentPosition(pos => {
      let url = "https://maps.google.com/maps?&z=15&t=k&q="+pos.coords.latitude+" "+pos.coords.longitude;

      if (event) {
        let reader = new FileReader();
        reader.onload = (data:any) => {
          list.unshift({ name:text, img:data.target.result, url:url });
          loading.dismiss();
          save(list);
        }
        reader.readAsDataURL(event.target.files[0]);
      }
      else {
        (<any>navigator).camera.getPicture(function success(data) { 
          list.unshift({ name:text, img:"data:image/jpeg;base64,"+data, url:url });
          loading.dismiss();
          save(list);
        }, function error(msg) {
          loading.dismiss();
        }, { targetWidth:100, destinationType:(<any>navigator).camera.DestinationType.DATA_URL });
      }
    });
  }

  save(list) {
    localStorage.setItem('places-list', JSON.stringify(list));
    location.href = "index.html";
  }

  delete(item) {
    this.list.splice(item, 1);
    this.save(this.list);
  }

  move(indexes) {
    let item = this.list[indexes.from];
    this.list.splice(indexes.from, 1);
    this.list.splice(indexes.to, 0, item);
    this.save(this.list);
    indexes.complete();
  }
}

Compilando la aplicación

El fichero config.xml (plugins de geolocalización y cámara)

Para que la aplicación tenga los permisos correctos, y podamos utilizar la funcionalidad nativa para conocer la ubicación y acceder a la cámara, añadiremos los plugins correspondientes al fichero config.xml (más información aquí y aquí):

...
    <plugin name="cordova-plugin-geolocation" />
    <plugin name="cordova-plugin-camera" />
...

Debemos recordar que si compilamos la aplicación utilizando PhoneGap Build, el archivo config.xml se deberá guardar dentro de la carpeta www.

Por último, tengamos en cuenta que toda la funcionalidad de la aplicación se encontrará disponible tanto en el navegador como en la app, exceptuando la cámara, ya que es específica del dispositivo, y no estará operativa en el navegador.

El fichero index.html

Si utilizamos PhoneGap Build deberemos modificar también el fichero src/index.html para enlazar el fichero cordova.js (no tenemos que crearlo nosotros, sólo enlazarlo, ya que dicho archivo se creará automáticamente al compilar el código con PhoneGap Build):

...
<body>
  <app-root></app-root>
  <script type="text/javascript" src="cordova.js"></script>
</body>
...

El resultado

Puedes hacer clic aquí para probar la aplicación propuesta. Se puede observar que el mismo código que hemos estado desarrollando se puede subir a cualquier servidor y el resultado se puede visualizar perfectamente en el navegador como cualquier otra página web.

Una app muy sencilla para guardar sitios interesantes con ubicaciones y fotografías con IONIC 5+JavaScript

El objetivo: Guardar ubicaciones e imágenes

En este ejercicio vamos a demostrar que puede resultar muy sencillo desarrollar una aplicación que nos permita mantener un registro de lugares interesantes, guardando la ubicación precisa y una imagen identificativa de cada uno.

Será el mismo navegador el que nos proporcione la ubicación actual de forma automática, y nos permita elegir las imágenes a utilizar, de entre las que tengamos en nuestros equipos. Además, al ejecutar la app en nuestros móviles, podremos utilizar también la cámara en el mismo momento, pudiendo hacer una foto que se quedará guardada en nuestra aplicación. De esa forma, tendremos imágenes asociadas con la latitud y la longitud de los sitios donde hayamos hecho las fotos, pudiendo acceder además al mapa correspondiente con un solo clic.

Con menos de 100 líneas de código

Veremos que al utilizar IONIC 5, con menos de 100 líneas de código JavaScript podemos desarrollar una aplicación multiplataforma, que funcionará perfectamente en cualquier navegador, o instalada como app en nuestros dispositivos móviles. Y todo ello, con el mismo código fuente, sin cambiar ni una sola línea.

La funcionalidad de la aplicación

La aplicación dispondrá de un cuadro de texto donde podamos especificar la descripción del nuevo lugar que queramos registrar.

Además de poder introducir alguna descripción, se habilitará un nuevo control para poder elegir una imagen. Si estamos ejecutando la aplicación desde nuestro ordenador de escritorio, podremos elegir cualquier imagen de las que se encuentran en nuestro equipo. Si ejecutamos la aplicación desde nuestros dispositivos móviles, podremos hacer una foto en ese mismo momento.

Una vez introducida la descripción y elegida o hecha la foto, la aplicación obtendrá automáticamente la ubicación en la que nos encontremos y creará un elemento en la lista de lugares en el que aparecerá la foto y la descripción.

Al hacer clic sobre cada elemento de la lista, si estamos utilizando el navegador de nuestro ordenador de escritorio, abrirá la página web correspondiente de Google Maps para mostrarnos la ubicación precisa. Si estamos ejecutando la aplicación desde nuestros dispositivos móviles, nos permitirá elegir qué aplicación se utilizará para visualizar la ubicación.

Además, podremos reordenar los elementos de la lista activando un control por cada elemento, que se podrá ocultar o mostrar mediante un botón ubicado en la barra superior de la aplicación.

Por último, permitiremos borrar cualquier elemento de la lista utilizando un botón oculto que se mostrará al deslizar el elemento correspondiente a la derecha.

Código HTML

El encabezado

Como ya viene siendo habitual, colocaremos una barra superior que contenga el título y el botón de reordenar:

<ion-header>
  ...
  <ion-title>Places!</ion-title>
  ...  
  <ion-button onclick="toggleReorder()">
  ...
</ion-header>

En cada pulsación del botón de reordenar, activaremos o desactivaremos dicha funcionalidad cambiando simplemente el valor del atributo correspondiente.

El formulario para la descripción y la imagen

Utilizaremos un simple cuadro de texto para introducir la descripción del lugar que queramos registrar y un elemento HTML estándar de tipo fichero para elegir la imagen:

<ion-item>
  <ion-input placeholder="Enter description"></ion-input>
</ion-item>
<ion-item id="file">
  <input type="file" accept="image/*" onchange="addItem(event)" />
  <ion-icon slot="end" name="camera" onclick="addItem()"></ion-icon>
</ion-item>  

Destacamos el uso del elemento <input type="file">, que automatiza la creación del campo de tipo fichero, y nos permitirá elegir fácilmente una imagen para asociarla al lugar que queramos dejar registrado.

Por último, al final del cuadro de elección de imagen colocaremos un icono para acceder a la cámara de nuestros dispositivos móviles, utilizando como imagen la fotografía que realicemos en ese momento. Esta funcionalidad no estará operativa cuando utilicemos la aplicación desde el navegador.

Los elementos de la lista

Cada elemento de la lista será un enlace a la página web correspondiente de Google Maps para conocer la ubicación del lugar, de forma que podamos acceder fácilmente con un simple clic en el elemento correspondiente. Para ello bastará con especificar la url mediante el atributo href del elemento <ion-item></ion-item> (más información aquí).

Además, utilizaremos el elemento <ion-thumbnail></ion-thumbnail> para mostrar una miniatura de la imagen. Por último, añadiremos dos botones, uno para reordenar (<ion-reorder></ion-reorder>) y otro para borrar (<ion-item-option></ion-item-option>):

<ion-item-sliding>
  <ion-item href="...">
    <ion-thumbnail slot="start"><img src="..." /></ion-thumbnail>
    ...
    <ion-reorder slot="end"></ion-reorder>
  </ion-item>
  <ion-item-options side="start">
    <ion-item-option color="danger" onclick="deleteItem(...)">
      <ion-icon slot="icon-only" name="trash"></ion-icon>
    </ion-item-option>
  </ion-item-options>
</ion-item-sliding>

Mostrando todos los elementos

Bastará con hacer un simple bucle para mostrar la lista completa de lugares:

getList().forEach((item, index) => {
  document.querySelector('ion-reorder-group').innerHTML += 
    `<ion-item-sliding>
        <ion-item href='` + item.url + `'>
          <ion-thumbnail><img src="` + item.img + `" /></ion-thumbnail>` + 
          item.name + `<ion-reorder></ion-reorder>
        </ion-item>
        <ion-item-options>
          <ion-item-option onclick="deleteItem(` + index + `)">
            <ion-icon name="trash"></ion-icon>
          </ion-item-option>
        </ion-item-options>
     </ion-item-sliding>`;
});

Sólo necesitamos recorrer la lista utilizando por ejemplo un bucle forEach, que además nos proporcionará cada elemento item del array, así como el índice index de cada uno de ellos.

El fichero «index.html» completo

<!doctype html>
<html lang="en">
<head>
  <meta charset="utf-8">
  <meta name="viewport" content="viewport-fit=cover, width=device-width, initial-scale=1.0, minimum-scale=1.0, maximum-scale=1.0, user-scalable=no">
  <script type="module" src="https://cdn.jsdelivr.net/npm/@ionic/[email protected]/dist/ionic/ionic.esm.js"></script>
  <script nomodule src="https://cdn.jsdelivr.net/npm/@ionic/[email protected]/dist/ionic/ionic.js"></script>
  <link rel="stylesheet" href="https://cdn.jsdelivr.net/npm/@ionic/[email protected]/css/ionic.bundle.css"/>
  <script src="cordova.js"></script>
  <script src="places.js"></script>
  <title>Places!</title>
</head>
<body onload="onLoad()">
  <ion-app>
    <ion-header>
      <ion-toolbar color="primary">
        <ion-title>Places!</ion-title>
        <ion-buttons slot="primary">
          <ion-button onclick="toggleReorder()">
            <ion-icon slot="icon-only" name="reorder-four"></ion-icon>
          </ion-button>
        </ion-buttons>
      </ion-toolbar>       
    </ion-header>
    <ion-content>
      <ion-list lines="full">
        <ion-item color="light">
          <ion-input placeholder="Enter description"></ion-input>
        </ion-item>
        <ion-item color="light" id="file">
          <input type="file" accept="image/*" onchange="addItem(event)" />
          <ion-icon slot="end" name="camera" onclick="addItem()"></ion-icon>
        </ion-item>    
        <ion-reorder-group disabled="false">
        </ion-reorder-group>
      </ion-list>
    </ion-content>
  </ion-app>
</body>
</html>

Código JavaScript

Accediendo a los elementos principales

En este ejercicio sólo necesitamos gestionar una lista de lugares, con lo que desarrollaremos funciones para leer, actualizar e imprimir dicha lista, utilizando para ello localStorage, como en ejercicios anteriores:

function getList() {
    let list = localStorage.getItem('places-list');
    return list ? JSON.parse(list) : [];
}

function saveList(list) {
    localStorage.setItem('places-list', JSON.stringify(list));
    printItems();
}

function printItems() {
    document.querySelector('ion-reorder-group').innerHTML = "";

    getList().forEach((item, index) => {
        document.querySelector('ion-reorder-group').innerHTML += ... ;
    });
}

Al iniciar la aplicación

Al iniciar la aplicación capturaremos el evento de IONIC que nos indicará cuándo debemos reordenar los elementos de la lista, y además, imprimiremos los lugares que tengamos guardados de una ejecución anterior:

function onLoad() {
    document.addEventListener('ionItemReorder', (event) => { moveItem(event.detail); });
    printItems();
}

Obteniendo la ubicación

Para conocer la ubicación desde la que vamos a registrar un nuevo lugar, utilizaremos la funcionalidad de geolocalización de HTML, de forma que consigamos que el código funcione perfectamente tanto en el navegador, como en la app una vez compilada (más información aquí):

navigator.geolocation.getCurrentPosition(pos => {
  let url = "https://maps.google.com/maps?&z=15&t=k&q="+pos.coords.latitude+" "+pos.coords.longitude;
  ...
});

Además, crear un enlace a Google Maps conociendo la ubicación resulta muy sencillo. En nuestro caso especificamos un zoom razonable (z=15), el tipo de mapa satélite (t=k), y la latitud y la longitud (q=…). Se puede obtener más información en la página oficial de Google, o también por ejemplo aquí.

Indicador de ejecución en proceso

Mientras se obtiene la ubicación y se procesa la imagen, resulta conveniente bloquear la aplicación mediante algún indicador para informar al usuario que todavía no se ha completado la acción. Para ello mostraremos un indicador en pantalla utilizando la funcionalidad que nos proporciona IONIC (más información aquí):

function addItem(event) {
  const loading = document.createElement('ion-loading');
  ...
  loading.present();
  ...
  loading.dismiss();
}

Obteniendo la imagen

Utilizando simple código HTML y JavaScript, podemos obtener la imagen seleccionada en formato base 64, de forma que la podemos guardar como si se tratara de cualquier otra cadena de texto (más información aquí, o aquí):

<input type="file" accept="image/*" onchange="addItem(event)" />

...

let reader = new FileReader();
reader.onload = (data) => {
    saveItem({ name:text, img:data.target.result, url:url });
    event.target.value = '';
}
reader.readAsDataURL(event.target.files[0]);

Además, añadiremos la funcionalidad necesaria para abrir la cámara cuando ejecutemos la aplicación en nuestros dispositivos móviles (más información en la documentación de Cordova):

...
navigator.camera.getPicture(function success(data) {
  saveItem({ name:text, img:"data:image/jpeg;base64,"+data, url:url });       
}, function error(msg) {
  alert(msg);
}, { targetWidth:100, destinationType:Camera.DestinationType.DATA_URL });
...

El fichero «places.js» completo

function onLoad() {
    document.addEventListener('ionItemReorder', (event) => { moveItem(event.detail); });
    printItems();
}

function getList() {
    let list = localStorage.getItem('places-list');
    return list ? JSON.parse(list) : [];
}

function saveList(list) {
    localStorage.setItem('places-list', JSON.stringify(list));
    printItems();
}

function printItems() {
    document.querySelector('ion-reorder-group').innerHTML = "";

    getList().forEach((item, index) => {
        document.querySelector('ion-reorder-group').innerHTML +=
        `<ion-item-sliding>
            <ion-item href='`+item.url+`'>
                <ion-thumbnail slot="start"><img src="`+item.img+`" />
                </ion-thumbnail>`+item.name+`<ion-reorder slot="end"></ion-reorder>
            </ion-item>
            <ion-item-options side="start">
                <ion-item-option color="danger" onclick="deleteItem(`+index+`)">
                    <ion-icon slot="icon-only" name="trash"></ion-icon>
                </ion-item-option>
            </ion-item-options>
        </ion-item-sliding>`;
    });
}

function addItem(event = false) {
    const loading = document.createElement('ion-loading');
    loading.duration = 15000;
    document.querySelector('ion-app').appendChild(loading);
    loading.present();

    navigator.geolocation.getCurrentPosition(pos => {
        let text = document.querySelector('ion-input').value;
        let url = "https://maps.google.com/maps?&z=15&t=k&q="+pos.coords.latitude+" "+pos.coords.longitude;

        if (event) {
            let reader = new FileReader();
            reader.onload = (data) => {
                saveItem({ name:text, img:data.target.result, url:url });
                event.target.value = '';
                loading.dismiss();
            }
            reader.readAsDataURL(event.target.files[0]);
        }
        else {
            loading.dismiss();
            navigator.camera.getPicture(function success(data) {
                saveItem({ name:text, img:"data:image/jpeg;base64,"+data, url:url });       
            }, function error(msg) {
                alert(msg);
            }, { targetWidth:100, destinationType:Camera.DestinationType.DATA_URL });
        }
    });
}

function saveItem(item) {
    let list = getList();
    list.unshift(item);
    saveList(list);    
}

function deleteItem(item) { 
    document.querySelector('ion-list').closeSlidingItems();
    let list = getList();
    list.splice(item, 1);
    saveList(list);
}

function moveItem(indexes) {
    let list = getList();
    let item = list[indexes.from];
    list.splice(indexes.from, 1);
    list.splice(indexes.to, 0, item);
    indexes.complete();
    saveList(list);
}

function toggleReorder() {
    let reorder = document.querySelector('ion-reorder-group').disabled;
    document.querySelector('ion-reorder-group').disabled = !reorder;
}

Icono y pantalla de bienvenida

Si queremos cambiar el icono o la pantalla de bienvenida, bastará con actualizar los archivos icon.png y splash.png respectivamente, colocándolos en el mismo archivo zip que el código fuente de nuestro proyecto.

Compilando la aplicación

Plugins de geolocalización y cámara

Para que la aplicación tenga los permisos correctos, y podamos utilizar la funcionalidad nativa para conocer la ubicación, añadiremos el plugin correspondiente (más información aquí):

<plugin name="cordova-plugin-geolocation" />

Además, para utilizar la cámara también echaremos mano de otro plugin, ya que sólo podemos acceder a dicha funcionalidad en nuestros dispositivos móviles (más información aquí):

<plugin name="cordova-plugin-camera" />

Debemos recordar que toda la funcionalidad de la aplicación se encontrará disponible tanto en el navegador como en la app, exceptuando la cámara, ya que es específica del dispositivo, y no estará operativa en el navegador.

El archivo «config.xml» completo

<?xml version="1.0" encoding="UTF-8" ?>
<widget xmlns   = "http://www.w3.org/ns/widgets"
    xmlns:gap   = "http://phonegap.com/ns/1.0"
    id          = "com.fernandoruizrico.places"
    versionCode = "1"
    version     = "0.0.1" >

  <name>Places!</name>

  <description>
      A simple application to save important places.
  </description>

  <author href="https://fernandoruizrico.com" email="[email protected]">
      Fernando Ruiz Rico
  </author>

  <access origin="*" />
  <allow-navigation href="*" />
  <allow-intent href="*" />

  <plugin name="cordova-plugin-whitelist" />
  <plugin name="cordova-plugin-splashscreen" />
  <plugin name="cordova-plugin-vibration" />
  <plugin name="cordova-plugin-geolocation" />
  <plugin name="cordova-plugin-camera" />

  <icon src="icon.png" />
  <splash src="splash.png" />

  <preference name="phonegap-version" value="cli-9.0.0" />

</widget>

Generando el archivo APK

Para instalar la aplicación en nuestros dispositivos móviles, tenemos que compilar el código fuente, con lo que deberemos incluir los siguientes archivos en el fichero zip que subiremos a la página web de PhoneGap:

  • index.html
  • places.js
  • config.xml
  • icon.png
  • splash.png

El resultado

Puedes hacer clic aquí para probar la aplicación propuesta. Se puede observar que el mismo código de la app generada también puede funcionar perfectamente en el navegador como una página web.

Lista de tareas con IONIC 5+Angular

El objetivo: IONIC + Angular

En los ejercicios que hemos desarrollado hasta ahora sólo hemos utilizado IONIC de manera independiente, sin echar mano de ningún otro framework adicional, ya que hemos interactuado con los nuevos elementos HTML utilizando código JavaScript.

Para aplicaciones sencillas, puede ser una opción completamente válida y muy recomendable, dado el bajo nivel de requerimientos. Sólo necesitamos conocimientos básicos de desarrollo web, y con una simple referencia desde el código HTML, ya tenemos a nuestro alcance todo el potencial ofrecido por IONIC 5.

Sin embargo, si pretendemos desarrollar aplicaciones más complejas, resulta muy recomendable usar la segunda posibilidad que nos ofrece IONIC 5, al permitirnos combinar unos resultados visuales muy buenos, con un estilo de programación muy estructurado, y con amplias posibilidades dentro del desarrollo web.

En este ejercicio vamos a dar un paso adelante, y observaremos el potencial del que disponemos al usar IONIC con un framework tan utilizado y conocido como Angular. Desarrollaremos la misma aplicación que en un ejercicio anterior, pero veremos que el código fuente estará mucho mejor estructurado, y nos ofrecerá muchas más posibilidades que el simple código JavaScript.

Además, al tener la suerte de poder utilizar IONIC 5, disponemos de toda la documentación por duplicado para que podamos escoger la opción que más nos interese. En las versiones anteriores, IONIC se encontraba empaquetado con Angular, con lo que sólo teníamos una alternativa. Ahora mismo tenemos a nuestro alcance las mismas facilidades, tanto si escogemos desarrollar nuestra app utilizando HTML+JavaScript o Angular. Además, sea cual sea la opción por la que nos declinemos, IONIC nos proporciona la funcionalidad y la documentación necesaria para cada uno de sus componentes en ambos casos:

Como opinión personal, me gustaría decir que una vez se aprende Angular, resulta difícil desprenderse de él, ya que se pueden desarrollar aplicaciones más complejas y mucho más estables, con un código fuente más legible y mucho más fácil de mantener y actualizar.

La funcionalidad de la aplicación

La funcionalidad a implementar será la misma que en el ejercicio anterior, aunque en esta ocasión vamos a estructurar mejor el código y dividiremos la aplicación en dos pantallas.

Vamos a enumerar de nuevo los aspectos principales de la aplicación, y observaremos que podemos obtener el mismo resultado desarrollando la aplicación con Angular.

La pantalla principal

  • Gestionar varias listas de tareas independientes.
  • Borrar de golpe todas las tareas de una lista.
  • Borrar una sola tarea de una lista determinada mediante un botón oculto que se visualizará al deslizar hacia la derecha la tarea a borrar.
  • Pedir confirmación antes de borrar cualquier tarea.
  • Permitir ordenar las tareas arrastrando cualquier elemento a una nueva posición.

La pantalla de detalles

  • Añadir tareas indicando la fecha, la descripción de la tarea, y un icono identificativo de la prioridad.
  • Visualizar y editar los detalles de cada tarea haciendo clic sobre el elemento correspondiente de la lista.

Primeros pasos

Requisitos

La mayoría de las aplicaciones de IONIC se crean y desarrollan principalmente utilizando la herramienta de la línea de comandos. A continuación enumeraremos los pasos necesarios para instalar el software necesario en nuestros ordenadores para poder desarrollar y compilar desde la consola aplicaciones de IONIC utilizando Angular.

En primer lugar, deberemos instalar Node.js (la versión LTS, recomendada para la mayoría de usuarios, tal como se indica en la web). Podremos comprobar si se ha instalado correctamente de la siguiente forma:

node --version
npm --version

A continuación deberemos instalar el cliente de IONIC, de la siguiente forma:

npm install -g ionic

La opción  -g significa que se va a instalar de manera global en nuestros equipos, y por lo tanto necesitaremos permisos de administrador. En el caso de utilizar un sistema operativo Windows es recomendable abrir un terminal en modo administrador. Para sistemas operativos Mac/Linux, el comando se deberá ejecutar con sudo.

Código base de la aplicación

Para comenzar a desarrollar nuestra aplicación deberemos partir de un código base generado automáticamente por IONIC. Bastará con ejecutar el siguiente comando para generar los ficheros necesarios:

ionic start tareas blank --type=angular --no-git --no-link

Al ejecutar el comando, IONIC nos informará del proceso con algunos avisos y  si todo va bien, al final nos mostrará un mensaje similar al siguiente:

[INFO] Next Steps:
       
       - Go to your newly created project: cd ./tareas
       - Run ionic serve within the app directory to see your app
       - Build features and components: https://ion.link/scaffolding-docs
       - Get Ionic DevApp for easy device testing: https://ion.link/devapp

Al finalizar el proceso, se habrá creado un directorio con el nombre tareas, que constituye el directorio raíz de nuestro proyecto, donde tendremos todo el código necesario para continuar con el desarrollo de nuestra aplicación. Para realizar los siguientes pasos deberemos acceder primero a dicha carpeta:

cd tareas

Y por último, puesto que la aplicación básica que acabamos de crear sólo dispone de una pantalla principal, procederemos además a añadir una página adicional, y un fichero auxiliar (servicio) donde ubicaremos la funcionalidad básica para gestionar las listas y sus elementos:

ionic generate page AddEditItem
ionic generate service list

Y a partir de ahora ya podemos comenzar a añadir el código específico de nuestra aplicación. Aunque la funcionalidad sea la misma, al utilizar Angular, el código fuente sí tendrá variaciones, que como vamos a observar a continuación, nos permitirán generar un código mejor estructurado y más legible que el de una aplicación del mismo tamaño desarrollada simplemente con JavaScript.

Si hemos ejecutado correctamente los comandos anteriores, IONIC ya nos habrá creado todos los ficheros que vamos a utilizar en este ejercicio:

  1. src/app/list.service.ts: Funciones auxiliares.
  2. src/app/home/home.page.html: Código HTML de la pantalla principal.
  3. src/app/home/home.page.ts: Código TypeScript de la pantalla principal.
  4. src/app/add-edit-item/add-edit-item.page.html: Código HTML de la pantalla con los detalles de las tareas.
  5. src/app/add-edit-item/add-edit-item.page.ts: Código TypeScript de la pantalla con los detalles de las tareas.

Probando la aplicación en el navegador

Una de las primeras ventajas con las que nos encontramos es que la misma herramienta de consola de IONIC se puede utilizar como servidor web, con lo que nos permitirá  probar nuestra aplicación en el navegador al mismo tiempo que se actualiza automáticamente después de realizar cualquier cambio en el código fuente. Bastará con ejecutar el siguiente comando desde la carpeta raíz del proyecto donde se encuentra nuestra aplicación, dejando el terminal abierto:

ionic serve

Si todo ha ido bien, se compilará la aplicación y deberíamos visualizar en el navegador algo del siguiente estilo:

(bastará con pulsar Ctrl+C en el terminal para finalizar la ejecución del comando)

Funciones auxiliares

En el fichero list.service.ts desarrollaremos el código de una clase que contenga un array de listas de tareas y las funciones básicas para acceder a las mismas y a sus elementos:

  1. getList(index): Nos devolverá la lista completa ubicada en la pestaña index (la primera pestaña vendrá indicada por el valor cero).
  2. saveList(listIndex): Guardará la lista indicada de manera permanente utilizando localStorage.
  3. getItem(listIndex, itemIndex): Devolverá una tarea en particular.
  4. setItem(listIndex, item, itemIndex?): Añadirá una tarea a la lista indicada, o si se indica el índice de una tarea concreta, la actualizará en vez de añadirla.
  5. deleteItem(listIndex, itemIndex): Borrará la tarea indicada.
  6. deleteList(listIndex): Borrará una lista entera.
  7. moveItem(listIndex, from, to): Cambiará de orden una tarea, moviéndola de la posición from a la posición to.

El fichero «list.service.ts» completo

import { Injectable } from '@angular/core';

@Injectable({
  providedIn: 'root'
})
export class ListService {

  list: any;

  constructor() { 
    this.list = [[]];
  }

  getList(index) {
    let list = localStorage.getItem('todo-list-'+index);
    if (list !== 'undefined' && list !== null) {
      this.list[index] = JSON.parse(list);
    }
    if (index>=this.list.length) this.list.push([]);
    return(this.list[index]);
  }

  saveList(listIndex) {
    localStorage.setItem('todo-list-'+listIndex, JSON.stringify(this.list[listIndex]));
  }

  getItem(listIndex, itemIndex) {
    return(this.list[listIndex][itemIndex]);
  }

  setItem(listIndex, item, itemIndex?) {
    if (itemIndex == undefined) this.list[listIndex].push(Object.assign({}, item));
    else this.list[listIndex][itemIndex] = Object.assign({}, item);
    this.saveList(listIndex);  
  }

  deleteItem(listIndex, itemIndex) {
    this.list[listIndex].splice(itemIndex,1);
    this.saveList(listIndex);
  }

  deleteList(listIndex) {
    this.list[listIndex].length = 0;
    this.saveList(listIndex);    
  }

  moveItem(listIndex, from, to) {
    let element = this.list[listIndex][from];
    this.list[listIndex].splice(from, 1);
    this.list[listIndex].splice(to, 0, element);
    this.saveList(listIndex);    
  }
}

La pantalla principal

En el fichero home.page.ts desarrollaremos el código de una clase que contendrá las variables y las funciones necesarias para manipular las listas y las tareas que se visualizan en la pantalla principal:

  1. constructor(ListService, AlertController): En el constructor inicializaremos el array de objetos con las propiedades que definen las diferentes listas (etiqueta, icono y lista de tareas). Si ya existen tareas al iniciar la aplicación, se leerán para que aparezcan en pantalla.
  2. toggleReorder(): Para activar o desactivar el botón de los elementos para reordenarlos.
  3. setTab(tabIndex): Activará la pestaña con el índice indicado.
  4. deleteItem(item?): Después de pedir confirmación, borrará todos los elementos de la lista de tareas activa, o sólo el elemento indicado, si éste se ha pasado por parámetro.
  5. moveItem(indexes): Recibe un objeto con la posición inicial de una tarea en la lista y la posición final donde se debe mover.

Para implementar la barra superior de la pantalla principal, ahora utilizaremos el siguiente código:

<ion-title>ToDo!</ion-title>
<ion-buttons slot="end">
  <ion-button (click)="deleteItem()" color="danger"><ion-icon slot="icon-only" name="trash"></ion-icon></ion-button>  
  <ion-button (click)="toggleReorder()"><ion-icon slot="icon-only" name="reorder"></ion-icon></ion-button>
  <ion-button [routerLink]="['/add-edit-item', { tab:tabIndex, item:-1 }]"><ion-icon slot="icon-only" name="add"></ion-icon></ion-button>
</ion-buttons>

Ya podemos observar algunas diferencias respecto al código JavaScript. En primer lugar, en vez de utilizar el atributo del evento onclick utilizaremos la sintaxis (click) (más detalles en la documentación de Angular). En segundo lugar, observamos una gran diferencia respecto a la opción de añadir un nuevo elemento. En este caso vamos a diseñar una nueva página (a la que accederemos con la url /add-edit-item) en vez de abrir un cuadro de diálogo modal. La sintaxis de Angular utilizada en este caso, nos permite pasar información ({ tab:tabIndex, item:-1 }) de una página a otra, de forma que sepamos a qué lista debemos añadir la nueva tarea, colocando además el índice del item a -1 para indicar que no queremos modificar ninguna tarea existente, sino añadir una nueva:

...
<ion-button [routerLink]="['/add-edit-item', { tab:tabIndex, item:-1 }]"><ion-icon slot="icon-only" name="add"></ion-icon></ion-button>
...

Para mostrar las diferentes pestañas (tabs) utilizaremos el siguiente código:

<ion-tab-bar #myTabs color="primary" [selectedTab]="tabs[0].label">
  <ion-tab-button *ngFor="let tab of tabs; let i=index" (click)="setTab(i)" [tab]="tab.label">
    <ion-label>{{tab.label}}</ion-label>
    <ion-icon [name]="tab.icon"></ion-icon>
  </ion-tab-button>
</ion-tab-bar>

Y para mostrar la lista de tareas:

<ion-list #myList lines="full">
  <ion-reorder-group [disabled]="!reorder" (ionItemReorder)="moveItem($event.detail)">
    <ion-item-sliding *ngFor="let item of tabs[tabIndex].list; let i=index">
      <ion-item [routerLink]="['/add-edit-item', { tab:tabIndex, item:i }]">
        <ion-label class="ion-text-wrap">
          <h2>{{item.task}}</h2>
          <p>{{item.date.substring(0,10)}}</p>
        </ion-label>
        <ion-icon slot="end" [name]="item.icon"></ion-icon>
        <ion-reorder slot="end"></ion-reorder>
      </ion-item>
      <ion-item-options side="start">
        <ion-item-option color="danger" (click)="deleteItem(i)">
          <ion-icon slot="icon-only" name="trash"></ion-icon>
        </ion-item-option>
      </ion-item-options>
    </ion-item-sliding>
  </ion-reorder-group>
</ion-list>

Respecto a la versión con JavaScript, la mayor diferencia la observamos en las siguientes líneas:

<ion-item-sliding *ngFor="let item of tabs[tabIndex].list; let i=index">

...

<ion-tab-button *ngFor="let tab of tabs; let i=index" (click)="setTab(i)" [tab]="tab.label">

Utilizando Angular podemos insertar bucles dentro de nuestro código HTML que nos permitirán mostrar muy fácilmente varios elementos que tengan las mismas propiedades (consultar la documentación de angular para más detalles). De esta forma tendremos el mismo código independientemente de la cantidad de listas de tareas que vayamos a utilizar. Bastará con especificar en el constructor las etiquetas y los iconos de cada pestaña utilizando un simple array:

constructor(...) {
  this.tabs = [
    {label: 'School', icon: 'school', list: []},
    {label: 'Home', icon: 'home', list: []}
  ];
  ...
}

Para gestionar el botón de reordenar, también mejora mucho la sintaxis respecto a JavaScript:

<ion-reorder-group [disabled]="!reorder" (ionItemReorder)="moveItem($event.detail)">
...
</ion-reorder-group>

En este caso, la propiedad disabled se encierra entre corchetes para unirla a la variable reorder, y el evento ionItemReorder se encierra entre paréntesis.

El fichero «home.page.html» completo

<ion-header>
  <ion-toolbar color="primary">
    <ion-title>ToDo!</ion-title>
    <ion-buttons slot="end">
      <ion-button (click)="deleteItem()" color="danger"><ion-icon slot="icon-only" name="trash"></ion-icon></ion-button>  
      <ion-button (click)="toggleReorder()"><ion-icon slot="icon-only" name="reorder-four"></ion-icon></ion-button>
      <ion-button [routerLink]="['/add-edit-item', { tab:tabIndex, item:-1 }]"><ion-icon slot="icon-only" name="add"></ion-icon></ion-button>
    </ion-buttons>
  </ion-toolbar>       
</ion-header>

<ion-content>
  <ion-list #myList lines="full">
    <ion-reorder-group [disabled]="!reorder" (ionItemReorder)="moveItem($event.detail)">
      <ion-item-sliding *ngFor="let item of tabs[tabIndex].list; let i=index" >
        <ion-item [routerLink]="['/add-edit-item', { tab:tabIndex, item:i }]"  >
          <ion-label class="ion-text-wrap">
            <h2>{{item.task}}</h2>
            <p>{{item.date.substring(0,10)}}</p>
          </ion-label>
          <ion-icon slot="end" [name]="item.icon"></ion-icon>
          <ion-reorder slot="end"></ion-reorder>
        </ion-item>
        <ion-item-options side="start">
          <ion-item-option color="danger" (click)="deleteItem(i)">
            <ion-icon slot="icon-only" name="trash"></ion-icon>
          </ion-item-option>
        </ion-item-options>
      </ion-item-sliding>
    </ion-reorder-group>
  </ion-list>
</ion-content>

<ion-footer>
  <ion-tab-bar #myTabs color="primary" [selectedTab]="tabs[0].label">
    <ion-tab-button *ngFor="let tab of tabs; let i=index" (click)="setTab(i)" [tab]="tab.label">
      <ion-label>{{tab.label}}</ion-label>
      <ion-icon [name]="tab.icon"></ion-icon>
    </ion-tab-button>
  </ion-tab-bar>
</ion-footer>

El fichero «home.page.ts» completo

import { Component, ViewChild } from '@angular/core';
import { IonTabBar, IonList, AlertController } from '@ionic/angular';
import { ListService } from '../list.service';

@Component({
  selector: 'app-home',
  templateUrl: 'home.page.html',
  styleUrls: ['home.page.scss'],
})

export class HomePage {
  @ViewChild('myTabs', {static: false}) tabRef: IonTabBar;
  @ViewChild('myList', {static: false}) listRef: IonList;
  tabs: any;
  tabIndex: number;
  reorder: boolean;

  constructor(private listService: ListService,
              private alertController: AlertController){
    this.tabs = [
      {label: 'School', icon: 'school', list: []},
      {label: 'Home', icon: 'home', list: []}
    ];
    this.tabs.forEach((tab, index) => {
      tab.list = this.listService.getList(index);
    });
    this.tabIndex = 0;
    this.reorder = false;
  }

  toggleReorder() {
    this.reorder = !this.reorder;
    this.listRef.closeSlidingItems();
  }

  setTab(tabIndex) {
    this.tabIndex = tabIndex;
    this.tabRef.selectedTab = this.tabs[this.tabIndex].label;
  } 

  async deleteItem(item?) {
    const alert = await this.alertController.create({
      header: item === undefined ? 'Delete all' : 'Delete item',
      message: 'Are you sure?',
      buttons: [
        {
          text: 'OK',
          handler: () => {
            this.listRef.closeSlidingItems();
            if (item === undefined) {
              this.listService.deleteList(this.tabIndex);
            }
            else {
              this.listService.deleteItem(this.tabIndex, item);              
            }
          }
        },       
        {
          text: 'CANCEL',
          role: 'cancel'
        }
      ]
    });

    await alert.present();
  }

  moveItem(indexes) {
    this.listService.moveItem(this.tabIndex, indexes.from, indexes.to);
    indexes.complete();
  }
}

El formulario para añadir o editar tareas

En el fichero add-edit-item-page.ts desarrollaremos el código de una clase que contendrá las variables y las funciones necesarias para editar o crear una tarea:

  1. constructor(Router, ActivatedRoute, AlertController, ListService): En el constructor inicializaremos por un lado un array con los nombres de los iconos para indicar las prioridades de las tareas, y a continuación recogeremos los parámetros proporcionados desde la pantalla principal (índices de la lista y elemento a modificar, si es el caso).
  2. error(message): Para mostrar mensajes de error si por ejemplo no se ha especificado ningún texto en la descripción de la tarea y pulsamos el botón de confirmar.
  3. save(): Comprobará si desde la pantalla principal se ha elegido una tarea a modificar o si por el contrario se ha pulsado el botón de nueva tarea, y llamará a la función del servicio, volviendo a la pantalla principal a continuación.

La barra superior de esta pantalla sólo contendrá un título y dos botones, uno para cancelar y otro para confirmar. Ambos nos llevarán de nuevo a la pantalla principal, pero si pulsamos el botón de confirmar, previamente se grabarán los datos introducidos:

<ion-title>ToDo</ion-title>
<ion-buttons slot="primary">
    <ion-button color="danger" [routerLink]="['/home']"><ion-icon slot="icon-only" name="close"></ion-icon></ion-button>
    <ion-button color="primary" (click)="save()"><ion-icon slot="icon-only" name="checkmark"></ion-icon></ion-button>
</ion-buttons>       
</ion-toolbar>

El formulario para introducir los datos de una nueva tarea o modificar los de una existente es muy sencillo:

<ion-list>
  <ion-item>
    <ion-label position="floating">Select date</ion-label>
    <ion-datetime display-format="D MMM YYYY" max="2050-12-31" [(ngModel)]="item.date"></ion-datetime>      
  </ion-item>
  <ion-item>
    <ion-label position="floating">Enter task</ion-label>
    <ion-input [(ngModel)]="item.task"></ion-input>
  </ion-item>
</ion-list>
<ion-segment [(ngModel)]="item.icon">
  <ion-segment-button *ngFor="let button of buttons" [value]="button">
    <ion-icon [name]="button"></ion-icon>
  </ion-segment-button>
</ion-segment>

La novedad a destacar es el uso de [(ngModel)], una de las indudables ventajas que nos ofrece Angular, ya que nos permite enlazar un atributo de la clase con un campo del formulario, de forma que las modificaciones que se produzcan queden reflejadas en los dos sentidos: el valor que tenga dicho atributo en el código TypeScript se mostrará en el código HTML, y cualquier modificación que hagamos en el formulario, también cambiará el valor del atributo de la clase. Se puede consultar la documentación oficial para más detalles.

Por otro lado, también estamos implementando un bucle para mostrar los posibles iconos indicativos de la prioridad de la tarea, lo que sin duda nos ahorra código HTML:

<ion-segment-button *ngFor="let button of buttons" [value]="button">
    <ion-icon [name]="button"></ion-icon>
</ion-segment-button>

Al igual que en el constructor de la página principal, bastará con especificar mediante un array todos los botones que deseemos visualizar:

constructor(...) { 
  this.buttons = ["radio-button-off", "radio-button-on", "snow", "flame"];
  ...
}

El fichero «add-edit-item.page.html» completo

<ion-header>
  <ion-toolbar>
    <ion-title>ToDo</ion-title>
    <ion-buttons slot="primary">
        <ion-button color="danger" [routerLink]="['/home']"><ion-icon slot="icon-only" name="close"></ion-icon></ion-button>
        <ion-button color="primary" (click)="save()"><ion-icon slot="icon-only" name="checkmark"></ion-icon></ion-button>
    </ion-buttons>       
  </ion-toolbar>
</ion-header>
<ion-content>
  <ion-list>
    <ion-item>
      <ion-label position="floating">Select date</ion-label>
      <ion-datetime display-format="D MMM YYYY" max="2050-12-31" [(ngModel)]="item.date"></ion-datetime>      
    </ion-item>
    <ion-item>
      <ion-label position="floating">Enter task</ion-label>
      <ion-input [(ngModel)]="item.task"></ion-input>
    </ion-item>
  </ion-list>
  <ion-segment [(ngModel)]="item.icon">
    <ion-segment-button *ngFor="let button of buttons" [value]="button">
      <ion-icon [name]="button"></ion-icon>
    </ion-segment-button>
  </ion-segment>
</ion-content>

El fichero «add-edit-item.page.ts» completo

import { Component } from '@angular/core';
import { Router, ActivatedRoute } from '@angular/router';
import { AlertController } from '@ionic/angular';
import { ListService } from '../list.service';

@Component({
  selector: 'app-add-edit-item',
  templateUrl: './add-edit-item.page.html',
  styleUrls: ['./add-edit-item.page.scss'],
})

export class AddEditItemPage {
  item: any;
  tabIndex: number;
  itemIndex: number;
  buttons: Array<string>;

  constructor(private router: Router,
              private route: ActivatedRoute,
              public alertController: AlertController,
              private ListService: ListService) { 
    this.buttons = ["radio-button-off", "radio-button-on", "snow", "flame"];

    this.tabIndex = +this.route.snapshot.paramMap.get('tab');
    this.itemIndex = +this.route.snapshot.paramMap.get('item'); 
    if (this.itemIndex >= 0) {
      this.item = Object.assign({}, this.ListService.getItem(this.tabIndex, this.itemIndex));
      this.item.date = new Date(this.item.date).toISOString();
    }
    else {
      this.item = { date: new Date().toISOString(), task: '', icon: 'radio-button-off'};
    } 
  }

  async error(message) {
    const alert = await this.alertController.create({
      message: message,
      buttons: ['OK']
    });

    await alert.present();
  }

  save() {
    if (!this.item.task.length) {
      this.error('The task cannot be empty');
    }
    else {
      if (this.itemIndex >= 0) {
        this.ListService.setItem(this.tabIndex, this.item, this.itemIndex);
      }
      else {
        this.ListService.setItem(this.tabIndex, this.item);      
      }
      this.router.navigate(['/home']);
    }
  }
}

El resultado

El procedimiento utilizado en este ejemplo supera obviamente al de la versión basada sólo en JavaScript, ya que ahora no sólo hemos mejorado a nivel de código, sino que hemos agilizado el proceso de desarrollo y actualización, obteniendo además una app que no requiere Internet, ya que ahora IONIC no se encuentra enlazado directamente como librería, sino empaquetado dentro de la propia aplicación.

Puedes hacer clic aquí para observar que también podemos acceder al código generado desde el navegador, como cualquier otra página web.

Menú lateral con opción de compartir con IONIC 5+JavaScript

El objetivo

En esta ocasión añadiremos al ejercicio anterior la opción de compartir, de forma que desde nuestra aplicación podamos enviar la lista de tareas mediante un mensaje de WhatsApp, un correo electrónico, un SMS, etc.

Además, añadiremos el código necesario para disponer de un menú lateral donde podremos añadir más opciones, y donde sugerimos además colocar la opción de borrar la lista de tareas, para que no esté tan accesible desde la pantalla principal:

El menú lateral

Primero añadiremos en la barra superior de la pantalla principal de nuestra aplicación un botón para desplegar el menú lateral:

<ion-header>
  ...
  <ion-menu-button></ion-menu-button>
  ...    
</ion-header>

Como podemos leer en la documentación de IONIC, el elemento <ion-menu-button></ion-menu-button> se encarga de todo, ya que crea automáticamente el icono del botón y añade la funcionalidad necesaria para desplegar el menú en la página actual.

Sólo nos falta añadir el código necesario con el menú lateral, que tendrá su propio encabezado y una lista con las opciones que deseemos. En nuestro caso pondremos la opción de reordenar, de compartir y de borrado de todos los elementos de la lista seleccionada. Además, añadiremos también un botón para volver a ocultar el menú:

<ion-menu content-id="content">
  <ion-header>
    ...
    <ion-menu-button></ion-menu-button>
    ...
  </ion-header>
  <ion-content>
    <ion-menu-toggle>
      <ion-list>
        <ion-item-divider><ion-label>Reorder and Share</ion-label></ion-item-divider>   
        <ion-item onclick="toggleReorder()">
          <ion-label>Enable/Disable reorder</ion-label>
          <ion-icon slot="end" name="reorder"></ion-icon>
        </ion-item>
        <ion-item onclick="share()">
          <ion-label>Share</ion-label>
          <ion-icon slot="end" name="share"></ion-icon>
        </ion-item>
        ...
      </ion-list>
    </ion-menu-toggle>
  </ion-content>
</ion-menu>

<div class="ion-page" id="content">
  <ion-header>
    ...
      <ion-menu-button></ion-menu-button>
    ...
  </ion-header>
  <ion-content>
    ...
  </ion-content>
</div>

Observaremos que el elemento <ion-menu></ion-menu> por defecto creará un menú que aparece desde la izquierda de la pantalla actual, tal como se especifica en la documentación de IONIC. Se podrá ocultar pulsando de nuevo el botón de menú, o con un gesto swipe hacia la izquierda, o incluso pulsando fuera del menú. Además, utilizaremos el elemento <ion-menu-toggle></ion-menu-toggle> para conseguir que al pulsar en cualquier opción, el menú también se cierre automáticamente.

El fichero «index.html» completo

<!DOCTYPE html>
<html>

<head>
  <meta charset="UTF-8">
  <title>ToDo!</title>
  <meta name="viewport" content="width=device-width, initial-scale=1.0, minimum-scale=1.0, maximum-scale=1.0, user-scalable=no">

  <script type="module" src="https://cdn.jsdelivr.net/npm/@ionic/[email protected]/dist/ionic/ionic.esm.js"></script>
  <script nomodule src="https://cdn.jsdelivr.net/npm/@ionic/[email protected]/dist/ionic/ionic.js"></script>
  <link rel="stylesheet" href="https://cdn.jsdelivr.net/npm/@ionic/[email protected]/css/ionic.bundle.css"/>

  <script src="cordova.js"></script>
  <script src="todo.js"></script>
</head>

<body onload="onLoad()">
  <ion-app>
    <ion-menu content-id="content">
      <ion-header>
        <ion-toolbar mode="ios" color="danger">
          <ion-buttons slot="start">
            <ion-menu-button></ion-menu-button>
          </ion-buttons>  
          <ion-title>Menu</ion-title>
        </ion-toolbar>
      </ion-header>
      <ion-content>
        <ion-menu-toggle>
          <ion-list lines="full">
            <ion-item-divider>
              <ion-label>Reorder and Share</ion-label>
            </ion-item-divider>
            <ion-item onclick="toggleReorder()">
              <ion-label>Enable/Disable reorder</ion-label>
              <ion-icon slot="end" name="reorder"></ion-icon>
            </ion-item>
            <ion-item onclick="share()">
              <ion-label>Share</ion-label>
              <ion-icon slot="end" name="share"></ion-icon>
            </ion-item>
            <ion-item-divider>
              <ion-label>Delete</ion-label>
            </ion-item-divider>          
            <ion-item onclick="deleteItem()">
              <ion-label>Delete all</ion-label>
              <ion-icon slot="end" name="trash" color="danger"></ion-icon>
            </ion-item>
          </ion-list>
        </ion-menu-toggle>
      </ion-content>
    </ion-menu>

    <div class="ion-page" id="content">
      <ion-header>
        <ion-toolbar mode="ios" color="primary">
          <ion-buttons slot="start">
            <ion-menu-button></ion-menu-button>
          </ion-buttons>          
          <ion-title>ToDo!</ion-title>
          <ion-buttons slot="end">
            <ion-button onclick="addEditItem()">
              <ion-icon name="add" slot="icon-only"></ion-icon>
            </ion-button>
          </ion-buttons>            
        </ion-toolbar>
      </ion-header>
      <ion-content>
        <ion-tabs>
          <ion-tab tab="school">
            <ion-list lines="full">
              <ion-reorder-group></ion-reorder-group>
            </ion-list>
          </ion-tab>
          <ion-tab tab="home">
            <ion-list lines="full">
              <ion-reorder-group></ion-reorder-group>
            </ion-list>
          </ion-tab>
          <ion-tab-bar slot="bottom" color="primary">
            <ion-tab-button tab="school">
              <ion-icon name="school"></ion-icon>
            </ion-tab-button>
            <ion-tab-button tab="home">
              <ion-icon name="home"></ion-icon>
            </ion-tab-button>
          </ion-tab-bar>
        </ion-tabs>
      </ion-content>
    </div>
  </ion-app>
</body>
</html>

La opción de compartir

Utilizaremos la funcionalidad nativa de nuestros dispositivos móviles para poder enviar la lista de tareas a alguno de nuestros contactos. Para incluir en nuestra aplicación el plugin necesario para acceder a dicha funcionalidad, bastará con añadir la siguiente línea al archivo config.xml:

<plugin name="cordova-plugin-x-socialsharing" spec="5.4.0" />

A continuación modificaremos el código fuente del archivo todo.js para añadir la función share(), que creará una cadena de texto y se la proporcionará al plugin de compartir para que se pueda enviar utilizando otra aplicación.

Bastará con crear la función share() con el código necesario para obtener en una única cadena de texto todas las tareas una detrás de otra, separadas por saltos de línea. Después sólo necesitamos llamar a la función socialsharing.share() del plugin para activar y mostrar el menú de compartir de nuestros móviles:

function share() {
    let text = "";
    getList().forEach((task, index) => {
      text += (index+1) + ". " + task.text + " - " + task.date.slice(0,10) + "\n";
    });
    window.plugins.socialsharing.share(text);
}

El archivo «config.xml» completo

<widget xmlns   = "http://www.w3.org/ns/widgets"
    xmlns:gap   = "http://phonegap.com/ns/1.0"
    id          = "com.fernandoruizrico.todo"
    versionCode = "1"
    version     = "0.0.1" >

    <name>ToDo!</name>

    <description>
        ToDo List.
    </description>

    <author href="https://fernandoruizrico.com" email="[email protected]">
        Fernando Ruiz Rico
    </author>

    <access origin="*" />
    <plugin name="cordova-plugin-whitelist" />
    <plugin name="cordova-plugin-vibration" />
    <plugin name="cordova-plugin-x-socialsharing" spec="5.4.0" />
</widget>

El archivo «todo.js» completo

function onLoad() { 
    document.addEventListener("ionModalDidDismiss", closeItems);
    document.addEventListener("ionAlertDidDismiss", closeItems);
    document.addEventListener("ionDidOpen", closeItems);
    document.addEventListener('ionItemReorder', (event) => { moveItem(event.detail); });
    document.querySelectorAll('ion-tab').forEach(function(t) { printList(t); });
}

function getTab() {
    return(document.querySelector('ion-tab:not(.tab-hidden)'));
}

function getList(tab = getTab()) {
    let list = localStorage.getItem('todo-list-'+tab.tab);
    return list ? JSON.parse(list) : [];
}

function saveList(tab, list) {
    localStorage.setItem('todo-list-'+tab.tab, JSON.stringify(list));
    printList(tab);
}

function printList(tab) {
    tab.querySelector('ion-reorder-group').innerHTML = "";

    getList(tab).forEach((item, index) => {
        tab.querySelector('ion-reorder-group').innerHTML +=
        `<ion-item-sliding>
           <ion-item onClick="addEditItem(`+index+`)">
             <ion-label text-wrap>
               <h2>`+item.text+`</h2>
               <p>`+item.date.slice(0,10)+`</p>
             </ion-label>
             <ion-icon slot="end" name="`+item.icon+`"></ion-icon>
             <ion-reorder slot="end"></ion-reorder>
          </ion-item>
          <ion-item-options side="start">
            <ion-item-option color="danger" onClick="deleteItem(`+index+`)">
                <ion-icon slot="icon-only" name="trash"></ion-icon>
            </ion-item-option>
          </ion-item-options>
        </ion-item-sliding>`;
    });    
}

function error(message) {
  const alert = document.createElement('ion-alert');
  alert.message = message;
  alert.buttons = ['OK'];

  document.querySelector('ion-app').appendChild(alert);
  alert.present();
}

function toggleReorder() {
    closeItems();
    let reorder = getTab().querySelector('ion-reorder-group');
    reorder.disabled = !reorder.disabled;
}

function closeItems() {
    getTab().querySelector('ion-list').closeSlidingItems();
}

function addEditItem(index = false) {
    closeItems();
    let list = getList();
    let item = null;

    if (index !== false) item = list[index];
    else item = { text:"", date:new Date().toISOString(), icon:"radio-button-off" };

    const modal = document.createElement('ion-modal');
    modal.component = document.createElement('div');
    modal.component.innerHTML = `
        <ion-header>
            <ion-toolbar>
                <ion-title>ToDo - `+(index !== false ? 'Edit task' : 'New task')+`</ion-title>
                <ion-buttons slot="primary">
                    <ion-button color="danger"><ion-icon slot="icon-only" name="close"></ion-icon></ion-button>
                    <ion-button color="primary"><ion-icon slot="icon-only" name="checkmark"></ion-icon></ion-button>
                </ion-buttons>       
            </ion-toolbar>
        </ion-header>
        <ion-content>
            <ion-list>
                <ion-item>
                    <ion-label position="floating">Select date</ion-label>
                    <ion-datetime display-format="D MMM YYYY" max="2050-12-31" value="`+item.date+`"></ion-datetime>            
                </ion-item>
                <ion-item>
                    <ion-label position="floating">Enter task</ion-label>
                    <ion-input value="`+item.text+`"></ion-input>
                </ion-item>
            </ion-list>
            <ion-segment value="`+item.icon+`">
                <ion-segment-button value="radio-button-off">
                    <ion-icon name="radio-button-off"></ion-icon>
                </ion-segment-button>  
                <ion-segment-button value="radio-button-on">
                    <ion-icon name="radio-button-on"></ion-icon>
                </ion-segment-button>  
                <ion-segment-button value="snow">
                    <ion-icon name="snow"></ion-icon>
                </ion-segment-button>
                <ion-segment-button value="flame">
                    <ion-icon name="flame"></ion-icon>
                </ion-segment-button>
            </ion-segment>
        </ion-content>`;

    modal.component.querySelector('[color="danger"]').addEventListener('click', () => {
        modal.dismiss();
    });

    modal.component.querySelector('[color="primary"]').addEventListener('click', () => {
        let newDate = modal.component.querySelector('ion-datetime').value;
        let newText = modal.component.querySelector('ion-input').value;
        let newIcon = modal.component.querySelector('ion-segment').value;

        if (!newText.length) {
            error('The task cannot be empty');
        }
        else {
            let newItem = { text:newText, date:newDate, icon:newIcon };
            if (index !== false) list[index] = newItem; 
            else list.unshift(newItem);
            saveList(getTab(), list);
            modal.dismiss();
        }
    });

    document.querySelector('ion-app').appendChild(modal);
    modal.present();
}

function moveItem(indexes) {
    let tab = getTab();
    let list = getList(tab);
    let item = list[indexes.from];
    list.splice(indexes.from, 1);
    list.splice(indexes.to, 0, item);
    indexes.complete();
    saveList(tab, list);
}

function deleteItem(index = false) {
    const alert = document.createElement('ion-alert');
  
    alert.header = index !== false ? 'Delete item' : 'Delete all',
    alert.message = 'Are you sure?',
    alert.buttons =  
            [{
                text: 'YES',
                handler: () => {
                    let list = getList();
                    if (index !== false) { list.splice(index, 1); }
                    else { list.length = 0; }
                    saveList(getTab(), list);
                }
            }, {
                text: 'NO',
                role: 'cancel'
            }]

    document.querySelector('ion-app').appendChild(alert);
    alert.present();
}

function share() {
    let text = "";
    getList().forEach((task, index) => {
      text += (index+1) + ". " + task.text + " - " + task.date.slice(0,10) + "\n";
    });
    window.plugins.socialsharing.share(text);
}

El resultado

Puedes hacer clic aquí para observar el aspecto final que tendrá la aplicación de la lista de tareas.

Una app muy sencilla para guardar sitios interesantes con ubicaciones y fotografías con IONIC 4+Angular

El objetivo: Guardar ubicaciones e imágenes

En este ejercicio vamos a demostrar que puede resultar muy sencillo desarrollar una aplicación que nos permita mantener un registro de lugares interesantes, guardando la ubicación precisa y una imagen identificativa de cada uno.

Será el mismo navegador el que nos proporcione la ubicación actual de forma automática, y nos permita elegir las imágenes a utilizar, de entre las que tengamos en nuestros equipos. Además, al ejecutarla como app en nuestros móviles, podremos utilizar también la cámara en el mismo momento, pudiendo hacer una foto que se quedará guardada en nuestra aplicación. De esa forma, tendremos imágenes asociadas con la latitud y la longitud de los sitios donde hayamos hecho las fotos, pudiendo acceder además al mapa correspondiente con un solo clic.

Con sólo 100 líneas de código

Veremos que al utilizar IONIC 4 + Angular, con sólo 100 líneas de código (sumando HTML y Typescript) podemos desarrollar una aplicación multiplataforma, que funcionará perfectamente en cualquier navegador, o instalada como app en nuestros dispositivos móviles. Y todo ello, con el mismo código fuente, sin cambiar ni una sola línea.

La funcionalidad de la aplicación

La aplicación dispondrá de un cuadro de texto donde podamos especificar la descripción del nuevo lugar que queramos registrar.

Si ya hemos introducido alguna descripción, se habilitará un nuevo control para poder elegir una imagen. Si estamos ejecutando la aplicación desde nuestro ordenador de escritorio, podremos elegir cualquier imagen de las que se encuentran en nuestro equipo. Si ejecutamos la aplicación desde nuestros dispositivos móviles, podremos hacer una foto en ese mismo momento.

Una vez introducida la descripción y elegida o hecha la foto, la aplicación obtendrá automáticamente la ubicación en la que nos encontremos y creará un elemento en la lista de lugares en el que aparecerá la foto y la descripción.

Al hacer clic sobre cada elemento de la lista, si estamos utilizando el navegador de nuestro ordenador de escritorio, abrirá la página web correspondiente de Google Maps para mostrarnos la ubicación precisa. Si estamos ejecutando la aplicación desde nuestros dispositivos móviles, nos permitirá elegir qué aplicación se utilizará para visualizar la ubicación.

Además, podremos reordenar los elementos de la lista activando un control por cada elemento, que se podrá ocultar o mostrar mediante un botón ubicado en la barra superior de la aplicación.

Por último, permitiremos borrar cualquier elemento de la lista utilizando un botón oculto que se mostrará al deslizar el elemento correspondiente a la derecha.

Primeros pasos

Código base de la aplicación

Para comenzar a desarrollar nuestra aplicación deberemos partir de un código base generado automáticamente por IONIC. Bastará con ejecutar el siguiente comando para generar los ficheros necesarios:

ionic start lugares blank --type=angular --no-git --no-link

Al finalizar el proceso, se habrá creado un directorio con el nombre lugares, que constituye el directorio raíz de nuestro proyecto, donde tendremos todo el código necesario para continuar con el desarrollo de nuestra aplicación. Para realizar los siguientes pasos deberemos acceder primero a dicha carpeta:

cd lugares

Y a partir de ahora ya podemos comenzar a añadir el código específico de nuestra aplicación. Puesto que toda la funcionalidad se encuentra ubicada en una única pantalla, sólo vamos a modificar dos ficheros:

  1. src/app/home/home.page.html: Código HTML.
  2. src/app/home/home.page.ts: Código TypeScript.

Probando la aplicación en el navegador

La misma herramienta de consola de IONIC se puede utilizar como servidor web, lo que nos permitirá  probar nuestra aplicación fácilmente en el navegador. Además, cada vez que realicemos cualquier cambio en el código fuente, veremos cómo se actualiza el resultado automáticamente. Bastará con ejecutar el siguiente comando desde la carpeta raíz del proyecto donde se encuentra nuestra aplicación, dejando el terminal abierto:

ionic serve

Si todo ha ido bien, se compilará la aplicación y deberíamos visualizar en el navegador algo del siguiente estilo:

(bastará con pulsar Ctrl+C en el terminal para finalizar la ejecución del comando)

Código HTML

El encabezado

Como ya viene siendo habitual, colocaremos una barra superior que contenga el título y el botón de reordenar:

<ion-header>
  ...
  <ion-title>Places!</ion-title>
  ...
  <ion-button (click)="reorder=!reorder">
  ...    
</ion-header>

En cada pulsación del botón de reordenar, activaremos o desactivaremos dicha funcionalidad cambiando simplemente el valor del atributo correspondiente.

El formulario para la descripción y la imagen

Utilizaremos un simple cuadro de texto para introducir la descripción del lugar que queramos registrar y un elemento HTML estándar de tipo fichero para elegir la imagen:

<ion-item>
  <ion-input placeholder="Enter description" [(ngModel)]="text"></ion-input>
</ion-item>
<ion-item *ngIf="text.length">
  <input type="file" accept="image/*" (change)="add($event)" />
  <ion-icon slot="end" name="camera" (click)="add()"></ion-icon>
</ion-item>

Debemos destacar de nuevo el uso de la directiva ngModel, que nos permitirá enlazar el valor del atributo text, de forma que cualquier modificación en el código HTML cambiará automáticamente el valor del atributo en el código TypeScript y viceversa.

En segundo lugar, también destacamos el uso de la directiva ngIf, que automatiza la creación del campo de tipo fichero, de forma que dicho campo no existirá hasta que se introduzca algún texto en el campo de la descripción. Además, al inicializar de nuevo la descripción desde el código TypeScript, dicho campo desaparecerá automáticamente.

Por último, al final del cuadro de elección de imagen colocaremos un icono para acceder a la cámara de nuestros dispositivos móviles, utilizando como imagen la fotografía que realicemos en ese momento. Esta funcionalidad no estará operativa cuando utilicemos la aplicación desde el navegador.

Los elementos de la lista

Cada elemento de la lista será un enlace a la página web correspondiente de Google Maps para conocer la ubicación del lugar, de forma que podamos acceder fácilmente con un simple clic en el elemento correspondiente. Para ello bastará con especificar la url mediante el atributo href del elemento <ion-item></ion-item> (más información aquí).

Además, utilizaremos el elemento <ion-thumbnail></ion-thumbnail> para mostrar una miniatura de la imagen. Por último, añadiremos dos botones, uno para reordenar (<ion-reorder></ion-reorder>) y otro para borrar (<ion-item-option></ion-item-option>):

<ion-item [href]='item.url'>
  <ion-thumbnail slot="start"><img [src]="item.img" /></ion-thumbnail>{{item.name}}
  <ion-reorder slot="end"></ion-reorder>
</ion-item>
<ion-item-options side="start">
  <ion-item-option color="danger" (click)="delete(i)">
    <ion-icon slot="icon-only" name="trash"></ion-icon>
  </ion-item-option>
</ion-item-options>

Mostrando todos los elementos

Vamos a hacer uso del potencial de Angular para mostrar la lista completa de lugares:

<ion-reorder-group [disabled]="!reorder" (ionItemReorder)="move($event.detail)">
  <ion-item-sliding *ngFor="let item of list; let i=index">
    ...
  </ion-item-sliding>
</ion-reorder-group>

En primer lugar utilizaremos la expresión [disabled]="!reorder" para activar o desactivar la opción de reordenar los elementos. Mediante los corchetes enlazamos el valor del atributo reorder de TypeScript para poder acceder a su valor directamente. El evento (ionItemReorder) nos indicará que se ha cambiado de sitio algún elemento, y por lo tanto ejecutaremos el método move(), cuya funcionalidad veremos más adelante.

Por último, sólo necesitamos realizar un bucle utilizando la directiva ngFor, que además nos proporcionará cada elemento item del array que representa la lista, así como el índice i de cada uno de ellos.

El fichero «home.page.html» completo

<ion-header>
  <ion-toolbar color="primary">
    <ion-title>Places!</ion-title>
    <ion-buttons slot="primary">
      <ion-button (click)="reorder=!reorder">
        <ion-icon slot="icon-only" name="reorder"></ion-icon>
      </ion-button>
    </ion-buttons>
  </ion-toolbar>       
</ion-header>
<ion-content>
  <ion-list lines="full">
    <ion-item color="light">
      <ion-input placeholder="Enter description" [(ngModel)]="text"></ion-input>
    </ion-item>
    <ion-item color="light" *ngIf="text.length">
      <input type="file" accept="image/*" (change)="add($event)" />
      <ion-icon slot="end" name="camera" (click)="add()"></ion-icon>
    </ion-item>    
    <ion-reorder-group [disabled]="!reorder" (ionItemReorder)="move($event.detail)">
      <ion-item-sliding *ngFor="let item of list; let i=index">
        <ion-item [href]='item.url'>
          <ion-thumbnail slot="start"><img [src]="item.img" /></ion-thumbnail>{{item.name}}
          <ion-reorder slot="end"></ion-reorder>
        </ion-item>
        <ion-item-options side="start">
          <ion-item-option color="danger" (click)="delete(i)">
            <ion-icon slot="icon-only" name="trash"></ion-icon>
          </ion-item-option>
        </ion-item-options>
      </ion-item-sliding>
    </ion-reorder-group>
  </ion-list>
</ion-content>

Código TypeScript

Los atributos

En este ejercicio sólo necesitamos cuatro campos para guardar todos los datos necesarios:

reorder: boolean;
list: any;
text: string;

El propósito de cada uno de ellos es el siguiente:

  • reorder: Su valor (falso o verdadero) indicará si se encuentra habilitada o no la funcionalidad de reordenar.
  • list: Array que contendrá todos los lugares.
  • text: Descripción del nuevo lugar que queremos registrar.

Los métodos

  • constructor: Recuperará la lista guardada para que se muestre al iniciar la aplicación.
  • add: Añadirá un nuevo lugar a la lista de lugares.
  • save: Guardará en localStorage el listado de lugares para que no se pierda al cerrar la aplicación o recargar el contenido del navegador.
  • delete: Borrará un elemento de la lista.
  • move: Moverá un elemento de la lista a otra posición.

Obteniendo la ubicación

Para conocer la ubicación desde la que vamos a registrar un nuevo lugar, utilizaremos la funcionalidad de geolocalización de HTML, de forma que consigamos que el código funcione perfectamente tanto en el navegador, como en la app una vez compilada (más información aquí):

navigator.geolocation.getCurrentPosition(pos => {
  let url = "https://maps.google.com/maps?&z=15&t=k&q="+pos.coords.latitude+" "+pos.coords.longitude;
  ...
});

Además, crear un enlace a Google Maps conociendo la ubicación resulta muy sencillo. En nuestro caso especificamos un zoom razonable (z=15), el tipo de mapa satélite (t=k), y la latitud y la longitud (q=…). Se puede obtener más información en la página oficial de Google, o también por ejemplo aquí.

Indicador de ejecución en proceso

Mientras se obtiene la ubicación y se procesa la imagen, resulta conveniente bloquear la aplicación mediante algún indicador para informar al usuario que todavía no se ha completado la acción. Para ello mostraremos un indicador en pantalla utilizando la funcionalidad que nos proporciona IONIC (más información aquí):

async add(event) {
  let loading = await this.loadingController.create();
  loading.present();
  ...
  loading.dismiss();
}

Obteniendo la imagen

Utilizando simple código HTML y JavaScript, podemos obtener la imagen seleccionada en formato base 64, de forma que la podemos guardar como si se tratara de cualquier otra cadena de texto (más información aquí, o aquí):

...
<input type="file" (change)="add($event)" />
...
<img [src]="item.img" />
...
async add(event) {
  let reader = new FileReader();
  reader.onload = (data:any) => {
    item.img = data.target.result;
    ...
  }
  reader.readAsDataURL(event.target.files[0]);
}

Además, añadiremos la funcionalidad necesaria para abrir la cámara cuando ejecutemos la aplicación en nuestros dispositivos móviles (más información en la documentación de Cordova):

...
  navigator.camera.getPicture(function success(data) {
    list.unshift({ name:this.text, img:"data:image/jpeg;base64,"+data, url:url });
    save();     
  }, function error(msg) {
  }, { targetWidth:100, navigator.camera.DestinationType.DATA_URL });
...

El fichero «home.page.ts» completo

import { Component } from '@angular/core';
import { LoadingController } from '@ionic/angular';

@Component({
  selector: 'app-home',
  templateUrl: 'home.page.html',
  styleUrls: ['home.page.scss'],
})

export class HomePage {
  reorder: boolean;
  list: any;
  text: string;

  constructor(public loadingController:LoadingController) {
    this.reorder = false;
    this.text = "";
    this.list = localStorage.getItem('places-list');
    if (this.list!=='undefined' && this.list!==null) this.list = JSON.parse(this.list);
    else this.list = [];
  }

  async add(event?) {
    let loading = await this.loadingController.create({duration:30000});
    loading.present();

    let text = this.text, list = this.list, save = this.save;
    this.text = "";

    navigator.geolocation.getCurrentPosition(pos => {
      let url = "https://maps.google.com/maps?&z=15&t=k&q="+pos.coords.latitude+" "+pos.coords.longitude;

      if (event) {
        let reader = new FileReader();
        reader.onload = (data:any) => {
          list.unshift({ name:text, img:data.target.result, url:url });
          loading.dismiss();
          save(list);
        }
        reader.readAsDataURL(event.target.files[0]);
      }
      else {
        (<any>navigator).camera.getPicture(function success(data) { 
          list.unshift({ name:text, img:"data:image/jpeg;base64,"+data, url:url });
          loading.dismiss();
          save(list);
        }, function error(msg) {
          loading.dismiss();
        }, { targetWidth:100, destinationType:(<any>navigator).camera.DestinationType.DATA_URL });
      }
    });
  }

  save(list) {
    localStorage.setItem('places-list', JSON.stringify(list));
    location.href = "index.html";
  }

  delete(item) {
    this.list.splice(item, 1);
    this.save(this.list);
  }

  move(indexes) {
    let item = this.list[indexes.from];
    this.list.splice(indexes.from, 1);
    this.list.splice(indexes.to, 0, item);
    this.save(this.list);
    indexes.complete();
  }
}

Compilando la aplicación

El fichero config.xml (plugins de geolocalización y cámara)

Para que la aplicación tenga los permisos correctos, y podamos utilizar la funcionalidad nativa para conocer la ubicación y acceder a la cámara, añadiremos los plugins correspondientes al fichero config.xml (más información aquí y aquí):

...
    <plugin name="cordova-plugin-geolocation" />
    <plugin name="cordova-plugin-camera" />
...

Debemos recordar que si compilamos la aplicación utilizando PhoneGap Build, el archivo config.xml se deberá guardar dentro de la carpeta www.

Por último, tengamos en cuenta que toda la funcionalidad de la aplicación se encontrará disponible tanto en el navegador como en la app, exceptuando la cámara, ya que es específica del dispositivo, y no estará operativa en el navegador.

El fichero index.html

Si utilizamos PhoneGap Build deberemos modificar también el fichero src/index.html para enlazar el fichero cordova.js (no tenemos que crearlo nosotros, sólo enlazarlo, ya que dicho archivo se creará automáticamente al compilar el código con PhoneGap Build):

...
<body>
  <app-root></app-root>
  <script type="text/javascript" src="cordova.js"></script>
</body>
...

El resultado

Puedes hacer clic aquí para probar la aplicación propuesta. Se puede observar que el mismo código que hemos estado desarrollando se puede subir a cualquier servidor y el resultado se puede visualizar perfectamente en el navegador como cualquier otra página web.

Lista de tareas con IONIC 4+Angular

El objetivo: IONIC + Angular

En los ejercicios que hemos desarrollado hasta ahora sólo hemos utilizado IONIC de manera independiente, sin echar mano de ningún otro framework adicional, ya que hemos interactuado con los nuevos elementos HTML utilizando código JavaScript.

Para aplicaciones sencillas, puede ser una opción completamente válida y muy recomendable, dado el bajo nivel de requerimientos. Sólo necesitamos conocimientos básicos de desarrollo web, y con una simple referencia desde el código HTML, ya tenemos a nuestro alcance todo el potencial ofrecido por IONIC 4.

Sin embargo, si pretendemos desarrollar aplicaciones más complejas, resulta muy recomendable usar la segunda posibilidad que nos ofrece IONIC 4, al permitirnos combinar unos resultados visuales muy buenos, con un estilo de programación muy estructurado, y con amplias posibilidades dentro del desarrollo web.

En este ejercicio vamos a dar un paso adelante, y observaremos el potencial del que disponemos al usar IONIC con un framework tan utilizado y conocido como Angular. Desarrollaremos la misma aplicación que en un ejercicio anterior, pero veremos que el código fuente estará mucho mejor estructurado, y nos ofrecerá muchas más posibilidades que el simple código JavaScript.

Además, al tener la suerte de poder utilizar IONIC 4, disponemos de toda la documentación por duplicado para que podamos escoger la opción que más nos interese. En las versiones anteriores, IONIC se encontraba empaquetado con Angular, con lo que sólo teníamos una alternativa. Ahora mismo tenemos a nuestro alcance las mismas facilidades, tanto si escogemos desarrollar nuestra app utilizando HTML+JavaScript o Angular. Además, sea cual sea la opción por la que nos declinemos, IONIC nos proporciona la funcionalidad y la documentación necesaria para cada uno de sus componentes en ambos casos:

Como opinión personal, me gustaría decir que una vez se aprende Angular, resulta difícil desprenderse de él, ya que se pueden desarrollar aplicaciones más complejas y mucho más estables, con un código fuente más legible y mucho más fácil de mantener y actualizar.

La funcionalidad de la aplicación

La funcionalidad a implementar será la misma que en el ejercicio anterior, aunque en esta ocasión vamos a estructurar mejor el código y dividiremos la aplicación en dos pantallas.

Vamos a enumerar de nuevo los aspectos principales de la aplicación, y observaremos que podemos obtener el mismo resultado desarrollando la aplicación con Angular.

La pantalla principal

  • Gestionar varias listas de tareas independientes.
  • Borrar de golpe todas las tareas de una lista.
  • Borrar una sola tarea de una lista determinada mediante un botón oculto que se visualizará al deslizar hacia la derecha la tarea a borrar.
  • Pedir confirmación antes de borrar cualquier tarea.
  • Permitir ordenar las tareas arrastrando cualquier elemento a una nueva posición.

La pantalla de detalles

  • Añadir tareas indicando la fecha, la descripción de la tarea, y un icono identificativo de la prioridad.
  • Visualizar y editar los detalles de cada tarea haciendo clic sobre el elemento correspondiente de la lista.

Primeros pasos

Requisitos

La mayoría de las aplicaciones de IONIC se crean y desarrollan principalmente utilizando la herramienta de la línea de comandos. A continuación enumeraremos los pasos necesarios para instalar el software necesario en nuestros ordenadores para poder desarrollar y compilar desde la consola aplicaciones de IONIC utilizando Angular.

En primer lugar, deberemos instalar Node.js (la versión LTS, recomendada para la mayoría de usuarios, tal como se indica en la web). Podremos comprobar si se ha instalado correctamente de la siguiente forma:

node --version
npm --version

A continuación deberemos instalar el cliente de IONIC, de la siguiente forma:

npm install -g ionic

La opción  -g significa que se va a instalar de manera global en nuestros equipos, y por lo tanto necesitaremos permisos de administrador. En el caso de utilizar un sistema operativo Windows es recomendable abrir un terminal en modo administrador. Para sistemas operativos Mac/Linux, el comando se deberá ejecutar con sudo.

Código base de la aplicación

Para comenzar a desarrollar nuestra aplicación deberemos partir de un código base generado automáticamente por IONIC. Bastará con ejecutar el siguiente comando para generar los ficheros necesarios:

ionic start tareas blank --type=angular --no-git --no-link

Al ejecutar el comando, IONIC nos informará del proceso con algunos avisos y  si todo va bien, al final nos mostrará un mensaje similar al siguiente:

[INFO] Next Steps:
       
       - Go to your newly created project: cd ./tareas
       - Run ionic serve within the app directory to see your app
       - Build features and components: https://ion.link/scaffolding-docs
       - Get Ionic DevApp for easy device testing: https://ion.link/devapp

Al finalizar el proceso, se habrá creado un directorio con el nombre tareas, que constituye el directorio raíz de nuestro proyecto, donde tendremos todo el código necesario para continuar con el desarrollo de nuestra aplicación. Para realizar los siguientes pasos deberemos acceder primero a dicha carpeta:

cd tareas

Y por último, puesto que la aplicación básica que acabamos de crear sólo dispone de una pantalla principal, procederemos además a añadir una página adicional, y un fichero auxiliar (servicio) donde ubicaremos la funcionalidad básica para gestionar las listas y sus elementos:

ionic generate page AddEditItem
ionic generate service list

Y a partir de ahora ya podemos comenzar a añadir el código específico de nuestra aplicación. Aunque la funcionalidad sea la misma, al utilizar Angular, el código fuente sí tendrá variaciones, que como vamos a observar a continuación, nos permitirán generar un código mejor estructurado y más legible que el de una aplicación del mismo tamaño desarrollada simplemente con JavaScript.

Si hemos ejecutado correctamente los comandos anteriores, IONIC ya nos habrá creado todos los ficheros que vamos a utilizar en este ejercicio:

  1. src/app/list.service.ts: Funciones auxiliares.
  2. src/app/home/home.page.html: Código HTML de la pantalla principal.
  3. src/app/home/home.page.ts: Código TypeScript de la pantalla principal.
  4. src/app/add-edit-item/add-edit-item.page.html: Código HTML de la pantalla con los detalles de las tareas.
  5. src/app/add-edit-item/add-edit-item.page.ts: Código TypeScript de la pantalla con los detalles de las tareas.

Probando la aplicación en el navegador

Una de las primeras ventajas con las que nos encontramos es que la misma herramienta de consola de IONIC se puede utilizar como servidor web, con lo que nos permitirá  probar nuestra aplicación en el navegador al mismo tiempo que se actualiza automáticamente después de realizar cualquier cambio en el código fuente. Bastará con ejecutar el siguiente comando desde la carpeta raíz del proyecto donde se encuentra nuestra aplicación, dejando el terminal abierto:

ionic serve

Si todo ha ido bien, se compilará la aplicación y deberíamos visualizar en el navegador algo del siguiente estilo:

(bastará con pulsar Ctrl+C en el terminal para finalizar la ejecución del comando)

Funciones auxiliares

En el fichero list.service.ts desarrollaremos el código de una clase que contenga un array de listas de tareas y las funciones básicas para acceder a las mismas y a sus elementos:

  1. getList(index): Nos devolverá la lista completa ubicada en la pestaña index (la primera pestaña vendrá indicada por el valor cero).
  2. saveList(listIndex): Guardará la lista indicada de manera permanente utilizando localStorage.
  3. getItem(listIndex, itemIndex): Devolverá una tarea en particular.
  4. setItem(listIndex, item, itemIndex?): Añadirá una tarea a la lista indicada, o si se indica el índice de una tarea concreta, la actualizará en vez de añadirla.
  5. deleteItem(listIndex, itemIndex): Borrará la tarea indicada.
  6. deleteList(listIndex): Borrará una lista entera.
  7. moveItem(listIndex, from, to): Cambiará de orden una tarea, moviéndola de la posición from a la posición to.

El fichero «list.service.ts» completo

import { Injectable } from '@angular/core';

@Injectable({
  providedIn: 'root'
})
export class ListService {

  list: any;

  constructor() { 
    this.list = [[]];
  }

  getList(index) {
    let list = localStorage.getItem('todo-list-'+index);
    if (list !== 'undefined' && list !== null) {
      this.list[index] = JSON.parse(list);
    }
    if (index>=this.list.length) this.list.push([]);
    return(this.list[index]);
  }

  saveList(listIndex) {
    localStorage.setItem('todo-list-'+listIndex, JSON.stringify(this.list[listIndex]));
  }

  getItem(listIndex, itemIndex) {
    return(this.list[listIndex][itemIndex]);
  }

  setItem(listIndex, item, itemIndex?) {
    if (itemIndex == undefined) this.list[listIndex].push(Object.assign({}, item));
    else this.list[listIndex][itemIndex] = Object.assign({}, item);
    this.saveList(listIndex);  
  }

  deleteItem(listIndex, itemIndex) {
    this.list[listIndex].splice(itemIndex,1);
    this.saveList(listIndex);
  }

  deleteList(listIndex) {
    this.list[listIndex].length = 0;
    this.saveList(listIndex);    
  }

  moveItem(listIndex, from, to) {
    let element = this.list[listIndex][from];
    this.list[listIndex].splice(from, 1);
    this.list[listIndex].splice(to, 0, element);
    this.saveList(listIndex);    
  }
}

La pantalla principal

En el fichero home.page.ts desarrollaremos el código de una clase que contendrá las variables y las funciones necesarias para manipular las listas y las tareas que se visualizan en la pantalla principal:

  1. constructor(ListService, AlertController): En el constructor inicializaremos el array de objetos con las propiedades que definen las diferentes listas (etiqueta, icono y lista de tareas). Si ya existen tareas al iniciar la aplicación, se leerán para que aparezcan en pantalla.
  2. toggleReorder(): Para activar o desactivar el botón de los elementos para reordenarlos.
  3. setTab(tabIndex): Activará la pestaña con el índice indicado.
  4. deleteItem(item?): Después de pedir confirmación, borrará todos los elementos de la lista de tareas activa, o sólo el elemento indicado, si éste se ha pasado por parámetro.
  5. moveItem(indexes): Recibe un objeto con la posición inicial de una tarea en la lista y la posición final donde se debe mover.

Para implementar la barra superior de la pantalla principal, ahora utilizaremos el siguiente código:

<ion-title>ToDo!</ion-title>
<ion-buttons slot="end">
  <ion-button (click)="deleteItem()" color="danger"><ion-icon slot="icon-only" name="trash"></ion-icon></ion-button>  
  <ion-button (click)="toggleReorder()"><ion-icon slot="icon-only" name="reorder"></ion-icon></ion-button>
  <ion-button [routerLink]="['/add-edit-item', { tab:tabIndex, item:-1 }]"><ion-icon slot="icon-only" name="add"></ion-icon></ion-button>
</ion-buttons>

Ya podemos observar algunas diferencias respecto al código JavaScript. En primer lugar, en vez de utilizar el atributo del evento onclick utilizaremos la sintaxis (click) (más detalles en la documentación de Angular). En segundo lugar, observamos una gran diferencia respecto a la opción de añadir un nuevo elemento. En este caso vamos a diseñar una nueva página (a la que accederemos con la url /add-edit-item) en vez de abrir un cuadro de diálogo modal. La sintaxis de Angular utilizada en este caso, nos permite pasar información ({ tab:tabIndex, item:-1 }) de una página a otra, de forma que sepamos a qué lista debemos añadir la nueva tarea, colocando además el índice del item a -1 para indicar que no queremos modificar ninguna tarea existente, sino añadir una nueva:

...
<ion-button [routerLink]="['/add-edit-item', { tab:tabIndex, item:-1 }]"><ion-icon slot="icon-only" name="add"></ion-icon></ion-button>
...

Para mostrar las diferentes pestañas (tabs) utilizaremos el siguiente código:

<ion-tab-bar #myTabs color="primary" [selectedTab]="tabs[0].label">
  <ion-tab-button *ngFor="let tab of tabs; let i=index" (click)="setTab(i)" [tab]="tab.label">
    <ion-label>{{tab.label}}</ion-label>
    <ion-icon [name]="tab.icon"></ion-icon>
  </ion-tab-button>
</ion-tab-bar>

Y para mostrar la lista de tareas:

<ion-list #myList lines="full">
  <ion-reorder-group [disabled]="!reorder" (ionItemReorder)="moveItem($event.detail)">
    <ion-item-sliding *ngFor="let item of tabs[tabIndex].list; let i=index">
      <ion-item [routerLink]="['/add-edit-item', { tab:tabIndex, item:i }]">
        <ion-label class="ion-text-wrap">
          <h2>{{item.task}}</h2>
          <p>{{item.date.substring(0,10)}}</p>
        </ion-label>
        <ion-icon slot="end" [name]="item.icon"></ion-icon>
        <ion-reorder slot="end"></ion-reorder>
      </ion-item>
      <ion-item-options side="start">
        <ion-item-option color="danger" (click)="deleteItem(i)">
          <ion-icon slot="icon-only" name="trash"></ion-icon>
        </ion-item-option>
      </ion-item-options>
    </ion-item-sliding>
  </ion-reorder-group>
</ion-list>

Respecto a la versión con JavaScript, la mayor diferencia la observamos en las siguientes líneas:

<ion-item-sliding *ngFor="let item of tabs[tabIndex].list; let i=index">

...

<ion-tab-button *ngFor="let tab of tabs; let i=index" (click)="setTab(i)" [tab]="tab.label">

Utilizando Angular podemos insertar bucles dentro de nuestro código HTML que nos permitirán mostrar muy fácilmente varios elementos que tengan las mismas propiedades (consultar la documentación de angular para más detalles). De esta forma tendremos el mismo código independientemente de la cantidad de listas de tareas que vayamos a utilizar. Bastará con especificar en el constructor las etiquetas y los iconos de cada pestaña utilizando un simple array:

constructor(...) {
  this.tabs = [
    {label: 'School', icon: 'school', list: []},
    {label: 'Home', icon: 'home', list: []}
  ];
  ...
}

Para gestionar el botón de reordenar, también mejora mucho la sintaxis respecto a JavaScript:

<ion-reorder-group [disabled]="!reorder" (ionItemReorder)="moveItem($event.detail)">
...
</ion-reorder-group>

En este caso, la propiedad disabled se encierra entre corchetes para unirla a la variable reorder, y el evento ionItemReorder se encierra entre paréntesis.

El fichero «home.page.html» completo

<ion-header>
  <ion-toolbar color="primary">
    <ion-title>ToDo!</ion-title>
    <ion-buttons slot="end">
      <ion-button (click)="deleteItem()" color="danger"><ion-icon slot="icon-only" name="trash"></ion-icon></ion-button>  
      <ion-button (click)="toggleReorder()"><ion-icon slot="icon-only" name="reorder"></ion-icon></ion-button>
      <ion-button [routerLink]="['/add-edit-item', { tab:tabIndex, item:-1 }]"><ion-icon slot="icon-only" name="add"></ion-icon></ion-button>
    </ion-buttons>
  </ion-toolbar>       
</ion-header>

<ion-content>
  <ion-list #myList lines="full">
    <ion-reorder-group [disabled]="!reorder" (ionItemReorder)="moveItem($event.detail)">
      <ion-item-sliding *ngFor="let item of tabs[tabIndex].list; let i=index" >
        <ion-item [routerLink]="['/add-edit-item', { tab:tabIndex, item:i }]"  >
          <ion-label class="ion-text-wrap">
            <h2>{{item.task}}</h2>
            <p>{{item.date.substring(0,10)}}</p>
          </ion-label>
          <ion-icon slot="end" [name]="item.icon"></ion-icon>
          <ion-reorder slot="end"></ion-reorder>
        </ion-item>
        <ion-item-options side="start">
          <ion-item-option color="danger" (click)="deleteItem(i)">
            <ion-icon slot="icon-only" name="trash"></ion-icon>
          </ion-item-option>
        </ion-item-options>
      </ion-item-sliding>
    </ion-reorder-group>
  </ion-list>
</ion-content>

<ion-footer>
  <ion-tab-bar #myTabs color="primary" [selectedTab]="tabs[0].label">
    <ion-tab-button *ngFor="let tab of tabs; let i=index" (click)="setTab(i)" [tab]="tab.label">
      <ion-label>{{tab.label}}</ion-label>
      <ion-icon [name]="tab.icon"></ion-icon>
    </ion-tab-button>
  </ion-tab-bar>
</ion-footer>

El fichero «home.page.ts» completo

import { Component, ViewChild } from '@angular/core';
import { IonTabBar, IonList, AlertController } from '@ionic/angular';
import { ListService } from '../list.service';

@Component({
  selector: 'app-home',
  templateUrl: 'home.page.html',
  styleUrls: ['home.page.scss'],
})

export class HomePage {
  @ViewChild('myTabs', {static: false}) tabRef: IonTabBar;
  @ViewChild('myList', {static: false}) listRef: IonList;
  tabs: any;
  tabIndex: number;
  reorder: boolean;

  constructor(private listService: ListService,
              private alertController: AlertController){
    this.tabs = [
      {label: 'School', icon: 'school', list: []},
      {label: 'Home', icon: 'home', list: []}
    ];
    this.tabs.forEach((tab, index) => {
      tab.list = this.listService.getList(index);
    });
    this.tabIndex = 0;
    this.reorder = false;
  }

  toggleReorder() {
    this.reorder = !this.reorder;
    this.listRef.closeSlidingItems();
  }

  setTab(tabIndex) {
    this.tabIndex = tabIndex;
    this.tabRef.selectedTab = this.tabs[this.tabIndex].label;
  } 

  async deleteItem(item?) {
    const alert = await this.alertController.create({
      header: item === undefined ? 'Delete all' : 'Delete item',
      message: 'Are you sure?',
      buttons: [
        {
          text: 'OK',
          handler: () => {
            this.listRef.closeSlidingItems();
            if (item === undefined) {
              this.listService.deleteList(this.tabIndex);
            }
            else {
              this.listService.deleteItem(this.tabIndex, item);              
            }
          }
        },       
        {
          text: 'CANCEL',
          role: 'cancel'
        }
      ]
    });

    await alert.present();
  }

  moveItem(indexes) {
    this.listService.moveItem(this.tabIndex, indexes.from, indexes.to);
    indexes.complete();
  }
}

El formulario para añadir o editar tareas

En el fichero add-edit-item-page.ts desarrollaremos el código de una clase que contendrá las variables y las funciones necesarias para editar o crear una tarea:

  1. constructor(Router, ActivatedRoute, AlertController, ListService): En el constructor inicializaremos por un lado un array con los nombres de los iconos para indicar las prioridades de las tareas, y a continuación recogeremos los parámetros proporcionados desde la pantalla principal (índices de la lista y elemento a modificar, si es el caso).
  2. error(message): Para mostrar mensajes de error si por ejemplo no se ha especificado ningún texto en la descripción de la tarea y pulsamos el botón de confirmar.
  3. save(): Comprobará si desde la pantalla principal se ha elegido una tarea a modificar o si por el contrario se ha pulsado el botón de nueva tarea, y llamará a la función del servicio, volviendo a la pantalla principal a continuación.

La barra superior de esta pantalla sólo contendrá un título y dos botones, uno para cancelar y otro para confirmar. Ambos nos llevarán de nuevo a la pantalla principal, pero si pulsamos el botón de confirmar, previamente se grabarán los datos introducidos:

<ion-title>ToDo</ion-title>
<ion-buttons slot="primary">
    <ion-button color="danger" [routerLink]="['/home']"><ion-icon slot="icon-only" name="close"></ion-icon></ion-button>
    <ion-button color="primary" (click)="save()"><ion-icon slot="icon-only" name="checkmark"></ion-icon></ion-button>
</ion-buttons>       
</ion-toolbar>

El formulario para introducir los datos de una nueva tarea o modificar los de una existente es muy sencillo:

<ion-list>
  <ion-item>
    <ion-label position="floating">Select date</ion-label>
    <ion-datetime display-format="D MMM YYYY" max="2050-12-31" [(ngModel)]="item.date"></ion-datetime>      
  </ion-item>
  <ion-item>
    <ion-label position="floating">Enter task</ion-label>
    <ion-input [(ngModel)]="item.task"></ion-input>
  </ion-item>
</ion-list>
<ion-segment [(ngModel)]="item.icon">
  <ion-segment-button *ngFor="let button of buttons" [value]="button">
    <ion-icon [name]="button"></ion-icon>
  </ion-segment-button>
</ion-segment>

La novedad a destacar es el uso de [(ngModel)], una de las indudables ventajas que nos ofrece Angular, ya que nos permite enlazar un atributo de la clase con un campo del formulario, de forma que las modificaciones que se produzcan queden reflejadas en los dos sentidos: el valor que tenga dicho atributo en el código TypeScript se mostrará en el código HTML, y cualquier modificación que hagamos en el formulario, también cambiará el valor del atributo de la clase. Se puede consultar la documentación oficial para más detalles.

Por otro lado, también estamos implementando un bucle para mostrar los posibles iconos indicativos de la prioridad de la tarea, lo que sin duda nos ahorra código HTML:

<ion-segment-button *ngFor="let button of buttons" [value]="button">
    <ion-icon [name]="button"></ion-icon>
</ion-segment-button>

Al igual que en el constructor de la página principal, bastará con especificar mediante un array todos los botones que deseemos visualizar:

constructor(...) { 
  this.buttons = ["radio-button-off", "radio-button-on", "snow", "flame"];
  ...
}

El fichero «add-edit-item.page.html» completo

<ion-header>
  <ion-toolbar>
    <ion-title>ToDo</ion-title>
    <ion-buttons slot="primary">
        <ion-button color="danger" [routerLink]="['/home']"><ion-icon slot="icon-only" name="close"></ion-icon></ion-button>
        <ion-button color="primary" (click)="save()"><ion-icon slot="icon-only" name="checkmark"></ion-icon></ion-button>
    </ion-buttons>       
  </ion-toolbar>
</ion-header>
<ion-content>
  <ion-list>
    <ion-item>
      <ion-label position="floating">Select date</ion-label>
      <ion-datetime display-format="D MMM YYYY" max="2050-12-31" [(ngModel)]="item.date"></ion-datetime>      
    </ion-item>
    <ion-item>
      <ion-label position="floating">Enter task</ion-label>
      <ion-input [(ngModel)]="item.task"></ion-input>
    </ion-item>
  </ion-list>
  <ion-segment [(ngModel)]="item.icon">
    <ion-segment-button *ngFor="let button of buttons" [value]="button">
      <ion-icon [name]="button"></ion-icon>
    </ion-segment-button>
  </ion-segment>
</ion-content>

El fichero «add-edit-item.page.ts» completo

import { Component } from '@angular/core';
import { Router, ActivatedRoute } from '@angular/router';
import { AlertController } from '@ionic/angular';
import { ListService } from '../list.service';

@Component({
  selector: 'app-add-edit-item',
  templateUrl: './add-edit-item.page.html',
  styleUrls: ['./add-edit-item.page.scss'],
})

export class AddEditItemPage {
  item: any;
  tabIndex: number;
  itemIndex: number;
  buttons: Array<string>;

  constructor(private router: Router,
              private route: ActivatedRoute,
              public alertController: AlertController,
              private ListService: ListService) { 
    this.buttons = ["radio-button-off", "radio-button-on", "snow", "flame"];

    this.tabIndex = +this.route.snapshot.paramMap.get('tab');
    this.itemIndex = +this.route.snapshot.paramMap.get('item'); 
    if (this.itemIndex >= 0) {
      this.item = Object.assign({}, this.ListService.getItem(this.tabIndex, this.itemIndex));
      this.item.date = new Date(this.item.date).toISOString();
    }
    else {
      this.item = { date: new Date().toISOString(), task: '', icon: 'radio-button-off'};
    } 
  }

  async error(message) {
    const alert = await this.alertController.create({
      message: message,
      buttons: ['OK']
    });

    await alert.present();
  }

  save() {
    if (!this.item.task.length) {
      this.error('The task cannot be empty');
    }
    else {
      if (this.itemIndex >= 0) {
        this.ListService.setItem(this.tabIndex, this.item, this.itemIndex);
      }
      else {
        this.ListService.setItem(this.tabIndex, this.item);      
      }
      this.router.navigate(['/home']);
    }
  }
}

El resultado

El procedimiento utilizado en este ejemplo supera obviamente al de la versión basada sólo en JavaScript, ya que ahora no sólo hemos mejorado a nivel de código, sino que hemos agilizado el proceso de desarrollo y actualización, obteniendo además una app que no requiere Internet, ya que ahora IONIC no se encuentra enlazado directamente como librería, sino empaquetado dentro de la propia aplicación.

Puedes hacer clic aquí para observar que también podemos acceder al código generado desde el navegador, como cualquier otra página web.

Una app muy sencilla para guardar sitios interesantes con ubicaciones y fotografías con IONIC 4+JavaScript

El objetivo: Guardar ubicaciones e imágenes

En este ejercicio vamos a demostrar que puede resultar muy sencillo desarrollar una aplicación que nos permita mantener un registro de lugares interesantes, guardando la ubicación precisa y una imagen identificativa de cada uno.

Será el mismo navegador el que nos proporcione la ubicación actual de forma automática, y nos permita elegir las imágenes a utilizar, de entre las que tengamos en nuestros equipos. Además, al ejecutar la app en nuestros móviles, podremos utilizar también la cámara en el mismo momento, pudiendo hacer una foto que se quedará guardada en nuestra aplicación. De esa forma, tendremos imágenes asociadas con la latitud y la longitud de los sitios donde hayamos hecho las fotos, pudiendo acceder además al mapa correspondiente con un solo clic.

Con menos de 100 líneas de código

Veremos que al utilizar IONIC 4, con menos de 100 líneas de código JavaScript podemos desarrollar una aplicación multiplataforma, que funcionará perfectamente en cualquier navegador, o instalada como app en nuestros dispositivos móviles. Y todo ello, con el mismo código fuente, sin cambiar ni una sola línea.

La funcionalidad de la aplicación

La aplicación dispondrá de un cuadro de texto donde podamos especificar la descripción del nuevo lugar que queramos registrar.

Además de poder introducir alguna descripción, se habilitará un nuevo control para poder elegir una imagen. Si estamos ejecutando la aplicación desde nuestro ordenador de escritorio, podremos elegir cualquier imagen de las que se encuentran en nuestro equipo. Si ejecutamos la aplicación desde nuestros dispositivos móviles, podremos hacer una foto en ese mismo momento.

Una vez introducida la descripción y elegida o hecha la foto, la aplicación obtendrá automáticamente la ubicación en la que nos encontremos y creará un elemento en la lista de lugares en el que aparecerá la foto y la descripción.

Al hacer clic sobre cada elemento de la lista, si estamos utilizando el navegador de nuestro ordenador de escritorio, abrirá la página web correspondiente de Google Maps para mostrarnos la ubicación precisa. Si estamos ejecutando la aplicación desde nuestros dispositivos móviles, nos permitirá elegir qué aplicación se utilizará para visualizar la ubicación.

Además, podremos reordenar los elementos de la lista activando un control por cada elemento, que se podrá ocultar o mostrar mediante un botón ubicado en la barra superior de la aplicación.

Por último, permitiremos borrar cualquier elemento de la lista utilizando un botón oculto que se mostrará al deslizar el elemento correspondiente a la derecha.

Código HTML

El encabezado

Como ya viene siendo habitual, colocaremos una barra superior que contenga el título y el botón de reordenar:

<ion-header>
  ...
  <ion-title>Places!</ion-title>
  ...  
  <ion-button onclick="toggleReorder()">
  ...
</ion-header>

En cada pulsación del botón de reordenar, activaremos o desactivaremos dicha funcionalidad cambiando simplemente el valor del atributo correspondiente.

El formulario para la descripción y la imagen

Utilizaremos un simple cuadro de texto para introducir la descripción del lugar que queramos registrar y un elemento HTML estándar de tipo fichero para elegir la imagen:

<ion-item>
  <ion-input placeholder="Enter description"></ion-input>
</ion-item>
<ion-item id="file">
  <input type="file" accept="image/*" onchange="addItem(event)" />
  <ion-icon slot="end" name="camera" onclick="addItem()"></ion-icon>
</ion-item>  

Destacamos el uso del elemento <input type="file">, que automatiza la creación del campo de tipo fichero, y nos permitirá elegir fácilmente una imagen para asociarla al lugar que queramos dejar registrado.

Por último, al final del cuadro de elección de imagen colocaremos un icono para acceder a la cámara de nuestros dispositivos móviles, utilizando como imagen la fotografía que realicemos en ese momento. Esta funcionalidad no estará operativa cuando utilicemos la aplicación desde el navegador.

Los elementos de la lista

Cada elemento de la lista será un enlace a la página web correspondiente de Google Maps para conocer la ubicación del lugar, de forma que podamos acceder fácilmente con un simple clic en el elemento correspondiente. Para ello bastará con especificar la url mediante el atributo href del elemento <ion-item></ion-item> (más información aquí).

Además, utilizaremos el elemento <ion-thumbnail></ion-thumbnail> para mostrar una miniatura de la imagen. Por último, añadiremos dos botones, uno para reordenar (<ion-reorder></ion-reorder>) y otro para borrar (<ion-item-option></ion-item-option>):

<ion-item-sliding>
  <ion-item href="...">
    <ion-thumbnail slot="start"><img src="..." /></ion-thumbnail>
    ...
    <ion-reorder slot="end"></ion-reorder>
  </ion-item>
  <ion-item-options side="start">
    <ion-item-option color="danger" onclick="deleteItem(...)">
      <ion-icon slot="icon-only" name="trash"></ion-icon>
    </ion-item-option>
  </ion-item-options>
</ion-item-sliding>

Mostrando todos los elementos

Bastará con hacer un simple bucle para mostrar la lista completa de lugares:

getList().forEach((item, index) => {
  document.querySelector('ion-reorder-group').innerHTML += 
    `<ion-item-sliding>
        <ion-item href='` + item.url + `'>
          <ion-thumbnail><img src="` + item.img + `" /></ion-thumbnail>` + 
          item.name + `<ion-reorder></ion-reorder>
        </ion-item>
        <ion-item-options>
          <ion-item-option onclick="deleteItem(` + index + `)">
            <ion-icon name="trash"></ion-icon>
          </ion-item-option>
        </ion-item-options>
     </ion-item-sliding>`;
});

Sólo necesitamos recorrer la lista utilizando por ejemplo un bucle forEach, que además nos proporcionará cada elemento item del array, así como el índice index de cada uno de ellos.

El fichero «index.html» completo

<!doctype html>
<html lang="en">
<head>
  <meta charset="utf-8">
  <meta name="viewport" content="viewport-fit=cover, width=device-width, initial-scale=1.0, minimum-scale=1.0, maximum-scale=1.0, user-scalable=no">
  <script type="module" src="https://cdn.jsdelivr.net/npm/@ionic/[email protected]/dist/ionic/ionic.esm.js"></script>
  <script nomodule src="https://cdn.jsdelivr.net/npm/@ionic/[email protected]/dist/ionic/ionic.js"></script>
  <link rel="stylesheet" href="https://cdn.jsdelivr.net/npm/@ionic/[email protected]/css/ionic.bundle.css"/>
  <script src="cordova.js"></script>
  <script src="places.js"></script>
  <title>Places!</title>
</head>
<body onload="onLoad()">
  <ion-app>
    <ion-header>
      <ion-toolbar color="primary">
        <ion-title>Places!</ion-title>
        <ion-buttons slot="primary">
          <ion-button onclick="toggleReorder()">
            <ion-icon slot="icon-only" name="reorder"></ion-icon>
          </ion-button>
        </ion-buttons>
      </ion-toolbar>       
    </ion-header>
    <ion-content>
      <ion-list lines="full">
        <ion-item color="light">
          <ion-input placeholder="Enter description"></ion-input>
        </ion-item>
        <ion-item color="light" id="file">
          <input type="file" accept="image/*" onchange="addItem(event)" />
          <ion-icon slot="end" name="camera" onclick="addItem()"></ion-icon>
        </ion-item>    
        <ion-reorder-group disabled="false">
        </ion-reorder-group>
      </ion-list>
    </ion-content>
  </ion-app>
</body>
</html>

Código JavaScript

Accediendo a los elementos principales

En este ejercicio sólo necesitamos gestionar una lista de lugares, con lo que desarrollaremos funciones para leer, actualizar e imprimir dicha lista, utilizando para ello localStorage, como en ejercicios anteriores:

function getList() {
    let list = localStorage.getItem('places-list');
    return list ? JSON.parse(list) : [];
}

function saveList(list) {
    localStorage.setItem('places-list', JSON.stringify(list));
    printItems();
}

function printItems() {
    document.querySelector('ion-reorder-group').innerHTML = "";

    getList().forEach((item, index) => {
        document.querySelector('ion-reorder-group').innerHTML += ... ;
    });
}

Al iniciar la aplicación

Al iniciar la aplicación capturaremos el evento de IONIC que nos indicará cuándo debemos reordenar los elementos de la lista, y además, imprimiremos los lugares que tengamos guardados de una ejecución anterior:

function onLoad() {
    document.addEventListener('ionItemReorder', (event) => { moveItem(event.detail); });
    printItems();
}

Obteniendo la ubicación

Para conocer la ubicación desde la que vamos a registrar un nuevo lugar, utilizaremos la funcionalidad de geolocalización de HTML, de forma que consigamos que el código funcione perfectamente tanto en el navegador, como en la app una vez compilada (más información aquí):

navigator.geolocation.getCurrentPosition(pos => {
  let url = "https://maps.google.com/maps?&z=15&t=k&q="+pos.coords.latitude+" "+pos.coords.longitude;
  ...
});

Además, crear un enlace a Google Maps conociendo la ubicación resulta muy sencillo. En nuestro caso especificamos un zoom razonable (z=15), el tipo de mapa satélite (t=k), y la latitud y la longitud (q=…). Se puede obtener más información en la página oficial de Google, o también por ejemplo aquí.

Indicador de ejecución en proceso

Mientras se obtiene la ubicación y se procesa la imagen, resulta conveniente bloquear la aplicación mediante algún indicador para informar al usuario que todavía no se ha completado la acción. Para ello mostraremos un indicador en pantalla utilizando la funcionalidad que nos proporciona IONIC (más información aquí):

function addItem(event) {
  const loading = document.createElement('ion-loading');
  ...
  loading.present();
  ...
  loading.dismiss();
}

Obteniendo la imagen

Utilizando simple código HTML y JavaScript, podemos obtener la imagen seleccionada en formato base 64, de forma que la podemos guardar como si se tratara de cualquier otra cadena de texto (más información aquí, o aquí):

<input type="file" accept="image/*" onchange="addItem(event)" />

...

let reader = new FileReader();
reader.onload = (data) => {
    saveItem({ name:text, img:data.target.result, url:url });
    event.target.value = '';
}
reader.readAsDataURL(event.target.files[0]);

Además, añadiremos la funcionalidad necesaria para abrir la cámara cuando ejecutemos la aplicación en nuestros dispositivos móviles (más información en la documentación de Cordova):

...
navigator.camera.getPicture(function success(data) {
  saveItem({ name:text, img:"data:image/jpeg;base64,"+data, url:url });       
}, function error(msg) {
  alert(msg);
}, { targetWidth:100, destinationType:Camera.DestinationType.DATA_URL });
...

El fichero «places.js» completo

function onLoad() {
    document.addEventListener('ionItemReorder', (event) => { moveItem(event.detail); });
    printItems();
}

function getList() {
    let list = localStorage.getItem('places-list');
    return list ? JSON.parse(list) : [];
}

function saveList(list) {
    localStorage.setItem('places-list', JSON.stringify(list));
    printItems();
}

function printItems() {
    document.querySelector('ion-reorder-group').innerHTML = "";

    getList().forEach((item, index) => {
        document.querySelector('ion-reorder-group').innerHTML +=
        `<ion-item-sliding>
            <ion-item href='`+item.url+`'>
                <ion-thumbnail slot="start"><img src="`+item.img+`" />
                </ion-thumbnail>`+item.name+`<ion-reorder slot="end"></ion-reorder>
            </ion-item>
            <ion-item-options side="start">
                <ion-item-option color="danger" onclick="deleteItem(`+index+`)">
                    <ion-icon slot="icon-only" name="trash"></ion-icon>
                </ion-item-option>
            </ion-item-options>
        </ion-item-sliding>`;
    });
}

function addItem(event = false) {
    const loading = document.createElement('ion-loading');
    loading.duration = 15000;
    document.querySelector('ion-app').appendChild(loading);
    loading.present();

    navigator.geolocation.getCurrentPosition(pos => {
        let text = document.querySelector('ion-input').value;
        let url = "https://maps.google.com/maps?&z=15&t=k&q="+pos.coords.latitude+" "+pos.coords.longitude;

        if (event) {
            let reader = new FileReader();
            reader.onload = (data) => {
                saveItem({ name:text, img:data.target.result, url:url });
                event.target.value = '';
                loading.dismiss();
            }
            reader.readAsDataURL(event.target.files[0]);
        }
        else {
            loading.dismiss();
            navigator.camera.getPicture(function success(data) {
                saveItem({ name:text, img:"data:image/jpeg;base64,"+data, url:url });       
            }, function error(msg) {
                alert(msg);
            }, { targetWidth:100, destinationType:Camera.DestinationType.DATA_URL });
        }
    });
}

function saveItem(item) {
    let list = getList();
    list.unshift(item);
    saveList(list);    
}

function deleteItem(item) { 
    document.querySelector('ion-list').closeSlidingItems();
    let list = getList();
    list.splice(item, 1);
    saveList(list);
}

function moveItem(indexes) {
    let list = getList();
    let item = list[indexes.from];
    list.splice(indexes.from, 1);
    list.splice(indexes.to, 0, item);
    indexes.complete();
    saveList(list);
}

function toggleReorder() {
    let reorder = document.querySelector('ion-reorder-group').disabled;
    document.querySelector('ion-reorder-group').disabled = !reorder;
}

Icono y pantalla de bienvenida

Si queremos cambiar el icono o la pantalla de bienvenida, bastará con actualizar los archivos icon.png y splash.png respectivamente, colocándolos en el mismo archivo zip que el código fuente de nuestro proyecto.

Compilando la aplicación

Plugins de geolocalización y cámara

Para que la aplicación tenga los permisos correctos, y podamos utilizar la funcionalidad nativa para conocer la ubicación, añadiremos el plugin correspondiente (más información aquí):

<plugin name="cordova-plugin-geolocation" />

Además, para utilizar la cámara también echaremos mano de otro plugin, ya que sólo podemos acceder a dicha funcionalidad en nuestros dispositivos móviles (más información aquí):

<plugin name="cordova-plugin-camera" />

Debemos recordar que toda la funcionalidad de la aplicación se encontrará disponible tanto en el navegador como en la app, exceptuando la cámara, ya que es específica del dispositivo, y no estará operativa en el navegador.

El archivo «config.xml» completo

<?xml version="1.0" encoding="UTF-8" ?>
<widget xmlns   = "http://www.w3.org/ns/widgets"
    xmlns:gap   = "http://phonegap.com/ns/1.0"
    id          = "com.fernandoruizrico.places"
    versionCode = "1"
    version     = "0.0.1" >

  <name>Places!</name>

  <description>
      A simple application to save important places.
  </description>

  <author href="https://fernandoruizrico.com" email="[email protected]">
      Fernando Ruiz Rico
  </author>

  <access origin="*" />
  <allow-navigation href="*" />
  <allow-intent href="*" />

  <plugin name="cordova-plugin-whitelist" />
  <plugin name="cordova-plugin-splashscreen" />
  <plugin name="cordova-plugin-vibration" />
  <plugin name="cordova-plugin-geolocation" />
  <plugin name="cordova-plugin-camera" />

  <icon src="icon.png" />
  <splash src="splash.png" />

  <preference name="phonegap-version" value="cli-9.0.0" />

</widget>

Generando el archivo APK

Para instalar la aplicación en nuestros dispositivos móviles, tenemos que compilar el código fuente, con lo que deberemos incluir los siguientes archivos en el fichero zip que subiremos a la página web de PhoneGap:

  • index.html
  • places.js
  • config.xml
  • icon.png
  • splash.png

El resultado

Puedes hacer clic aquí para probar la aplicación propuesta. Se puede observar que el mismo código de la app generada también puede funcionar perfectamente en el navegador como una página web.

Menú lateral con opción de compartir con IONIC 4+JavaScript

El objetivo

En esta ocasión añadiremos al ejercicio anterior la opción de compartir, de forma que desde nuestra aplicación podamos enviar la lista de tareas mediante un mensaje de WhatsApp, un correo electrónico, un SMS, etc.

Además, añadiremos el código necesario para disponer de un menú lateral donde podremos añadir más opciones, y donde sugerimos además colocar la opción de borrar la lista de tareas, para que no esté tan accesible desde la pantalla principal:

El menú lateral

Primero añadiremos en la barra superior de la pantalla principal de nuestra aplicación un botón para desplegar el menú lateral:

<ion-header>
  ...
  <ion-menu-button></ion-menu-button>
  ...    
</ion-header>

Como podemos leer en la documentación de IONIC, el elemento <ion-menu-button></ion-menu-button> se encarga de todo, ya que crea automáticamente el icono del botón y añade la funcionalidad necesaria para desplegar el menú en la página actual.

Sólo nos falta añadir el código necesario con el menú lateral, que tendrá su propio encabezado y una lista con las opciones que deseemos. En nuestro caso pondremos la opción de reordenar, de compartir y de borrado de todos los elementos de la lista seleccionada. Además, añadiremos también un botón para volver a ocultar el menú:

<ion-menu content-id="content">
  <ion-header>
    ...
    <ion-menu-button></ion-menu-button>
    ...
  </ion-header>
  <ion-content>
    <ion-menu-toggle>
      <ion-list>
        <ion-item-divider><ion-label>Reorder and Share</ion-label></ion-item-divider>   
        <ion-item onclick="toggleReorder()">
          <ion-label>Enable/Disable reorder</ion-label>
          <ion-icon slot="end" name="reorder"></ion-icon>
        </ion-item>
        <ion-item onclick="share()">
          <ion-label>Share</ion-label>
          <ion-icon slot="end" name="share"></ion-icon>
        </ion-item>
        ...
      </ion-list>
    </ion-menu-toggle>
  </ion-content>
</ion-menu>

<div class="ion-page" id="content">
  <ion-header>
    ...
      <ion-menu-button></ion-menu-button>
    ...
  </ion-header>
  <ion-content>
    ...
  </ion-content>
</div>

Observaremos que el elemento <ion-menu></ion-menu> por defecto creará un menú que aparece desde la izquierda de la pantalla actual, tal como se especifica en la documentación de IONIC. Se podrá ocultar pulsando de nuevo el botón de menú, o con un gesto swipe hacia la izquierda, o incluso pulsando fuera del menú. Además, utilizaremos el elemento <ion-menu-toggle></ion-menu-toggle> para conseguir que al pulsar en cualquier opción, el menú también se cierre automáticamente.

El fichero «index.html» completo

<!DOCTYPE html>
<html>

<head>
  <meta charset="UTF-8">
  <title>ToDo!</title>
  <meta name="viewport" content="width=device-width, initial-scale=1.0, minimum-scale=1.0, maximum-scale=1.0, user-scalable=no">

  <script type="module" src="https://cdn.jsdelivr.net/npm/@ionic/[email protected]/dist/ionic/ionic.esm.js"></script>
  <script nomodule src="https://cdn.jsdelivr.net/npm/@ionic/[email protected]/dist/ionic/ionic.js"></script>
  <link rel="stylesheet" href="https://cdn.jsdelivr.net/npm/@ionic/[email protected]/css/ionic.bundle.css"/>

  <script src="cordova.js"></script>
  <script src="todo.js"></script>
</head>

<body onload="onLoad()">
  <ion-app>
    <ion-menu content-id="content">
      <ion-header>
        <ion-toolbar mode="ios" color="danger">
          <ion-buttons slot="start">
            <ion-menu-button></ion-menu-button>
          </ion-buttons>  
          <ion-title>Menu</ion-title>
        </ion-toolbar>
      </ion-header>
      <ion-content>
        <ion-menu-toggle>
          <ion-list lines="full">
            <ion-item-divider>
              <ion-label>Reorder and Share</ion-label>
            </ion-item-divider>
            <ion-item onclick="toggleReorder()">
              <ion-label>Enable/Disable reorder</ion-label>
              <ion-icon slot="end" name="reorder"></ion-icon>
            </ion-item>
            <ion-item onclick="share()">
              <ion-label>Share</ion-label>
              <ion-icon slot="end" name="share"></ion-icon>
            </ion-item>
            <ion-item-divider>
              <ion-label>Delete</ion-label>
            </ion-item-divider>          
            <ion-item onclick="deleteItem()">
              <ion-label>Delete all</ion-label>
              <ion-icon slot="end" name="trash" color="danger"></ion-icon>
            </ion-item>
          </ion-list>
        </ion-menu-toggle>
      </ion-content>
    </ion-menu>

    <div class="ion-page" id="content">
      <ion-header>
        <ion-toolbar mode="ios" color="primary">
          <ion-buttons slot="start">
            <ion-menu-button></ion-menu-button>
          </ion-buttons>          
          <ion-title>ToDo!</ion-title>
          <ion-buttons slot="end">
            <ion-button onclick="addEditItem()">
              <ion-icon name="add" slot="icon-only"></ion-icon>
            </ion-button>
          </ion-buttons>            
        </ion-toolbar>
      </ion-header>
      <ion-content>
        <ion-tabs>
          <ion-tab tab="school">
            <ion-list lines="full">
              <ion-reorder-group></ion-reorder-group>
            </ion-list>
          </ion-tab>
          <ion-tab tab="home">
            <ion-list lines="full">
              <ion-reorder-group></ion-reorder-group>
            </ion-list>
          </ion-tab>
          <ion-tab-bar slot="bottom" color="primary">
            <ion-tab-button tab="school">
              <ion-icon name="school"></ion-icon>
            </ion-tab-button>
            <ion-tab-button tab="home">
              <ion-icon name="home"></ion-icon>
            </ion-tab-button>
          </ion-tab-bar>
        </ion-tabs>
      </ion-content>
    </div>
  </ion-app>
</body>
</html>

La opción de compartir

Utilizaremos la funcionalidad nativa de nuestros dispositivos móviles para poder enviar la lista de tareas a alguno de nuestros contactos. Para incluir en nuestra aplicación el plugin necesario para acceder a dicha funcionalidad, bastará con añadir la siguiente línea al archivo config.xml:

<plugin name="cordova-plugin-x-socialsharing" spec="5.4.0" />

A continuación modificaremos el código fuente del archivo todo.js para añadir la función share(), que creará una cadena de texto y se la proporcionará al plugin de compartir para que se pueda enviar utilizando otra aplicación.

Bastará con crear la función share() con el código necesario para obtener en una única cadena de texto todas las tareas una detrás de otra, separadas por saltos de línea. Después sólo necesitamos llamar a la función socialsharing.share() del plugin para activar y mostrar el menú de compartir de nuestros móviles:

function share() {
    let text = "";
    getList().forEach((task, index) => {
      text += (index+1) + ". " + task.text + " - " + task.date.slice(0,10) + "\n";
    });
    window.plugins.socialsharing.share(text);
}

El archivo «config.xml» completo

<widget xmlns   = "http://www.w3.org/ns/widgets"
    xmlns:gap   = "http://phonegap.com/ns/1.0"
    id          = "com.fernandoruizrico.todo"
    versionCode = "1"
    version     = "0.0.1" >

    <name>ToDo!</name>

    <description>
        ToDo List.
    </description>

    <author href="https://fernandoruizrico.com" email="[email protected]">
        Fernando Ruiz Rico
    </author>

    <access origin="*" />
    <plugin name="cordova-plugin-whitelist" />
    <plugin name="cordova-plugin-vibration" />
    <plugin name="cordova-plugin-x-socialsharing" spec="5.4.0" />
</widget>

El archivo «todo.js» completo

function onLoad() { 
    document.addEventListener("ionModalDidDismiss", closeItems);
    document.addEventListener("ionAlertDidDismiss", closeItems);
    document.addEventListener("ionDidOpen", closeItems);
    document.addEventListener('ionItemReorder', (event) => { moveItem(event.detail); });
    document.querySelectorAll('ion-tab').forEach(function(t) { printList(t); });
}

function getTab() {
    return(document.querySelector('ion-tab:not(.tab-hidden)'));
}

function getList(tab = getTab()) {
    let list = localStorage.getItem('todo-list-'+tab.tab);
    return list ? JSON.parse(list) : [];
}

function saveList(tab, list) {
    localStorage.setItem('todo-list-'+tab.tab, JSON.stringify(list));
    printList(tab);
}

function printList(tab) {
    tab.querySelector('ion-reorder-group').innerHTML = "";

    getList(tab).forEach((item, index) => {
        tab.querySelector('ion-reorder-group').innerHTML +=
        `<ion-item-sliding>
           <ion-item onClick="addEditItem(`+index+`)">
             <ion-label text-wrap>
               <h2>`+item.text+`</h2>
               <p>`+item.date.slice(0,10)+`</p>
             </ion-label>
             <ion-icon slot="end" name="`+item.icon+`"></ion-icon>
             <ion-reorder slot="end"></ion-reorder>
          </ion-item>
          <ion-item-options side="start">
            <ion-item-option color="danger" onClick="deleteItem(`+index+`)">
                <ion-icon slot="icon-only" name="trash"></ion-icon>
            </ion-item-option>
          </ion-item-options>
        </ion-item-sliding>`;
    });    
}

function error(message) {
  const alert = document.createElement('ion-alert');
  alert.message = message;
  alert.buttons = ['OK'];

  document.querySelector('ion-app').appendChild(alert);
  alert.present();
}

function toggleReorder() {
    closeItems();
    let reorder = getTab().querySelector('ion-reorder-group');
    reorder.disabled = !reorder.disabled;
}

function closeItems() {
    getTab().querySelector('ion-list').closeSlidingItems();
}

function addEditItem(index = false) {
    closeItems();
    let list = getList();
    let item = null;

    if (index !== false) item = list[index];
    else item = { text:"", date:new Date().toISOString(), icon:"radio-button-off" };

    const modal = document.createElement('ion-modal');
    modal.component = document.createElement('div');
    modal.component.innerHTML = `
        <ion-header>
            <ion-toolbar>
                <ion-title>ToDo - `+(index !== false ? 'Edit task' : 'New task')+`</ion-title>
                <ion-buttons slot="primary">
                    <ion-button color="danger"><ion-icon slot="icon-only" name="close"></ion-icon></ion-button>
                    <ion-button color="primary"><ion-icon slot="icon-only" name="checkmark"></ion-icon></ion-button>
                </ion-buttons>       
            </ion-toolbar>
        </ion-header>
        <ion-content>
            <ion-list>
                <ion-item>
                    <ion-label position="floating">Select date</ion-label>
                    <ion-datetime display-format="D MMM YYYY" max="2050-12-31" value="`+item.date+`"></ion-datetime>            
                </ion-item>
                <ion-item>
                    <ion-label position="floating">Enter task</ion-label>
                    <ion-input value="`+item.text+`"></ion-input>
                </ion-item>
            </ion-list>
            <ion-segment value="`+item.icon+`">
                <ion-segment-button value="radio-button-off">
                    <ion-icon name="radio-button-off"></ion-icon>
                </ion-segment-button>  
                <ion-segment-button value="radio-button-on">
                    <ion-icon name="radio-button-on"></ion-icon>
                </ion-segment-button>  
                <ion-segment-button value="snow">
                    <ion-icon name="snow"></ion-icon>
                </ion-segment-button>
                <ion-segment-button value="flame">
                    <ion-icon name="flame"></ion-icon>
                </ion-segment-button>
            </ion-segment>
        </ion-content>`;

    modal.component.querySelector('[color="danger"]').addEventListener('click', () => {
        modal.dismiss();
    });

    modal.component.querySelector('[color="primary"]').addEventListener('click', () => {
        let newDate = modal.component.querySelector('ion-datetime').value;
        let newText = modal.component.querySelector('ion-input').value;
        let newIcon = modal.component.querySelector('ion-segment').value;

        if (!newText.length) {
            error('The task cannot be empty');
        }
        else {
            let newItem = { text:newText, date:newDate, icon:newIcon };
            if (index !== false) list[index] = newItem; 
            else list.unshift(newItem);
            saveList(getTab(), list);
            modal.dismiss();
        }
    });

    document.querySelector('ion-app').appendChild(modal);
    modal.present();
}

function moveItem(indexes) {
    let tab = getTab();
    let list = getList(tab);
    let item = list[indexes.from];
    list.splice(indexes.from, 1);
    list.splice(indexes.to, 0, item);
    indexes.complete();
    saveList(tab, list);
}

function deleteItem(index = false) {
    const alert = document.createElement('ion-alert');
  
    alert.header = index !== false ? 'Delete item' : 'Delete all',
    alert.message = 'Are you sure?',
    alert.buttons =  
            [{
                text: 'YES',
                handler: () => {
                    let list = getList();
                    if (index !== false) { list.splice(index, 1); }
                    else { list.length = 0; }
                    saveList(getTab(), list);
                }
            }, {
                text: 'NO',
                role: 'cancel'
            }]

    document.querySelector('ion-app').appendChild(alert);
    alert.present();
}

function share() {
    let text = "";
    getList().forEach((task, index) => {
      text += (index+1) + ". " + task.text + " - " + task.date.slice(0,10) + "\n";
    });
    window.plugins.socialsharing.share(text);
}

El resultado

Puedes hacer clic aquí para observar el aspecto final que tendrá la aplicación de la lista de tareas.

Una app muy sencilla para guardar sitios interesantes con ubicaciones y fotografías con IONIC 4+Angular+Cordova

El objetivo: Guardar ubicaciones e imágenes

En este ejercicio vamos a demostrar que puede resultar muy sencillo desarrollar una aplicación que nos permita mantener un registro de lugares interesantes, guardando la ubicación precisa y una imagen identificativa de cada uno.

Será el mismo navegador el que nos proporcione la ubicación actual de forma automática, y nos permita elegir las imágenes a utilizar, de entre las que tengamos en nuestros equipos. Además, al ejecutarla como app en nuestros móviles, podremos utilizar también la cámara en el mismo momento, pudiendo hacer una foto que se quedará guardada en nuestra aplicación. De esa forma, tendremos imágenes asociadas con la latitud y la longitud de los sitios donde hayamos hecho las fotos, pudiendo acceder además al mapa correspondiente con un solo clic.

Con menos de 100 líneas de código

Veremos que al utilizar IONIC 4 + Angular, con menos de 100 líneas de código podemos desarrollar una aplicación multiplataforma, que funcionará perfectamente en cualquier navegador, o instalada como app en nuestros dispositivos móviles. Y todo ello, con el mismo código fuente, sin cambiar ni una sola línea.

La funcionalidad de la aplicación

La aplicación dispondrá de un cuadro de texto donde podamos especificar la descripción del nuevo lugar que queramos registrar.

Si ya hemos introducido alguna descripción, se habilitará un nuevo control para poder elegir una imagen. Si estamos ejecutando la aplicación desde nuestro ordenador de escritorio, podremos elegir cualquier imagen de las que se encuentran en nuestro equipo. Si ejecutamos la aplicación desde nuestros dispositivos móviles, podremos hacer una foto en ese mismo momento.

Una vez introducida la descripción y elegida o hecha la foto, la aplicación obtendrá automáticamente la ubicación en la que nos encontremos y creará un elemento en la lista de lugares en el que aparecerá la foto y la descripción.

Al hacer clic sobre cada elemento de la lista, si estamos utilizando el navegador de nuestro ordenador de escritorio, abrirá la página web correspondiente de Google Maps para mostrarnos la ubicación precisa. Si estamos ejecutando la aplicación desde nuestros dispositivos móviles, nos permitirá elegir qué aplicación se utilizará para visualizar la ubicación.

Además, podremos reordenar los elementos de la lista activando un control por cada elemento, que se podrá ocultar o mostrar mediante un botón ubicado en la barra superior de la aplicación.

Por último, permitiremos borrar cualquier elemento de la lista utilizando un botón oculto que se mostrará al deslizar el elemento correspondiente a la derecha.

Primeros pasos

Código base de la aplicación

Para comenzar a desarrollar nuestra aplicación deberemos partir de un código base generado automáticamente por IONIC. Bastará con ejecutar el siguiente comando para generar los ficheros necesarios:

ionic start lugares blank --type=angular --cordova --no-git --no-link

Al finalizar el proceso, se habrá creado un directorio con el nombre lugares, que constituye el directorio raíz de nuestro proyecto, donde tendremos todo el código necesario para continuar con el desarrollo de nuestra aplicación. Para realizar los siguientes pasos deberemos acceder primero a dicha carpeta:

cd lugares

Como todavía no disponemos de la versión definitiva de IONIC, vamos a instalar la versión beta más estable hasta el momento:

npm install @ionic/[email protected]

Y a partir de ahora ya podemos comenzar a añadir el código específico de nuestra aplicación. Puesto que toda la funcionalidad se encuentra ubicada en una única pantalla, sólo vamos a modificar dos ficheros:

  1. src/app/home/home.page.html: Código HTML.
  2. src/app/home/home.page.ts: Código TypeScript.

Probando la aplicación en el navegador

La misma herramienta de consola de IONIC se puede utilizar como servidor web, lo que nos permitirá  probar nuestra aplicación fácilmente en el navegador. Además, cada vez que realicemos cualquier cambio en el código fuente, veremos cómo se actualiza el resultado automáticamente. Bastará con ejecutar el siguiente comando desde la carpeta raíz del proyecto donde se encuentra nuestra aplicación, dejando el terminal abierto:

ionic serve

Si todo ha ido bien, se compilará la aplicación y deberíamos visualizar en el navegador algo del siguiente estilo:

(bastará con pulsar Ctrl+C en el terminal para finalizar la ejecución del comando)

Código HTML

El encabezado

Como ya viene siendo habitual, colocaremos una barra superior que contenga el título y el botón de reordenar:

<ion-header>
  ...
  <ion-title>Places!</ion-title>
  ...
  <ion-button (click)="reorder=!reorder">
  ...    
</ion-header>

En cada pulsación del botón de reordenar, activaremos o desactivaremos dicha funcionalidad cambiando simplemente el valor del atributo correspondiente.

El formulario para la descripción y la imagen

Utilizaremos un simple cuadro de texto para introducir la descripción del lugar que queramos registrar y un elemento HTML estándar de tipo fichero para elegir la imagen:

<ion-item>
  <ion-input placeholder="Enter description" [(ngModel)]="text"></ion-input>
</ion-item>
<ion-item *ngIf="text.length">
  <input type="file" accept="image/*" (change)="add($event)" />
  <ion-icon slot="end" name="camera" (click)="add()"></ion-icon>
</ion-item>

Debemos destacar de nuevo el uso de la directiva ngModel, que nos permitirá enlazar el valor del atributo text, de forma que cualquier modificación en el código HTML cambiará automáticamente el valor del atributo en el código TypeScript y viceversa.

En segundo lugar, también destacamos el uso de la directiva ngIf, que automatiza la creación del campo de tipo fichero, de forma que dicho campo no existirá hasta que se introduzca algún texto en el campo de la descripción. Además, al inicializar de nuevo la descripción desde el código TypeScript, dicho campo desaparecerá automáticamente.

Por último, al final del cuadro de elección de imagen colocaremos un icono para acceder a la cámara de nuestros dispositivos móviles, utilizando como imagen la fotografía que realicemos en ese momento. Esta funcionalidad no estará operativa cuando utilicemos la aplicación desde el navegador.

Los elementos de la lista

Cada elemento de la lista será un enlace a la página web correspondiente de Google Maps para conocer la ubicación del lugar, de forma que podamos acceder fácilmente con un simple clic en el elemento correspondiente. Para ello bastará con especificar la url mediante el atributo href del elemento <ion-item></ion-item> (más información aquí).

Además, utilizaremos el elemento <ion-thumbnail></ion-thumbnail> para mostrar una miniatura de la imagen. Por último, añadiremos dos botones, uno para reordenar (<ion-reorder></ion-reorder>) y otro para borrar (<ion-item-option></ion-item-option>):

<ion-item [href]='item.url'>
  <ion-thumbnail slot="start"><img [src]="item.img" /></ion-thumbnail>{{item.name}}
  <ion-reorder slot="end"></ion-reorder>
</ion-item>
<ion-item-options side="start">
  <ion-item-option color="danger" (click)="delete(i)">
    <ion-icon slot="icon-only" name="trash"></ion-icon>
  </ion-item-option>
</ion-item-options>

Mostrando todos los elementos

Vamos a hacer uso del potencial de Angular para mostrar la lista completa de lugares:

<ion-reorder-group [disabled]="!reorder" (ionItemReorder)="move($event.detail)">
  <ion-item-sliding *ngFor="let item of list; let i=index">
    ...
  </ion-item-sliding>
</ion-reorder-group>

En primer lugar utilizaremos la expresión [disabled]="!reorder" para activar o desactivar la opción de reordenar los elementos. Mediante los corchetes enlazamos el valor del atributo reorder de TypeScript para poder acceder a su valor directamente. El evento (ionItemReorder) nos indicará que se ha cambiado de sitio algún elemento, y por lo tanto ejecutaremos el método move(), cuya funcionalidad veremos más adelante.

Por último, sólo necesitamos realizar un bucle utilizando la directiva ngFor, que además nos proporcionará cada elemento item del array que representa la lista, así como el índice i de cada uno de ellos.

El fichero «home.page.html» completo

<ion-header>
  <ion-toolbar color="primary">
    <ion-title>Places!</ion-title>
    <ion-buttons slot="primary">
      <ion-button (click)="reorder=!reorder">
        <ion-icon slot="icon-only" name="reorder"></ion-icon>
      </ion-button>
    </ion-buttons>
  </ion-toolbar>       
</ion-header>
<ion-content>
  <ion-list #myList lines="full">
    <ion-item color="light">
      <ion-input placeholder="Enter description" [(ngModel)]="text"></ion-input>
    </ion-item>
    <ion-item color="light" *ngIf="text.length">
      <input type="file" accept="image/*" (change)="add($event)" />
      <ion-icon slot="end" name="camera" (click)="add()"></ion-icon>
    </ion-item>    
    <ion-reorder-group [disabled]="!reorder" (ionItemReorder)="move($event.detail)">
      <ion-item-sliding *ngFor="let item of list; let i=index">
        <ion-item [href]='item.url'>
          <ion-thumbnail slot="start"><img [src]="item.img" /></ion-thumbnail>{{item.name}}
          <ion-reorder slot="end"></ion-reorder>
        </ion-item>
        <ion-item-options side="start">
          <ion-item-option color="danger" (click)="delete(i)">
            <ion-icon slot="icon-only" name="trash"></ion-icon>
          </ion-item-option>
        </ion-item-options>
      </ion-item-sliding>
    </ion-reorder-group>
  </ion-list>
</ion-content>

Código TypeScript

Los atributos

En este ejercicio sólo necesitamos cuatro campos para guardar todos los datos necesarios:

@ViewChild('myList') listRef: List;
reorder: boolean;
list: any;
text: string;

El propósito de cada uno de ellos es el siguiente:

  • listRef: Referencia a la lista de html para poder cerrar todos los elementos antes de borrarlos.
  • reorder: Su valor (falso o verdadero) indicará si se encuentra habilitada o no la funcionalidad de reordenar.
  • list: Array que contendrá todos los lugares.
  • text: Descripción del nuevo lugar que queremos registrar.

Los métodos

  • constructor: Recuperará la lista guardada para que se muestre al iniciar la aplicación.
  • add: Añadirá un nuevo lugar a la lista de lugares.
  • save: Guardará en localStorage el listado de lugares para que no se pierda al cerrar la aplicación o recargar el contenido del navegador.
  • delete: Borrará un elemento de la lista.
  • move: Moverá un elemento de la lista a otra posición.

Obteniendo la ubicación

Para conocer la ubicación desde la que vamos a registrar un nuevo lugar, utilizaremos la funcionalidad de geolocalización de HTML, de forma que consigamos que el código funcione perfectamente tanto en el navegador, como en la app una vez compilada (más información aquí):

navigator.geolocation.getCurrentPosition(pos => {
  let url = "https://maps.google.com/maps?&z=15&t=k&q="+pos.coords.latitude+" "+pos.coords.longitude;
  ...
});

Además, crear un enlace a Google Maps conociendo la ubicación resulta muy sencillo. En nuestro caso especificamos un zoom razonable (z=15), el tipo de mapa satélite (t=k), y la latitud y la longitud (q=…). Se puede obtener más información en la página oficial de Google, o también por ejemplo aquí.

Indicador de ejecución en proceso

Mientras se obtiene la ubicación y se procesa la imagen, resulta conveniente bloquear la aplicación mediante algún indicador para informar al usuario que todavía no se ha completado la acción. Para ello mostraremos un indicador en pantalla utilizando la funcionalidad que nos proporciona IONIC (más información aquí):

async add(event) {
  let loading = await this.loadingController.create();
  loading.present();
  ...
  loading.dismiss();
}

Obteniendo la imagen

Utilizando simple código HTML y JavaScript, podemos obtener la imagen seleccionada en formato base 64, de forma que la podemos guardar como si se tratara de cualquier otra cadena de texto (más información aquí, o aquí):

...
<input type="file" (change)="add($event)" />
...
<img [src]="item.img" />
...
async add(event) {
  let reader = new FileReader();
  reader.onload = (data:any) => {
    item.img = data.target.result;
    ...
  }
  reader.readAsDataURL(event.target.files[0]);
}

Además, añadiremos la funcionalidad necesaria para abrir la cámara cuando ejecutemos la aplicación en nuestros dispositivos móviles (más información en la documentación de IONIC y en Cordova):

...
const options:CameraOptions = {targetWidth:100,destinationType:this.camera.DestinationType.DATA_URL};
this.camera.getPicture(options).then(data => {
  this.list.unshift({name:this.text,img:"data:image/jpeg;base64,"+data,url:url});
  this.save();         
});
...

El fichero «home.page.ts» completo

import { Component, ViewChild } from '@angular/core';
import { List, LoadingController } from '@ionic/angular';
import { Camera, CameraOptions } from '@ionic-native/camera/ngx';

@Component({
  selector: 'app-home',
  templateUrl: 'home.page.html',
  styleUrls: ['home.page.scss'],
})

export class HomePage {
  @ViewChild('myList') listRef: List;
  reorder: boolean;
  list: any;
  text: string;

  constructor(public loadingController:LoadingController,private camera:Camera) {
    this.text = "";
    this.list = localStorage.getItem('places-list');
    if (this.list) this.list = JSON.parse(this.list);
    else this.list = [];
  }

  async add(event?) {
    let loading = await this.loadingController.create({duration:15000});
    loading.present();

    navigator.geolocation.getCurrentPosition(pos => {
      let url = "https://maps.google.com/maps?&z=15&t=k&q="+pos.coords.latitude+" "+pos.coords.longitude;

      if (event) {
        let reader = new FileReader();
        reader.onload = (data:any) => {
          this.list.unshift({name:this.text,img:data.target.result,url:url});
          this.save();
          loading.dismiss();
        }
        reader.readAsDataURL(event.target.files[0]);
      }
      else {
        loading.dismiss();
        const options:CameraOptions = {targetWidth:100,destinationType:this.camera.DestinationType.DATA_URL};
        this.camera.getPicture(options).then(data => {
          this.list.unshift({name:this.text,img:"data:image/jpeg;base64,"+data,url:url});
          this.save();         
        });
      }
    });
  }

  save() {
    localStorage.setItem('places-list', JSON.stringify(this.list));
    this.text = "";
  }

  delete(item) {
    this.listRef.closeSlidingItems();
    this.list.splice(item, 1);
    this.save();
  }

  move(indexes) {
    let item = this.list[indexes.from];
    this.list.splice(indexes.from, 1);
    this.list.splice(indexes.to, 0, item);
    this.save();
  }
}

Icono y pantalla de bienvenida

Si queremos cambiar el icono o la pantalla de bienvenida, bastará con actualizar los archivos icon.png y splash.png respectivamente, colocándolos en la carpeta resources de nuestro proyecto (sobrescribiendo los que allí estuvieran, ya que normalmente IONIC nos habrá proporcionado unos por defecto). Después sólo debemos ejecutar el comando correspondiente para que ionic genere o actualice todos los archivos necesarios:

ionic cordova resources

Compilando la aplicación

Plugins de geolocalización y cámara

Para que la aplicación tenga los permisos correctos, y podamos utilizar la funcionalidad nativa para conocer la ubicación, añadiremos el plugin correspondiente (más información aquí):

ionic cordova plugin add cordova-plugin-geolocation --save

Además, para utilizar la cámara también echaremos mano de otro plugin, esta vez utilizando la API nativa que nos proporciona IONIC, ya que sólo podemos acceder a dicha funcionalidad en nuestros dispositivos móviles (más información aquí):

ionic cordova plugin add cordova-plugin-camera --save
npm install --save @ionic-native/camera@beta

Debemos recordar que toda la funcionalidad de la aplicación se encontrará disponible tanto en el navegador como en la app, exceptuando la cámara, ya que es específica del dispositivo, y no estará operativa en el navegador.

También será necesario modificar el archivo app.module.ts para poder acceder a la API de la cámara:

...
import { Camera } from '@ionic-native/camera/ngx';
...
@NgModule({
  ...
  providers: [
    ...
    Camera,
    ...
  ],
  ...
})
export class AppModule {}

El archivo «app.module.ts» completo

import { NgModule } from '@angular/core';
import { BrowserModule } from '@angular/platform-browser';
import { RouteReuseStrategy } from '@angular/router';

import { IonicModule, IonicRouteStrategy } from '@ionic/angular';
import { SplashScreen } from '@ionic-native/splash-screen/ngx';
import { StatusBar } from '@ionic-native/status-bar/ngx';
import { Camera } from '@ionic-native/camera/ngx';

import { AppComponent } from './app.component';
import { AppRoutingModule } from './app-routing.module';

@NgModule({
  declarations: [AppComponent],
  entryComponents: [],
  imports: [BrowserModule, IonicModule.forRoot(), AppRoutingModule],
  providers: [
    StatusBar,
    SplashScreen,
    Camera,
    { provide: RouteReuseStrategy, useClass: IonicRouteStrategy }
  ],
  bootstrap: [AppComponent]
})
export class AppModule {}

Generando el archivo APK

Para instalar la aplicación en nuestros dispositivos móviles, tenemos que compilar el código fuente:

ionic cordova build android --prod

O también lo podemos ejecutar directamente  en el móvil que tengamos conectado a nuestro ordenador:

ionic cordova run android --prod

El resultado

Puedes hacer clic aquí para probar la aplicación propuesta. Se puede observar que el mismo código de la app generada también puede funcionar perfectamente en el navegador como una página web.