diff --git a/README.md b/README.md
index e296391..1482403 100644
--- a/README.md
+++ b/README.md
@@ -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
```
diff --git a/app.config.js b/app.config.js
new file mode 100644
index 0000000..11ff6cc
--- /dev/null
+++ b/app.config.js
@@ -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;
+};
diff --git a/app.json b/app.json
index 552efb7..e05b765 100644
--- a/app.json
+++ b/app.json
@@ -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."
+ }
+ ]
]
}
}
diff --git a/eas.json b/eas.json
new file mode 100644
index 0000000..df8c051
--- /dev/null
+++ b/eas.json
@@ -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": {}
+ }
+}
diff --git a/package-lock.json b/package-lock.json
index 56c2a43..4add743 100644
--- a/package-lock.json
+++ b/package-lock.json
@@ -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",
diff --git a/package.json b/package.json
index 39ba267..9c14434 100644
--- a/package.json
+++ b/package.json
@@ -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"
},
diff --git a/src/components/SyncStatusBar.tsx b/src/components/SyncStatusBar.tsx
index f438479..fe119fd 100644
--- a/src/components/SyncStatusBar.tsx
+++ b/src/components/SyncStatusBar.tsx
@@ -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 = (
{syncing && }
@@ -22,8 +25,18 @@ export function SyncStatusBar({
{counts.conflict > 0 ? ` · ${counts.conflict} conflicto(s)` : ''}
{counts.error > 0 ? ` · ${counts.error} error(es)` : ''}
+ {hasProblems && onPress ? revisar › : null}
);
+
+ if (onPress) {
+ return (
+
+ {content}
+
+ );
+ }
+ 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' },
});
diff --git a/src/config.ts b/src/config.ts
index 2c2d342..769efd4 100644
--- a/src/config.ts
+++ b/src/config.ts
@@ -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';
diff --git a/src/db/database.ts b/src/db/database.ts
index c81948f..ee5bc39 100644
--- a/src/db/database.ts
+++ b/src/db/database.ts
@@ -18,9 +18,16 @@ async function openAndMigrate(): Promise {
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}`);
}
diff --git a/src/db/outbox.ts b/src/db/outbox.ts
index b4b1fb4..a1e3e17 100644
--- a/src/db/outbox.ts
+++ b/src/db/outbox.ts
@@ -20,19 +20,21 @@ export async function enqueueOperation(
entity: SyncEntity,
op: SyncOp,
data: Record,
+ opts?: { uuid?: string; localId?: number },
): Promise {
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 {
+export async function getPendingOutbox(limit = 200): Promise {
const db = await getDb();
const rows = await db.getAllAsync(
`SELECT * FROM outbox WHERE status = 'pending' ORDER BY created_at ASC LIMIT ?`,
@@ -64,9 +73,19 @@ export async function getPendingOperations(limit = 100): Promise {
uuid: r.uuid,
client_updated_at: r.client_updated_at,
data: JSON.parse(r.data) as Record,
+ localId: r.local_id,
}));
}
+/** Reescribe el `data` de una operación pendiente (tras remapear ids temporales). */
+export async function updateOutboxData(
+ uuid: string,
+ data: Record,
+): Promise {
+ 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 {
const db = await getDb();
@@ -97,6 +116,41 @@ export async function purgeSentOperations(): Promise {
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 {
+ const db = await getDb();
+ return db.getAllAsync(
+ `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 {
+ 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 {
+ 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 {
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(
- `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 {
+ const db = await getDb();
+ return db.getAllAsync(
+ `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 {
const db = await getDb();
await db.runAsync(
diff --git a/src/db/repositories.ts b/src/db/repositories.ts
index 08d7aff..23dcfb4 100644
--- a/src/db/repositories.ts
+++ b/src/db/repositories.ts
@@ -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 {
);
}
+export async function getIssue(id: number): Promise {
+ const db = await getDb();
+ return db.getFirstAsync('SELECT * FROM issues WHERE id = ?', id);
+}
+
+export async function getLayers(projectId: number): Promise {
+ const db = await getDb();
+ return db.getAllAsync(
+ '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 {
+ const db = await getDb();
+ const rows = await db.getAllAsync(
+ 'SELECT * FROM features WHERE project_id = ? ORDER BY layer_id, name',
+ projectId,
+ );
+ return rows.map(parseGeometry);
+}
+
+export async function getFeature(id: number): Promise {
+ const db = await getDb();
+ const row = await db.getFirstAsync(
+ '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 {
+ const db = await getDb();
+ const rows = await db.getAllAsync(
+ '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) : undefined,
+ }));
+}
+
+type IssueTaskRow = Omit & { is_done: number };
+
+export async function getIssueTasks(issueId: number): Promise {
+ const db = await getDb();
+ const rows = await db.getAllAsync(
+ '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 {
+ const db = await getDb();
+ return db.getAllAsync(
+ 'SELECT * FROM issue_comments WHERE issue_id = ? ORDER BY created_at, id',
+ issueId,
+ );
+}
+
+export async function getTemplates(projectId: number): Promise {
+ const db = await getDb();
+ const rows = await db.getAllAsync(
+ '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 {
+ const db = await getDb();
+ const row = await db.getFirstAsync(
+ '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 {
+ const db = await getDb();
+ return db.getAllAsync(
+ 'SELECT * FROM media WHERE parent_entity = ? AND parent_id = ? ORDER BY id DESC',
+ parentEntity,
+ parentId,
+ );
+}
+
export async function countRows(table: string): Promise {
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 = {
+ 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,
+): Promise {
+ const table = CREATE_TABLE[entity];
+ if (!table) return;
+ const db = await getDb();
+ const full: Record = { 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 {
+ 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;
+}
diff --git a/src/db/schema.ts b/src/db/schema.ts
index fc95032..38deebb 100644
--- a/src/db/schema.ts
+++ b/src/db/schema.ts
@@ -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;
diff --git a/src/navigation/index.tsx b/src/navigation/index.tsx
index 55e18ae..0ffddd9 100644
--- a/src/navigation/index.tsx
+++ b/src/navigation/index.tsx
@@ -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();
@@ -38,6 +43,31 @@ export function RootNavigator() {
component={ProjectDetailScreen}
options={({ route }) => ({ title: route.params.name })}
/>
+ ({ title: route.params.title })}
+ />
+ ({ title: route.params.name })}
+ />
+
+
+
);
diff --git a/src/navigation/types.ts b/src/navigation/types.ts
index e4a809c..6af630f 100644
--- a/src/navigation/types.ts
+++ b/src/navigation/types.ts
@@ -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;
};
diff --git a/src/screens/FeatureDetailScreen.tsx b/src/screens/FeatureDetailScreen.tsx
new file mode 100644
index 0000000..cbc0b30
--- /dev/null
+++ b/src/screens/FeatureDetailScreen.tsx
@@ -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;
+
+export function FeatureDetailScreen({ route }: Props) {
+ return ;
+}
diff --git a/src/screens/InspectionFormScreen.tsx b/src/screens/InspectionFormScreen.tsx
new file mode 100644
index 0000000..f1edbf2
--- /dev/null
+++ b/src/screens/InspectionFormScreen.tsx
@@ -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;
+
+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;
+ 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)?.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(null);
+ const [values, setValues] = useState>({});
+ 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 (
+
+ {featureName}
+ {template?.name ?? 'Inspección libre'}
+
+ {fields.map((f) => {
+ if (f.type === 'boolean') {
+ return (
+
+ {f.label}
+ setValue(f.key, v)}
+ />
+
+ );
+ }
+ if (f.type === 'select' && f.options.length) {
+ return (
+ setValue(f.key, v)}
+ />
+ );
+ }
+ return (
+ setValue(f.key, f.type === 'number' ? Number(t) : t)}
+ keyboardType={f.type === 'number' ? 'numeric' : 'default'}
+ multiline={f.type === 'textarea'}
+ />
+ );
+ })}
+
+ Resultado
+
+
+
+
+
+ void onSubmit()} loading={saving} />
+
+ navigation.goBack()} />
+
+ );
+}
+
+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 },
+});
diff --git a/src/screens/IssueCreateScreen.tsx b/src/screens/IssueCreateScreen.tsx
new file mode 100644
index 0000000..9cc8d2a
--- /dev/null
+++ b/src/screens/IssueCreateScreen.tsx
@@ -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;
+
+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('medium');
+ const [type, setType] = useState('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 (
+
+
+
+
+
+ void onSubmit()}
+ loading={saving}
+ disabled={!title.trim()}
+ />
+ navigation.goBack()} />
+
+ );
+}
+
+const styles = StyleSheet.create({
+ body: { padding: 16, gap: 4 },
+});
diff --git a/src/screens/IssueDetailScreen.tsx b/src/screens/IssueDetailScreen.tsx
new file mode 100644
index 0000000..7da50fe
--- /dev/null
+++ b/src/screens/IssueDetailScreen.tsx
@@ -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;
+
+export function IssueDetailScreen({ route }: Props) {
+ return ;
+}
diff --git a/src/screens/OutboxScreen.tsx b/src/screens/OutboxScreen.tsx
new file mode 100644
index 0000000..0d67f0a
--- /dev/null
+++ b/src/screens/OutboxScreen.tsx
@@ -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([]);
+
+ 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 (
+
+ {ops.length === 0 && }
+ {ops.map((o) => (
+
+
+
+ {o.entity}.{o.op}
+
+
+
+ {o.error ? {o.error} : null}
+ {o.server_payload ? (
+
+ Servidor: {o.server_payload}
+
+ ) : null}
+
+ Local: {o.data}
+
+
+
+ void onRetry(o.uuid)} />
+
+
+ void onDiscard(o.uuid)} />
+
+
+
+ ))}
+
+ );
+}
+
+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 },
+});
diff --git a/src/screens/ProjectDetailScreen.tsx b/src/screens/ProjectDetailScreen.tsx
index b54aad4..36101d8 100644
--- a/src/screens/ProjectDetailScreen.tsx
+++ b/src/screens/ProjectDetailScreen.tsx
@@ -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;
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([]);
- const [issues, setIssues] = useState([]);
+ const [tab, setTab] = useState('Fases');
const [counts, setCounts] = useState(EMPTY);
- const [cursor, setCursorState] = useState(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 (
-
+ navigation.navigate('Outbox')}
+ />
-
- Fases ({phases.length})
- {phases.map((p) => (
-
- {p.name}
- {Math.round(p.progress_percent ?? 0)}%
-
+
+ {TABS.map((t) => (
+ setTab(t)}
+ >
+ {t}
+
))}
+
- Incidencias ({issues.length})
- {issues.map((i) => (
-
- {i.title}
-
- {i.priority ?? '—'} · {i.status ?? '—'}
-
-
- ))}
+
+ {tab === 'Fases' && }
+ {tab === 'Features' && }
+ {tab === 'Incidencias' && }
+
-
- Último sync: {cursor ? new Date(cursor).toLocaleString() : 'nunca'}
-
-
-
-
- {syncing ? 'Sincronizando…' : 'Sincronizar'}
-
+
+ void onSync()}
+ loading={syncing}
+ />
+
);
}
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 },
});
diff --git a/src/screens/ProjectsScreen.tsx b/src/screens/ProjectsScreen.tsx
index 1858892..8e3dab4 100644
--- a/src/screens/ProjectsScreen.tsx
+++ b/src/screens/ProjectsScreen.tsx
@@ -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);
diff --git a/src/screens/detail/FeatureDetailContent.tsx b/src/screens/detail/FeatureDetailContent.tsx
new file mode 100644
index 0000000..6a1f984
--- /dev/null
+++ b/src/screens/detail/FeatureDetailContent.tsx
@@ -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;
+
+export function FeatureDetailContent({ featureId }: { featureId: number }) {
+ const navigation = useNavigation