feat(android): app de campo móvil+tablet (UI, mapa, fotos, auto-sync, reconciliación)

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>
This commit is contained in:
javier
2026-06-18 18:30:44 +02:00
co-authored by Claude Opus 4.8
parent 4e9c7d059f
commit 9bcc51e3b2
36 changed files with 2532 additions and 131 deletions
+233
View File
@@ -0,0 +1,233 @@
/**
* Contenido del detalle de una incidencia. Se reutiliza en móvil (pantalla
* navegada) y en tablet (panel derecho del maestro-detalle).
*
* Todas las acciones pasan por src/sync/mutations.ts: escriben en local y
* encolan la operación. Tras cada cambio refrescamos desde la BD.
*/
import React, { useCallback, useEffect, useState } from 'react';
import { ScrollView, StyleSheet, Text, TouchableOpacity, View } from 'react-native';
import {
Issue,
IssueComment,
IssuePriority,
IssueStatus,
IssueTask,
} from '../../api/types';
import { hasPermission, useSession } from '../../auth/session';
import {
getIssue,
getIssueComments,
getIssueTasks,
} from '../../db/repositories';
import {
createIssueComment,
createIssueTask,
updateIssue,
updateIssueTask,
} from '../../sync/mutations';
import {
Badge,
Card,
ChipSelect,
COLORS,
EmptyState,
Field,
ISSUE_PRIORITY_COLOR,
ISSUE_STATUS_COLOR,
PrimaryButton,
SectionTitle,
} from '../../ui/components';
import { MediaStrip } from '../../ui/MediaStrip';
const STATUSES: readonly IssueStatus[] = ['open', 'in_review', 'resolved', 'closed'];
const PRIORITIES: readonly IssuePriority[] = ['low', 'medium', 'high', 'critical'];
export function IssueDetailContent({ issueId }: { issueId: number }) {
const { user } = useSession();
const canEdit = hasPermission(user, 'edit issues');
const [issue, setIssue] = useState<Issue | null>(null);
const [tasks, setTasks] = useState<IssueTask[]>([]);
const [comments, setComments] = useState<IssueComment[]>([]);
const [newTask, setNewTask] = useState('');
const [newComment, setNewComment] = useState('');
const refresh = useCallback(async () => {
const [i, t, c] = await Promise.all([
getIssue(issueId),
getIssueTasks(issueId),
getIssueComments(issueId),
]);
setIssue(i);
setTasks(t);
setComments(c);
}, [issueId]);
useEffect(() => {
void refresh();
}, [refresh]);
const onStatus = useCallback(
async (status: IssueStatus) => {
await updateIssue({ id: issueId, status });
await refresh();
},
[issueId, refresh],
);
const onPriority = useCallback(
async (priority: IssuePriority) => {
await updateIssue({ id: issueId, priority });
await refresh();
},
[issueId, refresh],
);
const onToggleTask = useCallback(
async (task: IssueTask) => {
await updateIssueTask({ id: task.id, is_done: !task.is_done });
await refresh();
},
[refresh],
);
const onAddTask = useCallback(async () => {
const title = newTask.trim();
if (!title) return;
setNewTask('');
await createIssueTask({ issue_id: issueId, title });
await refresh();
}, [newTask, issueId, refresh]);
const onAddComment = useCallback(async () => {
const body = newComment.trim();
if (!body) return;
setNewComment('');
await createIssueComment({ issue_id: issueId, body });
await refresh();
}, [newComment, issueId, refresh]);
if (!issue) {
return (
<View style={styles.center}>
<Text style={{ color: COLORS.muted }}>Cargando</Text>
</View>
);
}
const done = tasks.filter((t) => t.is_done).length;
const progress = tasks.length ? Math.round((done / tasks.length) * 100) : 0;
return (
<ScrollView contentContainerStyle={styles.body}>
<Text style={styles.title}>{issue.title}</Text>
<View style={styles.badges}>
{issue.status && (
<Badge label={issue.status} color={ISSUE_STATUS_COLOR[issue.status] ?? COLORS.muted} />
)}
{issue.priority && (
<Badge
label={issue.priority}
color={ISSUE_PRIORITY_COLOR[issue.priority] ?? COLORS.muted}
/>
)}
{issue.type && <Badge label={issue.type} color={COLORS.muted} />}
</View>
{issue.description ? <Text style={styles.desc}>{issue.description}</Text> : null}
<MediaStrip parentEntity="issue" parentId={issue.id} canUpload={hasPermission(user, 'upload media')} />
{canEdit && (
<Card style={styles.editCard}>
<ChipSelect
label="Estado"
value={issue.status}
options={STATUSES}
onChange={(v) => void onStatus(v)}
/>
<ChipSelect
label="Prioridad"
value={issue.priority}
options={PRIORITIES}
onChange={(v) => void onPriority(v)}
/>
</Card>
)}
<SectionTitle>
Tareas · {done}/{tasks.length} ({progress}%)
</SectionTitle>
{tasks.length === 0 && <EmptyState text="Sin tareas." />}
{tasks.map((t) => (
<TouchableOpacity
key={t.id}
style={styles.taskRow}
disabled={!canEdit}
onPress={() => void onToggleTask(t)}
>
<Text style={styles.checkbox}>{t.is_done ? '☑' : '☐'}</Text>
<Text style={[styles.taskText, t.is_done && styles.taskDone]}>{t.title}</Text>
</TouchableOpacity>
))}
{canEdit && (
<View style={styles.addRow}>
<View style={{ flex: 1 }}>
<Field
label=""
placeholder="Nueva tarea…"
value={newTask}
onChangeText={setNewTask}
onSubmitEditing={() => void onAddTask()}
/>
</View>
<PrimaryButton title="Añadir" onPress={() => void onAddTask()} disabled={!newTask.trim()} />
</View>
)}
<SectionTitle>Comentarios</SectionTitle>
{comments.length === 0 && <EmptyState text="Sin comentarios." />}
{comments.map((c) => (
<Card key={c.id} style={styles.comment}>
<Text style={styles.commentBody}>{c.body}</Text>
{c.created_at ? (
<Text style={styles.commentMeta}>{new Date(c.created_at).toLocaleString()}</Text>
) : null}
</Card>
))}
<View style={styles.addRow}>
<View style={{ flex: 1 }}>
<Field
label=""
placeholder="Escribe un comentario…"
value={newComment}
onChangeText={setNewComment}
multiline
/>
</View>
<PrimaryButton
title="Enviar"
onPress={() => void onAddComment()}
disabled={!newComment.trim()}
/>
</View>
</ScrollView>
);
}
const styles = StyleSheet.create({
center: { flex: 1, justifyContent: 'center', alignItems: 'center' },
body: { padding: 16, gap: 4 },
title: { fontSize: 20, fontWeight: '700' },
badges: { flexDirection: 'row', gap: 6, marginTop: 6, flexWrap: 'wrap' },
desc: { marginTop: 8, color: '#333', fontSize: 14 },
editCard: { marginTop: 12, gap: 4 },
taskRow: { flexDirection: 'row', alignItems: 'center', paddingVertical: 8, gap: 10 },
checkbox: { fontSize: 20 },
taskText: { fontSize: 15, flex: 1 },
taskDone: { textDecorationLine: 'line-through', color: COLORS.muted },
addRow: { flexDirection: 'row', alignItems: 'flex-end', gap: 8, marginTop: 4 },
comment: { marginBottom: 6 },
commentBody: { fontSize: 14 },
commentMeta: { fontSize: 11, color: COLORS.muted, marginTop: 4 },
});