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>
This commit is contained in:
javier
2026-06-18 17:43:48 +02:00
co-authored by Claude Opus 4.8
parent 3f454b59a5
commit 4e9c7d059f
27 changed files with 9057 additions and 20 deletions
+101
View File
@@ -0,0 +1,101 @@
import React, { useState } from 'react';
import {
ActivityIndicator,
KeyboardAvoidingView,
Platform,
StyleSheet,
Text,
TextInput,
TouchableOpacity,
View,
} from 'react-native';
import { ApiError } from '../api/client';
import { useSession } from '../auth/session';
const DEVICE_NAME = `${Platform.OS}-avante`;
export function LoginScreen() {
const { signIn } = useSession();
const [email, setEmail] = useState('');
const [password, setPassword] = useState('');
const [loading, setLoading] = useState(false);
const [error, setError] = useState<string | null>(null);
const onSubmit = async () => {
setError(null);
setLoading(true);
try {
await signIn(email.trim(), password, DEVICE_NAME);
} catch (e) {
if (e instanceof ApiError && e.status === 422) {
setError('Credenciales inválidas.');
} else {
setError(e instanceof Error ? e.message : 'Error de conexión.');
}
} finally {
setLoading(false);
}
};
return (
<KeyboardAvoidingView
style={styles.container}
behavior={Platform.OS === 'ios' ? 'padding' : undefined}
>
<Text style={styles.title}>Avante · Seguimiento de obra</Text>
<TextInput
style={styles.input}
placeholder="Email"
autoCapitalize="none"
keyboardType="email-address"
value={email}
onChangeText={setEmail}
/>
<TextInput
style={styles.input}
placeholder="Contraseña"
secureTextEntry
value={password}
onChangeText={setPassword}
/>
{error && <Text style={styles.error}>{error}</Text>}
<TouchableOpacity
style={[styles.button, (loading || !email || !password) && styles.buttonDisabled]}
onPress={onSubmit}
disabled={loading || !email || !password}
>
{loading ? (
<ActivityIndicator color="#fff" />
) : (
<Text style={styles.buttonText}>Entrar</Text>
)}
</TouchableOpacity>
</KeyboardAvoidingView>
);
}
const styles = StyleSheet.create({
container: { flex: 1, justifyContent: 'center', padding: 24, gap: 12 },
title: { fontSize: 22, fontWeight: '700', textAlign: 'center', marginBottom: 16 },
input: {
borderWidth: 1,
borderColor: '#ccc',
borderRadius: 8,
paddingHorizontal: 12,
paddingVertical: 10,
fontSize: 16,
},
error: { color: '#b00020', textAlign: 'center' },
button: {
backgroundColor: '#1f6f43',
borderRadius: 8,
paddingVertical: 14,
alignItems: 'center',
marginTop: 8,
},
buttonDisabled: { opacity: 0.5 },
buttonText: { color: '#fff', fontSize: 16, fontWeight: '700' },
});
+125
View File
@@ -0,0 +1,125 @@
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 { 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 { RootStackParamList } from '../navigation/types';
type Props = NativeStackScreenProps<RootStackParamList, 'ProjectDetail'>;
const EMPTY: OutboxCounts = { pending: 0, conflict: 0, error: 0, mediaPending: 0 };
export function ProjectDetailScreen({ route }: Props) {
const { projectId } = route.params;
const [phases, setPhases] = useState<Phase[]>([]);
const [issues, setIssues] = useState<Issue[]>([]);
const [counts, setCounts] = useState<OutboxCounts>(EMPTY);
const [cursor, setCursorState] = useState<string | null>(null);
const [syncing, setSyncing] = useState(false);
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]);
useEffect(() => {
void refresh();
}, [refresh]);
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();
Alert.alert(
'Sincronización completada',
`Enviadas: ${report.applied}/${report.pushed}\n` +
`Conflictos: ${report.conflicts} · Errores: ${report.errors}\n` +
`Fotos: ${report.mediaUploaded} (errores ${report.mediaErrors})`,
);
} catch (e) {
Alert.alert('Error de sincronización', e instanceof Error ? e.message : String(e));
} finally {
setSyncing(false);
}
}, [projectId, refresh]);
return (
<View style={styles.container}>
<SyncStatusBar counts={counts} syncing={syncing} />
<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>
))}
<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>
))}
<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>
);
}
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' },
});
+125
View File
@@ -0,0 +1,125 @@
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' },
});