Imported from AldoO88/edukcontrol-app (
AGENTS.md). Install upstream withnpx skills add AldoO88/edukcontrol-app. Copyright stays with the author.
EdukControl — Agent Guide
Aplicación móvil escolar (Expo SDK 57 + React Native 0.86 + React 19.2.3) con dos roles: guardian (padres/tutores) y teacher (maestros).
Expo HAS CHANGED. Antes de escribir cualquier código de Expo/notificaciones/navegación, leer la doc versionada en https://docs.expo.dev/versions/v57.0.0/ — la SDK 57 introduce cambios incompatibles respecto a SDK 53/54 (ver "Trampas de SDK 57" abajo).
Stack y decisiones clave
- Lenguaje: JavaScript puro (
.js/.jsx), NO TypeScript. No añadir tipos, interfaces, ni.ts/.tsx. - Estilos: NativeWind v4 (Tailwind CSS) —
classNameen componentes RN, NOStyleSheet.createsalvo para estilos dinámicos calculados en runtime (sombras, animaciones, transformaciones). - Iconos:
lucide-react-native(set Lucide, ya instalado). Para MaterialCommunityIcons usar@expo/vector-iconsque viene empotrado en Expo. - HTTP:
axioscon instancia única ensrc/services/api.js. Todas las llamadas pasan por ahí (interceptor JWT, baseURL, manejo de errores centralizado). - Navegación: Expo Router (
expo-routerv5, instalado víanpx expo install). File-based routing conapp/como raíz.expo-linkingse usa automáticamente para deep-links a partir delschemedefinido enapp.json. - Estado global: Context API (empezando por
AuthContext). No introducir Redux/Zustand salvo que se pida explícitamente. - Persistencia local:
@react-native-async-storage/async-storagepara datos no sensibles. Para tokens usarexpo-secure-store(pendiente de instalar cuando se implemente el storage seguro del JWT). - Notificaciones push:
expo-notificationscon tokens Expo (no FCM directo). El push token requiere unprojectIdEAS configurado enapp.jsonbajoextra.eas.projectId. - Safe areas:
react-native-safe-area-context— usarSafeAreaViewyuseSafeAreaInsetsde esa librería, NO laSafeAreaViewdeprecada dereact-native.
Estructura del proyecto
school-parents-app/
├── app/ # Raíz del routing (Expo Router, file-based).
│ ├── _layout.jsx # Layout raíz: providers + Stack global.
│ ├── index.jsx # Ruta "/". Pantalla de Login (placeholder).
│ ├── (guardian)/ # Route group del TUTOR — no aparece en la URL.
│ │ ├── _layout.jsx # Auth gate + role gate + Stack + usePushNotifications.
│ │ ├── dashboard.jsx # Ruta "/dashboard". Renderiza GuardianDashboard.
│ │ ├── announcements.jsx # Ruta "/announcements". Feed de avisos/citatorios.
│ │ ├── announcements/[kind]/[id].jsx # Ruta "/announcements/:kind/:id".
│ │ ├── conduct.jsx # Ruta "/conduct". Reportes de conducta.
│ │ ├── grades.jsx # Ruta "/grades". Calificaciones.
│ │ ├── attendance.jsx # Ruta "/attendance". Asistencia.
│ │ └── _components/ # UI privada del grupo (GuardianDashboard, cards, tables).
│ ├── (teacher)/ # Route group del MAESTRO — no aparece en la URL.
│ │ ├── _layout.jsx # Auth gate + role gate + Stack + usePushNotifications.
│ │ ├── dashboard.jsx # Ruta "/dashboard". Renderiza TeacherDashboard.
│ │ ├── announcements.jsx # Ruta "/announcements". Avisos del maestro (spec visual).
│ │ ├── take-attendance.jsx# Ruta "/take-attendance". Tomar asistencia.
│ │ └── _components/ # UI privada del grupo (TeacherDashboard).
│ └── (auth)/ # Route group de activación de cuenta.
│ └── activation/ # index, verify, set-password.
├── src/
│ ├── components/ # UI reutilizable + chrome compartido (cards, inputs, botones,
│ │ # badges, DashboardHeader, SchoolInfoCard, BottomTabBar, StudentFilter, Screen).
│ ├── context/ # AuthContext y futuros contextos globales.
│ ├── hooks/ # useAuth, useLoginForm, useNotifications, useTeacherDashboard,
│ │ # useGuardianDashboard (lógica separada de UI).
│ ├── services/ # api.js (Axios), authService.js, teacherService.js, notificationService.js.
│ ├── constants/ # Tokens de diseño, URLs, mocks multi-tenant, navigationTabs.js.
│ └── utils/ # Helpers puros (formateo de fechas, announcementHelpers, etc.).
├── assets/ # Iconos, splash, imágenes nativas.
├── App.jsx # NO EXISTE. Reemplazado por app/_layout.jsx.
├── index.js # NO EXISTE. Lo gestiona expo-router/entry (main en package.json).
├── app.json # Expo config: scheme, plugins, iconos nativos.
├── babel.config.js # babel-preset-expo + nativewind/babel + worklets/plugin.
├── metro.config.js # withNativeWind(getDefaultConfig()).
├── tailwind.config.js # content: ['./app/**/*.{js,jsx,ts,tsx}', './src/**/*.{js,jsx,ts,tsx}'].
└── global.css # Directivas @tailwind base/components/utilities.
Reglas:
app/**/_components/(prefijo_) yapp/**/_hooks/son carpetas privadas dentro de un route group: expo-router las ignora para el routing y sirven para co-localizar UI/lógica específica de ese grupo.src/hooks/NUNCA importa deapp/,src/components/ni viceversa. Los hooks solo consumen contextos y servicios.app/**(rutas) NUNCA hacefetch/axiosdirecto. Toda llamada pasa por un hook o porsrc/services/.src/components/no conoce navegación ni contextos de negocio — son primitives reutilizables. El chrome compartido entre roles (DashboardHeader, SchoolInfoCard, BottomTabBar, StudentFilter) vive aquí.- Los route groups (carpetas entre paréntesis como
(guardian)/) NO añaden segmentos a la URL: existen solo para compartir layouts y agrupar rutas por dominio/rol. - Shared routes por rol: como cada rol tiene su propio route group, varios grupos pueden declarar la MISMA ruta (p. ej.
dashboarden(guardian)y(teacher)→ URL/dashboard). Navegar SIEMPRE con el prefijo del grupo (/(guardian)/dashboard,/(teacher)/announcements); el role gate de cada_layout.jsxredirige al grupo correcto si el rol no coincide. - Fuente única de tabs:
src/constants/navigationTabs.jsexportaGUARDIAN_TABS(default de<BottomTabBar />) yTEACHER_TABS(se pasa explícito). No duplicar arrays de tabs en las pantallas.
Sistema de diseño (NativeWind tokens)
Paleta institucional fija — no inventar colores nuevos:
| Uso | Clases |
|---|---|
| Fondo app | bg-slate-50 / bg-gray-100 |
| Primario | bg-slate-900 / text-slate-900 / bg-indigo-950 |
| Acento activo | bg-sky-500 / text-sky-600 |
| Éxito | bg-emerald-500 / text-emerald-600 / bg-emerald-50 |
| Advertencia | bg-amber-500 / bg-amber-50 / text-amber-600 |
| Crítico | bg-rose-500 / bg-rose-50 / text-rose-600 |
| Tarjetas | bg-white rounded-2xl con sombra (ver "Sombras" abajo) |
| Títulos | font-bold text-slate-900 |
| Subtítulos | text-slate-500 text-sm |
Sombras multiplataforma
NativeWind no siempre aplica elevation (Android) además de shadow* (iOS). Para sombras fiables en ambos:
<View className="bg-white rounded-2xl shadow-sm" style={{ elevation: 3 }}>
shadow-{sm,md,lg} da el render en iOS; elevation (vía style) cubre Android. No omitir elevation.
Trampas de SDK 57 (leer antes de tocar)
-
Notification handler deprecó
shouldShowAlert. UsarshouldShowBanneryshouldShowList:Notifications.setNotificationHandler({ handleNotification: async () => ({ shouldShowBanner: true, shouldShowList: true, shouldPlaySound: false, shouldSetBadge: false, }), }); -
Listeners usan
.remove(), noremoveNotificationSubscription.const sub = Notifications.addNotificationReceivedListener(handler); return () => sub.remove(); -
getExpoPushTokenAsyncexigeprojectId. Sin esto falla silenciosa o ruidosamente:const projectId = Constants?.expoConfig?.extra?.eas?.projectId; -
Android 13+ requiere canal creado ANTES de pedir permisos. Llamar
setNotificationChannelAsyncantes derequestPermissionsAsync, si no el prompt nunca aparece. -
Push notifications no funcionan en Expo Go en Android desde SDK 53. Para probar push real hace falta
npx expo run:androido un dev build. -
Expo Router v5 (SDK 57):
babel-preset-expoya incluye el transformador del filesystem. NO añadirexpo-router/babelcomo plugin extra.expo install expo-routerañade automáticamente"expo-router"al arraypluginsdeapp.json. -
Auth flow con Expo Router: implementar con
<Redirect />declarativo en el render del layout (NO conuseEffect + router.replace). Verapp/_layout.jsx → AuthGatecomo referencia canónica.
Convenciones de código
- Comentarios en español, línea por línea, exhaustivos. El usuario lo pidió explícitamente para este proyecto. NO es la regla por defecto del sistema — es una excepción documentada.
- Componentes funcionales con hooks. No clases.
- Nombres de archivos:
.jsx(PascalCase) para todo lo que renderiza UI:app/_layout.jsx,app/(guardian)/dashboard.jsx,src/components/Card.jsx,src/components/SchoolHeader.jsx..js(camelCase) para módulos que NO renderizan JSX: hooks (src/hooks/useAuth.js), services (src/services/api.js) y módulos de contexto (src/context/AuthContext.js, híbrido: exporta el objeto Context + el Provider, pero se considera módulo de estado, no componente de UI).
- No barrel files (
index.jsre-exportando) salvo que se pida — el árbol de imports debe ser explícito. - Strings de UI en español. Hardcoded en esta fase; cuando se introduzca i18n, mover a
src/i18n/es.json. - Componentes privados de un route group: usar prefijo
_en el nombre de archivo/carpeta (_components/,_hooks/,TeacherDashboard.jsxSIN prefijo porque se importa, pero la carpeta que los contiene sí lo lleva). Expo Router ignora estos archivos para routing.
Imports: rutas absolutas con alias @/ (convención)
A partir del refactor de rutas (route groups anidados), todos los imports usan el alias @/ que apunta a la raíz del proyecto (school-parents-app/). Configurado vía babel-plugin-module-resolver en babel.config.js.
// ✅ CORRECTO — funciona desde cualquier profundidad
import { useTeacherDashboard } from '@/src/hooks/useTeacherDashboard';
import DashboardHeader from '@/src/components/DashboardHeader';
import EvaluacionTab from '@/app/(teacher)/_components/EvaluacionTab';
// ❌ EVITAR — relativas largas son frágiles ante refactors
import { useTeacherDashboard } from '../../../../src/hooks/useTeacherDashboard';
Reglas:
- Para
src/(componentes chrome, hooks, utils, constants, types, services):@/src/... - Para componentes privados del route group
_components/:@/app/(teacher)/_components/...o@/app/(guardian)/_components/...según el grupo. - Para paquetes npm: sin alias (import normal:
import Foo from 'foo'). - NO usar barrel files (
index.jsre-exportando) — el árbol de imports debe ser explícito. - Los archivos con profundidad ≤ 3 niveles (top-level routes como
dashboard.jsx,grades.jsx,profile.jsx) pueden seguir usando relativas cortas — el alias no es obligatorio ahí.
Por qué @/ y no relativas: las rutas de este proyecto tienen hasta 7 niveles de profundidad (app/(teacher)/(tabs)/groups/[groupId]/students/[studentId]/file.jsx). Calcular manualmente '../../../../' es propenso a errores y se rompe cada vez que se mueve un archivo. Con @/ los imports son refactor-safe.
BottomTabBar — renderizado UNA vez por el Tabs navigator
Regla: el <BottomTabBar tabs={TEACHER_TABS} /> se renderiza una sola vez, vía la prop tabBar del <Tabs> en app/(teacher)/(tabs)/_layout.jsx:
<Tabs
screenOptions={{ headerShown: false }}
tabBar={(props) => <BottomTabBar {...props} tabs={TEACHER_TABS} />}
>
Las pantallas que viven bajo (tabs)/ NUNCA deben pintar su propio <BottomTabBar>. Pintarlo dentro de la pantalla provoca doble render (una barra del navigator, otra de la pantalla) con la misma UI duplicada. Esto se rompe especialmente en el dashboard del maestro.
- ❌ NO hacer:
<BottomTabBar tabs={TEACHER_TABS} />dentro dedashboard.jsx,groups/index.jsx,groups/[groupId]/index.jsx,profile.jsx,grades.jsxni cualquier screen bajo(tabs)/. - ✅ Hacer: dejar que el Tabs navigator pinte la barra vía
tabBar={...}. - 🟡 Excepción:
app/(teacher)/attendance.jsx(placeholder top-level orphan, NO vive bajo(tabs)/) puede pintarla porque es una ruta plana fuera del Tabs tree. Considerar eliminarla si no se va a usar.
Entry points y orden de providers
NO existe App.jsx. El entry point está en package.json ("main": "expo-router/entry") y carga automáticamente app/_layout.jsx como raíz del routing.
Orden obligatorio de providers en app/_layout.jsx (de fuera hacia adentro):
SafeAreaProvider(dereact-native-safe-area-context)AuthProvider(desrc/context/AuthContext.js)<Stack />deexpo-router— navigator raíz, SIN hijos (headerShown: falseglobal). Las rutas se auto-descubren del filesystem; cada route group pinta su propio chrome.
El auth flow NO vive en el root layout: cada route group tiene su propio _layout.jsx con sus gates:
app/(guardian)/_layout.jsx(rol'tutor') yapp/(teacher)/_layout.jsx(rol'teacher') implementan:isLoading→ splash (<RootSplash />).!user→Redirect href="/"(auth gate).- rol equivocado →
Redirectal dashboard del otro grupo (role gate, p. ej./(teacher)/dashboard). usePushNotifications(!!user)montado SOLO con user logueado.
app/index.jsx(login): siuserexiste,Redirectrol-aware —user.role === 'teacher'→/(teacher)/dashboard, si no →/(guardian)/dashboard.
Dashboards por rol (sin dispatcher): app/(guardian)/dashboard.jsx renderiza GuardianDashboard y app/(teacher)/dashboard.jsx renderiza TeacherDashboard. Ambos comparten la URL /dashboard (shared route); la navegación siempre lleva el prefijo de grupo y el role gate descarta accesos cruzados.
Comandos
npm start # expo start (Metro)
npx expo run:android # build nativo + instala en device/emulador
npx expo run:ios # idem iOS
npx expo install <pkg> # instalar con versión compatible con SDK 57 (usar SIEMPRE, no npm install)
npx expo install --check # detectar deps desalineadas con la SDK
No hay scripts de lint, typecheck o test configurados todavía. Antes de añadir código que asuma ESLint/Prettier/Jest, verificar que el config exista; si no, crearlo como tarea separada y documentarlo aquí.
Estado actual de dependencias (a julio 2026)
Instaladas (package.json):
expo,expo-status-bar,expo-constants,expo-device,expo-notifications,expo-linkingexpo-router(file-based routing)react,react-nativereact-native-safe-area-context,react-native-screens,react-native-gesture-handler,react-native-reanimated,react-native-worklets@react-native-async-storage/async-storagenativewind,tailwindcssclsx,lucide-react-native,axios- Dev:
babel-preset-expo
Pendientes de instalar (cuando se vaya a usar cada feature):
expo-secure-store(para guardar el JWT de forma segura en lugar de AsyncStorage).@react-navigation/*ya NO se necesita — el proyecto migró a Expo Router. Si ves imports de@react-navigation/*en código nuevo, es un error.- ESLint, Prettier, Jest (crear configs antes de añadir código que los asuma).
Usar siempre npx expo install para mantener compatibilidad con SDK 57.
Lo que NO asumir
- No asumir
@react-navigation/*— este proyecto migró a Expo Router. Cualquierimport { ... } from '@react-navigation/native'en código nuevo es un error de arquitectura. UsaruseRouter(),useSegments(),<Stack>,<Redirect>, etc. deexpo-router. - No asumir que
tailwind.config.jsobabel.config.jsestán "pendientes" — ya están configurados. Verificar antes de duplicar setup. - No asumir TypeScript ni tipos en ningún archivo.
- No asumir que existe un backend real: la URL base de
api.jsdebe venir de una constante de entorno (process.env.EXPO_PUBLIC_API_URLo similar), no hardcoded. - No asumir que
src/screens/existe — esa carpeta fue eliminada durante la migración a Expo Router. Las pantallas viven ahora enapp/. - No asumir que
App.jsxoindex.jsexisten en la raíz — el entry point esexpo-router/entryconfigurado enpackage.json.
Variables de entorno
La app lee su URL del backend de process.env.EXPO_PUBLIC_API_URL (patrón de Expo SDK 53+). Configurar:
- Copiar
.env.examplea.env(gitignored) y editarEXPO_PUBLIC_API_URLal host del backend que se quiera apuntar. - La resolución vive en
config.js(pickApiUrl). Si no hay.envse usa el fallback LAN (http://192.168.100.52:5050) para no romper dev sin archivo. - Para staging / producción, no tocar el código: definir la variable por build con
eas env create --environment production --name EXPO_PUBLIC_API_URL --value https://api.tu-dominio.com, o crear.env.productionen la raíz del proyecto (cargado por EAS al ejecutareas build --profile production). - En dev, Metro imprime
[config] API_URL = ...al iniciar — útil para verificar que la env se inyectó correctamente.
Push Notifications (Expo Push API)
Esta sección documenta todo el sistema de notificaciones push del mobile.
Arquitectura
Backend (Node/Express) → fetch() → Expo Push API → FCM (Android) / APNs (iOS) → celular
↓
Notification (MongoDB) ← persistencia in-app (campanita)
↓
GET /api/me/notifications → bell dropdown
El backend NO envía a Firebase Admin SDK ni Google Service Account. Solo hace fetch() a https://exp.host/--/api/v2/push/send. Expo traduce a FCM/APNs internamente.
Token registration
| Rol | Endpoint | Body |
|---|---|---|
tutor |
POST /api/guardians/me/fcm-token |
{ fcm_token: "ExponentPushToken[…]" } |
staff (teacher, admin, etc.) |
POST /auth/fcm-token |
{ fcm_token: "ExponentPushToken[…]" } |
El mobile rutéa por user.role en src/services/pushNotificationService.js (getEndpointForRole). El token se guarda en Guardian.fcm_token o User.fcm_token (campo legacy, ahora almacena Expo Push Token).
Canales de Android
src/services/notificationService.js#createAndroidChannel() crea 3 canales separados:
| Channel ID | Importance | Uso |
|---|---|---|
eduk_attendance_channel |
MAX | Asistencia (RFID/face tap, ausencias) |
eduk_citations_channel |
MAX | Citatorios |
eduk_announcements_channel |
DEFAULT | Avisos |
El backend manda channelId en cada push (services/notification.service.js del backend). El mobile debe respetar el channelId que viene del push para que el OS enrute correctamente.
Importante: En iOS no hay canales — el backend también manda el channelId pero iOS lo ignora. Todos los push suenan con el default.
Tipos de notificación (kind)
El backend incluye data.kind en cada push. El mobile usa src/utils/notificationData.js para mapear kind → ruta de Expo Router.
kind |
Quién lo manda | Quién lo recibe | Ruta (tutor) | Ruta (staff) |
|---|---|---|---|---|
attendance |
attendance.service | tutor | /(guardian)/attendance |
— |
absence |
attendance.service | tutor | /(guardian)/attendance |
— |
citation |
citations.controller | tutor | /(guardian)/announcements/citation/[id] |
/(teacher)/citations/[id] |
citation_rescheduled |
citations.controller | tutor | /(guardian)/announcements/citation/[id] |
/(teacher)/citations/[id] |
citation_cancelled |
citations.controller | tutor | /(guardian)/announcements |
/(teacher)/citations/[id] |
citation_confirmed |
guardians.controller | staff (creator) | — | /(teacher)/citations/[id] |
citation_reschedule_request |
guardians.controller | staff (creator) | — | /(teacher)/citations/[id] |
announcement |
announcements.controller | tutor | /(guardian)/announcements/announcement/[id] |
/(teacher)/announcements/[id] |
notificationDataToRoute(data, role) retorna { pathname, params } o null si el kind es desconocido.
Listeners (usePushNotifications)
El hook instala 3 listeners de expo-notifications:
addPushTokenListener— cuando el SO rota el Expo Push Token (reinstall, backup restore). Re-registra automáticamente.addNotificationReceivedListener— push recibida con app en foreground. GuardalastNotificationen state para UI custom (ej. toast in-app).addNotificationResponseReceivedListener— usuario tocó la push. UsanotificationDataToRoute()para navegar a la pantalla correcta.
Cleanup: todos usan .remove() (NO removeNotificationSubscription que está deprecado en SDK 57).
Hook contract
const { expoPushToken, status, error, lastNotification, lastNotificationResponse } = usePushNotifications(enabled);
| Estado | Significado |
|---|---|
idle |
enabled = false |
requesting |
Pidiendo permisos / creando canal |
registering |
Token obtenido, registrándolo |
registered |
OK, push activas |
denied |
Usuario denegó permisos |
unsupported |
Simulador o falta extra.eas.projectId |
error |
Error inesperado (red, server, etc.) |
Limitaciones de Expo Go
- Android: push remotos NO funcionan en Expo Go desde SDK 53+. Necesita dev build (
eas build --profile development). - iOS: push SÍ funcionan en Expo Go (todavía, hasta nuevo aviso).
- Notificaciones locales (programadas por la app) funcionan en ambos.
Campanita in-app (NotificationBell)
Componente src/components/NotificationBell.jsx que muestra:
- Ícono de campana en
DashboardHeader(todos los roles) - Badge rojo con count de no leídas (polling cada 60s)
- Dropdown panel con últimas 20 notificaciones
- Tap en item: marca como leída + navega al deep link
Backend soporta:
GET /api/me/notifications?unread=true&limit=50GET /api/me/notifications/unread-countPATCH /api/me/notifications/:id/readPATCH /api/me/notifications/read-all
Colección MongoDB: Notification (ver models/Notification.model.js).
Archivos clave
| Archivo | Responsabilidad |
|---|---|
src/services/notificationService.js |
Handler global, createAndroidChannel (3 canales), getExpoPushToken |
src/services/pushNotificationService.js |
HTTP hacia backend (registro/unregistro por rol) |
src/services/notificationsService.js |
HTTP hacia backend (lista, mark as read) |
src/hooks/usePushNotifications.js |
Setup + listeners (token rotation, foreground, tap) |
src/hooks/useNotifications.js |
Polling de campanita (count + lista) |
src/utils/notificationData.js |
notificationDataToRoute(data, role) — deep linking |
src/components/NotificationBell.jsx |
UI campanita + dropdown |
src/components/DashboardHeader.jsx |
Integra <NotificationBell /> (todos los roles) |
