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