Capa API tipada de los 8 endpoints, BD local espejo del bundle + outbox (operaciones y media) + cursor de sync, motor runSync (PUSH /sync -> PUSH /media -> PULL bundle?since) con idempotencia por uuid y last-write-wins, mutaciones de alto nivel (write local + encolar), sesion con token en SecureStore, conectividad NetInfo y UI minima (Login -> Proyectos -> Detalle). Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
126 lines
3.7 KiB
TypeScript
126 lines
3.7 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 } 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 {
|
||
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' },
|
||
});
|