Files
avante-movil/src/screens/ProjectsScreen.tsx
T
javierandClaude Opus 4.8 4e9c7d059f feat: scaffold offline-first mobile app (RN+Expo, expo-sqlite)
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>
2026-06-18 17:43:48 +02:00

126 lines
3.7 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
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' },
});