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:
co-authored by
Claude Opus 4.8
parent
3f454b59a5
commit
4e9c7d059f
@@ -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' },
|
||||
});
|
||||
@@ -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' },
|
||||
});
|
||||
@@ -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' },
|
||||
});
|
||||
Reference in New Issue
Block a user