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 },
|
||||
});
|
||||
@@ -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 (0–1). */
|
||||
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 },
|
||||
});
|
||||
@@ -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' },
|
||||
});
|
||||
@@ -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' },
|
||||
});
|
||||
@@ -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,
|
||||
};
|
||||
}
|
||||
@@ -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,
|
||||
};
|
||||
}
|
||||
Reference in New Issue
Block a user