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>
127 lines
3.8 KiB
TypeScript
127 lines
3.8 KiB
TypeScript
import { NativeStackScreenProps } from '@react-navigation/native-stack';
|
||
import React, { useCallback, useEffect, useState } from 'react';
|
||
import {
|
||
ActivityIndicator,
|
||
FlatList,
|
||
RefreshControl,
|
||
StyleSheet,
|
||
Text,
|
||
TouchableOpacity,
|
||
View,
|
||
} from 'react-native';
|
||
import * as api from '../api/endpoints';
|
||
import { Project } from '../api/types';
|
||
import { useSession } from '../auth/session';
|
||
import { getCursor, getProjects, saveProjectList, setActiveProjectId } from '../db/repositories';
|
||
import { isOnline } from '../net/connectivity';
|
||
import { initialPull, runSync } from '../sync/engine';
|
||
import { RootStackParamList } from '../navigation/types';
|
||
|
||
type Props = NativeStackScreenProps<RootStackParamList, 'Projects'>;
|
||
|
||
function normalize(res: { data?: Project[] } | Project[]): Project[] {
|
||
return Array.isArray(res) ? res : res.data ?? [];
|
||
}
|
||
|
||
export function ProjectsScreen({ navigation }: Props) {
|
||
const { user, signOut } = useSession();
|
||
const [projects, setProjects] = useState<Project[]>([]);
|
||
const [loading, setLoading] = useState(false);
|
||
const [opening, setOpening] = useState<number | null>(null);
|
||
|
||
const load = useCallback(async () => {
|
||
setLoading(true);
|
||
try {
|
||
if (await isOnline()) {
|
||
const list = normalize(await api.listProjects());
|
||
await saveProjectList(list);
|
||
}
|
||
setProjects(await getProjects());
|
||
} catch {
|
||
setProjects(await getProjects()); // fallback offline
|
||
} finally {
|
||
setLoading(false);
|
||
}
|
||
}, []);
|
||
|
||
useEffect(() => {
|
||
void load();
|
||
}, [load]);
|
||
|
||
const openProject = useCallback(
|
||
async (p: Project) => {
|
||
setOpening(p.id);
|
||
try {
|
||
await setActiveProjectId(p.id);
|
||
const online = await isOnline();
|
||
if (online) {
|
||
const cursor = await getCursor(p.id);
|
||
if (cursor) await runSync(p.id);
|
||
else await initialPull(p.id);
|
||
}
|
||
navigation.navigate('ProjectDetail', { projectId: p.id, name: p.name });
|
||
} finally {
|
||
setOpening(null);
|
||
}
|
||
},
|
||
[navigation],
|
||
);
|
||
|
||
return (
|
||
<View style={styles.container}>
|
||
<View style={styles.header}>
|
||
<Text style={styles.hello}>Hola, {user?.name ?? ''}</Text>
|
||
<TouchableOpacity onPress={signOut}>
|
||
<Text style={styles.logout}>Salir</Text>
|
||
</TouchableOpacity>
|
||
</View>
|
||
|
||
<FlatList
|
||
data={projects}
|
||
keyExtractor={(p) => String(p.id)}
|
||
refreshControl={<RefreshControl refreshing={loading} onRefresh={load} />}
|
||
ListEmptyComponent={
|
||
loading ? null : <Text style={styles.empty}>No hay proyectos.</Text>
|
||
}
|
||
renderItem={({ item }) => (
|
||
<TouchableOpacity style={styles.row} onPress={() => openProject(item)}>
|
||
<View style={{ flex: 1 }}>
|
||
<Text style={styles.name}>{item.name}</Text>
|
||
{item.reference ? <Text style={styles.ref}>{item.reference}</Text> : null}
|
||
</View>
|
||
{opening === item.id ? (
|
||
<ActivityIndicator />
|
||
) : (
|
||
<Text style={styles.chevron}>›</Text>
|
||
)}
|
||
</TouchableOpacity>
|
||
)}
|
||
/>
|
||
</View>
|
||
);
|
||
}
|
||
|
||
const styles = StyleSheet.create({
|
||
container: { flex: 1 },
|
||
header: {
|
||
flexDirection: 'row',
|
||
justifyContent: 'space-between',
|
||
alignItems: 'center',
|
||
padding: 16,
|
||
},
|
||
hello: { fontSize: 16, fontWeight: '600' },
|
||
logout: { color: '#b00020', fontWeight: '600' },
|
||
row: {
|
||
flexDirection: 'row',
|
||
alignItems: 'center',
|
||
paddingHorizontal: 16,
|
||
paddingVertical: 14,
|
||
borderTopWidth: StyleSheet.hairlineWidth,
|
||
borderColor: '#ddd',
|
||
},
|
||
name: { fontSize: 16, fontWeight: '600' },
|
||
ref: { fontSize: 13, color: '#666', marginTop: 2 },
|
||
chevron: { fontSize: 24, color: '#999' },
|
||
empty: { textAlign: 'center', marginTop: 40, color: '#888' },
|
||
});
|