+
+ {{ route.code }}
+
+
+
+
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 @@
+
+