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
+145
View File
@@ -0,0 +1,145 @@
/**
* Mapa de features. Dibuja la geometría GeoJSON del proyecto (puntos, líneas,
* polígonos) sobre Google Maps y permite seleccionar una feature tocándola.
*
* Nota: las tiles de Google Maps requieren conexión; la geometría sí se dibuja
* sin red. Requiere una API key de Google Maps (ver app.config.js / README).
*/
import * as Location from 'expo-location';
import React, { useMemo, useRef } from 'react';
import { StyleSheet, Text, TouchableOpacity, View } from 'react-native';
import MapView, { Marker, Polygon, Polyline, PROVIDER_GOOGLE } from 'react-native-maps';
import { Feature } from '../api/types';
import { COLORS } from './components';
import { geometryToShapes, LatLng, regionFor } from './geojson';
const STATUS_COLOR: Record<string, string> = {
pending: '#9aa0a6',
in_progress: '#1f6f43',
completed: '#2e7d32',
blocked: '#b00020',
};
function colorFor(status?: string): string {
return (status && STATUS_COLOR[status]) || COLORS.primary;
}
export function FeatureMap({
features,
selectedId,
onSelect,
}: {
features: Feature[];
selectedId: number | null;
onSelect: (id: number) => void;
}) {
const mapRef = useRef<MapView>(null);
const { shaped, region } = useMemo(() => {
const all: LatLng[] = [];
const shaped = features.map((f) => {
const shapes = geometryToShapes(f.geometry);
all.push(...shapes.points, ...shapes.lines.flat(), ...shapes.polygons.flat());
return { feature: f, shapes };
});
return { shaped, region: regionFor(all) };
}, [features]);
const recenter = async () => {
const perm = await Location.requestForegroundPermissionsAsync();
if (!perm.granted) return;
const pos = await Location.getCurrentPositionAsync({});
mapRef.current?.animateToRegion({
latitude: pos.coords.latitude,
longitude: pos.coords.longitude,
latitudeDelta: 0.01,
longitudeDelta: 0.01,
});
};
if (!region) {
return (
<View style={styles.empty}>
<Text style={{ color: COLORS.muted }}>Las features no tienen geometría.</Text>
</View>
);
}
return (
<View style={styles.container}>
<MapView
ref={mapRef}
style={StyleSheet.absoluteFill}
provider={PROVIDER_GOOGLE}
initialRegion={region}
showsUserLocation
>
{shaped.map(({ feature, shapes }) => {
const color = colorFor(feature.status);
const selected = feature.id === selectedId;
const stroke = selected ? '#000' : color;
return (
<React.Fragment key={feature.id}>
{shapes.points.map((p, i) => (
<Marker
key={`pt${feature.id}-${i}`}
coordinate={p}
pinColor={color}
title={feature.name}
onPress={() => onSelect(feature.id)}
/>
))}
{shapes.lines.map((line, i) => (
<Polyline
key={`ln${feature.id}-${i}`}
coordinates={line}
strokeColor={stroke}
strokeWidth={selected ? 5 : 3}
tappable
onPress={() => onSelect(feature.id)}
/>
))}
{shapes.polygons.map((poly, i) => (
<Polygon
key={`pg${feature.id}-${i}`}
coordinates={poly}
strokeColor={stroke}
fillColor={`${color}55`}
strokeWidth={selected ? 4 : 2}
tappable
onPress={() => onSelect(feature.id)}
/>
))}
</React.Fragment>
);
})}
</MapView>
<TouchableOpacity style={styles.locBtn} onPress={() => void recenter()}>
<Text style={styles.locIcon}></Text>
</TouchableOpacity>
</View>
);
}
const styles = StyleSheet.create({
container: { flex: 1 },
empty: { flex: 1, justifyContent: 'center', alignItems: 'center', padding: 24 },
locBtn: {
position: 'absolute',
right: 16,
bottom: 16,
width: 48,
height: 48,
borderRadius: 24,
backgroundColor: '#fff',
justifyContent: 'center',
alignItems: 'center',
elevation: 4,
shadowColor: '#000',
shadowOpacity: 0.2,
shadowRadius: 4,
shadowOffset: { width: 0, height: 2 },
},
locIcon: { fontSize: 22, color: COLORS.primary },
});
+67
View File
@@ -0,0 +1,67 @@
/**
* Layout maestro-detalle adaptativo.
*
* - **Tablet**: dos paneles lado a lado. La selección vive en estado local; al
* tocar un ítem se muestra su detalle en el panel derecho (no navega).
* - **Móvil**: solo el panel maestro; al tocar un ítem se invoca `onSelectPhone`
* (la pantalla lo conecta con `navigation.navigate`).
*/
import React, { ReactNode, useState } from 'react';
import { StyleSheet, Text, View } from 'react-native';
import { useLayout } from './responsive';
interface MasterDetailProps {
/** Pinta la lista. Recibe la selección actual (tablet) y el callback de selección. */
renderMaster: (args: {
selectedId: number | null;
onSelect: (id: number) => void;
}) => ReactNode;
/** Pinta el detalle de un id (solo se usa en tablet). */
renderDetail: (id: number) => ReactNode;
/** En móvil: qué hacer al seleccionar (normalmente navegar al detalle). */
onSelectPhone: (id: number) => void;
/** Texto/elemento cuando aún no hay selección (tablet). */
placeholder?: ReactNode;
/** Proporción del panel maestro en tablet (01). */
masterFlex?: number;
}
export function MasterDetail({
renderMaster,
renderDetail,
onSelectPhone,
placeholder,
masterFlex = 0.38,
}: MasterDetailProps) {
const { isTablet } = useLayout();
const [selectedId, setSelectedId] = useState<number | null>(null);
if (!isTablet) {
return <>{renderMaster({ selectedId: null, onSelect: onSelectPhone })}</>;
}
return (
<View style={styles.row}>
<View style={[styles.master, { flex: masterFlex }]}>
{renderMaster({ selectedId, onSelect: setSelectedId })}
</View>
<View style={[styles.detail, { flex: 1 - masterFlex }]}>
{selectedId != null ? (
renderDetail(selectedId)
) : (
<View style={styles.placeholder}>
{placeholder ?? <Text style={styles.placeholderText}>Selecciona un elemento</Text>}
</View>
)}
</View>
</View>
);
}
const styles = StyleSheet.create({
row: { flex: 1, flexDirection: 'row' },
master: { borderRightWidth: StyleSheet.hairlineWidth, borderColor: '#ddd' },
detail: { flex: 1 },
placeholder: { flex: 1, justifyContent: 'center', alignItems: 'center', padding: 24 },
placeholderText: { color: '#888', fontSize: 15 },
});
+126
View File
@@ -0,0 +1,126 @@
/**
* Tira de fotos de un registro (feature/issue/issue_task/issue_comment).
* Muestra las ya sincronizadas (tabla `media`, con url) y las locales en cola
* (`media_outbox`), y permite añadir nuevas desde cámara o galería (offline).
*/
import * as ImagePicker from 'expo-image-picker';
import React, { useCallback, useEffect, useState } from 'react';
import { Alert, Image, ScrollView, StyleSheet, Text, TouchableOpacity, View } from 'react-native';
import { Media, MediaParentEntity } from '../api/types';
import { absoluteUrl } from '../config';
import { enqueueMedia, getMediaOutboxFor, MediaOutboxRow } from '../db/outbox';
import { getMediaFor } from '../db/repositories';
import { COLORS } from './components';
export function MediaStrip({
parentEntity,
parentId,
canUpload,
}: {
parentEntity: MediaParentEntity;
parentId: number;
canUpload: boolean;
}) {
const [synced, setSynced] = useState<Media[]>([]);
const [pending, setPending] = useState<MediaOutboxRow[]>([]);
const refresh = useCallback(async () => {
const [s, p] = await Promise.all([
getMediaFor(parentEntity, parentId),
getMediaOutboxFor(parentEntity, parentId),
]);
setSynced(s);
setPending(p);
}, [parentEntity, parentId]);
useEffect(() => {
void refresh();
}, [refresh]);
const add = useCallback(
async (source: 'camera' | 'library') => {
const perm =
source === 'camera'
? await ImagePicker.requestCameraPermissionsAsync()
: await ImagePicker.requestMediaLibraryPermissionsAsync();
if (!perm.granted) {
Alert.alert('Permiso necesario', 'Concede el permiso para añadir fotos.');
return;
}
const result =
source === 'camera'
? await ImagePicker.launchCameraAsync({ quality: 0.7 })
: await ImagePicker.launchImageLibraryAsync({ quality: 0.7, mediaTypes: 'images' });
if (result.canceled || !result.assets?.length) return;
const asset = result.assets[0];
await enqueueMedia({
parentEntity,
parentId,
localUri: asset.uri,
fileName: asset.fileName ?? undefined,
mimeType: asset.mimeType ?? 'image/jpeg',
category: 'image',
});
await refresh();
},
[parentEntity, parentId, refresh],
);
const onAdd = useCallback(() => {
Alert.alert('Añadir foto', undefined, [
{ text: 'Cámara', onPress: () => void add('camera') },
{ text: 'Galería', onPress: () => void add('library') },
{ text: 'Cancelar', style: 'cancel' },
]);
}, [add]);
if (synced.length === 0 && pending.length === 0 && !canUpload) return null;
return (
<View style={styles.container}>
<ScrollView horizontal showsHorizontalScrollIndicator={false} contentContainerStyle={styles.row}>
{canUpload && (
<TouchableOpacity style={styles.addBtn} onPress={onAdd}>
<Text style={styles.addPlus}></Text>
<Text style={styles.addText}>Foto</Text>
</TouchableOpacity>
)}
{pending.map((m) => (
<View key={m.uuid} style={styles.thumbWrap}>
<Image source={{ uri: m.local_uri }} style={styles.thumb} />
<View style={[styles.tag, { backgroundColor: m.status === 'error' ? COLORS.danger : COLORS.warn }]}>
<Text style={styles.tagText}>{m.status === 'error' ? 'error' : 'en cola'}</Text>
</View>
</View>
))}
{synced.map((m) => (
<View key={`s${m.id}`} style={styles.thumbWrap}>
<Image source={{ uri: absoluteUrl(m.url) }} style={styles.thumb} />
</View>
))}
</ScrollView>
</View>
);
}
const styles = StyleSheet.create({
container: { marginVertical: 8 },
row: { gap: 8, paddingRight: 8 },
addBtn: {
width: 72,
height: 72,
borderRadius: 8,
borderWidth: 1,
borderColor: COLORS.primary,
borderStyle: 'dashed',
justifyContent: 'center',
alignItems: 'center',
},
addPlus: { color: COLORS.primary, fontSize: 22, fontWeight: '700' },
addText: { color: COLORS.primary, fontSize: 11 },
thumbWrap: { width: 72, height: 72, borderRadius: 8, overflow: 'hidden', backgroundColor: COLORS.bg },
thumb: { width: '100%', height: '100%' },
tag: { position: 'absolute', bottom: 0, left: 0, right: 0, paddingVertical: 1, alignItems: 'center' },
tagText: { color: '#fff', fontSize: 9, fontWeight: '700' },
});
+191
View File
@@ -0,0 +1,191 @@
/**
* Primitivas de UI compartidas (botones, tarjetas, badges, campos).
* Estilo sobrio, pensado para uso en campo (targets grandes, buen contraste).
*/
import React, { ReactNode } from 'react';
import {
ActivityIndicator,
StyleProp,
StyleSheet,
Text,
TextInput,
TextInputProps,
TouchableOpacity,
View,
ViewStyle,
} from 'react-native';
export const COLORS = {
primary: '#1f6f43',
warn: '#8a6d00',
danger: '#b00020',
muted: '#666',
border: '#ddd',
bg: '#f4f4f4',
};
export const ISSUE_STATUS_COLOR: Record<string, string> = {
open: COLORS.danger,
in_review: COLORS.warn,
resolved: COLORS.primary,
closed: COLORS.muted,
};
export const ISSUE_PRIORITY_COLOR: Record<string, string> = {
low: COLORS.muted,
medium: COLORS.primary,
high: COLORS.warn,
critical: COLORS.danger,
};
export function Badge({ label, color }: { label: string; color: string }) {
return (
<View style={[styles.badge, { backgroundColor: color }]}>
<Text style={styles.badgeText}>{label}</Text>
</View>
);
}
export function PrimaryButton({
title,
onPress,
disabled,
loading,
variant = 'primary',
}: {
title: string;
onPress: () => void;
disabled?: boolean;
loading?: boolean;
variant?: 'primary' | 'danger' | 'ghost';
}) {
const bg =
variant === 'danger' ? COLORS.danger : variant === 'ghost' ? 'transparent' : COLORS.primary;
const fg = variant === 'ghost' ? COLORS.primary : '#fff';
return (
<TouchableOpacity
style={[
styles.button,
{ backgroundColor: bg },
variant === 'ghost' && styles.buttonGhost,
(disabled || loading) && styles.buttonDisabled,
]}
onPress={onPress}
disabled={disabled || loading}
>
{loading ? (
<ActivityIndicator color={fg} />
) : (
<Text style={[styles.buttonText, { color: fg }]}>{title}</Text>
)}
</TouchableOpacity>
);
}
export function Card({
children,
style,
}: {
children: ReactNode;
style?: StyleProp<ViewStyle>;
}) {
return <View style={[styles.card, style]}>{children}</View>;
}
export function SectionTitle({ children }: { children: ReactNode }) {
return <Text style={styles.sectionTitle}>{children}</Text>;
}
export function EmptyState({ text }: { text: string }) {
return <Text style={styles.empty}>{text}</Text>;
}
export function Field({
label,
...props
}: TextInputProps & { label: string }) {
return (
<View style={styles.field}>
<Text style={styles.fieldLabel}>{label}</Text>
<TextInput style={styles.input} placeholderTextColor="#aaa" {...props} />
</View>
);
}
/** Selector simple de opciones (chips). */
export function ChipSelect<T extends string>({
label,
value,
options,
onChange,
}: {
label: string;
value: T | undefined;
options: readonly T[];
onChange: (v: T) => void;
}) {
return (
<View style={styles.field}>
<Text style={styles.fieldLabel}>{label}</Text>
<View style={styles.chips}>
{options.map((opt) => {
const active = opt === value;
return (
<TouchableOpacity
key={opt}
style={[styles.chip, active && styles.chipActive]}
onPress={() => onChange(opt)}
>
<Text style={[styles.chipText, active && styles.chipTextActive]}>{opt}</Text>
</TouchableOpacity>
);
})}
</View>
</View>
);
}
const styles = StyleSheet.create({
badge: { paddingHorizontal: 8, paddingVertical: 2, borderRadius: 10, alignSelf: 'flex-start' },
badgeText: { color: '#fff', fontSize: 11, fontWeight: '700' },
button: {
borderRadius: 8,
paddingVertical: 13,
paddingHorizontal: 16,
alignItems: 'center',
justifyContent: 'center',
},
buttonGhost: { borderWidth: 1, borderColor: COLORS.primary },
buttonDisabled: { opacity: 0.5 },
buttonText: { fontSize: 15, fontWeight: '700' },
card: {
backgroundColor: COLORS.bg,
borderRadius: 10,
padding: 12,
},
sectionTitle: { fontSize: 16, fontWeight: '700', marginTop: 8, marginBottom: 4 },
empty: { color: '#888', textAlign: 'center', marginTop: 24 },
field: { marginBottom: 12 },
fieldLabel: { fontSize: 13, color: COLORS.muted, marginBottom: 4, fontWeight: '600' },
input: {
borderWidth: 1,
borderColor: COLORS.border,
borderRadius: 8,
paddingHorizontal: 12,
paddingVertical: 10,
fontSize: 16,
backgroundColor: '#fff',
},
chips: { flexDirection: 'row', flexWrap: 'wrap', gap: 8 },
chip: {
paddingHorizontal: 12,
paddingVertical: 6,
borderRadius: 16,
borderWidth: 1,
borderColor: COLORS.border,
backgroundColor: '#fff',
},
chipActive: { backgroundColor: COLORS.primary, borderColor: COLORS.primary },
chipText: { fontSize: 13, color: COLORS.muted },
chipTextActive: { color: '#fff', fontWeight: '700' },
});
+92
View File
@@ -0,0 +1,92 @@
/**
* Conversión de geometría GeoJSON a coordenadas de react-native-maps.
* GeoJSON usa [lng, lat]; react-native-maps usa { latitude, longitude }.
*/
export interface LatLng {
latitude: number;
longitude: number;
}
export interface ShapeSet {
points: LatLng[];
lines: LatLng[][];
polygons: LatLng[][];
}
function toLatLng(pos: unknown): LatLng | null {
if (Array.isArray(pos) && pos.length >= 2 && typeof pos[0] === 'number' && typeof pos[1] === 'number') {
return { longitude: pos[0], latitude: pos[1] };
}
return null;
}
function ring(coords: unknown): LatLng[] {
if (!Array.isArray(coords)) return [];
return coords.map(toLatLng).filter((c): c is LatLng => c !== null);
}
/** Aplana una geometría GeoJSON a conjuntos de puntos/líneas/polígonos. */
export function geometryToShapes(geometry: unknown): ShapeSet {
const out: ShapeSet = { points: [], lines: [], polygons: [] };
const g = geometry as { type?: string; coordinates?: unknown } | null;
if (!g || !g.type) return out;
switch (g.type) {
case 'Point': {
const p = toLatLng(g.coordinates);
if (p) out.points.push(p);
break;
}
case 'MultiPoint':
out.points.push(...ring(g.coordinates));
break;
case 'LineString':
out.lines.push(ring(g.coordinates));
break;
case 'MultiLineString':
if (Array.isArray(g.coordinates)) g.coordinates.forEach((l) => out.lines.push(ring(l)));
break;
case 'Polygon':
// Solo el anillo exterior (índice 0).
if (Array.isArray(g.coordinates) && g.coordinates[0]) out.polygons.push(ring(g.coordinates[0]));
break;
case 'MultiPolygon':
if (Array.isArray(g.coordinates)) {
g.coordinates.forEach((poly) => {
if (Array.isArray(poly) && poly[0]) out.polygons.push(ring(poly[0]));
});
}
break;
default:
break;
}
return out;
}
/** Calcula una región que englobe todas las coordenadas (para el encuadre inicial). */
export function regionFor(all: LatLng[]): {
latitude: number;
longitude: number;
latitudeDelta: number;
longitudeDelta: number;
} | null {
if (all.length === 0) return null;
let minLat = all[0].latitude;
let maxLat = all[0].latitude;
let minLng = all[0].longitude;
let maxLng = all[0].longitude;
for (const c of all) {
minLat = Math.min(minLat, c.latitude);
maxLat = Math.max(maxLat, c.latitude);
minLng = Math.min(minLng, c.longitude);
maxLng = Math.max(maxLng, c.longitude);
}
const latitudeDelta = Math.max((maxLat - minLat) * 1.4, 0.01);
const longitudeDelta = Math.max((maxLng - minLng) * 1.4, 0.01);
return {
latitude: (minLat + maxLat) / 2,
longitude: (minLng + maxLng) / 2,
latitudeDelta,
longitudeDelta,
};
}
+38
View File
@@ -0,0 +1,38 @@
/**
* Capa responsive: un único punto donde se decide cómo se comporta la UI según
* el tamaño del dispositivo (móvil vs tablet). Sin librerías externas.
*/
import { useWindowDimensions } from 'react-native';
/** Umbral de "tablet": ancho disponible >= 900dp (tablet en horizontal o grande). */
export const TABLET_BREAKPOINT = 900;
export interface Layout {
width: number;
height: number;
isTablet: boolean;
isLandscape: boolean;
/** Columnas sugeridas para rejillas de tarjetas. */
columns: number;
/** Padding base de pantalla. */
gutter: number;
}
export function useLayout(): Layout {
const { width, height } = useWindowDimensions();
const isLandscape = width > height;
const isTablet = width >= TABLET_BREAKPOINT;
let columns = 1;
if (width >= 1200) columns = 3;
else if (width >= TABLET_BREAKPOINT) columns = 2;
return {
width,
height,
isTablet,
isLandscape,
columns,
gutter: isTablet ? 24 : 16,
};
}