diff --git a/lab-02-django-api/apps/routes/migrations/0001_initial.py b/lab-02-django-api/apps/routes/migrations/0001_initial.py new file mode 100644 index 0000000..c3fe1a4 --- /dev/null +++ b/lab-02-django-api/apps/routes/migrations/0001_initial.py @@ -0,0 +1,52 @@ +# Generated by Django 6.0.3 on 2026-03-26 03:30 + +import django.contrib.gis.db.models.fields +from django.db import migrations, models + + +class Migration(migrations.Migration): + + initial = True + + dependencies = [ + ] + + operations = [ + migrations.CreateModel( + name='Stop', + fields=[ + ('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')), + ('code', models.CharField(max_length=20, unique=True)), + ('name', models.CharField(max_length=200)), + ('location', django.contrib.gis.db.models.fields.PointField(srid=4326)), + ('zone', models.CharField(choices=[('norte', 'Norte'), ('sur', 'Sur'), ('centro', 'Centro'), ('este', 'Este'), ('oeste', 'Oeste')], default='centro', max_length=20)), + ('total_routes', models.PositiveSmallIntegerField(default=0)), + ('is_active', models.BooleanField(default=True)), + ('created_at', models.DateTimeField(auto_now_add=True)), + ('updated_at', models.DateTimeField(auto_now=True)), + ], + options={ + 'ordering': ['code'], + 'indexes': [models.Index(fields=['location'], name='stop_location_gist'), models.Index(fields=['is_active'], name='stop_is_active_idx'), models.Index(fields=['zone'], name='stop_zone_idx')], + }, + ), + migrations.CreateModel( + name='Route', + fields=[ + ('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')), + ('code', models.CharField(max_length=20, unique=True)), + ('name', models.CharField(max_length=200)), + ('origin', models.CharField(max_length=200)), + ('destination', models.CharField(max_length=200)), + ('path', django.contrib.gis.db.models.fields.LineStringField(blank=True, null=True, srid=4326)), + ('is_active', models.BooleanField(default=True)), + ('created_at', models.DateTimeField(auto_now_add=True)), + ('updated_at', models.DateTimeField(auto_now=True)), + ('stops', models.ManyToManyField(blank=True, related_name='routes', to='routes.stop')), + ], + options={ + 'ordering': ['code'], + 'indexes': [models.Index(fields=['is_active'], name='route_is_active_idx')], + }, + ), + ] diff --git a/lab-02-django-api/apps/routes/migrations/__init__.py b/lab-02-django-api/apps/routes/migrations/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/lab-09-mobile/Dockerfile b/lab-09-mobile/Dockerfile new file mode 100644 index 0000000..45d1c9a --- /dev/null +++ b/lab-09-mobile/Dockerfile @@ -0,0 +1,14 @@ +FROM node:20-alpine + +RUN npm install -g pnpm + +WORKDIR /app + +COPY package.json ./ +RUN pnpm install + +COPY . . + +EXPOSE 3002 + +CMD ["pnpm", "dev"] diff --git a/lab-09-mobile/Dockerfile.android b/lab-09-mobile/Dockerfile.android new file mode 100644 index 0000000..a97b5e1 --- /dev/null +++ b/lab-09-mobile/Dockerfile.android @@ -0,0 +1,44 @@ +# Imagen con JDK 17 (requerido por Gradle 8 / Android SDK 34) +FROM eclipse-temurin:17-jdk-jammy + +# Node 20 + pnpm + Capacitor CLI +RUN apt-get update && apt-get install -y curl unzip && \ + curl -fsSL https://deb.nodesource.com/setup_20.x | bash - && \ + apt-get install -y nodejs && \ + npm install -g pnpm @capacitor/cli && \ + apt-get clean && rm -rf /var/lib/apt/lists/* + +# Android SDK (command-line tools) +ENV ANDROID_HOME=/opt/android-sdk +ENV PATH=$PATH:$ANDROID_HOME/cmdline-tools/latest/bin:$ANDROID_HOME/platform-tools + +RUN mkdir -p $ANDROID_HOME/cmdline-tools && \ + curl -o /tmp/cmdline-tools.zip \ + https://dl.google.com/android/repository/commandlinetools-linux-11076708_latest.zip && \ + unzip /tmp/cmdline-tools.zip -d $ANDROID_HOME/cmdline-tools && \ + mv $ANDROID_HOME/cmdline-tools/cmdline-tools $ANDROID_HOME/cmdline-tools/latest && \ + rm /tmp/cmdline-tools.zip + +RUN yes | sdkmanager --licenses && \ + sdkmanager \ + "platform-tools" \ + "platforms;android-34" \ + "build-tools;34.0.0" + +WORKDIR /app + +COPY package.json ./ +RUN pnpm install + +COPY . . + +# 1. Genera la SPA estática con la URL de la API apuntando al host del emulador +# 2. Sincroniza assets con el proyecto Android via Capacitor +# 3. Compila el APK con Gradle +CMD ["sh", "-c", \ + "pnpm generate && \ + npx cap add android || true && \ + npx cap sync android && \ + cd android && \ + chmod +x gradlew && \ + ./gradlew assembleDebug --no-daemon"] diff --git a/lab-09-mobile/README.md b/lab-09-mobile/README.md new file mode 100644 index 0000000..7dfb01f --- /dev/null +++ b/lab-09-mobile/README.md @@ -0,0 +1,229 @@ +# Lab 09 — Mobile: Capacitor · Ionic · Vue 3 · Nuxt 3 + +Versión mobile del dashboard de transporte público (Lab 06) empaquetada con Capacitor para Android. Usa componentes Ionic para UX mobile-first y el plugin nativo de Geolocation para obtener la posición GPS del dispositivo. + +--- + +## Stack + +| Componente | Tecnología | +|---|---| +| **Framework** | Nuxt 3 (SSR desactivado → static export) | +| **UI** | @ionic/vue · Ionic 8 | +| **Lenguaje** | TypeScript | +| **Mapa** | Leaflet 1.9 (UMD via server route — sin procesamiento Vite) | +| **Native runtime** | @capacitor/core | +| **Plugin nativo** | @capacitor/geolocation | +| **Plataforma nativa** | @capacitor/android | +| **Gestor de paquetes** | pnpm | +| **Infraestructura** | Docker · Docker Compose · Node 20 · Android SDK 34 | + +--- + +## Conceptos Demostrados + +### Capacitor como puente nativo +Capacitor envuelve la web app en un WebView nativo y expone APIs del dispositivo (GPS, cámara, filesystem) como módulos JavaScript. El mismo código corre en Android, iOS, y en el browser sin modificaciones. + +### Ionic Vue para mobile-first +`@ionic/vue` provee componentes optimizados para móvil (`IonPage`, `IonHeader`, `IonList`, `IonCard`) con gestos nativos, animaciones de navegación, y variantes de plataforma automáticas (Android Material / iOS Cupertino). + +### useGeolocation — composable nativo con fallback +`composables/useGeolocation.ts` detecta si corre en Capacitor o en el browser y usa la API correspondiente (`@capacitor/geolocation` vs `navigator.geolocation`). El mismo composable funciona en el servicio `web` de Docker y en el emulador Android. + +### SSR desactivado para Capacitor +Nuxt se construye con `ssr: false` para generar una SPA estática en `.output/public/`. Este directorio es el que Capacitor copia al proyecto Android como `android/app/src/main/assets/public/` durante `cap sync`. + +### Build Android 100% en Docker +El servicio `android-build` incluye el Android SDK y Gradle. Ejecuta `cap sync` y `./gradlew assembleDebug` dentro del contenedor y escribe el APK resultante en `build/outputs/`. No se requiere Android Studio ni SDK instalados en el host. + +--- + +## Estructura + +``` +lab-09-mobile/ +├── docker-compose.yml +├── Dockerfile # Node 20: Nuxt dev server +├── Dockerfile.android # Android SDK 34 + Gradle: generación del APK +├── nuxt.config.ts # SSR off · Ionic plugin · proxy a Lab 02 +├── capacitor.config.ts # appId · webDir · androidScheme +├── ionic.config.json +├── package.json +├── app.vue # IonApp + NuxtPage (sin @ionic/vue-router) +├── plugins/ +│ ├── ionic.client.ts # Registrar IonicVue (client-only) +│ └── leaflet.client.ts # No-op: window.L lo inyecta el UMD via script tag +├── pages/ +│ ├── index.vue # Dashboard: conteo rutas + posición GPS +│ ├── routes.vue # Lista de rutas (IonList) ← Lab 02 +│ └── map.vue # Mapa Leaflet + posición GPS en tiempo real +├── components/ +│ ├── RouteCard.vue # IonCard por ruta +│ └── GpsStatus.vue # Coordenadas + accuracy del dispositivo +├── server/ +│ ├── api/ +│ │ └── routes.ts # Proxy BFF → Lab 02 (evita CORS desde SPA) +│ └── routes/ +│ └── leaflet-dist/ +│ └── leaflet.js.ts # Sirve el UMD de leaflet desde node_modules en runtime +├── composables/ +│ ├── useGeolocation.ts # Wrapper Capacitor Geolocation + fallback web +│ └── useApi.ts # Fetch tipado → /api/routes (proxy) +├── tests/ +│ ├── composables/ +│ │ ├── useApi.test.ts # 4 tests — fetchRoutes, fetchRoute, unwrap results +│ │ └── useGeolocation.test.ts # 4 tests — ruta browser + ruta nativa Capacitor +│ └── components/ +│ ├── RouteCard.test.ts # 3 tests — renderizado de nombre, ruta, código +│ └── GpsStatus.test.ts # 3 tests — sin posición, con coords, con error +├── vitest.config.ts +├── docs/ +│ └── architecture.md # Arquitectura, flujo de datos y decisiones de diseño +└── README.md +``` + +--- + +## Servicios Docker + +| Servicio | Puerto (host) | Descripción | +|---|---|---| +| `web` | 3002 | Nuxt 3 dev server (modo SPA) | +| `android-build` | — | Android SDK 34 + Gradle — genera el APK (`--profile build`) | +| Lab 02 `app` | 8000 | Django REST API (rutas, paradas) | + +> El lab-09 no tiene backend propio. Consume la API REST del Lab 02 directamente. + +--- + +## Inicio Rápido + +### 1. Levantar el Lab 02 (API de rutas) + +```bash +cd ../lab-02-django-api +docker compose up -d +docker compose exec app python manage.py migrate +``` + +Crear datos de prueba: + +```bash +docker compose exec app python manage.py shell -c " +from apps.routes.models import Route +Route.objects.create(code='R01', name='Ruta Escazu', origin='San Jose', destination='Escazu') +Route.objects.create(code='R02', name='Ruta Cartago', origin='San Jose', destination='Cartago') +Route.objects.create(code='R03', name='Ruta Alajuela', origin='San Jose', destination='Alajuela') +" +``` + +### 2. Levantar el Lab 09 (app mobile en modo web) + +```bash +cd ../lab-09-mobile +docker compose up -d + +# Ver logs +docker compose logs -f web + +# http://localhost:3002 +``` + +--- + +## Páginas + +### `/` — Dashboard +Resumen con contador de rutas activas y la posición GPS del dispositivo (o del browser cuando corre en web). Incluye botón de actualización GPS. + +### `/routes` — Lista de Rutas +Lista mobile con `IonSearchbar` para filtrado y `IonItem` por ruta. Mismos datos que Lab 06, misma API del Lab 02. + +### `/map` — Mapa con GPS +Mapa Leaflet centrado en San José, CR. Un botón flotante activa el GPS nativo y añade un marcador con la posición actual del dispositivo. + +--- + +## Build del APK (Android) + +El proceso completo de compilación corre dentro de Docker mediante el servicio `android-build`: + +### 1. Generar la web app estática + APK + +```bash +docker compose --profile build run --rm android-build +``` + +El servicio ejecuta internamente: + +``` +pnpm generate # Nuxt → .output/public/ +npx cap sync android # Capacitor copia assets al proyecto Android +./gradlew assembleDebug # Gradle compila el APK +``` + +El archivo resultante queda en: + +``` +android/app/build/outputs/apk/debug/app-debug.apk +``` + +### 2. Instalar el APK en un dispositivo o emulador + +El servicio `android-build` monta el directorio del lab como volumen (`.:/app`), por lo que el APK queda disponible directamente en el host al terminar el build: + +``` +lab-09-mobile/android/app/build/outputs/apk/debug/app-debug.apk +``` + +Para instalarlo en un dispositivo conectado por USB (depuración USB habilitada): + +```bash +adb install android/app/build/outputs/apk/debug/app-debug.apk +``` + +Para instalarlo en el emulador Android Studio, basta con arrastrar el archivo `.apk` sobre la ventana del emulador. + +> **Conexión al backend desde el emulador:** El emulador Android usa `10.0.2.2` como alias de la IP del host. La variable `NUXT_PUBLIC_API_BASE=http://10.0.2.2:8000` se inyecta en el contenedor `android-build` durante el build estático, por lo que la app compilada apunta al Lab 02 correctamente. + +--- + +## Tests + +Los tests cubren la lógica de composables y el renderizado de componentes con Vitest + Vue Test Utils. Los componentes Ionic se stubbean para aislar la lógica Vue del runtime nativo. + +```bash +docker compose exec web pnpm test +``` + +| Archivo | Tests | Qué cubre | +|---|---|---| +| `tests/composables/useApi.test.ts` | 3 | URL del proxy `/api/routes`, unwrap de array, array vacío | +| `tests/composables/useGeolocation.test.ts` | 4 | Ruta browser (`navigator.geolocation`), ruta nativa (mock Capacitor), loading, error | +| `tests/components/RouteCard.test.ts` | 3 | Renderizado de nombre, origen → destino, código | +| `tests/components/GpsStatus.test.ts` | 3 | Estado sin posición, con coordenadas, con error | + +--- + +## Diferencias respecto al Lab 06 + +| Aspecto | Lab 06 | Lab 09 | +|---|---|---| +| SSR | Activado (híbrido) | Desactivado (SPA) | +| UI library | Nuxt UI | Ionic Vue | +| GPS | `navigator.geolocation` (web) | `@capacitor/geolocation` (nativo + fallback) | +| Target | Browser | Android / iOS / Browser | +| Puerto | 3000 | 3002 | +| Build nativo | — | Docker + Android SDK 34 + Gradle | + +--- + +## Qué Demuestra Este Laboratorio + +- **Capacitor** como capa de empaquetado nativo sobre una web app Nuxt existente +- **Ionic Vue** con componentes mobile-first integrados en Nuxt 3 +- **Plugin nativo `@capacitor/geolocation`** con composable que abstrae la diferencia entre browser y dispositivo nativo +- **SSR desactivado** en Nuxt para generar el static bundle que Capacitor consume +- **Pipeline de build Android 100% en Docker** — sin dependencias en el host +- **Reutilización de backend** — misma API REST del Lab 02, sin modificaciones diff --git a/lab-09-mobile/app.vue b/lab-09-mobile/app.vue new file mode 100644 index 0000000..bbd0487 --- /dev/null +++ b/lab-09-mobile/app.vue @@ -0,0 +1,9 @@ + + + diff --git a/lab-09-mobile/capacitor.config.ts b/lab-09-mobile/capacitor.config.ts new file mode 100644 index 0000000..91796b1 --- /dev/null +++ b/lab-09-mobile/capacitor.config.ts @@ -0,0 +1,10 @@ +const config = { + appId: 'cr.simovi.mobile', + appName: 'SIMOVI Mobile', + webDir: '.output/public', + server: { + androidScheme: 'https', + }, +} + +export default config diff --git a/lab-09-mobile/components/GpsStatus.vue b/lab-09-mobile/components/GpsStatus.vue new file mode 100644 index 0000000..c467bb8 --- /dev/null +++ b/lab-09-mobile/components/GpsStatus.vue @@ -0,0 +1,36 @@ + + + diff --git a/lab-09-mobile/components/RouteCard.vue b/lab-09-mobile/components/RouteCard.vue new file mode 100644 index 0000000..1030180 --- /dev/null +++ b/lab-09-mobile/components/RouteCard.vue @@ -0,0 +1,16 @@ + + + diff --git a/lab-09-mobile/composables/useApi.ts b/lab-09-mobile/composables/useApi.ts new file mode 100644 index 0000000..96f5321 --- /dev/null +++ b/lab-09-mobile/composables/useApi.ts @@ -0,0 +1,17 @@ +export interface Route { + id: number + code: string + name: string + origin: string + destination: string +} + +export function useApi() { + // Llama al server route de Nuxt (mismo origen → sin CORS). + // El proxy server-side resuelve hacia Lab 02 via host.docker.internal. + async function fetchRoutes(): Promise { + return $fetch('/api/routes') + } + + return { fetchRoutes } +} diff --git a/lab-09-mobile/composables/useGeolocation.ts b/lab-09-mobile/composables/useGeolocation.ts new file mode 100644 index 0000000..61b2b9a --- /dev/null +++ b/lab-09-mobile/composables/useGeolocation.ts @@ -0,0 +1,63 @@ +import { ref } from 'vue' + +export interface GpsPosition { + latitude: number + longitude: number + accuracy: number +} + +export function useGeolocation() { + const position = ref(null) + const error = ref(null) + const loading = ref(false) + + async function getCurrentPosition() { + loading.value = true + error.value = null + + try { + // En plataforma nativa Capacitor usa la API del dispositivo. + // En browser (Docker dev) usa navigator.geolocation como fallback. + if ( + typeof window !== 'undefined' && + (window as { Capacitor?: { isNativePlatform: () => boolean } }) + .Capacitor?.isNativePlatform() + ) { + const { Geolocation } = await import('@capacitor/geolocation') + const result = await Geolocation.getCurrentPosition({ enableHighAccuracy: true }) + position.value = { + latitude: result.coords.latitude, + longitude: result.coords.longitude, + accuracy: result.coords.accuracy, + } + } else { + // En desktop no hay GPS hardware — enableHighAccuracy: false usa IP/WiFi + const result = await new Promise((resolve, reject) => + navigator.geolocation.getCurrentPosition(resolve, reject, { + enableHighAccuracy: false, + timeout: 10000, + }) + ) + console.log('[GPS] posición obtenida:', result.coords) + position.value = { + latitude: result.coords.latitude, + longitude: result.coords.longitude, + accuracy: result.coords.accuracy, + } + } + } catch (e: unknown) { + const geoErr = e as GeolocationPositionError + console.error('[GPS] error code:', geoErr.code, 'message:', geoErr.message, 'raw:', e) + const msgs: Record = { + 1: 'Permiso denegado', + 2: 'Posición no disponible', + 3: 'Tiempo de espera agotado (10 s)', + } + error.value = msgs[geoErr.code] ?? (geoErr.message || 'Error obteniendo ubicación') + } finally { + loading.value = false + } + } + + return { position, error, loading, getCurrentPosition } +} diff --git a/lab-09-mobile/docker-compose.yml b/lab-09-mobile/docker-compose.yml new file mode 100644 index 0000000..12b4e95 --- /dev/null +++ b/lab-09-mobile/docker-compose.yml @@ -0,0 +1,38 @@ +services: + web: + build: + context: . + dockerfile: Dockerfile + ports: + - "3002:3002" + volumes: + - .:/app + - node_modules:/app/node_modules + environment: + # El server route proxy corre en el contenedor → host.docker.internal resuelve Lab 02 + NUXT_PUBLIC_API_BASE: http://host.docker.internal:8000 + NODE_OPTIONS: --max-old-space-size=3072 + extra_hosts: + - "host.docker.internal:host-gateway" + restart: unless-stopped + + android-build: + build: + context: . + dockerfile: Dockerfile.android + volumes: + - .:/app + - node_modules_android:/app/node_modules + - android-sdk-cache:/opt/android-sdk + - gradle-cache:/root/.gradle + environment: + # 10.0.2.2 es el alias del emulador Android para llegar al host + NUXT_PUBLIC_API_BASE: http://10.0.2.2:8000 + profiles: + - build + +volumes: + node_modules: + node_modules_android: + android-sdk-cache: + gradle-cache: diff --git a/lab-09-mobile/docs/architecture.md b/lab-09-mobile/docs/architecture.md new file mode 100644 index 0000000..847839f --- /dev/null +++ b/lab-09-mobile/docs/architecture.md @@ -0,0 +1,139 @@ +# Arquitectura — Lab 09: Mobile (Capacitor · Ionic · Nuxt 3) + +## Visión General + +El lab-09 empaqueta la web app de transporte como aplicación Android nativa usando Capacitor. El mismo código Vue/Nuxt corre en tres contextos: browser (Docker dev), WebView nativo (Android), y APK generado via Docker. + +--- + +## Diagrama de Componentes + +``` +┌─────────────────────────────────────────────────────────────┐ +│ MODO DESARROLLO │ +│ │ +│ Browser → http://localhost:3002 │ +│ │ │ +│ ┌────▼────────────────────────────────┐ │ +│ │ Docker: servicio web (Node 20) │ │ +│ │ Nuxt 3 dev server — puerto 3002 │ │ +│ │ │ │ +│ │ pages/index.vue (Dashboard) │ │ +│ │ pages/routes.vue (IonList) │ │ +│ │ pages/map.vue (Leaflet + GPS) │ │ +│ │ │ │ +│ │ server/api/routes.ts ──────────────┼──► Lab 02 Django :8000 +│ │ (BFF proxy — evita CORS) │ /api/routes/ +│ │ │ +│ │ server/routes/leaflet-dist/ │ +│ │ leaflet.js.ts ─────────────────────►│ node_modules/leaflet/ +│ │ (sirve UMD en runtime) │ dist/leaflet.js +│ └──────────────────────────────────────┘ +└─────────────────────────────────────────────────────────────┘ + +┌─────────────────────────────────────────────────────────────┐ +│ MODO NATIVO (Android) │ +│ │ +│ Dispositivo / Emulador │ +│ ┌───────────────────────────────────────────────────┐ │ +│ │ Android WebView │ │ +│ │ ┌─────────────────────────────────────────────┐ │ │ +│ │ │ SPA estática (.output/public/) │ │ │ +│ │ │ Ionic components · Leaflet · useApi │ │ │ +│ │ └──────────────┬──────────────────────────────┘ │ │ +│ │ │ │ │ +│ │ Capacitor Bridge (JS ↔ Native) │ │ +│ │ │ │ │ +│ │ @capacitor/geolocation → GPS chip del dispositivo │ │ +│ └───────────────────────────────────────────────────┘ │ +│ │ HTTP /api/routes/ │ +│ 10.0.2.2:8000 (alias host del emulador) │ +│ ┌────────────────▼─────────────────────┐ │ +│ │ Lab 02 — Django REST API :8000 │ │ +│ └──────────────────────────────────────┘ │ +└─────────────────────────────────────────────────────────────┘ + +┌─────────────────────────────────────────────────────────────┐ +│ PIPELINE DE BUILD (Docker) │ +│ │ +│ docker compose --profile build run --rm android-build │ +│ │ +│ Dockerfile.android (eclipse-temurin:17-jdk-jammy) │ +│ ┌──────────────────────────────────────────────────────┐ │ +│ │ 1. pnpm generate │ │ +│ │ Nuxt → SPA estática en .output/public/ │ │ +│ │ │ │ +│ │ 2. npx cap sync android │ │ +│ │ Copia assets → android/app/src/main/assets/public/ │ │ +│ │ │ │ +│ │ 3. ./gradlew assembleDebug │ │ +│ │ Compila APK con Android SDK 34 │ │ +│ │ │ │ +│ │ Output: │ │ +│ │ android/app/build/outputs/apk/debug/app-debug.apk │ │ +│ └──────────────────────────────────────────────────────┘ │ +└─────────────────────────────────────────────────────────────┘ +``` + +--- + +## Flujo de Datos + +### Rutas (API REST via BFF proxy) +``` +pages/routes.vue + └── onMounted → useApi().fetchRoutes() + └── $fetch('/api/routes') ← mismo origen, sin CORS + └── server/api/routes.ts ← BFF proxy (server-side) + └── $fetch('http://host.docker.internal:8000/api/routes/') + └── Lab 02 DRF → [{id, code, name, origin, destination}] + └── filtered (IonSearchbar) → RouteCard[] +``` + +### GPS +``` +GpsStatus.vue / pages/map.vue + └── useGeolocation().getCurrentPosition() + ├── [nativo] Capacitor.isNativePlatform() = true + │ └── @capacitor/geolocation.getCurrentPosition({ enableHighAccuracy: true }) + │ └── GPS chip → {latitude, longitude, accuracy} + └── [web] navigator.geolocation.getCurrentPosition({ enableHighAccuracy: false }) + └── Browser Geolocation API (IP/WiFi) → {latitude, longitude, accuracy} +``` + +--- + +## Composables + +### `useGeolocation` +Detecta en tiempo de ejecución si corre dentro de Capacitor (`window.Capacitor?.isNativePlatform()`) y despacha hacia la API nativa o al fallback del browser. En browser usa `enableHighAccuracy: false` porque el desktop no tiene chip GPS. Expone `position`, `error`, `loading`, y `getCurrentPosition()`. + +### `useApi` +Wrapper tipado sobre `$fetch` de Nuxt. Llama al server route proxy `/api/routes` (mismo origen) en lugar de llamar a Lab 02 directamente, evitando CORS en el browser. + +--- + +## Decisiones de Diseño + +| Decisión | Alternativa | Motivo | +|----------|-------------|--------| +| `ssr: false` en Nuxt | SSR activado | Capacitor requiere un bundle estático; el WebView no tiene servidor Node | +| `@ionic/vue` sobre Nuxt UI | Nuxt UI (como Lab 06) | Ionic provee gestos nativos, scroll momentum y look nativo en Android/iOS | +| BFF proxy `server/api/routes.ts` | Llamada directa al backend | Con `ssr: false` el browser hace las peticiones — `host.docker.internal` no resuelve desde el browser ni desde el WebView Android | +| `IonButton` + `router.back()` en lugar de `IonBackButton` | `IonBackButton` | `IonBackButton` requiere el `navManager` de `@ionic/vue-router`, incompatible con el router de Nuxt | +| `
` en vez de `` | `IonPage` | Mismo motivo: `IonPage` depende del router de Ionic | +| Leaflet UMD via server route | `import` desde Vite / `@nuxtjs/leaflet` | `leaflet-src.esm.js` tiene exports incompatibles con Vite; incluirlo en `optimizeDeps` causa OOM (>1.5 GB heap). El server route sirve el UMD directamente desde `node_modules` en runtime, Vite nunca lo toca | +| Fallback `navigator.geolocation` con `enableHighAccuracy: false` | Solo Capacitor / `enableHighAccuracy: true` | Permite probar GPS en Docker dev sin emulador. `enableHighAccuracy: true` falla en desktop (código 2 `POSITION_UNAVAILABLE`) porque no hay hardware GPS | +| Build Android en Docker | Android Studio local | Elimina dependencias de host; reproducible en CI | +| `eclipse-temurin:17` como base | `openjdk` oficial | JDK 17 LTS + JVM optimizada para builds Gradle | +| `--profile build` en docker-compose | Servicio siempre activo | El build Android tarda varios minutos; se invoca explícitamente | +| `NODE_OPTIONS=--max-old-space-size=3072` | Default Node (~1.5 GB) | Vite + Ionic + optimizeDeps consume más de 1.5 GB durante el arranque del dev server | + +--- + +## Puertos + +| Servicio | Puerto | Descripción | +|----------|--------|-------------| +| `web` | 3002 | Nuxt dev server (browser) | +| Lab 02 | 8000 | Django REST API — fuente de datos de rutas | diff --git a/lab-09-mobile/ionic.config.json b/lab-09-mobile/ionic.config.json new file mode 100644 index 0000000..5c39f1c --- /dev/null +++ b/lab-09-mobile/ionic.config.json @@ -0,0 +1,7 @@ +{ + "name": "simovi-mobile", + "type": "vue", + "integrations": { + "capacitor": {} + } +} diff --git a/lab-09-mobile/nuxt.config.ts b/lab-09-mobile/nuxt.config.ts new file mode 100644 index 0000000..547e028 --- /dev/null +++ b/lab-09-mobile/nuxt.config.ts @@ -0,0 +1,46 @@ +export default defineNuxtConfig({ + ssr: false, + + plugins: ['~/plugins/ionic.client.ts', '~/plugins/leaflet.client.ts'], + + css: [ + 'leaflet/dist/leaflet.css', + '@ionic/vue/css/core.css', + '@ionic/vue/css/normalize.css', + '@ionic/vue/css/structure.css', + '@ionic/vue/css/typography.css', + '@ionic/vue/css/padding.css', + '@ionic/vue/css/float-elements.css', + '@ionic/vue/css/text-alignment.css', + '@ionic/vue/css/text-transformation.css', + '@ionic/vue/css/flex-utils.css', + '@ionic/vue/css/display.css', + ], + + runtimeConfig: { + public: { + apiBase: process.env.NUXT_PUBLIC_API_BASE || 'http://localhost:8000', + }, + }, + + experimental: { + // #app-manifest solo se genera en build; desactivar para evitar errores en dev con ssr:false + appManifest: false, + }, + + // Leaflet UMD se sirve via server route (ver server/routes/leaflet-dist/leaflet.js.ts) + // y se carga como diff --git a/lab-09-mobile/pages/map.vue b/lab-09-mobile/pages/map.vue new file mode 100644 index 0000000..c3781f7 --- /dev/null +++ b/lab-09-mobile/pages/map.vue @@ -0,0 +1,76 @@ + + + diff --git a/lab-09-mobile/pages/routes.vue b/lab-09-mobile/pages/routes.vue new file mode 100644 index 0000000..ac09e72 --- /dev/null +++ b/lab-09-mobile/pages/routes.vue @@ -0,0 +1,60 @@ + + + diff --git a/lab-09-mobile/plugins/ionic.client.ts b/lab-09-mobile/plugins/ionic.client.ts new file mode 100644 index 0000000..d8bfb8a --- /dev/null +++ b/lab-09-mobile/plugins/ionic.client.ts @@ -0,0 +1,5 @@ +import { IonicVue } from '@ionic/vue' + +export default defineNuxtPlugin((nuxtApp) => { + nuxtApp.vueApp.use(IonicVue) +}) diff --git a/lab-09-mobile/plugins/leaflet.client.ts b/lab-09-mobile/plugins/leaflet.client.ts new file mode 100644 index 0000000..9ff2310 --- /dev/null +++ b/lab-09-mobile/plugins/leaflet.client.ts @@ -0,0 +1,2 @@ +// window.L lo inyecta el UMD de leaflet cargado via app.head.script en nuxt.config.ts +export default defineNuxtPlugin(() => {}) diff --git a/lab-09-mobile/server/api/routes.ts b/lab-09-mobile/server/api/routes.ts new file mode 100644 index 0000000..7037cc4 --- /dev/null +++ b/lab-09-mobile/server/api/routes.ts @@ -0,0 +1,5 @@ +export default defineEventHandler(async () => { + const base = process.env.NUXT_PUBLIC_API_BASE || 'http://localhost:8000' + const data = await $fetch<{ count: number; results: unknown[] }>(`${base}/api/routes/`) + return data.results ?? [] +}) diff --git a/lab-09-mobile/server/routes/leaflet-dist/leaflet.js.ts b/lab-09-mobile/server/routes/leaflet-dist/leaflet.js.ts new file mode 100644 index 0000000..f457e02 --- /dev/null +++ b/lab-09-mobile/server/routes/leaflet-dist/leaflet.js.ts @@ -0,0 +1,9 @@ +import { readFileSync } from 'node:fs' +import { resolve } from 'node:path' + +// Sirve el UMD de leaflet desde node_modules en runtime. +// Esto evita que Vite lo procese (OOM + named export issues con leaflet-src.esm.js). +export default defineEventHandler((event) => { + setHeader(event, 'content-type', 'application/javascript; charset=utf-8') + return readFileSync(resolve(process.cwd(), 'node_modules/leaflet/dist/leaflet.js'), 'utf-8') +}) diff --git a/lab-09-mobile/tests/components/GpsStatus.test.ts b/lab-09-mobile/tests/components/GpsStatus.test.ts new file mode 100644 index 0000000..967fc4a --- /dev/null +++ b/lab-09-mobile/tests/components/GpsStatus.test.ts @@ -0,0 +1,58 @@ +import { describe, it, expect, vi } from 'vitest' +import { mount } from '@vue/test-utils' +import { ref } from 'vue' + +const ionStubs = { + IonCard: { template: '
' }, + IonCardHeader: { template: '
' }, + IonCardSubtitle: { template: '
' }, + IonCardContent: { template: '
' }, + IonButton: { template: '' }, + IonSpinner: { template: '' }, +} + +// useGeolocation como vi.fn() para poder usar mockReturnValueOnce +vi.mock('~/composables/useGeolocation', () => ({ + useGeolocation: vi.fn(() => ({ + position: ref(null), + error: ref(null), + loading: ref(false), + getCurrentPosition: vi.fn(), + })), +})) + +import GpsStatus from '~/components/GpsStatus.vue' +import { useGeolocation } from '~/composables/useGeolocation' + +describe('GpsStatus', () => { + it('muestra mensaje por defecto sin posición', () => { + const wrapper = mount(GpsStatus, { global: { stubs: ionStubs } }) + expect(wrapper.text()).toContain('Sin posición') + }) + + it('muestra coordenadas cuando hay posición', () => { + vi.mocked(useGeolocation).mockReturnValueOnce({ + position: ref({ latitude: 9.9281, longitude: -84.0907, accuracy: 12 }), + error: ref(null), + loading: ref(false), + getCurrentPosition: vi.fn(), + }) + + const wrapper = mount(GpsStatus, { global: { stubs: ionStubs } }) + expect(wrapper.text()).toContain('9.928100') + expect(wrapper.text()).toContain('-84.090700') + expect(wrapper.text()).toContain('±12') + }) + + it('muestra el error cuando falla el GPS', () => { + vi.mocked(useGeolocation).mockReturnValueOnce({ + position: ref(null), + error: ref('Posición no disponible'), + loading: ref(false), + getCurrentPosition: vi.fn(), + }) + + const wrapper = mount(GpsStatus, { global: { stubs: ionStubs } }) + expect(wrapper.text()).toContain('Posición no disponible') + }) +}) diff --git a/lab-09-mobile/tests/components/RouteCard.test.ts b/lab-09-mobile/tests/components/RouteCard.test.ts new file mode 100644 index 0000000..e6d31c1 --- /dev/null +++ b/lab-09-mobile/tests/components/RouteCard.test.ts @@ -0,0 +1,37 @@ +import { describe, it, expect } from 'vitest' +import { mount } from '@vue/test-utils' +import RouteCard from '~/components/RouteCard.vue' +import type { Route } from '~/composables/useApi' + +// Stubs con template para que el slot se renderice y wrapper.text() funcione +const ionStubs = { + IonItem: { template: '
' }, + IonLabel: { template: '
' }, + IonBadge: { template: '' }, +} + +const route: Route = { + id: 1, + code: 'R01', + name: 'Ruta Escazu', + origin: 'San Jose', + destination: 'Escazu', +} + +describe('RouteCard', () => { + it('muestra el nombre de la ruta', () => { + const wrapper = mount(RouteCard, { props: { route }, global: { stubs: ionStubs } }) + expect(wrapper.text()).toContain('Ruta Escazu') + }) + + it('muestra origen → destino', () => { + const wrapper = mount(RouteCard, { props: { route }, global: { stubs: ionStubs } }) + expect(wrapper.text()).toContain('San Jose') + expect(wrapper.text()).toContain('Escazu') + }) + + it('muestra el código de ruta', () => { + const wrapper = mount(RouteCard, { props: { route }, global: { stubs: ionStubs } }) + expect(wrapper.text()).toContain('R01') + }) +}) diff --git a/lab-09-mobile/tests/composables/useApi.test.ts b/lab-09-mobile/tests/composables/useApi.test.ts new file mode 100644 index 0000000..4317d9c --- /dev/null +++ b/lab-09-mobile/tests/composables/useApi.test.ts @@ -0,0 +1,48 @@ +import { describe, it, expect, vi, beforeEach } from 'vitest' + +// Mockear $fetch de Nuxt antes de importar el composable +const mockFetch = vi.fn() +vi.stubGlobal('$fetch', mockFetch) + +// Importar después del mock para que el composable recoja el stub +const { useApi } = await import('~/composables/useApi') + +describe('useApi', () => { + beforeEach(() => { + mockFetch.mockReset() + }) + + describe('fetchRoutes', () => { + it('devuelve el array de rutas desde el proxy /api/routes', async () => { + mockFetch.mockResolvedValue([ + { id: 1, code: 'R01', name: 'Ruta Escazu', origin: 'San Jose', destination: 'Escazu' }, + { id: 2, code: 'R02', name: 'Ruta Cartago', origin: 'San Jose', destination: 'Cartago' }, + ]) + + const { fetchRoutes } = useApi() + const routes = await fetchRoutes() + + expect(routes).toHaveLength(2) + expect(routes[0].code).toBe('R01') + expect(routes[1].destination).toBe('Cartago') + }) + + it('llama al endpoint del proxy (mismo origen, sin CORS)', async () => { + mockFetch.mockResolvedValue([]) + + const { fetchRoutes } = useApi() + await fetchRoutes() + + expect(mockFetch).toHaveBeenCalledWith('/api/routes') + }) + + it('devuelve array vacío si el proxy no retorna rutas', async () => { + mockFetch.mockResolvedValue([]) + + const { fetchRoutes } = useApi() + const routes = await fetchRoutes() + + expect(routes).toEqual([]) + }) + }) +}) diff --git a/lab-09-mobile/tests/composables/useGeolocation.test.ts b/lab-09-mobile/tests/composables/useGeolocation.test.ts new file mode 100644 index 0000000..eed8503 --- /dev/null +++ b/lab-09-mobile/tests/composables/useGeolocation.test.ts @@ -0,0 +1,97 @@ +import { describe, it, expect, vi, beforeEach } from 'vitest' +import { useGeolocation } from '~/composables/useGeolocation' + +function mockBrowserGeolocation(coords: { latitude: number; longitude: number; accuracy: number }) { + Object.defineProperty(navigator, 'geolocation', { + value: { + getCurrentPosition: vi.fn((success) => + success({ coords }) + ), + }, + configurable: true, + }) +} + +function setNativePlatform(isNative: boolean) { + Object.defineProperty(window, 'Capacitor', { + value: isNative ? { isNativePlatform: () => true } : undefined, + configurable: true, + writable: true, + }) +} + +describe('useGeolocation', () => { + beforeEach(() => { + setNativePlatform(false) + vi.restoreAllMocks() + }) + + describe('ruta browser (web)', () => { + it('obtiene posición desde navigator.geolocation', async () => { + mockBrowserGeolocation({ latitude: 9.9281, longitude: -84.0907, accuracy: 15 }) + + const { position, error, getCurrentPosition } = useGeolocation() + await getCurrentPosition() + + expect(error.value).toBeNull() + expect(position.value).toEqual({ latitude: 9.9281, longitude: -84.0907, accuracy: 15 }) + }) + + it('establece loading durante la solicitud', async () => { + let resolveGeo!: (p: any) => void + Object.defineProperty(navigator, 'geolocation', { + value: { + getCurrentPosition: vi.fn((success) => { + resolveGeo = success + }), + }, + configurable: true, + }) + + const { loading, getCurrentPosition } = useGeolocation() + const promise = getCurrentPosition() + + expect(loading.value).toBe(true) + resolveGeo({ coords: { latitude: 0, longitude: 0, accuracy: 1 } }) + await promise + expect(loading.value).toBe(false) + }) + + it('captura error si geolocation falla', async () => { + Object.defineProperty(navigator, 'geolocation', { + value: { + getCurrentPosition: vi.fn((_success, error) => + error(new Error('Permission denied')) + ), + }, + configurable: true, + }) + + const { position, error, getCurrentPosition } = useGeolocation() + await getCurrentPosition() + + expect(position.value).toBeNull() + expect(error.value).toBe('Permission denied') + }) + }) + + describe('ruta nativa (Capacitor)', () => { + it('usa @capacitor/geolocation cuando isNativePlatform es true', async () => { + setNativePlatform(true) + + const mockGetCurrentPosition = vi.fn().mockResolvedValue({ + coords: { latitude: 9.9337, longitude: -84.0800, accuracy: 5 }, + }) + + vi.doMock('@capacitor/geolocation', () => ({ + Geolocation: { getCurrentPosition: mockGetCurrentPosition }, + })) + + const { position, getCurrentPosition } = useGeolocation() + await getCurrentPosition() + + expect(mockGetCurrentPosition).toHaveBeenCalledWith({ enableHighAccuracy: true }) + expect(position.value?.latitude).toBe(9.9337) + }) + }) +}) diff --git a/lab-09-mobile/vitest.config.ts b/lab-09-mobile/vitest.config.ts new file mode 100644 index 0000000..c8e94f8 --- /dev/null +++ b/lab-09-mobile/vitest.config.ts @@ -0,0 +1,21 @@ +import { defineConfig } from 'vitest/config' +import vue from '@vitejs/plugin-vue' +import { fileURLToPath } from 'url' + +export default defineConfig({ + plugins: [vue()], + test: { + environment: 'happy-dom', + globals: true, + }, + resolve: { + alias: { + '~': fileURLToPath(new URL('.', import.meta.url)), + // vue no es dependencia directa del proyecto (viene via nuxt) — apuntar al store pnpm + 'vue': fileURLToPath(new URL( + 'node_modules/.pnpm/vue@3.5.31_typescript@5.9.3/node_modules/vue/dist/vue.esm-bundler.js', + import.meta.url, + )), + }, + }, +})