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.
1. Firebase y servicios utilizados
Section titled “1. Firebase y servicios utilizados”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.
1.1. Tipos de bases de datos en Firebase
Section titled “1.1. Tipos de bases de datos en Firebase”La base de datos más reciente de Firebase. Utiliza un modelo de datos intuitivo con consultas ricas y rápidas, y escalamiento avanzado.
La base de datos original de Firebase. Solución eficiente y de baja latencia para apps que necesitan estados sincronizados en tiempo real.
Para esta actividad se utiliza Cloud Firestore.
2. Configuración del proyecto Firebase
Section titled “2. Configuración del proyecto Firebase”2.1. Crear proyecto en Firebase
Section titled “2.1. Crear proyecto en Firebase”- Acceder a la consola de Firebase con una cuenta de Google
- Crear un nuevo proyecto (por ejemplo,
dah-2026) - Añadir una aplicación web (
</>) y obtener la configuración
2.2. Configurar Authentication
Section titled “2.2. Configurar Authentication”- En la consola de Firebase, ir a Authentication > Comenzar
- Habilitar el método de autenticación por correo electrónico/password
- Crear usuarios de prueba en la pestaña Users
2.3. Configurar Cloud Firestore
Section titled “2.3. Configurar Cloud Firestore”- Ir a Firestore Database > Crear base de datos
- Seleccionar modo de edición Standard
- Elegir ubicación en Europa
- Iniciar en modo de prueba (permite lectura/escritura)
3. Configuración de la aplicación Ionic
Section titled “3. Configuración de la aplicación Ionic”3.1. Crear proyecto e instalar dependencias
Section titled “3.1. Crear proyecto e instalar dependencias”ionic start storeApp blank --type angularcd storeAppnpm install firebase @angular/fire3.2. Configurar environment
Section titled “3.2. Configurar environment”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', },};3.3. Configurar proveedores en main.ts
Section titled “3.3. Configurar proveedores en 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()), ],});4. Modelo de datos
Section titled “4. Modelo de datos”Se define la interfaz TypeScript que representa un producto:
export interface Product { id: string; name: string; description: string; imageUrl: string; userId: string;}5. Servicios
Section titled “5. Servicios”5.1. Servicio de autenticación (AuthService)
Section titled “5.1. Servicio de autenticación (AuthService)”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)”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); }}6. Páginas de la aplicación
Section titled “6. Páginas de la aplicación”6.1. Página de inicio (Home)
Section titled “6.1. Página de inicio (Home)”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']); }}<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>6.2. Página de login
Section titled “6.2. Página de login”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(); }}6.3. Página de productos (Mis Productos)
Section titled “6.3. Página de productos (Mis Productos)”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']); }}<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>6.4. Página de añadir producto
Section titled “6.4. Página de añadir producto”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(); }}<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.
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);}<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>6.6. Página de detalles de producto
Section titled “6.6. Página de detalles de producto”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(); }}<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>6.7. Componente Product List
Section titled “6.7. Componente Product List”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.';}<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>7. Rutas de la aplicación
Section titled “7. Rutas de la aplicación”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
ProductsServicedefine unObservable(products$) que emite valores cuando cambia el estado de autenticación - Se convierte el observable a un
SignalmediantetoSignal()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);9. Ejecución del proyecto
Section titled “9. Ejecución del proyecto”-
Instalar dependencias
Terminal window npm install -
Ejecutar en el navegador
Terminal window ionic serve -
Esto abrirá la aplicación en
http://localhost:8100.
11. Capturas del diseño
Section titled “11. Capturas del diseño”Pantalla de Inicio
Section titled “Pantalla de Inicio”
Página de Login
Section titled “Página de Login”
Mis Productos
Section titled “Mis Productos”
Añadir Producto
Section titled “Añadir Producto”
Listado Público
Section titled “Listado Público”
Detalle de Producto
Section titled “Detalle de Producto”