Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
52 changes: 52 additions & 0 deletions lab-02-django-api/apps/routes/migrations/0001_initial.py
Original file line number Diff line number Diff line change
@@ -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')],
},
),
]
Empty file.
14 changes: 14 additions & 0 deletions lab-09-mobile/Dockerfile
Original file line number Diff line number Diff line change
@@ -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"]
44 changes: 44 additions & 0 deletions lab-09-mobile/Dockerfile.android
Original file line number Diff line number Diff line change
@@ -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"]
229 changes: 229 additions & 0 deletions lab-09-mobile/README.md
Original file line number Diff line number Diff line change
@@ -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
9 changes: 9 additions & 0 deletions lab-09-mobile/app.vue
Original file line number Diff line number Diff line change
@@ -0,0 +1,9 @@
<template>
<ion-app>
<NuxtPage />
</ion-app>
</template>

<script setup lang="ts">
import { IonApp } from '@ionic/vue'
</script>
10 changes: 10 additions & 0 deletions lab-09-mobile/capacitor.config.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,10 @@
const config = {
appId: 'cr.simovi.mobile',
appName: 'SIMOVI Mobile',
webDir: '.output/public',
server: {
androidScheme: 'https',
},
}

export default config
Loading
Loading