Actividad 2 - Actividad de Servicios Ionic
Descripción
Section titled “Descripción”Esta actividad consiste en crear una aplicación Ionic que integra servicios web externos (OMDb API) utilizando Angular Signals para gestionar el estado de la aplicación. La aplicación permite buscar películas, ver sus detalles y gestionar una lista de favoritos.
Prerrequisitos
Section titled “Prerrequisitos”-
Obtención de clave API
Registrarse en OMDb API para obtener una clave de acceso gratuita (hasta 1000 peticiones/día).
Estructura del Proyecto
Section titled “Estructura del Proyecto”Directorysrc/
Directoryapp/
Directorycore/
Directorymodels/
- movies.model.ts
Directoryservices/
Directorymovie/
- movie.service.ts
Directoryfeatures/
Directoryhome/
- …
Directorymovie-detail/
- …
Directorymovie-card/
- …
- app.component.ts
- app.routes.ts
Directoryenvironments/
- environment.ts
- environment.prod.ts
- main.ts
Paso 1: Modelo de Datos
Section titled “Paso 1: Modelo de Datos”Definir las interfaces TypeScript que representan los datosdevueltos por la OMDb API.
export interface OMDbMovie { Title: string; Year: string; imdbID: string; Type: string; Poster: string; Response?: string;}
export interface OMDbSearchApiResponse { Search: OMDbMovie[]; totalResults: string; Response: string;}Paso 2: Variables de Entorno
Section titled “Paso 2: Variables de Entorno”Almacenar la clave API de forma segura, separando configuración del código.
export const environment = { production: false, apikeyOMDb: "TU_CLAVE_AQUI",};Paso 3: Configuración de HTTP en main.ts
Section titled “Paso 3: Configuración de HTTP en main.ts”Habilitar el cliente HTTP en aplicaciones standalone de Angular.
import { bootstrapApplication } from "@angular/platform-browser";import { RouteReuseStrategy, provideRouter, withPreloading, PreloadAllModules,} from "@angular/router";import { IonicRouteStrategy, provideIonicAngular,} from "@ionic/angular/standalone";import { routes } from "./app/app.routes";import { AppComponent } from "./app/app.component";import { provideHttpClient } from "@angular/common/http";
bootstrapApplication(AppComponent, { providers: [ { provide: RouteReuseStrategy, useClass: IonicRouteStrategy }, provideIonicAngular(), provideRouter(routes, withPreloading(PreloadAllModules)), provideHttpClient(), ],});Paso 4: Servicio MovieService
Section titled “Paso 4: Servicio MovieService”Crear un servicio que actúe como “Single Source of Truth” usando Signals de Angular para gestionar el estado de películas, favoritos y película actual.
import { HttpClient } from "@angular/common/http";import { computed, inject, Injectable, signal } from "@angular/core";import { environment } from "src/environments/environment";import { OMDbMovie, OMDbSearchApiResponse } from "../../models/movies.model";
@Injectable({ providedIn: "root",})export class MovieService { private http = inject(HttpClient); private readonly API_URL = `https://www.omdbapi.com/?apikey=${environment.apikeyOMDb}`;
private _movies = signal<OMDbMovie[]>([]); private _currentMovie = signal<OMDbMovie | null>(null); private _favorites = signal<OMDbMovie[]>([]);
public movies = this._movies.asReadonly(); public currentMovie = this._currentMovie.asReadonly(); public favorites = this._favorites.asReadonly();
public totalResults = computed(() => this._movies().length); public favoritesCount = computed(() => this._favorites().length);
public toggleFavorite(movie: OMDbMovie) { const currentFavorites = this._favorites(); const isAlreadyFavorite = currentFavorites.some( (m) => m.imdbID === movie.imdbID );
if (isAlreadyFavorite) { this._favorites.set( currentFavorites.filter((m) => m.imdbID !== movie.imdbID) ); } else { this._favorites.set([...currentFavorites, movie]); } }
public isFavorite(imdbID: string): boolean { return this._favorites().some((m) => m.imdbID === imdbID); }
searchMovies(title: string) { this.http .get<OMDbSearchApiResponse>(`${this.API_URL}&s=${title}`) .subscribe((response) => { this._movies.set(response.Response === "True" ? response.Search : []); }); }
getMovieDetails(id: string) { this.http.get<OMDbMovie>(`${this.API_URL}&i=${id}`).subscribe((movie) => { this._currentMovie.set(movie.Response === "True" ? movie : null); }); }}Paso 5: Página Home
Section titled “Paso 5: Página Home”Crear la página principal con:
- Barra de búsqueda (modal)
- Lista de favoritos
- Contador de favoritos con computed
import { Component, inject, ViewChild } from "@angular/core";import { CommonModule } from "@angular/common";import { FormsModule } from "@angular/forms";import { RouterLink } from "@angular/router";import { IonContent, IonSearchbar, IonModal, IonHeader, IonToolbar, IonTitle, IonButtons, IonButton, IonIcon, IonGrid, IonRow, IonCol,} from "@ionic/angular/standalone";import { MovieCardPage } from "../movie-card/movie-card.page";import { MovieService } from "src/app/core/services/movie/movie.service";import { addIcons } from "ionicons";import { star, closeOutline } from "ionicons/icons";
@Component({ selector: "app-home", templateUrl: "home.page.html", styleUrls: ["home.page.scss"], imports: [ CommonModule, FormsModule, RouterLink, IonContent, IonSearchbar, IonModal, IonHeader, IonToolbar, IonTitle, IonButtons, IonButton, IonIcon, IonGrid, IonRow, IonCol, MovieCardPage, ],})export class HomePage { @ViewChild(IonModal) modal!: IonModal; public movieService = inject(MovieService);
constructor() { addIcons({ star, closeOutline }); }
onSearch(event: any) { const query = event.target.value; if (query) { this.movieService.searchMovies(query); } }
closeModal() { this.modal.dismiss(); }}<ion-header><ion-toolbar> <div class="title-wrapper"> <ion-title size="large" color="primary" class="brand-title silver-text-glow" > COSMIC CINEMA </ion-title> <div class="brand-divider"></div> </div></ion-toolbar></ion-header>
<ion-content class="ion-padding"><!-- Botón de búsqueda (abre modal) --><div class="search-trigger-wrapper" id="open-search-modal"> <ion-searchbar placeholder="Search..." disabled="true"></ion-searchbar></div>
<!-- Sección de favoritos con contador computed -->
<ion-grid> <ion-row class="ion-align-items-center"> <ion-col> <h3 class="heralds-title ion-text-uppercase ion-no-margin"> <ion-icon name="star"></ion-icon> MY HERALDS </h3> </ion-col> <ion-col size="auto"> <span class="heralds-count">{{ movieService.favoritesCount() }}</span> </ion-col> </ion-row></ion-grid>
<!-- Lista de favoritos -->
<ion-grid> <ion-row> @for (movie of movieService.favorites(); track movie.imdbID) { <ion-col size="6"> <app-movie-card [title]="movie.Title" [subtitle]="movie.Year" [imageUrl]="movie.Poster" [routerLink]="['/movie-detail', movie.imdbID]" > </app-movie-card> </ion-col> } @empty { <ion-col size="12"> <p class="ion-text-center ion-padding">No favorites yet.</p> </ion-col> } </ion-row></ion-grid>
<!-- Modal de búsqueda -->
<ion-modal trigger="open-search-modal"> <ng-template> <ion-header class="ion-no-border"> <ion-toolbar> <ion-title class="ion-text-uppercase">Cosmic Navigation</ion-title> <ion-buttons slot="end"> <ion-button (click)="closeModal()"> <ion-icon name="close-outline" size="large"></ion-icon> </ion-button> </ion-buttons> </ion-toolbar> <ion-toolbar> <ion-searchbar placeholder="Search..." (ionInput)="onSearch($event)" debounce="500" ></ion-searchbar> </ion-toolbar> </ion-header>
<ion-content class="ion-padding"> <!-- Resultados de búsqueda --> <div class="modal-cards-container"> @for (movie of movieService.movies(); track movie.imdbID) { <div class="modal-celestial-wrapper"> <app-movie-card [title]="movie.Title" [subtitle]="movie.Year" [imageUrl]="movie.Poster" [routerLink]="['/movie-detail', movie.imdbID]" (click)="closeModal()" > </app-movie-card> </div> } @empty { <p class="ion-text-center empty-sector-text"> No signals found in this sector. </p> } </div> </ion-content> </ng-template>
</ion-modal></ion-content>Paso 6: Página de Detalle de Película
Section titled “Paso 6: Página de Detalle de Película”Mostrar información detallada de una película y permitir añadir/quitar de favoritos.
import { Component, OnInit, inject } from "@angular/core";import { CommonModule } from "@angular/common";import { FormsModule } from "@angular/forms";import { ActivatedRoute } from "@angular/router";import { IonContent, IonHeader, IonTitle, IonToolbar, IonBackButton, IonButtons, IonImg, IonGrid, IonCol, IonRow, IonCard, IonCardContent, IonButton, IonBadge,} from "@ionic/angular/standalone";import { MovieService } from "src/app/core/services/movie/movie.service";
@Component({ selector: "app-movie-detail", templateUrl: "movie-detail.page.html", styleUrls: ["movie-detail.page.scss"], imports: [ IonContent, IonHeader, IonTitle, IonToolbar, CommonModule, FormsModule, IonBackButton, IonButtons, IonImg, IonGrid, IonCol, IonRow, IonCard, IonCardContent, IonButton, IonBadge, ],})export class MovieDetailPage implements OnInit { private route = inject(ActivatedRoute); public movieService = inject(MovieService);
ngOnInit() { const id = this.route.snapshot.paramMap.get("id"); if (id) { this.movieService.getMovieDetails(id); } }
toggleFavorite() { const movie = this.movieService.currentMovie(); if (movie) { this.movieService.toggleFavorite(movie); } }
isFavorite(): boolean { const movie = this.movieService.currentMovie(); return movie ? this.movieService.isFavorite(movie.imdbID) : false; }}<ion-header class="ion-no-border"><ion-toolbar> <div class="title-wrapper"> <ion-buttons slot="start"> <ion-back-button defaultHref="/"></ion-back-button> </ion-buttons> <ion-title size="large" color="primary" class="brand-title silver-text-glow" > COSMIC CINEMA </ion-title> </div></ion-toolbar></ion-header>
<ion-content><div class="poster-container"> @if (movieService.currentMovie(); as movie) { <div class="metallic-border poster-wrapper"> <div class="gradient-overlay"></div> <ion-img [src]="movie.Poster" [alt]="movie.Title + ' Poster'"></ion-img> <ion-badge class="live-signal-badge"> <span class="material-symbols-outlined">fiber_manual_record</span> Live Signal </ion-badge> </div> }</div>
<div class="content-section"> @if (movieService.currentMovie(); as movie) { <div class="title-section"> <div class="label-wrapper"> <div class="label-line"></div> <span class="label-text">Identification</span> </div> <h1 class="movie-title">{{ movie.Title }}</h1> </div>
<ion-card class="registry-card glass-panel"> <ion-card-content> <div class="qr-decoration"> <span class="material-symbols-outlined qr-icon">qr_code_2</span> </div> <div class="registry-content"> <span class="registry-label">Global Registry ID</span> <div class="id-row"> <span class="imdb-id">{{ movie.imdbID }}</span> <div class="pulse-dot"></div> </div> </div> </ion-card-content> </ion-card>
<ion-grid class="ion-no-margin"> <ion-row> <ion-col class="ion-padding-end"> <ion-card class="info-card glass-panel ion-no-margin"> <ion-card-content> <p class="info-subtitle">Chronology</p> <p class="info-value">{{ movie.Year }}</p> </ion-card-content> </ion-card> </ion-col>
<ion-col class="ion-padding-start"> <ion-card class="info-card glass-panel ion-no-margin"> <ion-card-content> <p class="info-subtitle">Classification</p> <p class="info-value">{{ movie.Type | titlecase }}</p> </ion-card-content> </ion-card> </ion-col> </ion-row> </ion-grid>
<ion-button size="large" expand="full" fill="clear" class="chrome-gradient favorite-button" (click)="toggleFavorite()" > <span class="material-symbols-outlined"> {{ isFavorite() ? 'favorite' : 'bookmark' }} </span> {{ isFavorite() ? 'Remove from Favorites' : 'Save to Favorites' }} </ion-button> }
</div></ion-content>Paso 7: Componente Movie Card
Section titled “Paso 7: Componente Movie Card”Componente reutilizable para mostrar información de películas.
import { Component, Input } from "@angular/core";import { CommonModule } from "@angular/common";import { IonCard, IonCardContent, IonImg, IonLabel,} from "@ionic/angular/standalone";
@Component({ selector: "app-movie-card", templateUrl: "movie-card.page.html", standalone: true, styleUrls: ["movie-card.page.scss"], imports: [CommonModule, IonCard, IonCardContent, IonImg, IonLabel],})export class MovieCardPage { @Input() title: string = ""; @Input() subtitle: string = ""; @Input() imageUrl: string = "";}<ion-card class="cosmic-card"><div class="image-container"> <ion-img [src]="imageUrl" [alt]="title" [class.no-image]="!imageUrl || imageUrl === 'N/A'" ></ion-img> <div class="cosmic-overlay"></div></div><ion-card-content class="cosmic-content"> <ion-label class="cosmic-title">{{ title }}</ion-label> <ion-label class="cosmic-subtitle">{{ subtitle }}</ion-label></ion-card-content></ion-card>Paso 8: Configuración de Rutas
Section titled “Paso 8: Configuración de Rutas”Definir las rutas de navegación 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: "movie-detail/:id", loadComponent: () => import("./features/movie-detail/movie-detail.page").then( (m) => m.MovieDetailPage ), }, { path: "movie-card", loadComponent: () => import("./features/movie-card/movie-card.page").then( (m) => m.MovieCardPage ), },];Ejecución del Proyecto
Section titled “Ejecución del Proyecto”-
npm install
Terminal window npm install -
ionic serve
Terminal window ionic serve
Capturas del Diseño
Section titled “Capturas del Diseño”Pantalla Principal
Section titled “Pantalla Principal”
Modal de Búsqueda
Section titled “Modal de Búsqueda”
Detalle de Película
Section titled “Detalle de Película”