feat(android): app de campo móvil+tablet (UI, mapa, fotos, auto-sync, reconciliación)

Fase 0: config Android (package, permisos, orientación), eas.json (APK), deps
  nativas (react-native-maps, image-picker, location), app.config.js para la key
  de Google Maps por secreto EAS.
Fase 1: capa responsive (useLayout) + componente MasterDetail (dos paneles en
  tablet, navegación en móvil).
Fase 2: pantallas funcionales — detalle de proyecto con secciones Fases/Features/
  Incidencias, edición de progreso/estado, formulario de inspección dinámico
  desde plantilla, incidencias maestro-detalle (checklist + comentarios), alta de
  incidencia; gating por permisos Spatie.
Fase 3: fotos (cámara/galería) → cola de media, con miniaturas pendientes/sync.
Fase 4: mapa de features (Google Maps) con geometría GeoJSON y selección.
Fase 5: auto-sync (foreground/reconexión/intervalo, con candado) + reconciliación
  de creaciones offline (id temporal negativo → server_id, remapeo de FKs hijas).
Fase 6: revisión de conflictos/errores del outbox, indicadores y APK preview.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
javier
2026-06-18 18:30:44 +02:00
co-authored by Claude Opus 4.8
parent 4e9c7d059f
commit 9bcc51e3b2
36 changed files with 2532 additions and 131 deletions
+40 -3
View File
@@ -8,18 +8,55 @@ Contrato de la API: [`docs/openapi.yaml`](docs/openapi.yaml) ·
brief: [`docs/MOBILE_APP_BRIEF.md`](docs/MOBILE_APP_BRIEF.md) ·
protocolo de sync: [`docs/MOBILE_SYNC_PROTOCOL.md`](docs/MOBILE_SYNC_PROTOCOL.md).
## Arranque
## Arranque (Android)
La app usa módulos nativos (expo-sqlite, expo-secure-store, react-native-maps) que **no
funcionan en Expo Go**: hay que usar un *development build* o un APK.
```bash
npm install
npm start # abre Expo; pulsa 'a' (Android) / 'i' (iOS) / 'w' (web)
npm run typecheck # comprobación de tipos
npm run typecheck # comprobación de tipos
# Development build (emulador o dispositivo con depuración USB):
npx expo run:android # compila e instala un dev client con hot reload
```
> **Backend local (XAMPP):** ajusta `BASE_URL` en [`src/config.ts`](src/config.ts).
> Desde emulador Android usa `http://10.0.2.2/...`; desde dispositivo físico, la IP LAN del PC.
> `localhost` apunta al propio teléfono, no al PC.
### APK para repartir (sideload)
Con [EAS Build](https://docs.expo.dev/build/introduction/) (nube de Expo, no necesita Mac):
```bash
npm i -g eas-cli # una vez
eas login # cuenta Expo
eas build -p android --profile preview # genera un .apk (distribution: internal)
```
Al terminar, EAS da un enlace de descarga del `.apk`; instálalo en el dispositivo
(habilitando "orígenes desconocidos"). Perfiles en [`eas.json`](eas.json):
`development` (dev client), `preview` (APK interno), `production` (AAB, para Play más adelante).
### Mapa (Google Maps)
La sección Features incluye un mapa (react-native-maps) que dibuja la geometría GeoJSON.
Requiere una **API key de Google Maps (Android)**, que se inyecta vía variable de entorno
`GOOGLE_MAPS_API_KEY` en [`app.config.js`](app.config.js) — **no se commitea**:
```bash
# local
export GOOGLE_MAPS_API_KEY=AIza... # (PowerShell: $env:GOOGLE_MAPS_API_KEY="AIza...")
npx expo run:android
# EAS: guárdala como secreto
eas secret:create --name GOOGLE_MAPS_API_KEY --value AIza...
```
Sin key, la app funciona pero el mapa no carga tiles (la lista de features sí). Las tiles
necesitan conexión; la geometría se dibuja también sin red.
## Arquitectura
```
+14
View File
@@ -0,0 +1,14 @@
// Config dinámica: toma app.json como base e inyecta la API key de Google Maps
// desde la variable de entorno GOOGLE_MAPS_API_KEY (secreto de EAS), para no
// commitearla. En local puedes exportarla antes de `npx expo run:android`.
module.exports = ({ config }) => {
const apiKey = process.env.GOOGLE_MAPS_API_KEY;
if (apiKey) {
config.android = config.android || {};
config.android.config = {
...(config.android.config || {}),
googleMaps: { apiKey },
};
}
return config;
};
+24 -2
View File
@@ -3,13 +3,21 @@
"name": "Avante",
"slug": "avante-movil",
"version": "1.0.0",
"orientation": "portrait",
"orientation": "default",
"icon": "./assets/icon.png",
"userInterfaceStyle": "light",
"ios": {
"supportsTablet": true
},
"android": {
"package": "group.mai.avante",
"versionCode": 1,
"permissions": [
"android.permission.INTERNET",
"android.permission.CAMERA",
"android.permission.ACCESS_FINE_LOCATION",
"android.permission.ACCESS_COARSE_LOCATION"
],
"adaptiveIcon": {
"backgroundColor": "#E6F4FE",
"foregroundImage": "./assets/android-icon-foreground.png",
@@ -23,7 +31,21 @@
},
"plugins": [
"expo-sqlite",
"expo-secure-store"
"expo-secure-store",
"expo-build-properties",
[
"expo-image-picker",
{
"photosPermission": "Avante usa tus fotos para adjuntarlas a incidencias e inspecciones.",
"cameraPermission": "Avante usa la cámara para fotografiar el avance de obra e incidencias."
}
],
[
"expo-location",
{
"locationWhenInUsePermission": "Avante usa tu ubicación para situar avances e incidencias en el mapa."
}
]
]
}
}
+29
View File
@@ -0,0 +1,29 @@
{
"cli": {
"version": ">= 12.0.0",
"appVersionSource": "local"
},
"build": {
"development": {
"developmentClient": true,
"distribution": "internal",
"android": {
"buildType": "apk"
}
},
"preview": {
"distribution": "internal",
"android": {
"buildType": "apk"
}
},
"production": {
"android": {
"buildType": "app-bundle"
}
}
},
"submit": {
"production": {}
}
}
+81 -2
View File
@@ -1,24 +1,28 @@
{
"name": "avante_init",
"name": "avante-movil",
"version": "1.0.0",
"lockfileVersion": 3,
"requires": true,
"packages": {
"": {
"name": "avante_init",
"name": "avante-movil",
"version": "1.0.0",
"dependencies": {
"@react-native-community/netinfo": "12.0.1",
"@react-navigation/native": "^7.3.3",
"@react-navigation/native-stack": "^7.17.5",
"expo": "~56.0.12",
"expo-build-properties": "~56.0.19",
"expo-crypto": "~56.0.4",
"expo-file-system": "~56.0.8",
"expo-image-picker": "~56.0.18",
"expo-location": "~56.0.18",
"expo-secure-store": "~56.0.4",
"expo-sqlite": "~56.0.5",
"expo-status-bar": "~56.0.4",
"react": "19.2.3",
"react-native": "0.85.3",
"react-native-maps": "1.27.2",
"react-native-safe-area-context": "~5.7.0",
"react-native-screens": "4.25.2"
},
@@ -1790,6 +1794,12 @@
"integrity": "sha512-MTBk/3jGLNB2tVxv6uLlFh1iu64iYOQ2PbdOSK3NW8JZsmlaOh2q6sdtKowBhfw8QFLmYNzTW4/oK4uATIi6ZA==",
"license": "MIT"
},
"node_modules/@types/geojson": {
"version": "7946.0.16",
"resolved": "https://registry.npmjs.org/@types/geojson/-/geojson-7946.0.16.tgz",
"integrity": "sha512-6C8nqWur3j98U6+lXDfTUWIfgvZU+EumvpHKcYjujKH7woYyLj2sUmff0tRhrqM7BohUw7Pz3ZB1jj2gW9Fvmg==",
"license": "MIT"
},
"node_modules/@types/istanbul-lib-coverage": {
"version": "2.0.6",
"resolved": "https://registry.npmjs.org/@types/istanbul-lib-coverage/-/istanbul-lib-coverage-2.0.6.tgz",
@@ -2777,6 +2787,20 @@
}
}
},
"node_modules/expo-build-properties": {
"version": "56.0.19",
"resolved": "https://registry.npmjs.org/expo-build-properties/-/expo-build-properties-56.0.19.tgz",
"integrity": "sha512-InoviXcxWosNp4cC7L3SWoiY99Xr2HdgN+LYHb6mUm/BBVxy1mIMrZR+3PJ2gwDZzW6EJNDz8ioASWGHBTmzpA==",
"license": "MIT",
"dependencies": {
"@expo/schema-utils": "^56.0.0",
"resolve-from": "^5.0.0",
"semver": "^7.6.0"
},
"peerDependencies": {
"expo": "*"
}
},
"node_modules/expo-crypto": {
"version": "56.0.4",
"resolved": "https://registry.npmjs.org/expo-crypto/-/expo-crypto-56.0.4.tgz",
@@ -2796,6 +2820,39 @@
"react-native": "*"
}
},
"node_modules/expo-image-loader": {
"version": "56.0.3",
"resolved": "https://registry.npmjs.org/expo-image-loader/-/expo-image-loader-56.0.3.tgz",
"integrity": "sha512-JgUo4fUeU1ZC+z8iBFj8v7yoGQnZrLbOVPyNE+DWVrld55F2F6R1ck+rmdm/8TNWLz1LhNQfD7c3XYP1ZikxXA==",
"license": "MIT",
"peerDependencies": {
"expo": "*"
}
},
"node_modules/expo-image-picker": {
"version": "56.0.18",
"resolved": "https://registry.npmjs.org/expo-image-picker/-/expo-image-picker-56.0.18.tgz",
"integrity": "sha512-sCjQ8M27bhGUv2vUavIE+uWdYo79b2D7Q5h9B66BSDZ+Rd8YyLVSf7vYGfIzQ7nMVoENZ6c4xo/JiDkEeQ9iTg==",
"license": "MIT",
"dependencies": {
"expo-image-loader": "~56.0.3"
},
"peerDependencies": {
"expo": "*"
}
},
"node_modules/expo-location": {
"version": "56.0.18",
"resolved": "https://registry.npmjs.org/expo-location/-/expo-location-56.0.18.tgz",
"integrity": "sha512-6xP0UwGy8a7EEHAMeigYAp3HNo3yWHAg05tVPUfwrOWepWPpFXmjsfUBUxQdkpfpjddJ9r+f4PplxZqKI0LtjA==",
"license": "MIT",
"dependencies": {
"@expo/image-utils": "^0.10.1"
},
"peerDependencies": {
"expo": "*"
}
},
"node_modules/expo-modules-autolinking": {
"version": "56.0.16",
"resolved": "https://registry.npmjs.org/expo-modules-autolinking/-/expo-modules-autolinking-56.0.16.tgz",
@@ -5248,6 +5305,28 @@
}
}
},
"node_modules/react-native-maps": {
"version": "1.27.2",
"resolved": "https://registry.npmjs.org/react-native-maps/-/react-native-maps-1.27.2.tgz",
"integrity": "sha512-VKr+xZ2RZGHHJlY6KhlafvGSmK0dq/tUu5uhfJ7K9rwN5pUdubdugzMKGDU/16lXmQSg7xbClKhRctj3Pm5F5g==",
"license": "MIT",
"dependencies": {
"@types/geojson": "^7946.0.13"
},
"engines": {
"node": ">= 20.19.4"
},
"peerDependencies": {
"react": ">= 18.3.1",
"react-native": ">= 0.76.0",
"react-native-web": ">= 0.11"
},
"peerDependenciesMeta": {
"react-native-web": {
"optional": true
}
}
},
"node_modules/react-native-safe-area-context": {
"version": "5.7.0",
"resolved": "https://registry.npmjs.org/react-native-safe-area-context/-/react-native-safe-area-context-5.7.0.tgz",
+4
View File
@@ -7,13 +7,17 @@
"@react-navigation/native": "^7.3.3",
"@react-navigation/native-stack": "^7.17.5",
"expo": "~56.0.12",
"expo-build-properties": "~56.0.19",
"expo-crypto": "~56.0.4",
"expo-file-system": "~56.0.8",
"expo-image-picker": "~56.0.18",
"expo-location": "~56.0.18",
"expo-secure-store": "~56.0.4",
"expo-sqlite": "~56.0.5",
"expo-status-bar": "~56.0.4",
"react": "19.2.3",
"react-native": "0.85.3",
"react-native-maps": "1.27.2",
"react-native-safe-area-context": "~5.7.0",
"react-native-screens": "4.25.2"
},
+17 -3
View File
@@ -1,19 +1,22 @@
import React from 'react';
import { ActivityIndicator, StyleSheet, Text, View } from 'react-native';
import { ActivityIndicator, StyleSheet, Text, TouchableOpacity, View } from 'react-native';
import { OutboxCounts } from '../db/outbox';
import { useIsOnline } from '../net/connectivity';
export function SyncStatusBar({
counts,
syncing,
onPress,
}: {
counts: OutboxCounts;
syncing: boolean;
onPress?: () => void;
}) {
const online = useIsOnline();
const pendientes = counts.pending + counts.mediaPending;
const hasProblems = counts.conflict > 0 || counts.error > 0;
return (
const content = (
<View style={[styles.bar, { backgroundColor: online ? '#1f6f43' : '#8a6d00' }]}>
{syncing && <ActivityIndicator color="#fff" size="small" />}
<Text style={styles.text}>
@@ -22,8 +25,18 @@ export function SyncStatusBar({
{counts.conflict > 0 ? ` · ${counts.conflict} conflicto(s)` : ''}
{counts.error > 0 ? ` · ${counts.error} error(es)` : ''}
</Text>
{hasProblems && onPress ? <Text style={styles.chevron}>revisar </Text> : null}
</View>
);
if (onPress) {
return (
<TouchableOpacity activeOpacity={0.8} onPress={onPress}>
{content}
</TouchableOpacity>
);
}
return content;
}
const styles = StyleSheet.create({
@@ -34,5 +47,6 @@ const styles = StyleSheet.create({
paddingHorizontal: 12,
paddingVertical: 8,
},
text: { color: '#fff', fontSize: 13, fontWeight: '600' },
text: { color: '#fff', fontSize: 13, fontWeight: '600', flex: 1 },
chevron: { color: '#fff', fontSize: 12, fontWeight: '700', textDecorationLine: 'underline' },
});
+10
View File
@@ -12,6 +12,16 @@ const DEV_HOST = 'http://10.0.2.2/construprogress/public';
export const BASE_URL = `${DEV_HOST}/api/v1`;
/** Raíz del host (para resolver URLs relativas de media, p.ej. `/storage/...`). */
export const ORIGIN = DEV_HOST;
/** Convierte una url de media (posiblemente relativa) en absoluta. */
export function absoluteUrl(url?: string | null): string | undefined {
if (!url) return undefined;
if (/^https?:\/\//i.test(url)) return url;
return `${ORIGIN}${url.startsWith('/') ? '' : '/'}${url}`;
}
/** Se envía en cada petición como cabecera X-App-Version (ver protocolo §7). */
export const APP_VERSION = '1.0.0';
+8 -1
View File
@@ -18,9 +18,16 @@ async function openAndMigrate(): Promise<SQLite.SQLiteDatabase> {
const db = await SQLite.openDatabaseAsync(DB_NAME);
await db.execAsync(SCHEMA_SQL);
// Versionado por PRAGMA user_version (migraciones futuras van aquí).
// Versionado por PRAGMA user_version. Migraciones incrementales.
const row = await db.getFirstAsync<{ user_version: number }>('PRAGMA user_version');
const current = row?.user_version ?? 0;
if (current < 2) {
// local_id: id temporal de la fila creada offline que produjo esta operación
// (para reconciliar el id del servidor tras el PUSH).
await db.execAsync('ALTER TABLE outbox ADD COLUMN local_id INTEGER');
}
if (current < SCHEMA_VERSION) {
await db.execAsync(`PRAGMA user_version = ${SCHEMA_VERSION}`);
}
+74 -5
View File
@@ -20,19 +20,21 @@ export async function enqueueOperation(
entity: SyncEntity,
op: SyncOp,
data: Record<string, unknown>,
opts?: { uuid?: string; localId?: number },
): Promise<string> {
const db = await getDb();
const uuid = newUuid();
const uuid = opts?.uuid ?? newUuid();
const clientUpdatedAt = nowIso();
await db.runAsync(
`INSERT INTO outbox (uuid, entity, op, data, client_updated_at, status, attempts, created_at)
VALUES (?, ?, ?, ?, ?, 'pending', 0, ?)`,
`INSERT INTO outbox (uuid, entity, op, data, client_updated_at, status, attempts, created_at, local_id)
VALUES (?, ?, ?, ?, ?, 'pending', 0, ?, ?)`,
uuid,
entity,
op,
JSON.stringify(data),
clientUpdatedAt,
clientUpdatedAt,
opts?.localId ?? null,
);
return uuid;
}
@@ -49,10 +51,17 @@ interface RawOutbox {
server_payload: string | null;
attempts: number;
created_at: string;
local_id: number | null;
}
/** Operación pendiente enriquecida con su id local temporal (si la creó offline). */
export interface PendingOp extends Operation {
/** id temporal (negativo) de la fila local que produjo esta operación, o null. */
localId: number | null;
}
/** Operaciones pendientes de enviar, en orden de creación. */
export async function getPendingOperations(limit = 100): Promise<Operation[]> {
export async function getPendingOutbox(limit = 200): Promise<PendingOp[]> {
const db = await getDb();
const rows = await db.getAllAsync<RawOutbox>(
`SELECT * FROM outbox WHERE status = 'pending' ORDER BY created_at ASC LIMIT ?`,
@@ -64,9 +73,19 @@ export async function getPendingOperations(limit = 100): Promise<Operation[]> {
uuid: r.uuid,
client_updated_at: r.client_updated_at,
data: JSON.parse(r.data) as Record<string, unknown>,
localId: r.local_id,
}));
}
/** Reescribe el `data` de una operación pendiente (tras remapear ids temporales). */
export async function updateOutboxData(
uuid: string,
data: Record<string, unknown>,
): Promise<void> {
const db = await getDb();
await db.runAsync('UPDATE outbox SET data = ? WHERE uuid = ?', JSON.stringify(data), uuid);
}
/** Aplica el resultado de /sync de una operación a su fila del outbox. */
export async function applyOperationResult(result: OperationResult): Promise<void> {
const db = await getDb();
@@ -97,6 +116,41 @@ export async function purgeSentOperations(): Promise<void> {
await db.runAsync(`DELETE FROM outbox WHERE status = 'sent'`);
}
export interface ProblemOp {
uuid: string;
entity: SyncEntity;
op: SyncOp;
status: 'conflict' | 'error';
error: string | null;
server_payload: string | null;
data: string;
created_at: string;
}
/** Operaciones que requieren atención (conflicto o error). */
export async function getProblemOps(): Promise<ProblemOp[]> {
const db = await getDb();
return db.getAllAsync<ProblemOp>(
`SELECT uuid, entity, op, status, error, server_payload, data, created_at
FROM outbox WHERE status IN ('conflict', 'error') ORDER BY created_at DESC`,
);
}
/** Reintentar: vuelve a poner la operación como pendiente. */
export async function retryOp(uuid: string): Promise<void> {
const db = await getDb();
await db.runAsync(
`UPDATE outbox SET status = 'pending', error = NULL, server_payload = NULL WHERE uuid = ?`,
uuid,
);
}
/** Descartar: elimina la operación del outbox. */
export async function discardOp(uuid: string): Promise<void> {
const db = await getDb();
await db.runAsync('DELETE FROM outbox WHERE uuid = ?', uuid);
}
export interface OutboxCounts {
pending: number;
conflict: number;
@@ -173,12 +227,27 @@ export interface MediaOutboxRow {
export async function getPendingMedia(limit = 50): Promise<MediaOutboxRow[]> {
const db = await getDb();
// parent_id > 0: el padre ya tiene id de servidor (los creados offline aún
// tienen id temporal negativo; esperan a reconciliarse).
return db.getAllAsync<MediaOutboxRow>(
`SELECT * FROM media_outbox WHERE status = 'pending' ORDER BY created_at ASC LIMIT ?`,
`SELECT * FROM media_outbox WHERE status = 'pending' AND parent_id > 0 ORDER BY created_at ASC LIMIT ?`,
limit,
);
}
/** Media local (pendiente o con error) de un padre, para previsualizar en la UI. */
export async function getMediaOutboxFor(
parentEntity: string,
parentId: number,
): Promise<MediaOutboxRow[]> {
const db = await getDb();
return db.getAllAsync<MediaOutboxRow>(
`SELECT * FROM media_outbox WHERE parent_entity = ? AND parent_id = ? AND status != 'sent' ORDER BY created_at DESC`,
parentEntity,
parentId,
);
}
export async function markMediaSent(uuid: string, mediaId: number | null): Promise<void> {
const db = await getDb();
await db.runAsync(
+185
View File
@@ -8,10 +8,12 @@ import {
Bundle,
DeletedTombstones,
Feature,
Inspection,
Issue,
IssueComment,
IssueTask,
Layer,
Media,
Phase,
Project,
Template,
@@ -298,6 +300,117 @@ export async function getIssues(projectId: number): Promise<Issue[]> {
);
}
export async function getIssue(id: number): Promise<Issue | null> {
const db = await getDb();
return db.getFirstAsync<Issue>('SELECT * FROM issues WHERE id = ?', id);
}
export async function getLayers(projectId: number): Promise<Layer[]> {
const db = await getDb();
return db.getAllAsync<Layer>(
'SELECT * FROM layers WHERE project_id = ? ORDER BY name',
projectId,
);
}
/** Features de un proyecto. `geometry` se devuelve como objeto ya parseado. */
export async function getFeatures(projectId: number): Promise<Feature[]> {
const db = await getDb();
const rows = await db.getAllAsync<Feature & { geometry: string | null }>(
'SELECT * FROM features WHERE project_id = ? ORDER BY layer_id, name',
projectId,
);
return rows.map(parseGeometry);
}
export async function getFeature(id: number): Promise<Feature | null> {
const db = await getDb();
const row = await db.getFirstAsync<Feature & { geometry: string | null }>(
'SELECT * FROM features WHERE id = ?',
id,
);
return row ? parseGeometry(row) : null;
}
function parseGeometry(row: Feature & { geometry: string | null }): Feature {
let geometry: unknown = null;
if (row.geometry && typeof row.geometry === 'string') {
try {
geometry = JSON.parse(row.geometry);
} catch {
geometry = null;
}
}
return { ...row, geometry };
}
export async function getInspectionsByFeature(featureId: number): Promise<Inspection[]> {
const db = await getDb();
const rows = await db.getAllAsync<Inspection & { data: string | null }>(
'SELECT * FROM inspections WHERE feature_id = ? ORDER BY created_at DESC, id DESC',
featureId,
);
return rows.map((r) => ({
...r,
data: r.data ? (JSON.parse(r.data) as Record<string, unknown>) : undefined,
}));
}
type IssueTaskRow = Omit<IssueTask, 'is_done'> & { is_done: number };
export async function getIssueTasks(issueId: number): Promise<IssueTask[]> {
const db = await getDb();
const rows = await db.getAllAsync<IssueTaskRow>(
'SELECT * FROM issue_tasks WHERE issue_id = ? ORDER BY "order", id',
issueId,
);
return rows.map((r) => ({ ...r, is_done: !!r.is_done }));
}
export async function getIssueComments(issueId: number): Promise<IssueComment[]> {
const db = await getDb();
return db.getAllAsync<IssueComment>(
'SELECT * FROM issue_comments WHERE issue_id = ? ORDER BY created_at, id',
issueId,
);
}
export async function getTemplates(projectId: number): Promise<Template[]> {
const db = await getDb();
const rows = await db.getAllAsync<Template & { fields: string | null }>(
'SELECT * FROM templates WHERE project_id = ? OR project_id IS NULL ORDER BY name',
projectId,
);
return rows.map((r) => ({
...r,
fields: r.fields ? (JSON.parse(r.fields) as unknown[]) : [],
}));
}
export async function getTemplate(id: number): Promise<Template | null> {
const db = await getDb();
const row = await db.getFirstAsync<Template & { fields: string | null }>(
'SELECT * FROM templates WHERE id = ?',
id,
);
return row
? { ...row, fields: row.fields ? (JSON.parse(row.fields) as unknown[]) : [] }
: null;
}
/** Media YA sincronizada (con url) para un padre. */
export async function getMediaFor(
parentEntity: string,
parentId: number,
): Promise<Media[]> {
const db = await getDb();
return db.getAllAsync<Media>(
'SELECT * FROM media WHERE parent_entity = ? AND parent_id = ? ORDER BY id DESC',
parentEntity,
parentId,
);
}
export async function countRows(table: string): Promise<number> {
const db = await getDb();
const row = await db.getFirstAsync<{ n: number }>(`SELECT COUNT(*) AS n FROM ${table}`);
@@ -313,3 +426,75 @@ export async function applyServerValue(
const db = await getDb();
await upsertById(db, table, { id, ...serverValue });
}
// ---------- creaciones offline (optimista + reconciliación) ----------
/** Entidad de creación → tabla local. */
const CREATE_TABLE: Record<string, string> = {
issue: 'issues',
inspection: 'inspections',
issue_task: 'issue_tasks',
issue_comment: 'issue_comments',
};
/** Inserta una fila creada offline con un id temporal negativo y su local_uuid. */
export async function insertLocalCreate(
entity: string,
tempId: number,
localUuid: string,
row: Record<string, unknown>,
): Promise<void> {
const table = CREATE_TABLE[entity];
if (!table) return;
const db = await getDb();
const full: Record<string, unknown> = { id: tempId, local_uuid: localUuid, ...row };
const cols = Object.keys(full);
const sql = `INSERT INTO ${table} (${cols.map((c) => `"${c}"`).join(', ')}) VALUES (${cols
.map(() => '?')
.join(', ')})`;
await db.runAsync(sql, ...cols.map((c) => full[c] as never));
}
/**
* Sustituye el id temporal de una fila creada offline por el id real del
* servidor y re-apunta las FKs de sus hijos (tareas/comentarios/fotos en cola).
* Devuelve el id temporal que tenía (para que el motor remapee el outbox).
*/
export async function reconcileCreate(
entity: string,
localUuid: string,
serverId: number,
): Promise<number | null> {
const table = CREATE_TABLE[entity];
if (!table) return null;
const db = await getDb();
const found = await db.getFirstAsync<{ id: number }>(
`SELECT id FROM ${table} WHERE local_uuid = ?`,
localUuid,
);
if (!found) return null;
const tempId = found.id;
await db.withTransactionAsync(async () => {
if (tempId !== serverId) {
await db.runAsync(`UPDATE ${table} SET id = ?, local_uuid = NULL WHERE id = ?`, serverId, tempId);
// Hijos de una incidencia creada offline.
if (entity === 'issue') {
await db.runAsync('UPDATE issue_tasks SET issue_id = ? WHERE issue_id = ?', serverId, tempId);
await db.runAsync('UPDATE issue_comments SET issue_id = ? WHERE issue_id = ?', serverId, tempId);
}
// Fotos en cola que apuntaban a esta fila por su id temporal.
await db.runAsync(
'UPDATE media_outbox SET parent_id = ? WHERE parent_entity = ? AND parent_id = ?',
serverId,
entity,
tempId,
);
} else {
await db.runAsync(`UPDATE ${table} SET local_uuid = NULL WHERE id = ?`, serverId);
}
});
return tempId;
}
+1 -1
View File
@@ -6,7 +6,7 @@
* Versionado simple por `user_version` de SQLite (ver database.ts).
*/
export const SCHEMA_VERSION = 1;
export const SCHEMA_VERSION = 2;
export const SCHEMA_SQL = `
PRAGMA journal_mode = WAL;
+30
View File
@@ -6,6 +6,11 @@ import { useSession } from '../auth/session';
import { LoginScreen } from '../screens/LoginScreen';
import { ProjectDetailScreen } from '../screens/ProjectDetailScreen';
import { ProjectsScreen } from '../screens/ProjectsScreen';
import { IssueDetailScreen } from '../screens/IssueDetailScreen';
import { FeatureDetailScreen } from '../screens/FeatureDetailScreen';
import { InspectionFormScreen } from '../screens/InspectionFormScreen';
import { IssueCreateScreen } from '../screens/IssueCreateScreen';
import { OutboxScreen } from '../screens/OutboxScreen';
import { RootStackParamList } from './types';
const Stack = createNativeStackNavigator<RootStackParamList>();
@@ -38,6 +43,31 @@ export function RootNavigator() {
component={ProjectDetailScreen}
options={({ route }) => ({ title: route.params.name })}
/>
<Stack.Screen
name="IssueDetail"
component={IssueDetailScreen}
options={({ route }) => ({ title: route.params.title })}
/>
<Stack.Screen
name="FeatureDetail"
component={FeatureDetailScreen}
options={({ route }) => ({ title: route.params.name })}
/>
<Stack.Screen
name="InspectionForm"
component={InspectionFormScreen}
options={{ title: 'Nueva inspección', presentation: 'modal' }}
/>
<Stack.Screen
name="IssueCreate"
component={IssueCreateScreen}
options={{ title: 'Nueva incidencia', presentation: 'modal' }}
/>
<Stack.Screen
name="Outbox"
component={OutboxScreen}
options={{ title: 'Cola de sincronización' }}
/>
</Stack.Navigator>
</NavigationContainer>
);
+5
View File
@@ -1,4 +1,9 @@
export type RootStackParamList = {
Projects: undefined;
ProjectDetail: { projectId: number; name: string };
IssueDetail: { issueId: number; title: string };
FeatureDetail: { featureId: number; name: string };
InspectionForm: { featureId: number; featureName: string; templateId?: number };
IssueCreate: { projectId: number; featureId?: number };
Outbox: undefined;
};
+10
View File
@@ -0,0 +1,10 @@
import { NativeStackScreenProps } from '@react-navigation/native-stack';
import React from 'react';
import { RootStackParamList } from '../navigation/types';
import { FeatureDetailContent } from './detail/FeatureDetailContent';
type Props = NativeStackScreenProps<RootStackParamList, 'FeatureDetail'>;
export function FeatureDetailScreen({ route }: Props) {
return <FeatureDetailContent featureId={route.params.featureId} />;
}
+156
View File
@@ -0,0 +1,156 @@
/**
* Formulario de inspección generado dinámicamente desde los `fields` de una
* plantilla. El esquema exacto de cada campo no está fijado en el contrato, así
* que el renderer es tolerante: deduce key/label/type de varias formas posibles.
*/
import { NativeStackScreenProps } from '@react-navigation/native-stack';
import React, { useCallback, useEffect, useState } from 'react';
import { ScrollView, StyleSheet, Switch, Text, View } from 'react-native';
import { Template } from '../api/types';
import { getTemplate } from '../db/repositories';
import { createInspection } from '../sync/mutations';
import { RootStackParamList } from '../navigation/types';
import {
Card,
ChipSelect,
COLORS,
Field,
PrimaryButton,
SectionTitle,
} from '../ui/components';
type Props = NativeStackScreenProps<RootStackParamList, 'InspectionForm'>;
interface NormField {
key: string;
label: string;
type: 'text' | 'textarea' | 'number' | 'boolean' | 'select';
options: string[];
}
function normalizeField(raw: unknown, idx: number): NormField {
const f = (raw ?? {}) as Record<string, unknown>;
const key = String(f.key ?? f.name ?? f.id ?? `field_${idx}`);
const label = String(f.label ?? f.name ?? f.key ?? key);
let type = String(f.type ?? 'text').toLowerCase();
if (!['text', 'textarea', 'number', 'boolean', 'select'].includes(type)) {
if (type === 'checkbox' || type === 'bool') type = 'boolean';
else if (type === 'dropdown') type = 'select';
else type = 'text';
}
const options = Array.isArray(f.options)
? (f.options as unknown[]).map((o) =>
typeof o === 'string' ? o : String((o as Record<string, unknown>)?.value ?? o),
)
: [];
return { key, label, type: type as NormField['type'], options };
}
const RESULTS = ['pass', 'fail', 'na'] as const;
export function InspectionFormScreen({ route, navigation }: Props) {
const { featureId, featureName, templateId } = route.params;
const [template, setTemplate] = useState<Template | null>(null);
const [values, setValues] = useState<Record<string, unknown>>({});
const [result, setResult] = useState<(typeof RESULTS)[number] | undefined>();
const [notes, setNotes] = useState('');
const [saving, setSaving] = useState(false);
useEffect(() => {
if (templateId != null) void getTemplate(templateId).then(setTemplate);
}, [templateId]);
const fields: NormField[] = (template?.fields ?? []).map(normalizeField);
const setValue = useCallback((key: string, v: unknown) => {
setValues((prev) => ({ ...prev, [key]: v }));
}, []);
const onSubmit = useCallback(async () => {
setSaving(true);
try {
await createInspection({
feature_id: featureId,
template_id: templateId,
data: values,
result,
notes: notes.trim() || undefined,
status: 'completed',
});
navigation.goBack();
} finally {
setSaving(false);
}
}, [featureId, templateId, values, result, notes, navigation]);
return (
<ScrollView contentContainerStyle={styles.body}>
<Text style={styles.subtitle}>{featureName}</Text>
<Text style={styles.tplName}>{template?.name ?? 'Inspección libre'}</Text>
{fields.map((f) => {
if (f.type === 'boolean') {
return (
<View key={f.key} style={styles.switchRow}>
<Text style={styles.switchLabel}>{f.label}</Text>
<Switch
value={!!values[f.key]}
onValueChange={(v) => setValue(f.key, v)}
/>
</View>
);
}
if (f.type === 'select' && f.options.length) {
return (
<ChipSelect
key={f.key}
label={f.label}
value={values[f.key] as string | undefined}
options={f.options}
onChange={(v) => setValue(f.key, v)}
/>
);
}
return (
<Field
key={f.key}
label={f.label}
value={values[f.key] != null ? String(values[f.key]) : ''}
onChangeText={(t) => setValue(f.key, f.type === 'number' ? Number(t) : t)}
keyboardType={f.type === 'number' ? 'numeric' : 'default'}
multiline={f.type === 'textarea'}
/>
);
})}
<SectionTitle>Resultado</SectionTitle>
<Card style={{ marginBottom: 12 }}>
<ChipSelect label="" value={result} options={RESULTS} onChange={setResult} />
<Field
label="Notas"
value={notes}
onChangeText={setNotes}
placeholder="Observaciones…"
multiline
/>
</Card>
<PrimaryButton title="Guardar inspección" onPress={() => void onSubmit()} loading={saving} />
<View style={{ height: 8 }} />
<PrimaryButton title="Cancelar" variant="ghost" onPress={() => navigation.goBack()} />
</ScrollView>
);
}
const styles = StyleSheet.create({
body: { padding: 16 },
subtitle: { color: COLORS.muted, fontSize: 13 },
tplName: { fontSize: 18, fontWeight: '700', marginBottom: 12 },
switchRow: {
flexDirection: 'row',
justifyContent: 'space-between',
alignItems: 'center',
paddingVertical: 10,
},
switchLabel: { fontSize: 15, flex: 1 },
});
+69
View File
@@ -0,0 +1,69 @@
/**
* Alta de incidencia (modal). Crea en local + encola issue.create.
*/
import { NativeStackScreenProps } from '@react-navigation/native-stack';
import React, { useCallback, useState } from 'react';
import { ScrollView, StyleSheet } from 'react-native';
import { IssuePriority, IssueType } from '../api/types';
import { createIssue } from '../sync/mutations';
import { RootStackParamList } from '../navigation/types';
import { ChipSelect, Field, PrimaryButton } from '../ui/components';
type Props = NativeStackScreenProps<RootStackParamList, 'IssueCreate'>;
const PRIORITIES: readonly IssuePriority[] = ['low', 'medium', 'high', 'critical'];
const TYPES: readonly IssueType[] = ['defect', 'safety', 'quality', 'documentation', 'other'];
export function IssueCreateScreen({ route, navigation }: Props) {
const { projectId, featureId } = route.params;
const [title, setTitle] = useState('');
const [description, setDescription] = useState('');
const [priority, setPriority] = useState<IssuePriority>('medium');
const [type, setType] = useState<IssueType>('defect');
const [saving, setSaving] = useState(false);
const onSubmit = useCallback(async () => {
if (!title.trim()) return;
setSaving(true);
try {
await createIssue({
project_id: projectId,
feature_id: featureId,
title: title.trim(),
description: description.trim() || undefined,
priority,
type,
status: 'open',
});
navigation.goBack();
} finally {
setSaving(false);
}
}, [title, description, priority, type, projectId, featureId, navigation]);
return (
<ScrollView contentContainerStyle={styles.body}>
<Field label="Título" value={title} onChangeText={setTitle} placeholder="Resumen de la incidencia" />
<Field
label="Descripción"
value={description}
onChangeText={setDescription}
placeholder="Detalle…"
multiline
/>
<ChipSelect label="Prioridad" value={priority} options={PRIORITIES} onChange={setPriority} />
<ChipSelect label="Tipo" value={type} options={TYPES} onChange={setType} />
<PrimaryButton
title="Crear incidencia"
onPress={() => void onSubmit()}
loading={saving}
disabled={!title.trim()}
/>
<PrimaryButton title="Cancelar" variant="ghost" onPress={() => navigation.goBack()} />
</ScrollView>
);
}
const styles = StyleSheet.create({
body: { padding: 16, gap: 4 },
});
+10
View File
@@ -0,0 +1,10 @@
import { NativeStackScreenProps } from '@react-navigation/native-stack';
import React from 'react';
import { RootStackParamList } from '../navigation/types';
import { IssueDetailContent } from './detail/IssueDetailContent';
type Props = NativeStackScreenProps<RootStackParamList, 'IssueDetail'>;
export function IssueDetailScreen({ route }: Props) {
return <IssueDetailContent issueId={route.params.issueId} />;
}
+80
View File
@@ -0,0 +1,80 @@
/**
* Revisión del outbox: operaciones en conflicto o con error. Permite reintentar
* (volver a encolar) o descartar. Los conflictos muestran el valor del servidor.
*/
import { useFocusEffect } from '@react-navigation/native';
import React, { useCallback, useState } from 'react';
import { ScrollView, StyleSheet, Text, View } from 'react-native';
import { discardOp, getProblemOps, ProblemOp, retryOp } from '../db/outbox';
import { Badge, Card, COLORS, EmptyState, PrimaryButton } from '../ui/components';
export function OutboxScreen() {
const [ops, setOps] = useState<ProblemOp[]>([]);
const load = useCallback(() => {
void getProblemOps().then(setOps);
}, []);
useFocusEffect(load);
const onRetry = useCallback(
async (uuid: string) => {
await retryOp(uuid);
load();
},
[load],
);
const onDiscard = useCallback(
async (uuid: string) => {
await discardOp(uuid);
load();
},
[load],
);
return (
<ScrollView contentContainerStyle={styles.body}>
{ops.length === 0 && <EmptyState text="Nada pendiente de revisión." />}
{ops.map((o) => (
<Card key={o.uuid} style={styles.card}>
<View style={styles.headerRow}>
<Text style={styles.entity}>
{o.entity}.{o.op}
</Text>
<Badge
label={o.status}
color={o.status === 'conflict' ? COLORS.warn : COLORS.danger}
/>
</View>
{o.error ? <Text style={styles.error}>{o.error}</Text> : null}
{o.server_payload ? (
<Text style={styles.mono} numberOfLines={6}>
Servidor: {o.server_payload}
</Text>
) : null}
<Text style={styles.mono} numberOfLines={6}>
Local: {o.data}
</Text>
<View style={styles.actions}>
<View style={{ flex: 1 }}>
<PrimaryButton title="Reintentar" variant="ghost" onPress={() => void onRetry(o.uuid)} />
</View>
<View style={{ flex: 1 }}>
<PrimaryButton title="Descartar" variant="danger" onPress={() => void onDiscard(o.uuid)} />
</View>
</View>
</Card>
))}
</ScrollView>
);
}
const styles = StyleSheet.create({
body: { padding: 16, gap: 12 },
card: { gap: 8 },
headerRow: { flexDirection: 'row', justifyContent: 'space-between', alignItems: 'center' },
entity: { fontSize: 15, fontWeight: '700' },
error: { color: COLORS.danger, fontSize: 13 },
mono: { fontSize: 12, color: COLORS.muted, fontFamily: 'monospace' },
actions: { flexDirection: 'row', gap: 8, marginTop: 4 },
});
+72 -79
View File
@@ -1,52 +1,63 @@
import { NativeStackScreenProps } from '@react-navigation/native-stack';
import React, { useCallback, useEffect, useState } from 'react';
import { Alert, ScrollView, StyleSheet, Text, TouchableOpacity, View } from 'react-native';
import { Issue, Phase } from '../api/types';
import { useFocusEffect } from '@react-navigation/native';
import React, { useCallback, useState } from 'react';
import { Alert, StyleSheet, Text, TouchableOpacity, View } from 'react-native';
import { SyncStatusBar } from '../components/SyncStatusBar';
import { getOutboxCounts, OutboxCounts } from '../db/outbox';
import { getCursor, getIssues, getPhases } from '../db/repositories';
import { isOnline } from '../net/connectivity';
import { runSync } from '../sync/engine';
import { useAutoSync } from '../sync/useAutoSync';
import { RootStackParamList } from '../navigation/types';
import { COLORS, PrimaryButton } from '../ui/components';
import { PhasesSection } from './sections/PhasesSection';
import { FeaturesSection } from './sections/FeaturesSection';
import { IssuesSection } from './sections/IssuesSection';
type Props = NativeStackScreenProps<RootStackParamList, 'ProjectDetail'>;
const EMPTY: OutboxCounts = { pending: 0, conflict: 0, error: 0, mediaPending: 0 };
const TABS = ['Fases', 'Features', 'Incidencias'] as const;
type Tab = (typeof TABS)[number];
export function ProjectDetailScreen({ route }: Props) {
export function ProjectDetailScreen({ route, navigation }: Props) {
const { projectId } = route.params;
const [phases, setPhases] = useState<Phase[]>([]);
const [issues, setIssues] = useState<Issue[]>([]);
const [tab, setTab] = useState<Tab>('Fases');
const [counts, setCounts] = useState<OutboxCounts>(EMPTY);
const [cursor, setCursorState] = useState<string | null>(null);
const [syncing, setSyncing] = useState(false);
// Se incrementa tras cada sync para forzar el recargado de la sección visible.
const [nonce, setNonce] = useState(0);
const refresh = useCallback(async () => {
const [ph, iss, c, cur] = await Promise.all([
getPhases(projectId),
getIssues(projectId),
getOutboxCounts(),
getCursor(projectId),
]);
setPhases(ph);
setIssues(iss);
setCounts(c);
setCursorState(cur);
}, [projectId]);
const refreshCounts = useCallback(() => {
void getOutboxCounts().then(setCounts);
}, []);
useEffect(() => {
void refresh();
}, [refresh]);
useFocusEffect(refreshCounts);
/** Ciclo de sync; refresca contadores y la sección visible. */
const doSync = useCallback(async () => {
setSyncing(true);
try {
return await runSync(projectId);
} finally {
refreshCounts();
setNonce((n) => n + 1);
setSyncing(false);
}
}, [projectId, refreshCounts]);
// Auto-sync (silencioso) al recuperar red / volver a primer plano / por intervalo.
useAutoSync(true, async () => {
await doSync();
});
// Sync manual (con resumen).
const onSync = useCallback(async () => {
if (!(await isOnline())) {
Alert.alert('Sin conexión', 'Conéctate para sincronizar.');
return;
}
setSyncing(true);
try {
const report = await runSync(projectId);
await refresh();
const report = await doSync();
Alert.alert(
'Sincronización completada',
`Enviadas: ${report.applied}/${report.pushed}\n` +
@@ -55,71 +66,53 @@ export function ProjectDetailScreen({ route }: Props) {
);
} catch (e) {
Alert.alert('Error de sincronización', e instanceof Error ? e.message : String(e));
} finally {
setSyncing(false);
}
}, [projectId, refresh]);
}, [doSync]);
return (
<View style={styles.container}>
<SyncStatusBar counts={counts} syncing={syncing} />
<SyncStatusBar
counts={counts}
syncing={syncing}
onPress={() => navigation.navigate('Outbox')}
/>
<ScrollView contentContainerStyle={styles.body}>
<Text style={styles.section}>Fases ({phases.length})</Text>
{phases.map((p) => (
<View key={p.id} style={styles.card}>
<Text style={styles.cardTitle}>{p.name}</Text>
<Text style={styles.cardMeta}>{Math.round(p.progress_percent ?? 0)}%</Text>
</View>
<View style={styles.tabs}>
{TABS.map((t) => (
<TouchableOpacity
key={t}
style={[styles.tab, tab === t && styles.tabActive]}
onPress={() => setTab(t)}
>
<Text style={[styles.tabText, tab === t && styles.tabTextActive]}>{t}</Text>
</TouchableOpacity>
))}
</View>
<Text style={styles.section}>Incidencias ({issues.length})</Text>
{issues.map((i) => (
<View key={i.id} style={styles.card}>
<Text style={styles.cardTitle}>{i.title}</Text>
<Text style={styles.cardMeta}>
{i.priority ?? '—'} · {i.status ?? '—'}
</Text>
</View>
))}
<View style={styles.content} key={`${tab}-${nonce}`}>
{tab === 'Fases' && <PhasesSection projectId={projectId} />}
{tab === 'Features' && <FeaturesSection projectId={projectId} />}
{tab === 'Incidencias' && <IssuesSection projectId={projectId} />}
</View>
<Text style={styles.cursor}>
Último sync: {cursor ? new Date(cursor).toLocaleString() : 'nunca'}
</Text>
</ScrollView>
<TouchableOpacity
style={[styles.syncBtn, syncing && styles.syncBtnDisabled]}
onPress={onSync}
disabled={syncing}
>
<Text style={styles.syncBtnText}>{syncing ? 'Sincronizando…' : 'Sincronizar'}</Text>
</TouchableOpacity>
<View style={styles.footer}>
<PrimaryButton
title={syncing ? 'Sincronizando…' : 'Sincronizar'}
onPress={() => void onSync()}
loading={syncing}
/>
</View>
</View>
);
}
const styles = StyleSheet.create({
container: { flex: 1 },
body: { padding: 16, gap: 8 },
section: { fontSize: 16, fontWeight: '700', marginTop: 12 },
card: {
flexDirection: 'row',
justifyContent: 'space-between',
backgroundColor: '#f4f4f4',
borderRadius: 8,
padding: 12,
},
cardTitle: { fontSize: 15, flex: 1 },
cardMeta: { fontSize: 13, color: '#666', marginLeft: 8 },
cursor: { marginTop: 20, color: '#888', fontSize: 12, textAlign: 'center' },
syncBtn: {
backgroundColor: '#1f6f43',
margin: 16,
borderRadius: 8,
paddingVertical: 14,
alignItems: 'center',
},
syncBtnDisabled: { opacity: 0.5 },
syncBtnText: { color: '#fff', fontSize: 16, fontWeight: '700' },
tabs: { flexDirection: 'row', borderBottomWidth: StyleSheet.hairlineWidth, borderColor: COLORS.border },
tab: { flex: 1, paddingVertical: 12, alignItems: 'center' },
tabActive: { borderBottomWidth: 2, borderColor: COLORS.primary },
tabText: { fontSize: 14, color: COLORS.muted },
tabTextActive: { color: COLORS.primary, fontWeight: '700' },
content: { flex: 1 },
footer: { padding: 12, borderTopWidth: StyleSheet.hairlineWidth, borderColor: COLORS.border },
});
+2 -1
View File
@@ -12,7 +12,7 @@ import {
import * as api from '../api/endpoints';
import { Project } from '../api/types';
import { useSession } from '../auth/session';
import { getCursor, getProjects, saveProjectList } from '../db/repositories';
import { getCursor, getProjects, saveProjectList, setActiveProjectId } from '../db/repositories';
import { isOnline } from '../net/connectivity';
import { initialPull, runSync } from '../sync/engine';
import { RootStackParamList } from '../navigation/types';
@@ -52,6 +52,7 @@ export function ProjectsScreen({ navigation }: Props) {
async (p: Project) => {
setOpening(p.id);
try {
await setActiveProjectId(p.id);
const online = await isOnline();
if (online) {
const cursor = await getCursor(p.id);
+166
View File
@@ -0,0 +1,166 @@
/**
* Contenido del detalle de una feature: estado/progreso editable, inspecciones
* y fotos. Reutilizado en móvil (pantalla) y tablet (panel del maestro-detalle).
*/
import { useNavigation } from '@react-navigation/native';
import { NativeStackNavigationProp } from '@react-navigation/native-stack';
import React, { useCallback, useEffect, useState } from 'react';
import { ScrollView, StyleSheet, Text, TouchableOpacity, View } from 'react-native';
import { Feature, Inspection } from '../../api/types';
import { hasPermission, useSession } from '../../auth/session';
import { getFeature, getInspectionsByFeature } from '../../db/repositories';
import { updateFeature } from '../../sync/mutations';
import { RootStackParamList } from '../../navigation/types';
import {
Badge,
Card,
ChipSelect,
COLORS,
EmptyState,
PrimaryButton,
SectionTitle,
} from '../../ui/components';
import { MediaStrip } from '../../ui/MediaStrip';
const STATUSES = ['pending', 'in_progress', 'completed', 'blocked'] as const;
const QUICK_PROGRESS = [0, 25, 50, 75, 100];
type Nav = NativeStackNavigationProp<RootStackParamList>;
export function FeatureDetailContent({ featureId }: { featureId: number }) {
const navigation = useNavigation<Nav>();
const { user } = useSession();
const canProgress = hasPermission(user, 'update progress');
const canInspect = hasPermission(user, 'create inspections');
const [feature, setFeature] = useState<Feature | null>(null);
const [inspections, setInspections] = useState<Inspection[]>([]);
const refresh = useCallback(async () => {
const [f, ins] = await Promise.all([
getFeature(featureId),
getInspectionsByFeature(featureId),
]);
setFeature(f);
setInspections(ins);
}, [featureId]);
useEffect(() => {
void refresh();
}, [refresh]);
const onStatus = useCallback(
async (status: string) => {
await updateFeature({ id: featureId, status });
await refresh();
},
[featureId, refresh],
);
const onProgress = useCallback(
async (progress: number) => {
await updateFeature({ id: featureId, progress });
await refresh();
},
[featureId, refresh],
);
if (!feature) {
return (
<View style={styles.center}>
<Text style={{ color: COLORS.muted }}>Cargando</Text>
</View>
);
}
return (
<ScrollView contentContainerStyle={styles.body}>
<Text style={styles.title}>{feature.name}</Text>
<View style={styles.badges}>
{feature.status && <Badge label={feature.status} color={COLORS.primary} />}
<Badge label={`${Math.round(feature.progress ?? 0)}%`} color={COLORS.muted} />
</View>
<MediaStrip
parentEntity="feature"
parentId={feature.id}
canUpload={hasPermission(user, 'upload media')}
/>
{canProgress && (
<Card style={styles.editCard}>
<ChipSelect
label="Estado"
value={feature.status as (typeof STATUSES)[number] | undefined}
options={STATUSES}
onChange={(v) => void onStatus(v)}
/>
<Text style={styles.fieldLabel}>Progreso</Text>
<View style={styles.progressRow}>
{QUICK_PROGRESS.map((p) => {
const active = Math.round(feature.progress ?? 0) === p;
return (
<TouchableOpacity
key={p}
style={[styles.pBtn, active && styles.pBtnActive]}
onPress={() => void onProgress(p)}
>
<Text style={[styles.pText, active && styles.pTextActive]}>{p}%</Text>
</TouchableOpacity>
);
})}
</View>
</Card>
)}
<SectionTitle>Inspecciones ({inspections.length})</SectionTitle>
{inspections.length === 0 && <EmptyState text="Sin inspecciones." />}
{inspections.map((ins) => (
<Card key={ins.id} style={styles.insCard}>
<Text style={styles.insTitle}>
{ins.result ?? ins.status ?? 'Inspección'} ·{' '}
{ins.created_at ? new Date(ins.created_at).toLocaleDateString() : ''}
</Text>
{ins.notes ? <Text style={styles.insNotes}>{ins.notes}</Text> : null}
</Card>
))}
{canInspect && (
<PrimaryButton
title="Nueva inspección"
variant="ghost"
onPress={() =>
navigation.navigate('InspectionForm', {
featureId: feature.id,
featureName: feature.name,
templateId: feature.template_id ?? undefined,
})
}
/>
)}
</ScrollView>
);
}
const styles = StyleSheet.create({
center: { flex: 1, justifyContent: 'center', alignItems: 'center' },
body: { padding: 16, gap: 6 },
title: { fontSize: 20, fontWeight: '700' },
badges: { flexDirection: 'row', gap: 6, marginTop: 6 },
editCard: { marginTop: 12 },
fieldLabel: { fontSize: 13, color: COLORS.muted, marginBottom: 6, fontWeight: '600' },
progressRow: { flexDirection: 'row', gap: 6, flexWrap: 'wrap' },
pBtn: {
paddingHorizontal: 12,
paddingVertical: 8,
borderRadius: 8,
borderWidth: 1,
borderColor: COLORS.border,
backgroundColor: '#fff',
},
pBtnActive: { backgroundColor: COLORS.primary, borderColor: COLORS.primary },
pText: { color: COLORS.muted, fontWeight: '600' },
pTextActive: { color: '#fff' },
insCard: { marginBottom: 6 },
insTitle: { fontSize: 14, fontWeight: '600' },
insNotes: { fontSize: 13, color: COLORS.muted, marginTop: 2 },
});
+233
View File
@@ -0,0 +1,233 @@
/**
* Contenido del detalle de una incidencia. Se reutiliza en móvil (pantalla
* navegada) y en tablet (panel derecho del maestro-detalle).
*
* Todas las acciones pasan por src/sync/mutations.ts: escriben en local y
* encolan la operación. Tras cada cambio refrescamos desde la BD.
*/
import React, { useCallback, useEffect, useState } from 'react';
import { ScrollView, StyleSheet, Text, TouchableOpacity, View } from 'react-native';
import {
Issue,
IssueComment,
IssuePriority,
IssueStatus,
IssueTask,
} from '../../api/types';
import { hasPermission, useSession } from '../../auth/session';
import {
getIssue,
getIssueComments,
getIssueTasks,
} from '../../db/repositories';
import {
createIssueComment,
createIssueTask,
updateIssue,
updateIssueTask,
} from '../../sync/mutations';
import {
Badge,
Card,
ChipSelect,
COLORS,
EmptyState,
Field,
ISSUE_PRIORITY_COLOR,
ISSUE_STATUS_COLOR,
PrimaryButton,
SectionTitle,
} from '../../ui/components';
import { MediaStrip } from '../../ui/MediaStrip';
const STATUSES: readonly IssueStatus[] = ['open', 'in_review', 'resolved', 'closed'];
const PRIORITIES: readonly IssuePriority[] = ['low', 'medium', 'high', 'critical'];
export function IssueDetailContent({ issueId }: { issueId: number }) {
const { user } = useSession();
const canEdit = hasPermission(user, 'edit issues');
const [issue, setIssue] = useState<Issue | null>(null);
const [tasks, setTasks] = useState<IssueTask[]>([]);
const [comments, setComments] = useState<IssueComment[]>([]);
const [newTask, setNewTask] = useState('');
const [newComment, setNewComment] = useState('');
const refresh = useCallback(async () => {
const [i, t, c] = await Promise.all([
getIssue(issueId),
getIssueTasks(issueId),
getIssueComments(issueId),
]);
setIssue(i);
setTasks(t);
setComments(c);
}, [issueId]);
useEffect(() => {
void refresh();
}, [refresh]);
const onStatus = useCallback(
async (status: IssueStatus) => {
await updateIssue({ id: issueId, status });
await refresh();
},
[issueId, refresh],
);
const onPriority = useCallback(
async (priority: IssuePriority) => {
await updateIssue({ id: issueId, priority });
await refresh();
},
[issueId, refresh],
);
const onToggleTask = useCallback(
async (task: IssueTask) => {
await updateIssueTask({ id: task.id, is_done: !task.is_done });
await refresh();
},
[refresh],
);
const onAddTask = useCallback(async () => {
const title = newTask.trim();
if (!title) return;
setNewTask('');
await createIssueTask({ issue_id: issueId, title });
await refresh();
}, [newTask, issueId, refresh]);
const onAddComment = useCallback(async () => {
const body = newComment.trim();
if (!body) return;
setNewComment('');
await createIssueComment({ issue_id: issueId, body });
await refresh();
}, [newComment, issueId, refresh]);
if (!issue) {
return (
<View style={styles.center}>
<Text style={{ color: COLORS.muted }}>Cargando</Text>
</View>
);
}
const done = tasks.filter((t) => t.is_done).length;
const progress = tasks.length ? Math.round((done / tasks.length) * 100) : 0;
return (
<ScrollView contentContainerStyle={styles.body}>
<Text style={styles.title}>{issue.title}</Text>
<View style={styles.badges}>
{issue.status && (
<Badge label={issue.status} color={ISSUE_STATUS_COLOR[issue.status] ?? COLORS.muted} />
)}
{issue.priority && (
<Badge
label={issue.priority}
color={ISSUE_PRIORITY_COLOR[issue.priority] ?? COLORS.muted}
/>
)}
{issue.type && <Badge label={issue.type} color={COLORS.muted} />}
</View>
{issue.description ? <Text style={styles.desc}>{issue.description}</Text> : null}
<MediaStrip parentEntity="issue" parentId={issue.id} canUpload={hasPermission(user, 'upload media')} />
{canEdit && (
<Card style={styles.editCard}>
<ChipSelect
label="Estado"
value={issue.status}
options={STATUSES}
onChange={(v) => void onStatus(v)}
/>
<ChipSelect
label="Prioridad"
value={issue.priority}
options={PRIORITIES}
onChange={(v) => void onPriority(v)}
/>
</Card>
)}
<SectionTitle>
Tareas · {done}/{tasks.length} ({progress}%)
</SectionTitle>
{tasks.length === 0 && <EmptyState text="Sin tareas." />}
{tasks.map((t) => (
<TouchableOpacity
key={t.id}
style={styles.taskRow}
disabled={!canEdit}
onPress={() => void onToggleTask(t)}
>
<Text style={styles.checkbox}>{t.is_done ? '☑' : '☐'}</Text>
<Text style={[styles.taskText, t.is_done && styles.taskDone]}>{t.title}</Text>
</TouchableOpacity>
))}
{canEdit && (
<View style={styles.addRow}>
<View style={{ flex: 1 }}>
<Field
label=""
placeholder="Nueva tarea…"
value={newTask}
onChangeText={setNewTask}
onSubmitEditing={() => void onAddTask()}
/>
</View>
<PrimaryButton title="Añadir" onPress={() => void onAddTask()} disabled={!newTask.trim()} />
</View>
)}
<SectionTitle>Comentarios</SectionTitle>
{comments.length === 0 && <EmptyState text="Sin comentarios." />}
{comments.map((c) => (
<Card key={c.id} style={styles.comment}>
<Text style={styles.commentBody}>{c.body}</Text>
{c.created_at ? (
<Text style={styles.commentMeta}>{new Date(c.created_at).toLocaleString()}</Text>
) : null}
</Card>
))}
<View style={styles.addRow}>
<View style={{ flex: 1 }}>
<Field
label=""
placeholder="Escribe un comentario…"
value={newComment}
onChangeText={setNewComment}
multiline
/>
</View>
<PrimaryButton
title="Enviar"
onPress={() => void onAddComment()}
disabled={!newComment.trim()}
/>
</View>
</ScrollView>
);
}
const styles = StyleSheet.create({
center: { flex: 1, justifyContent: 'center', alignItems: 'center' },
body: { padding: 16, gap: 4 },
title: { fontSize: 20, fontWeight: '700' },
badges: { flexDirection: 'row', gap: 6, marginTop: 6, flexWrap: 'wrap' },
desc: { marginTop: 8, color: '#333', fontSize: 14 },
editCard: { marginTop: 12, gap: 4 },
taskRow: { flexDirection: 'row', alignItems: 'center', paddingVertical: 8, gap: 10 },
checkbox: { fontSize: 20 },
taskText: { fontSize: 15, flex: 1 },
taskDone: { textDecorationLine: 'line-through', color: COLORS.muted },
addRow: { flexDirection: 'row', alignItems: 'flex-end', gap: 8, marginTop: 4 },
comment: { marginBottom: 6 },
commentBody: { fontSize: 14 },
commentMeta: { fontSize: 11, color: COLORS.muted, marginTop: 4 },
});
+115
View File
@@ -0,0 +1,115 @@
/**
* Sección Features: maestro-detalle con conmutador Lista/Mapa en el panel
* maestro. Lista o mapa a la izquierda, detalle a la derecha (tablet) o
* navegación a pantalla (móvil).
*/
import { useFocusEffect, useNavigation } from '@react-navigation/native';
import { NativeStackNavigationProp } from '@react-navigation/native-stack';
import React, { useCallback, useState } from 'react';
import { FlatList, StyleSheet, Text, TouchableOpacity, View } from 'react-native';
import { Feature } from '../../api/types';
import { getFeatures } from '../../db/repositories';
import { RootStackParamList } from '../../navigation/types';
import { Badge, COLORS, EmptyState } from '../../ui/components';
import { FeatureMap } from '../../ui/FeatureMap';
import { MasterDetail } from '../../ui/MasterDetail';
import { FeatureDetailContent } from '../detail/FeatureDetailContent';
type Nav = NativeStackNavigationProp<RootStackParamList>;
type ViewMode = 'list' | 'map';
export function FeaturesSection({ projectId }: { projectId: number }) {
const navigation = useNavigation<Nav>();
const [features, setFeatures] = useState<Feature[]>([]);
const [mode, setMode] = useState<ViewMode>('list');
const load = useCallback(() => {
void getFeatures(projectId).then(setFeatures);
}, [projectId]);
useFocusEffect(load);
const goPhone = (id: number) => {
const f = features.find((x) => x.id === id);
navigation.navigate('FeatureDetail', { featureId: id, name: f?.name ?? 'Feature' });
};
return (
<MasterDetail
onSelectPhone={goPhone}
renderDetail={(id) => <FeatureDetailContent featureId={id} />}
renderMaster={({ selectedId, onSelect }) => (
<View style={{ flex: 1 }}>
<View style={styles.toggle}>
{(['list', 'map'] as ViewMode[]).map((m) => (
<TouchableOpacity
key={m}
style={[styles.toggleBtn, mode === m && styles.toggleActive]}
onPress={() => setMode(m)}
>
<Text style={[styles.toggleText, mode === m && styles.toggleTextActive]}>
{m === 'list' ? 'Lista' : 'Mapa'}
</Text>
</TouchableOpacity>
))}
</View>
{features.length === 0 ? (
<EmptyState text="Sin features." />
) : mode === 'map' ? (
<FeatureMap features={features} selectedId={selectedId} onSelect={onSelect} />
) : (
<FlatList
data={features}
keyExtractor={(f) => String(f.id)}
renderItem={({ item }) => (
<TouchableOpacity
style={[styles.row, item.id === selectedId && styles.rowActive]}
onPress={() => onSelect(item.id)}
>
<View style={{ flex: 1 }}>
<Text style={styles.name}>{item.name}</Text>
{item.status ? <Text style={styles.meta}>{item.status}</Text> : null}
</View>
<Badge label={`${Math.round(item.progress ?? 0)}%`} color={COLORS.muted} />
</TouchableOpacity>
)}
/>
)}
</View>
)}
/>
);
}
const styles = StyleSheet.create({
toggle: {
flexDirection: 'row',
padding: 8,
gap: 8,
borderBottomWidth: StyleSheet.hairlineWidth,
borderColor: COLORS.border,
},
toggleBtn: {
paddingHorizontal: 14,
paddingVertical: 6,
borderRadius: 16,
borderWidth: 1,
borderColor: COLORS.border,
},
toggleActive: { backgroundColor: COLORS.primary, borderColor: COLORS.primary },
toggleText: { fontSize: 13, color: COLORS.muted },
toggleTextActive: { color: '#fff', fontWeight: '700' },
row: {
flexDirection: 'row',
alignItems: 'center',
paddingHorizontal: 16,
paddingVertical: 14,
borderBottomWidth: StyleSheet.hairlineWidth,
borderColor: COLORS.border,
gap: 8,
},
rowActive: { backgroundColor: '#eef5f0' },
name: { fontSize: 15, fontWeight: '600' },
meta: { fontSize: 12, color: COLORS.muted, marginTop: 2 },
});
+107
View File
@@ -0,0 +1,107 @@
/**
* Sección Incidencias: maestro-detalle + alta de incidencia.
*/
import { useFocusEffect, useNavigation } from '@react-navigation/native';
import { NativeStackNavigationProp } from '@react-navigation/native-stack';
import React, { useCallback, useState } from 'react';
import { FlatList, StyleSheet, Text, TouchableOpacity, View } from 'react-native';
import { Issue } from '../../api/types';
import { hasPermission, useSession } from '../../auth/session';
import { getIssues } from '../../db/repositories';
import { RootStackParamList } from '../../navigation/types';
import {
Badge,
COLORS,
EmptyState,
ISSUE_PRIORITY_COLOR,
ISSUE_STATUS_COLOR,
PrimaryButton,
} from '../../ui/components';
import { MasterDetail } from '../../ui/MasterDetail';
import { IssueDetailContent } from '../detail/IssueDetailContent';
type Nav = NativeStackNavigationProp<RootStackParamList>;
export function IssuesSection({ projectId }: { projectId: number }) {
const navigation = useNavigation<Nav>();
const { user } = useSession();
const canCreate = hasPermission(user, 'create issues');
const [issues, setIssues] = useState<Issue[]>([]);
const load = useCallback(() => {
void getIssues(projectId).then(setIssues);
}, [projectId]);
useFocusEffect(load);
return (
<View style={{ flex: 1 }}>
{canCreate && (
<View style={styles.toolbar}>
<PrimaryButton
title="+ Nueva incidencia"
variant="ghost"
onPress={() => navigation.navigate('IssueCreate', { projectId })}
/>
</View>
)}
<MasterDetail
onSelectPhone={(id) => {
const i = issues.find((x) => x.id === id);
navigation.navigate('IssueDetail', { issueId: id, title: i?.title ?? 'Incidencia' });
}}
renderDetail={(id) => <IssueDetailContent issueId={id} />}
renderMaster={({ selectedId, onSelect }) =>
issues.length === 0 ? (
<EmptyState text="Sin incidencias." />
) : (
<FlatList
data={issues}
keyExtractor={(i) => String(i.id)}
renderItem={({ item }) => (
<TouchableOpacity
style={[styles.row, item.id === selectedId && styles.rowActive]}
onPress={() => onSelect(item.id)}
>
<View style={{ flex: 1 }}>
<Text style={styles.title} numberOfLines={1}>
{item.title}
</Text>
<View style={styles.badges}>
{item.priority && (
<Badge
label={item.priority}
color={ISSUE_PRIORITY_COLOR[item.priority] ?? COLORS.muted}
/>
)}
{item.status && (
<Badge
label={item.status}
color={ISSUE_STATUS_COLOR[item.status] ?? COLORS.muted}
/>
)}
{item.id < 0 && <Badge label="local" color={COLORS.warn} />}
</View>
</View>
</TouchableOpacity>
)}
/>
)
}
/>
</View>
);
}
const styles = StyleSheet.create({
toolbar: { padding: 12, borderBottomWidth: StyleSheet.hairlineWidth, borderColor: COLORS.border },
row: {
paddingHorizontal: 16,
paddingVertical: 14,
borderBottomWidth: StyleSheet.hairlineWidth,
borderColor: COLORS.border,
},
rowActive: { backgroundColor: '#eef5f0' },
title: { fontSize: 15, fontWeight: '600' },
badges: { flexDirection: 'row', gap: 6, marginTop: 6 },
});
+93
View File
@@ -0,0 +1,93 @@
/**
* Sección Fases: lista con barra de progreso y registro rápido de avance
* (operación append-only progress_update).
*/
import { useFocusEffect } from '@react-navigation/native';
import React, { useCallback, useState } from 'react';
import { ScrollView, StyleSheet, Text, TouchableOpacity, View } from 'react-native';
import { Phase } from '../../api/types';
import { hasPermission, useSession } from '../../auth/session';
import { getPhases } from '../../db/repositories';
import { recordProgressUpdate } from '../../sync/mutations';
import { Card, COLORS, EmptyState } from '../../ui/components';
import { useLayout } from '../../ui/responsive';
const QUICK = [25, 50, 75, 100];
export function PhasesSection({ projectId }: { projectId: number }) {
const { user } = useSession();
const canProgress = hasPermission(user, 'update progress');
const { columns, gutter } = useLayout();
const [phases, setPhases] = useState<Phase[]>([]);
const load = useCallback(() => {
void getPhases(projectId).then(setPhases);
}, [projectId]);
useFocusEffect(load);
const onQuick = useCallback(
async (phase: Phase, progress: number) => {
await recordProgressUpdate({ phase_id: phase.id, progress });
// Reflejo optimista en la barra (el valor real llega en el próximo PULL).
setPhases((prev) =>
prev.map((p) => (p.id === phase.id ? { ...p, progress_percent: progress } : p)),
);
},
[],
);
if (phases.length === 0) return <EmptyState text="Sin fases." />;
return (
<ScrollView contentContainerStyle={[styles.body, { padding: gutter }]}>
<View style={[styles.grid, columns > 1 && { gap: gutter }]}>
{phases.map((p) => {
const pct = Math.round(p.progress_percent ?? 0);
return (
<Card key={p.id} style={[styles.card, columns > 1 && { width: `${100 / columns - 2}%` }]}>
<View style={styles.headerRow}>
{p.color ? <View style={[styles.dot, { backgroundColor: p.color }]} /> : null}
<Text style={styles.name}>{p.name}</Text>
<Text style={styles.pct}>{pct}%</Text>
</View>
<View style={styles.track}>
<View style={[styles.fill, { width: `${pct}%` }]} />
</View>
{canProgress && (
<View style={styles.quickRow}>
{QUICK.map((q) => (
<TouchableOpacity key={q} style={styles.quickBtn} onPress={() => void onQuick(p, q)}>
<Text style={styles.quickText}>{q}%</Text>
</TouchableOpacity>
))}
</View>
)}
</Card>
);
})}
</View>
</ScrollView>
);
}
const styles = StyleSheet.create({
body: { gap: 10 },
grid: { flexDirection: 'row', flexWrap: 'wrap', gap: 10 },
card: { width: '100%', gap: 8 },
headerRow: { flexDirection: 'row', alignItems: 'center', gap: 8 },
dot: { width: 12, height: 12, borderRadius: 6 },
name: { fontSize: 15, fontWeight: '600', flex: 1 },
pct: { fontSize: 13, color: COLORS.muted },
track: { height: 8, borderRadius: 4, backgroundColor: '#e3e3e3', overflow: 'hidden' },
fill: { height: '100%', backgroundColor: COLORS.primary },
quickRow: { flexDirection: 'row', gap: 6 },
quickBtn: {
paddingHorizontal: 10,
paddingVertical: 5,
borderRadius: 6,
borderWidth: 1,
borderColor: COLORS.border,
},
quickText: { fontSize: 12, color: COLORS.muted, fontWeight: '600' },
});
+110 -26
View File
@@ -9,16 +9,28 @@
* Idempotente: cada operación lleva su uuid, así reenviar la cola es seguro.
* Conflictos: last-write-wins en servidor; si responde `conflict`, aplicamos
* el valor del servidor a la BD local y dejamos la op marcada para revisión.
*
* Creaciones offline: las filas nacen con un id temporal negativo. Al recibir
* `applied` con `server_id` reconciliamos el id real y remapeamos las FKs de
* las operaciones hijas que aún referencian el id temporal (p. ej. una tarea
* creada sobre una incidencia que aún no existía en el servidor).
*/
import { getBundle, sync as syncApi, uploadMedia } from '../api/endpoints';
import { Operation, OperationResult, SyncEntity } from '../api/types';
import { applyBundle, applyServerValue, getCursor } from '../db/repositories';
import {
applyBundle,
applyServerValue,
getCursor,
reconcileCreate,
} from '../db/repositories';
import {
applyOperationResult,
getPendingMedia,
getPendingOperations,
getPendingOutbox,
markMediaError,
markMediaSent,
PendingOp,
updateOutboxData,
} from '../db/outbox';
const SYNC_BATCH = 100;
@@ -32,6 +44,9 @@ const ENTITY_TABLE: Partial<Record<SyncEntity, string>> = {
issue_comment: 'issue_comments',
};
/** Campos que pueden contener un id temporal (negativo) a remapear. */
const FK_FIELDS = ['id', 'issue_id', 'feature_id', 'parent_id', 'layer_id', 'phase_id'] as const;
export interface SyncReport {
pushed: number;
applied: number;
@@ -42,54 +57,123 @@ export interface SyncReport {
pulled: boolean;
}
/** Vacía el outbox de operaciones contra /sync. */
async function pushOperations(report: SyncReport): Promise<void> {
// eslint-disable-next-line no-constant-condition
while (true) {
const ops = await getPendingOperations(SYNC_BATCH);
if (ops.length === 0) break;
function negativeRefs(data: Record<string, unknown>): number[] {
const out: number[] = [];
for (const f of FK_FIELDS) {
const v = data[f];
if (typeof v === 'number' && v < 0) out.push(v);
}
return out;
}
const { results } = await syncApi(ops);
report.pushed += ops.length;
const byUuid = new Map<string, Operation>(ops.map((o) => [o.uuid, o]));
for (const result of results) {
await processResult(result, byUuid.get(result.uuid), report);
/** Reescribe en `data` los ids temporales ya resueltos. Devuelve si cambió algo. */
function rewriteRefs(data: Record<string, unknown>, idMap: Map<number, number>): boolean {
let changed = false;
for (const f of FK_FIELDS) {
const v = data[f];
if (typeof v === 'number' && v < 0 && idMap.has(v)) {
data[f] = idMap.get(v)!;
changed = true;
}
}
return changed;
}
// Si nada quedó 'pending' que pudiéramos reintentar, salimos para no
// bucear infinitamente con conflictos/errores.
if (results.every((r) => r.status !== 'applied' && r.status !== 'duplicate')) {
break;
function toOperation(op: PendingOp): Operation {
return {
entity: op.entity,
op: op.op,
uuid: op.uuid,
client_updated_at: op.client_updated_at,
data: op.data,
};
}
/** Persiste el remapeo de un id temporal en las operaciones pendientes (durabilidad). */
async function remapPendingOps(tempId: number, serverId: number): Promise<void> {
const pending = await getPendingOutbox(SYNC_BATCH * 4);
const single = new Map([[tempId, serverId]]);
for (const op of pending) {
if (rewriteRefs(op.data, single)) {
await updateOutboxData(op.uuid, op.data);
}
}
}
/** Vacía el outbox de operaciones contra /sync, respetando dependencias. */
async function pushOperations(report: SyncReport): Promise<void> {
const idMap = new Map<number, number>(); // tempId -> serverId
// eslint-disable-next-line no-constant-condition
while (true) {
const pending = await getPendingOutbox(SYNC_BATCH * 4);
if (pending.length === 0) break;
// Construir un lote: no incluir una op que dependa de un id temporal
// producido por un create de este mismo lote (aún sin id de servidor).
const producedInBatch = new Set<number>();
const batch: PendingOp[] = [];
for (const op of pending) {
rewriteRefs(op.data, idMap);
if (negativeRefs(op.data).some((r) => producedInBatch.has(r))) break;
batch.push(op);
if (op.op === 'create' && op.localId != null) producedInBatch.add(op.localId);
if (batch.length >= SYNC_BATCH) break;
}
if (batch.length === 0) break;
const { results } = await syncApi(batch.map(toOperation));
report.pushed += batch.length;
const byUuid = new Map(batch.map((o) => [o.uuid, o]));
let progressed = false;
for (const result of results) {
progressed = (await processResult(result, byUuid.get(result.uuid), report, idMap)) || progressed;
}
// Si ningún ítem avanzó (todo conflicto/error), evitamos un bucle infinito.
if (!progressed) break;
}
}
/** Procesa un resultado; devuelve true si "avanzó" (applied/duplicate). */
async function processResult(
result: OperationResult,
op: Operation | undefined,
op: PendingOp | undefined,
report: SyncReport,
): Promise<void> {
idMap: Map<number, number>,
): Promise<boolean> {
await applyOperationResult(result);
if (result.status === 'applied' || result.status === 'duplicate') {
report.applied += 1;
} else if (result.status === 'conflict') {
if (op && op.op === 'create' && op.localId != null && result.server_id != null) {
const tempId = await reconcileCreate(op.entity, op.uuid, result.server_id);
if (tempId != null && tempId !== result.server_id) {
idMap.set(tempId, result.server_id);
await remapPendingOps(tempId, result.server_id);
}
}
return true;
}
if (result.status === 'conflict') {
report.conflicts += 1;
// Volcamos el valor del servidor a la BD local (last-write-wins servidor).
if (op && result.server) {
const table = ENTITY_TABLE[op.entity];
const id = (op.data as { id?: number }).id;
if (table && id != null) {
if (table && id != null && id > 0) {
await applyServerValue(table, id, result.server);
}
}
} else {
report.errors += 1;
return false;
}
report.errors += 1;
return false;
}
/** Sube los ficheros pendientes a /media. */
/** Sube los ficheros pendientes a /media (solo los de padres ya sincronizados). */
async function pushMedia(report: SyncReport): Promise<void> {
const pending = await getPendingMedia();
for (const m of pending) {
+55 -8
View File
@@ -8,6 +8,8 @@
*/
import { getDb } from '../db/database';
import { enqueueOperation } from '../db/outbox';
import { insertLocalCreate } from '../db/repositories';
import { newUuid, nextTempId, nowIso } from './uuid';
import {
IssuePriority,
IssueStatus,
@@ -60,7 +62,7 @@ export async function updateFeature(input: {
// ----- inspection.create (append-only) -----
export function createInspection(input: {
export async function createInspection(input: {
feature_id: number;
template_id?: number;
data?: Record<string, unknown>;
@@ -68,12 +70,25 @@ export function createInspection(input: {
result?: string;
notes?: string;
}): Promise<string> {
return enqueueOperation('inspection', 'create', { ...input });
const uuid = newUuid();
const tempId = nextTempId();
await insertLocalCreate('inspection', tempId, uuid, {
feature_id: input.feature_id,
template_id: input.template_id ?? null,
data: input.data ? JSON.stringify(input.data) : null,
status: input.status ?? null,
result: input.result ?? null,
notes: input.notes ?? null,
created_at: nowIso(),
updated_at: nowIso(),
dirty: 1,
});
return enqueueOperation('inspection', 'create', { ...input }, { uuid, localId: tempId });
}
// ----- issues -----
export function createIssue(input: {
export async function createIssue(input: {
project_id: number;
feature_id?: number;
title: string;
@@ -82,7 +97,20 @@ export function createIssue(input: {
status?: IssueStatus;
type?: IssueType;
}): Promise<string> {
return enqueueOperation('issue', 'create', { ...input });
const uuid = newUuid();
const tempId = nextTempId();
await insertLocalCreate('issue', tempId, uuid, {
project_id: input.project_id,
feature_id: input.feature_id ?? null,
title: input.title,
description: input.description ?? null,
priority: input.priority ?? null,
status: input.status ?? 'open',
type: input.type ?? null,
updated_at: nowIso(),
dirty: 1,
});
return enqueueOperation('issue', 'create', { ...input }, { uuid, localId: tempId });
}
export async function updateIssue(input: {
@@ -125,14 +153,25 @@ export async function updateIssue(input: {
// ----- issue tasks -----
export function createIssueTask(input: {
export async function createIssueTask(input: {
issue_id: number;
title: string;
assigned_to?: number;
due_date?: string;
is_done?: boolean;
}): Promise<string> {
return enqueueOperation('issue_task', 'create', { ...input });
const uuid = newUuid();
const tempId = nextTempId();
await insertLocalCreate('issue_task', tempId, uuid, {
issue_id: input.issue_id,
title: input.title,
is_done: input.is_done ? 1 : 0,
assigned_to: input.assigned_to ?? null,
due_date: input.due_date ?? null,
updated_at: nowIso(),
dirty: 1,
});
return enqueueOperation('issue_task', 'create', { ...input }, { uuid, localId: tempId });
}
export async function updateIssueTask(input: {
@@ -155,9 +194,17 @@ export async function updateIssueTask(input: {
// ----- issue comments (append-only) -----
export function createIssueComment(input: {
export async function createIssueComment(input: {
issue_id: number;
body: string;
}): Promise<string> {
return enqueueOperation('issue_comment', 'create', { ...input });
const uuid = newUuid();
const tempId = nextTempId();
await insertLocalCreate('issue_comment', tempId, uuid, {
issue_id: input.issue_id,
body: input.body,
created_at: nowIso(),
updated_at: nowIso(),
});
return enqueueOperation('issue_comment', 'create', { ...input }, { uuid, localId: tempId });
}
+60
View File
@@ -0,0 +1,60 @@
/**
* Auto-sync mientras hay un proyecto abierto. Dispara una sincronización:
* - al recuperar la conexión,
* - al volver la app a primer plano,
* - periódicamente (intervalo suave),
* con un candado para no solapar ciclos. La sincronización en segundo plano con
* la app cerrada queda fuera de v1.
*/
import NetInfo from '@react-native-community/netinfo';
import { useEffect, useRef } from 'react';
import { AppState } from 'react-native';
import { isOnline } from '../net/connectivity';
const INTERVAL_MS = 60_000;
export function useAutoSync(enabled: boolean, run: () => Promise<void>) {
const running = useRef(false);
// Mantener la última `run` sin re-suscribir los listeners en cada render.
const runRef = useRef(run);
runRef.current = run;
useEffect(() => {
if (!enabled) return;
const trigger = async () => {
if (running.current) return;
if (!(await isOnline())) return;
running.current = true;
try {
await runRef.current();
} catch {
// silencioso: el estado/errores se reflejan en el outbox y la barra
} finally {
running.current = false;
}
};
// Al recuperar conexión.
const unsubNet = NetInfo.addEventListener((state) => {
if (state.isConnected && state.isInternetReachable !== false) void trigger();
});
// Al volver a primer plano.
const sub = AppState.addEventListener('change', (s) => {
if (s === 'active') void trigger();
});
// Intervalo suave.
const id = setInterval(() => void trigger(), INTERVAL_MS);
// Un primer intento al montar.
void trigger();
return () => {
unsubNet();
sub.remove();
clearInterval(id);
};
}, [enabled]);
}
+13
View File
@@ -9,3 +9,16 @@ export function newUuid(): string {
export function nowIso(): string {
return new Date().toISOString();
}
let tempCounter = 0;
/**
* Id local temporal para filas creadas offline. Siempre **negativo** para no
* colisionar nunca con los ids (positivos) del servidor; al sincronizar se
* sustituye por el id real (ver reconcileCreate).
*/
export function nextTempId(): number {
tempCounter = (tempCounter + 1) % 1000;
return -(Date.now() * 1000 + tempCounter);
}
+145
View File
@@ -0,0 +1,145 @@
/**
* Mapa de features. Dibuja la geometría GeoJSON del proyecto (puntos, líneas,
* polígonos) sobre Google Maps y permite seleccionar una feature tocándola.
*
* Nota: las tiles de Google Maps requieren conexión; la geometría sí se dibuja
* sin red. Requiere una API key de Google Maps (ver app.config.js / README).
*/
import * as Location from 'expo-location';
import React, { useMemo, useRef } from 'react';
import { StyleSheet, Text, TouchableOpacity, View } from 'react-native';
import MapView, { Marker, Polygon, Polyline, PROVIDER_GOOGLE } from 'react-native-maps';
import { Feature } from '../api/types';
import { COLORS } from './components';
import { geometryToShapes, LatLng, regionFor } from './geojson';
const STATUS_COLOR: Record<string, string> = {
pending: '#9aa0a6',
in_progress: '#1f6f43',
completed: '#2e7d32',
blocked: '#b00020',
};
function colorFor(status?: string): string {
return (status && STATUS_COLOR[status]) || COLORS.primary;
}
export function FeatureMap({
features,
selectedId,
onSelect,
}: {
features: Feature[];
selectedId: number | null;
onSelect: (id: number) => void;
}) {
const mapRef = useRef<MapView>(null);
const { shaped, region } = useMemo(() => {
const all: LatLng[] = [];
const shaped = features.map((f) => {
const shapes = geometryToShapes(f.geometry);
all.push(...shapes.points, ...shapes.lines.flat(), ...shapes.polygons.flat());
return { feature: f, shapes };
});
return { shaped, region: regionFor(all) };
}, [features]);
const recenter = async () => {
const perm = await Location.requestForegroundPermissionsAsync();
if (!perm.granted) return;
const pos = await Location.getCurrentPositionAsync({});
mapRef.current?.animateToRegion({
latitude: pos.coords.latitude,
longitude: pos.coords.longitude,
latitudeDelta: 0.01,
longitudeDelta: 0.01,
});
};
if (!region) {
return (
<View style={styles.empty}>
<Text style={{ color: COLORS.muted }}>Las features no tienen geometría.</Text>
</View>
);
}
return (
<View style={styles.container}>
<MapView
ref={mapRef}
style={StyleSheet.absoluteFill}
provider={PROVIDER_GOOGLE}
initialRegion={region}
showsUserLocation
>
{shaped.map(({ feature, shapes }) => {
const color = colorFor(feature.status);
const selected = feature.id === selectedId;
const stroke = selected ? '#000' : color;
return (
<React.Fragment key={feature.id}>
{shapes.points.map((p, i) => (
<Marker
key={`pt${feature.id}-${i}`}
coordinate={p}
pinColor={color}
title={feature.name}
onPress={() => onSelect(feature.id)}
/>
))}
{shapes.lines.map((line, i) => (
<Polyline
key={`ln${feature.id}-${i}`}
coordinates={line}
strokeColor={stroke}
strokeWidth={selected ? 5 : 3}
tappable
onPress={() => onSelect(feature.id)}
/>
))}
{shapes.polygons.map((poly, i) => (
<Polygon
key={`pg${feature.id}-${i}`}
coordinates={poly}
strokeColor={stroke}
fillColor={`${color}55`}
strokeWidth={selected ? 4 : 2}
tappable
onPress={() => onSelect(feature.id)}
/>
))}
</React.Fragment>
);
})}
</MapView>
<TouchableOpacity style={styles.locBtn} onPress={() => void recenter()}>
<Text style={styles.locIcon}></Text>
</TouchableOpacity>
</View>
);
}
const styles = StyleSheet.create({
container: { flex: 1 },
empty: { flex: 1, justifyContent: 'center', alignItems: 'center', padding: 24 },
locBtn: {
position: 'absolute',
right: 16,
bottom: 16,
width: 48,
height: 48,
borderRadius: 24,
backgroundColor: '#fff',
justifyContent: 'center',
alignItems: 'center',
elevation: 4,
shadowColor: '#000',
shadowOpacity: 0.2,
shadowRadius: 4,
shadowOffset: { width: 0, height: 2 },
},
locIcon: { fontSize: 22, color: COLORS.primary },
});
+67
View File
@@ -0,0 +1,67 @@
/**
* Layout maestro-detalle adaptativo.
*
* - **Tablet**: dos paneles lado a lado. La selección vive en estado local; al
* tocar un ítem se muestra su detalle en el panel derecho (no navega).
* - **Móvil**: solo el panel maestro; al tocar un ítem se invoca `onSelectPhone`
* (la pantalla lo conecta con `navigation.navigate`).
*/
import React, { ReactNode, useState } from 'react';
import { StyleSheet, Text, View } from 'react-native';
import { useLayout } from './responsive';
interface MasterDetailProps {
/** Pinta la lista. Recibe la selección actual (tablet) y el callback de selección. */
renderMaster: (args: {
selectedId: number | null;
onSelect: (id: number) => void;
}) => ReactNode;
/** Pinta el detalle de un id (solo se usa en tablet). */
renderDetail: (id: number) => ReactNode;
/** En móvil: qué hacer al seleccionar (normalmente navegar al detalle). */
onSelectPhone: (id: number) => void;
/** Texto/elemento cuando aún no hay selección (tablet). */
placeholder?: ReactNode;
/** Proporción del panel maestro en tablet (01). */
masterFlex?: number;
}
export function MasterDetail({
renderMaster,
renderDetail,
onSelectPhone,
placeholder,
masterFlex = 0.38,
}: MasterDetailProps) {
const { isTablet } = useLayout();
const [selectedId, setSelectedId] = useState<number | null>(null);
if (!isTablet) {
return <>{renderMaster({ selectedId: null, onSelect: onSelectPhone })}</>;
}
return (
<View style={styles.row}>
<View style={[styles.master, { flex: masterFlex }]}>
{renderMaster({ selectedId, onSelect: setSelectedId })}
</View>
<View style={[styles.detail, { flex: 1 - masterFlex }]}>
{selectedId != null ? (
renderDetail(selectedId)
) : (
<View style={styles.placeholder}>
{placeholder ?? <Text style={styles.placeholderText}>Selecciona un elemento</Text>}
</View>
)}
</View>
</View>
);
}
const styles = StyleSheet.create({
row: { flex: 1, flexDirection: 'row' },
master: { borderRightWidth: StyleSheet.hairlineWidth, borderColor: '#ddd' },
detail: { flex: 1 },
placeholder: { flex: 1, justifyContent: 'center', alignItems: 'center', padding: 24 },
placeholderText: { color: '#888', fontSize: 15 },
});
+126
View File
@@ -0,0 +1,126 @@
/**
* Tira de fotos de un registro (feature/issue/issue_task/issue_comment).
* Muestra las ya sincronizadas (tabla `media`, con url) y las locales en cola
* (`media_outbox`), y permite añadir nuevas desde cámara o galería (offline).
*/
import * as ImagePicker from 'expo-image-picker';
import React, { useCallback, useEffect, useState } from 'react';
import { Alert, Image, ScrollView, StyleSheet, Text, TouchableOpacity, View } from 'react-native';
import { Media, MediaParentEntity } from '../api/types';
import { absoluteUrl } from '../config';
import { enqueueMedia, getMediaOutboxFor, MediaOutboxRow } from '../db/outbox';
import { getMediaFor } from '../db/repositories';
import { COLORS } from './components';
export function MediaStrip({
parentEntity,
parentId,
canUpload,
}: {
parentEntity: MediaParentEntity;
parentId: number;
canUpload: boolean;
}) {
const [synced, setSynced] = useState<Media[]>([]);
const [pending, setPending] = useState<MediaOutboxRow[]>([]);
const refresh = useCallback(async () => {
const [s, p] = await Promise.all([
getMediaFor(parentEntity, parentId),
getMediaOutboxFor(parentEntity, parentId),
]);
setSynced(s);
setPending(p);
}, [parentEntity, parentId]);
useEffect(() => {
void refresh();
}, [refresh]);
const add = useCallback(
async (source: 'camera' | 'library') => {
const perm =
source === 'camera'
? await ImagePicker.requestCameraPermissionsAsync()
: await ImagePicker.requestMediaLibraryPermissionsAsync();
if (!perm.granted) {
Alert.alert('Permiso necesario', 'Concede el permiso para añadir fotos.');
return;
}
const result =
source === 'camera'
? await ImagePicker.launchCameraAsync({ quality: 0.7 })
: await ImagePicker.launchImageLibraryAsync({ quality: 0.7, mediaTypes: 'images' });
if (result.canceled || !result.assets?.length) return;
const asset = result.assets[0];
await enqueueMedia({
parentEntity,
parentId,
localUri: asset.uri,
fileName: asset.fileName ?? undefined,
mimeType: asset.mimeType ?? 'image/jpeg',
category: 'image',
});
await refresh();
},
[parentEntity, parentId, refresh],
);
const onAdd = useCallback(() => {
Alert.alert('Añadir foto', undefined, [
{ text: 'Cámara', onPress: () => void add('camera') },
{ text: 'Galería', onPress: () => void add('library') },
{ text: 'Cancelar', style: 'cancel' },
]);
}, [add]);
if (synced.length === 0 && pending.length === 0 && !canUpload) return null;
return (
<View style={styles.container}>
<ScrollView horizontal showsHorizontalScrollIndicator={false} contentContainerStyle={styles.row}>
{canUpload && (
<TouchableOpacity style={styles.addBtn} onPress={onAdd}>
<Text style={styles.addPlus}></Text>
<Text style={styles.addText}>Foto</Text>
</TouchableOpacity>
)}
{pending.map((m) => (
<View key={m.uuid} style={styles.thumbWrap}>
<Image source={{ uri: m.local_uri }} style={styles.thumb} />
<View style={[styles.tag, { backgroundColor: m.status === 'error' ? COLORS.danger : COLORS.warn }]}>
<Text style={styles.tagText}>{m.status === 'error' ? 'error' : 'en cola'}</Text>
</View>
</View>
))}
{synced.map((m) => (
<View key={`s${m.id}`} style={styles.thumbWrap}>
<Image source={{ uri: absoluteUrl(m.url) }} style={styles.thumb} />
</View>
))}
</ScrollView>
</View>
);
}
const styles = StyleSheet.create({
container: { marginVertical: 8 },
row: { gap: 8, paddingRight: 8 },
addBtn: {
width: 72,
height: 72,
borderRadius: 8,
borderWidth: 1,
borderColor: COLORS.primary,
borderStyle: 'dashed',
justifyContent: 'center',
alignItems: 'center',
},
addPlus: { color: COLORS.primary, fontSize: 22, fontWeight: '700' },
addText: { color: COLORS.primary, fontSize: 11 },
thumbWrap: { width: 72, height: 72, borderRadius: 8, overflow: 'hidden', backgroundColor: COLORS.bg },
thumb: { width: '100%', height: '100%' },
tag: { position: 'absolute', bottom: 0, left: 0, right: 0, paddingVertical: 1, alignItems: 'center' },
tagText: { color: '#fff', fontSize: 9, fontWeight: '700' },
});
+191
View File
@@ -0,0 +1,191 @@
/**
* Primitivas de UI compartidas (botones, tarjetas, badges, campos).
* Estilo sobrio, pensado para uso en campo (targets grandes, buen contraste).
*/
import React, { ReactNode } from 'react';
import {
ActivityIndicator,
StyleProp,
StyleSheet,
Text,
TextInput,
TextInputProps,
TouchableOpacity,
View,
ViewStyle,
} from 'react-native';
export const COLORS = {
primary: '#1f6f43',
warn: '#8a6d00',
danger: '#b00020',
muted: '#666',
border: '#ddd',
bg: '#f4f4f4',
};
export const ISSUE_STATUS_COLOR: Record<string, string> = {
open: COLORS.danger,
in_review: COLORS.warn,
resolved: COLORS.primary,
closed: COLORS.muted,
};
export const ISSUE_PRIORITY_COLOR: Record<string, string> = {
low: COLORS.muted,
medium: COLORS.primary,
high: COLORS.warn,
critical: COLORS.danger,
};
export function Badge({ label, color }: { label: string; color: string }) {
return (
<View style={[styles.badge, { backgroundColor: color }]}>
<Text style={styles.badgeText}>{label}</Text>
</View>
);
}
export function PrimaryButton({
title,
onPress,
disabled,
loading,
variant = 'primary',
}: {
title: string;
onPress: () => void;
disabled?: boolean;
loading?: boolean;
variant?: 'primary' | 'danger' | 'ghost';
}) {
const bg =
variant === 'danger' ? COLORS.danger : variant === 'ghost' ? 'transparent' : COLORS.primary;
const fg = variant === 'ghost' ? COLORS.primary : '#fff';
return (
<TouchableOpacity
style={[
styles.button,
{ backgroundColor: bg },
variant === 'ghost' && styles.buttonGhost,
(disabled || loading) && styles.buttonDisabled,
]}
onPress={onPress}
disabled={disabled || loading}
>
{loading ? (
<ActivityIndicator color={fg} />
) : (
<Text style={[styles.buttonText, { color: fg }]}>{title}</Text>
)}
</TouchableOpacity>
);
}
export function Card({
children,
style,
}: {
children: ReactNode;
style?: StyleProp<ViewStyle>;
}) {
return <View style={[styles.card, style]}>{children}</View>;
}
export function SectionTitle({ children }: { children: ReactNode }) {
return <Text style={styles.sectionTitle}>{children}</Text>;
}
export function EmptyState({ text }: { text: string }) {
return <Text style={styles.empty}>{text}</Text>;
}
export function Field({
label,
...props
}: TextInputProps & { label: string }) {
return (
<View style={styles.field}>
<Text style={styles.fieldLabel}>{label}</Text>
<TextInput style={styles.input} placeholderTextColor="#aaa" {...props} />
</View>
);
}
/** Selector simple de opciones (chips). */
export function ChipSelect<T extends string>({
label,
value,
options,
onChange,
}: {
label: string;
value: T | undefined;
options: readonly T[];
onChange: (v: T) => void;
}) {
return (
<View style={styles.field}>
<Text style={styles.fieldLabel}>{label}</Text>
<View style={styles.chips}>
{options.map((opt) => {
const active = opt === value;
return (
<TouchableOpacity
key={opt}
style={[styles.chip, active && styles.chipActive]}
onPress={() => onChange(opt)}
>
<Text style={[styles.chipText, active && styles.chipTextActive]}>{opt}</Text>
</TouchableOpacity>
);
})}
</View>
</View>
);
}
const styles = StyleSheet.create({
badge: { paddingHorizontal: 8, paddingVertical: 2, borderRadius: 10, alignSelf: 'flex-start' },
badgeText: { color: '#fff', fontSize: 11, fontWeight: '700' },
button: {
borderRadius: 8,
paddingVertical: 13,
paddingHorizontal: 16,
alignItems: 'center',
justifyContent: 'center',
},
buttonGhost: { borderWidth: 1, borderColor: COLORS.primary },
buttonDisabled: { opacity: 0.5 },
buttonText: { fontSize: 15, fontWeight: '700' },
card: {
backgroundColor: COLORS.bg,
borderRadius: 10,
padding: 12,
},
sectionTitle: { fontSize: 16, fontWeight: '700', marginTop: 8, marginBottom: 4 },
empty: { color: '#888', textAlign: 'center', marginTop: 24 },
field: { marginBottom: 12 },
fieldLabel: { fontSize: 13, color: COLORS.muted, marginBottom: 4, fontWeight: '600' },
input: {
borderWidth: 1,
borderColor: COLORS.border,
borderRadius: 8,
paddingHorizontal: 12,
paddingVertical: 10,
fontSize: 16,
backgroundColor: '#fff',
},
chips: { flexDirection: 'row', flexWrap: 'wrap', gap: 8 },
chip: {
paddingHorizontal: 12,
paddingVertical: 6,
borderRadius: 16,
borderWidth: 1,
borderColor: COLORS.border,
backgroundColor: '#fff',
},
chipActive: { backgroundColor: COLORS.primary, borderColor: COLORS.primary },
chipText: { fontSize: 13, color: COLORS.muted },
chipTextActive: { color: '#fff', fontWeight: '700' },
});
+92
View File
@@ -0,0 +1,92 @@
/**
* Conversión de geometría GeoJSON a coordenadas de react-native-maps.
* GeoJSON usa [lng, lat]; react-native-maps usa { latitude, longitude }.
*/
export interface LatLng {
latitude: number;
longitude: number;
}
export interface ShapeSet {
points: LatLng[];
lines: LatLng[][];
polygons: LatLng[][];
}
function toLatLng(pos: unknown): LatLng | null {
if (Array.isArray(pos) && pos.length >= 2 && typeof pos[0] === 'number' && typeof pos[1] === 'number') {
return { longitude: pos[0], latitude: pos[1] };
}
return null;
}
function ring(coords: unknown): LatLng[] {
if (!Array.isArray(coords)) return [];
return coords.map(toLatLng).filter((c): c is LatLng => c !== null);
}
/** Aplana una geometría GeoJSON a conjuntos de puntos/líneas/polígonos. */
export function geometryToShapes(geometry: unknown): ShapeSet {
const out: ShapeSet = { points: [], lines: [], polygons: [] };
const g = geometry as { type?: string; coordinates?: unknown } | null;
if (!g || !g.type) return out;
switch (g.type) {
case 'Point': {
const p = toLatLng(g.coordinates);
if (p) out.points.push(p);
break;
}
case 'MultiPoint':
out.points.push(...ring(g.coordinates));
break;
case 'LineString':
out.lines.push(ring(g.coordinates));
break;
case 'MultiLineString':
if (Array.isArray(g.coordinates)) g.coordinates.forEach((l) => out.lines.push(ring(l)));
break;
case 'Polygon':
// Solo el anillo exterior (índice 0).
if (Array.isArray(g.coordinates) && g.coordinates[0]) out.polygons.push(ring(g.coordinates[0]));
break;
case 'MultiPolygon':
if (Array.isArray(g.coordinates)) {
g.coordinates.forEach((poly) => {
if (Array.isArray(poly) && poly[0]) out.polygons.push(ring(poly[0]));
});
}
break;
default:
break;
}
return out;
}
/** Calcula una región que englobe todas las coordenadas (para el encuadre inicial). */
export function regionFor(all: LatLng[]): {
latitude: number;
longitude: number;
latitudeDelta: number;
longitudeDelta: number;
} | null {
if (all.length === 0) return null;
let minLat = all[0].latitude;
let maxLat = all[0].latitude;
let minLng = all[0].longitude;
let maxLng = all[0].longitude;
for (const c of all) {
minLat = Math.min(minLat, c.latitude);
maxLat = Math.max(maxLat, c.latitude);
minLng = Math.min(minLng, c.longitude);
maxLng = Math.max(maxLng, c.longitude);
}
const latitudeDelta = Math.max((maxLat - minLat) * 1.4, 0.01);
const longitudeDelta = Math.max((maxLng - minLng) * 1.4, 0.01);
return {
latitude: (minLat + maxLat) / 2,
longitude: (minLng + maxLng) / 2,
latitudeDelta,
longitudeDelta,
};
}
+38
View File
@@ -0,0 +1,38 @@
/**
* Capa responsive: un único punto donde se decide cómo se comporta la UI según
* el tamaño del dispositivo (móvil vs tablet). Sin librerías externas.
*/
import { useWindowDimensions } from 'react-native';
/** Umbral de "tablet": ancho disponible >= 900dp (tablet en horizontal o grande). */
export const TABLET_BREAKPOINT = 900;
export interface Layout {
width: number;
height: number;
isTablet: boolean;
isLandscape: boolean;
/** Columnas sugeridas para rejillas de tarjetas. */
columns: number;
/** Padding base de pantalla. */
gutter: number;
}
export function useLayout(): Layout {
const { width, height } = useWindowDimensions();
const isLandscape = width > height;
const isTablet = width >= TABLET_BREAKPOINT;
let columns = 1;
if (width >= 1200) columns = 3;
else if (width >= TABLET_BREAKPOINT) columns = 2;
return {
width,
height,
isTablet,
isLandscape,
columns,
gutter: isTablet ? 24 : 16,
};
}