Files
avante-movil/src/ui/components.tsx
T
javierandClaude Opus 4.8 9bcc51e3b2 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>
2026-06-18 18:30:44 +02:00

192 lines
4.8 KiB
TypeScript

/**
* 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' },
});