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,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' },
|
||||
});
|
||||
Reference in New Issue
Block a user