Skip to content

Actividad 4 - Almacenamiento y recuperación de datos en aplicaciones Ionic

Esta actividad consiste en crear una aplicación Ionic que haga uso de Firebase para realizar el proceso de autenticación y disponer de una base de datos común para los usuarios de la aplicación. Cada usuario podrá gestionar sus propios productos, que se almacenarán en Cloud Firestore.

Firebase es un servicio en la nube ofrecido por Google que facilita el desarrollo de aplicaciones. Para esta actividad se utilizan los siguientes servicios:

  • Authentication: Gestiona la autenticación de usuarios mediante correo electrónico y contraseña.
  • Cloud Firestore: Base de datos NoSQL en la nube para almacenar los productos asociados a cada usuario.
  • Firebase Storage (configurado pero no utilizado): Servicio para almacenar archivos binarios como imágenes.

La base de datos más reciente de Firebase. Utiliza un modelo de datos intuitivo con consultas ricas y rápidas, y escalamiento avanzado.

Para esta actividad se utiliza Cloud Firestore.

  1. Acceder a la consola de Firebase con una cuenta de Google
  2. Crear un nuevo proyecto (por ejemplo, dah-2026)
  3. Añadir una aplicación web (</>) y obtener la configuración
  1. En la consola de Firebase, ir a Authentication > Comenzar
  2. Habilitar el método de autenticación por correo electrónico/password
  3. Crear usuarios de prueba en la pestaña Users
  1. Ir a Firestore Database > Crear base de datos
  2. Seleccionar modo de edición Standard
  3. Elegir ubicación en Europa
  4. Iniciar en modo de prueba (permite lectura/escritura)

3.1. Crear proyecto e instalar dependencias

Section titled “3.1. Crear proyecto e instalar dependencias”
Terminal window
ionic start storeApp blank --type angular
cd storeApp
npm install firebase @angular/fire
src/environments/environment.ts
export const environment = {
production: false,
firebaseConfig: {
apiKey: 'TU_API_KEY',
authDomain: 'TU_PROYECTO.firebaseapp.com',
projectId: 'TU_PROYECTO',
storageBucket: 'TU_PROYECTO.firebasestorage.app',
messagingSenderId: 'TU_SENDER_ID',
appId: 'TU_APP_ID',
measurementId: 'TU_MEASUREMENT_ID',
},
};
actividad4/src/main.ts
import { bootstrapApplication } from '@angular/platform-browser';
import { RouteReuseStrategy, provideRouter, withPreloading, PreloadAllModules } from '@angular/router';
import { IonicRouteStrategy, provideIonicAngular } from '@ionic/angular/standalone';
import {
RouteReuseStrategy,
provideRouter,
withPreloading,
PreloadAllModules,
} from '@angular/router';
import {
IonicRouteStrategy,
provideIonicAngular,
} from '@ionic/angular/standalone';
import { provideFirebaseApp, initializeApp } from '@angular/fire/app';
import { provideAuth, getAuth } from '@angular/fire/auth';
import { provideStorage, getStorage } from '@angular/fire/storage';
import { provideFirestore, getFirestore } from '@angular/fire/firestore';
import { routes } from './app/app.routes';
import { AppComponent } from './app/app.component';
import { environment } from './environments/environment.example';
bootstrapApplication(AppComponent, {
providers: [
{ provide: RouteReuseStrategy, useClass: IonicRouteStrategy },
provideIonicAngular(),
provideRouter(routes, withPreloading(PreloadAllModules)),
provideFirebaseApp(() => initializeApp(environment.firebaseConfig)),
provideAuth(() => getAuth()),
provideFirestore(() => getFirestore()),
provideStorage(() => getStorage()),
],
});

Se define la interfaz TypeScript que representa un producto:

actividad4/src/app/core/models/products.model.ts
export interface Product {
id: string;
name: string;
description: string;
imageUrl: string;
userId: string;
}

5.1. Servicio de autenticación (AuthService)

Section titled “5.1. Servicio de autenticación (AuthService)”
actividad4/src/app/core/services/auth.service.ts
import { inject, Injectable } from '@angular/core';
import {
Auth,
user,
signInWithEmailAndPassword,
signOut,
} from '@angular/fire/auth';
import { toSignal } from '@angular/core/rxjs-interop';
@Injectable({
providedIn: 'root',
})
export class AuthService {
private auth = inject(Auth);
public user$ = user(this.auth);
public currentUser = toSignal(this.user$);
async login(email: string, password: string) {
return signInWithEmailAndPassword(this.auth, email, password);
}
async logout() {
return signOut(this.auth);
}
getUID() {
return this.currentUser()?.uid;
}
}

5.2. Servicio de productos (ProductsService)

Section titled “5.2. Servicio de productos (ProductsService)”
src/app/core/services/products.service.ts
import { inject, Injectable } from '@angular/core';
import {
addDoc,
collection,
deleteDoc,
doc,
docData,
Firestore,
query,
where,
collectionData,
} from '@angular/fire/firestore';
import { AuthService } from './auth.service';
import { Observable, of, switchMap } from 'rxjs';
import { toSignal } from '@angular/core/rxjs-interop';
import { Product } from '../models/products.model';
@Injectable({
providedIn: 'root',
})
export class ProductsService {
private firestore = inject(Firestore);
private authService = inject(AuthService);
private productsCollection = collection(this.firestore, 'products');
private products$ = this.authService.user$.pipe(
switchMap((user) => {
if (user) {
const q = query(
this.productsCollection,
where('userId', '==', user.uid),
);
return collectionData(q, { idField: 'id' }) as Observable<Product[]>;
}
return of([]);
}),
);
public products = toSignal(this.products$, { initialValue: [] });
async addProduct(product: Product) {
const uid = this.authService.getUID();
if (!uid) throw new Error('No authenticated user');
return addDoc(this.productsCollection, { ...product, userId: uid });
}
public allProducts = toSignal(this.getAllProducts(), { initialValue: [] });
getAllProducts() {
return collectionData(this.productsCollection, {
idField: 'id',
}) as Observable<Product[]>;
}
getProductById(id: string): Observable<Product | undefined> {
const productDoc = doc(this.firestore, `products/${id}`);
return docData(productDoc, { idField: 'id' }) as Observable<Product | undefined>;
}
async deleteProduct(id: string) {
const productDoc = doc(this.firestore, `products/${id}`);
return deleteDoc(productDoc);
}
}
src/app/features/home/home.page.ts
import { Component, inject } from '@angular/core';
import { Router } from '@angular/router';
import {
IonHeader,
IonToolbar,
IonTitle,
IonContent,
IonIcon,
} from '@ionic/angular/standalone';
@Component({
selector: 'app-home',
templateUrl: 'home.page.html',
styleUrls: ['home.page.scss'],
imports: [IonHeader, IonToolbar, IonTitle, IonContent, IonIcon],
})
export class HomePage {
private router = inject(Router);
goToLogin() {
this.router.navigate(['/login']);
}
goToProductsList() {
this.router.navigate(['/products-list']);
}
}
src/app/features/home/home.page.html
<ion-header [translucent]="true">
<ion-toolbar>
<ion-title>
<ion-icon name="planet"></ion-icon>
Portal Estelar
</ion-title>
</ion-toolbar>
</ion-header>
<ion-content [fullscreen]="true">
<div class="hero-section">
<div class="hero-icon">
<ion-icon name="rocket"></ion-icon>
</div>
<h1 class="hero-title">Bienvenido al Sistema Central</h1>
<p class="hero-subtitle">Gestiona tu inventario intergaláctico</p>
</div>
<div class="orbs-container">
<div class="orb-wrapper" (click)="goToLogin()">
<div class="orb orb-purple">
<ion-icon name="finger-print"></ion-icon>
</div>
<span class="orb-label">Acceso</span>
</div>
<div class="orb-wrapper" (click)="goToProductsList()">
<div class="orb orb-cyan">
<ion-icon name="cube"></ion-icon>
</div>
<span class="orb-label">Inventario</span>
</div>
</div>
</ion-content>
actividad4/src/app/features/login/login.page.ts
import { Component, inject, OnInit } from '@angular/core';
import { CommonModule } from '@angular/common';
import {
FormControl,
FormGroup,
FormsModule,
ReactiveFormsModule,
Validators,
} from '@angular/forms';
import {
IonContent,
IonHeader,
IonTitle,
IonToolbar,
LoadingController,
ToastController,
IonInput,
IonText,
IonButton,
} from '@ionic/angular/standalone';
import { AuthService } from 'src/app/core/services/auth.service';
import { Router } from '@angular/router';
@Component({
selector: 'app-login',
templateUrl: './login.page.html',
styleUrls: ['./login.page.scss'],
standalone: true,
imports: [
IonContent,
IonHeader,
IonTitle,
IonToolbar,
CommonModule,
FormsModule,
ReactiveFormsModule,
IonInput,
IonText,
IonButton,
],
})
export class LoginPage implements OnInit {
private authService = inject(AuthService);
private toastController = inject(ToastController);
private loadingController = inject(LoadingController);
private router = inject(Router);
constructor() {}
ngOnInit() {}
loginForm = new FormGroup({
email: new FormControl('', [Validators.required, Validators.email]),
password: new FormControl('', [
Validators.required,
Validators.minLength(6),
]),
});
async login() {
if (this.loginForm.invalid) {
this.showErrorMessage('Please complete the fields correctly.');
return;
}
const loading = await this.loadingController.create({
message: 'Logging in...',
spinner: 'crescent',
duration: 2000,
});
await loading.present();
const { email, password } = this.loginForm.value;
try {
await this.authService.login(email!, password!);
this.showInfoMessage('Login successful. Redirecting...');
setTimeout(() => {
this.router.navigate(['/products']);
}, 2000);
} catch (error) {
this.showErrorMessage('Authentication error: ' + (error as any).message);
} catch (error: any) {
let customMessage = 'Ocurrió un error de autenticación.';
if (error.code === 'auth/user-not-found') {
customMessage = 'El usuario no existe en nuestros registros.';
} else if (error.code === 'auth/wrong-password') {
customMessage = 'La contraseña es incorrecta.';
} else if (error.code === 'auth/invalid-credential') {
customMessage = 'El correo electrónico o la contraseña son incorrectos.';
} else if (error.code === 'auth/too-many-requests') {
customMessage = 'Demasiados intentos fallidos. Por favor, inténtalo más tarde.';
}
this.showErrorMessage(customMessage);
} finally {
loading.dismiss();
}
}
async showErrorMessage(message: string) {
const toast = await this.toastController.create({
message,
duration: 3000,
position: 'bottom',
color: 'danger',
});
toast.present();
}
async showInfoMessage(message: string) {
const toast = await this.toastController.create({
message,
duration: 2000,
position: 'bottom',
color: 'success',
});
toast.present();
}
}
src/app/features/products/products.page.ts
import { Component, inject } from '@angular/core';
import {
IonContent,
IonHeader,
IonTitle,
IonToolbar,
IonButton,
IonIcon,
IonButtons,
} from '@ionic/angular/standalone';
import { ProductsService } from 'src/app/core/services/products.service';
import { AuthService } from 'src/app/core/services/auth.service';
import { Router } from '@angular/router';
import { add, logOutOutline } from 'ionicons/icons';
import { addIcons } from 'ionicons';
import { ProductListComponent } from 'src/app/shared/components/product-list/product-list.component';
@Component({
selector: 'app-products',
templateUrl: './products.page.html',
styleUrls: ['./products.page.scss'],
standalone: true,
imports: [
IonContent,
IonHeader,
IonTitle,
IonToolbar,
IonButton,
IonIcon,
IonButtons,
ProductListComponent,
],
})
export class ProductsPage {
public productService = inject(ProductsService);
private authService = inject(AuthService);
private router = inject(Router);
constructor() {
addIcons({ add, logOutOutline });
}
goToAddProduct() {
this.router.navigate(['/add-product']);
}
logout() {
this.authService.logout();
this.router.navigate(['/login']);
}
}
src/app/features/products/products.page.html
<ion-header [translucent]="true">
<ion-toolbar>
<ion-title>My products</ion-title>
<ion-buttons slot="end">
<ion-button (click)="goToAddProduct()">
<ion-icon slot="icon-only" name="add"></ion-icon>
</ion-button>
<div class="vertical-divider"></div>
<ion-button (click)="logout()">
<ion-icon slot="icon-only" name="log-out-outline"></ion-icon>
</ion-button>
</ion-buttons>
</ion-toolbar>
</ion-header>
<ion-content>
<app-product-list
[products]="productService.products()"
[isClickable]="true"
emptyMessage="You don't have products.">
</app-product-list>
</ion-content>
src/app/features/add-product/add-product.page.ts
import { Component, inject } from '@angular/core';
import {
FormControl,
FormGroup,
ReactiveFormsModule,
Validators,
} from '@angular/forms';
import {
IonContent,
IonHeader,
IonTitle,
IonToolbar,
LoadingController,
ToastController,
IonButtons,
IonBackButton,
IonItem,
IonLabel,
IonButton,
IonInput,
IonTextarea,
} from '@ionic/angular/standalone';
import { ProductsService } from 'src/app/core/services/products.service';
import { Router } from '@angular/router';
@Component({
selector: 'app-add-product',
templateUrl: './add-product.page.html',
styleUrls: ['./add-product.page.scss'],
standalone: true,
imports: [
ReactiveFormsModule,
IonContent,
IonHeader,
IonTitle,
IonToolbar,
IonButtons,
IonBackButton,
IonItem,
IonLabel,
IonButton,
IonInput,
IonTextarea,
],
})
export class AddProductPage {
private productService = inject(ProductsService);
private router = inject(Router);
private loadingController = inject(LoadingController);
private toastController = inject(ToastController);
productForm = new FormGroup({
name: new FormControl('', [Validators.required]),
description: new FormControl('', [Validators.required]),
imageUrl: new FormControl('', [Validators.required]),
});
async addProduct() {
if (this.productForm.invalid) return;
const loading = await this.loadingController.create({
message: 'Saving...',
});
await loading.present();
try {
await this.productService.addProduct(this.productForm.value as any);
this.showToast('Product added');
this.router.navigate(['/products']);
} catch (error) {
this.showToast('Error to save', 'danger');
} finally {
loading.dismiss();
}
}
async showToast(message: string, color: string = 'success') {
const toast = await this.toastController.create({
message,
duration: 2000,
color,
});
toast.present();
}
}
src/app/features/add-product/add-product.page.html
<ion-header>
<ion-toolbar>
<ion-buttons slot="start">
<ion-back-button defaultHref="/products"></ion-back-button>
</ion-buttons>
<ion-title>Add from URL</ion-title>
</ion-toolbar>
</ion-header>
<ion-content class="ion-padding">
<form [formGroup]="productForm" (ngSubmit)="addProduct()">
<ion-item>
<ion-label position="floating">Product Name</ion-label>
<ion-input formControlName="name"></ion-input>
</ion-item>
<ion-item>
<ion-label position="floating">Description</ion-label>
<ion-textarea formControlName="description"></ion-textarea>
</ion-item>
<ion-item>
<ion-label position="floating">Image URL (Internet)</ion-label>
<ion-input
type="url"
formControlName="imageUrl"
placeholder="https://example.com/photo.jpg"
></ion-input>
</ion-item>
@if (productForm.get('imageUrl')?.valid) {
<div class="ion-text-center ion-padding">
<p><small>External preview:</small></p>
<img
[src]="productForm.value.imageUrl"
style="width: 150px; border-
radius: 8px; border: 1px solid #ddd;"
/>
</div>
}
<ion-button
expand="full"
type="submit"
[disabled]="productForm.invalid"
class="ion-margin-top"
>
Upload to Firebase
</ion-button>
</form>
</ion-content>

6.5. Página de listado público (Products List)

Section titled “6.5. Página de listado público (Products List)”

Muestra todos los productos disponibles en la base de datos (sin filtrar por usuario). Es accesible sin autenticación.

src/app/features/products-list/products-list.page.ts
import { Component, inject } from '@angular/core';
import {
IonContent,
IonHeader,
IonTitle,
IonToolbar,
IonButtons,
IonBackButton,
} from '@ionic/angular/standalone';
import { ProductsService } from 'src/app/core/services/products.service';
import { ProductListComponent } from 'src/app/shared/components/product-list/product-list.component';
@Component({
selector: 'app-products-list',
templateUrl: './products-list.page.html',
styleUrls: ['./products-list.page.scss'],
standalone: true,
imports: [
IonContent,
IonHeader,
IonTitle,
IonToolbar,
IonButtons,
IonBackButton,
ProductListComponent,
],
})
export class ProductsListPage {
public productsService = inject(ProductsService);
}
src/app/features/products-list/products-list.page.html
<ion-header [translucent]="true">
<ion-toolbar>
<ion-buttons slot="start">
<ion-back-button defaultHref="/home" color="primary"></ion-back-button>
</ion-buttons>
<ion-title>Products for Sale</ion-title>
</ion-toolbar>
</ion-header>
<ion-content>
<app-product-list
[products]="productsService.allProducts()"
emptyMessage="No products available.">
</app-product-list>
</ion-content>
actividad4/src/app/features/product-details/product-details.page.ts
import { Component, inject, OnInit } from '@angular/core';
import { CommonModule } from '@angular/common';
import { ActivatedRoute, Router } from '@angular/router';
import {
AlertController,
IonContent,
IonHeader,
IonTitle,
IonToolbar,
IonButtons,
IonBackButton,
IonThumbnail,
IonLabel,
IonItem,
IonButton,
IonIcon,
ToastController,
} from '@ionic/angular/standalone';
import { ProductsService } from 'src/app/core/services/products.service';
import { Product } from 'src/app/core/models/products.model';
import { Observable } from 'rxjs';
import { trashOutline } from 'ionicons/icons';
import { addIcons } from 'ionicons';
@Component({
selector: 'app-product-details',
templateUrl: './product-details.page.html',
styleUrls: ['./product-details.page.scss'],
standalone: true,
imports: [
IonContent,
IonHeader,
IonTitle,
IonToolbar,
CommonModule,
IonButtons,
IonBackButton,
IonThumbnail,
IonLabel,
IonItem,
IonButton,
IonIcon,
],
})
export class ProductDetailsPage implements OnInit {
private route = inject(ActivatedRoute);
private router = inject(Router);
private productsService = inject(ProductsService);
private alertController = inject(AlertController);
private toastController = inject(ToastController);
product$!: Observable<Product | undefined>;
private productId: string | null = null;
constructor() {
addIcons({ trashOutline });
}
ngOnInit() {
const productId = this.route.snapshot.paramMap.get('id');
if (productId) {
this.product$ = this.productsService.getProductById(productId);
this.productId = this.route.snapshot.paramMap.get('id');
if (this.productId) {
this.product$ = this.productsService.getProductById(this.productId);
} else {
this.router.navigate(['/products']);
}
}
async confirmDelete() {
if (!this.productId) return;
const alert = await this.alertController.create({
header: 'Delete Product',
message: 'Are you sure you want to delete this product?',
buttons: [
{
text: 'Cancel',
role: 'cancel',
},
{
text: 'Delete',
role: 'destructive',
handler: () => this.deleteProduct(),
},
],
});
await alert.present();
}
async deleteProduct() {
if (!this.productId) return;
try {
await this.productsService.deleteProduct(this.productId);
this.showToast('Product deleted');
this.router.navigate(['/products']);
} catch (error) {
this.showToast('Error deleting product', 'danger');
}
}
async showToast(message: string, color: string = 'success') {
const toast = await this.toastController.create({
message,
duration: 2000,
position: 'bottom',
color,
});
await toast.present();
}
}
src/app/features/product-details/product-details.page.html
<ion-header>
<ion-toolbar>
<ion-buttons slot="start">
<ion-back-button defaultHref="/products" color="primary"></ion-back-button>
</ion-buttons>
<ion-title>Product Details</ion-title>
<ion-buttons slot="end">
<ion-button (click)="confirmDelete()" color="danger">
<ion-icon slot="icon-only" name="trash-outline"></ion-icon>
</ion-button>
</ion-buttons>
</ion-toolbar>
</ion-header>
<ion-content class="ion-padding">
@if (product$ | async; as product) {
<div class="product-details">
<div class="image-container">
<img [src]="product.imageUrl" [alt]="product.name" class="product-image" />
</div>
<div class="product-info">
<h1 class="product-name">{{ product.name }}</h1>
<p class="product-description">{{ product.description }}</p>
</div>
</div>
} @else {
<div class="not-found">
<ion-label>Product not found.</ion-label>
</div>
}
</ion-content>
actividad4/src/app/shared/components/product-list/product-list.component.ts
import { Component, Input } from '@angular/core';
import { CommonModule } from '@angular/common';
import { RouterModule } from '@angular/router';
import {
IonList,
IonItem,
IonThumbnail,
IonLabel,
} from '@ionic/angular/standalone';
import { Product } from '../../../core/models/products.model';
@Component({
selector: 'app-product-list',
standalone: true,
imports: [CommonModule, RouterModule, IonList, IonItem, IonThumbnail, IonLabel],
templateUrl: './product-list.component.html',
styleUrls: ['./product-list.component.scss'],
})
export class ProductListComponent {
@Input() products: Product[] = [];
@Input() isClickable: boolean = false;
@Input() emptyMessage: string = 'No products available.';
}
src/app/shared/components/product-list/product-list.component.html
<ion-list>
@for (product of products; track product.id) {
<ion-item [button]="isClickable" [detail]="isClickable" [routerLink]="isClickable ? ['/product-details', product.id] : null">
<ion-thumbnail slot="start">
<img [src]="product.imageUrl" [alt]="product.name" />
</ion-thumbnail>
<ion-label>
<h2>{{ product.name }}</h2>
<p>{{ product.description }}</p>
</ion-label>
</ion-item>
} @empty {
<ion-item>{{ emptyMessage }}</ion-item>
}
</ion-list>
actividad4/src/app/app.routes.ts
import { Routes } from '@angular/router';
export const routes: Routes = [
{
path: 'home',
loadComponent: () => import('./features/home/home.page').then((m) => m.HomePage),
},
{
path: '',
redirectTo: 'home',
pathMatch: 'full',
},
{
path: 'login',
loadComponent: () => import('./features/login/login.page').then( m => m.LoginPage)
},
{
path: 'products',
loadComponent: () => import('./features/products/products.page').then( m => m.ProductsPage)
},
{
path: 'add-product',
loadComponent: () => import('./features/add-product/add-product.page').then( m => m.AddProductPage)
},
{
path: 'products-list',
loadComponent: () => import('./features/products-list/products-list.page').then( m => m.ProductsListPage)
},
];

8. Respuestas a las preguntas del ejercicio

Section titled “8. Respuestas a las preguntas del ejercicio”

8.1. ¿Cómo se consigue que un usuario acceda solo a los productos que él ha creado?

Section titled “8.1. ¿Cómo se consigue que un usuario acceda solo a los productos que él ha creado?”

Se utiliza una consulta filtrada en Cloud Firestore:

const q = query(
this.productsCollection,
where('userId', '==', user.uid)
);

Cada producto se almacena con un campo userId que corresponde al UID del usuario que lo creó. Al realizar la consulta con este filtro, solo se obtienen los productos asociados a ese usuario.

8.2. ¿Cómo se consigue que las listas de productos se actualicen dinámicamente?

Section titled “8.2. ¿Cómo se consigue que las listas de productos se actualicen dinámicamente?”

Se utiliza programación reactiva con RxJS y Signals:

  • El servicio ProductsService define un Observable (products$) que emite valores cuando cambia el estado de autenticación
  • Se convierte el observable a un Signal mediante toSignal() para usarlo en la plantilla
  • Cuando se añade o elimina un producto, el observable emite nuevos valores automáticamente
  • Angular detecta los cambios y actualiza la vista automáticamente

8.3. ¿Cómo se podrían crear nuevos usuarios desde la aplicación Ionic?

Section titled “8.3. ¿Cómo se podrían crear nuevos usuarios desde la aplicación Ionic?”

Agregando el método createUserWithEmailAndPassword al AuthService:

import { createUserWithEmailAndPassword } from '@angular/fire/auth';
async register(email: string, password: string) {
return createUserWithEmailAndPassword(this.auth, email, password);
}

Y en el componente de registro:

await this.authService.register(email, password);
  1. Instalar dependencias

    Terminal window
    npm install
  2. Ejecutar en el navegador

    Terminal window
    ionic serve
  3. Esto abrirá la aplicación en http://localhost:8100.

Pantalla de inicio Página de login Listado de productos del usuario Formulario para añadir producto Listado público de productos Detalles de un producto