Files
Antoni Nuñez Romeu 2a7ab64bbf
Run Tests on Branches / Detect Changes (push) Successful in 8s
Run Tests on Branches / Backend Tests (push) Has been skipped
Run Tests on Branches / Frontend Tests (push) Successful in 1m41s
Run Tests on Branches / Frontend Mobile Tests (push) Successful in 1m46s
feat(mobile): improve login, profile, alerts screens and dark mode background
- Unify login/register into single screen with tab switcher, logo, and themed inputs
- Redesign profile with header card, segmented theme toggle, icon-based menu, and bottom-sheet modals
- Add auth guard to alerts screen showing login prompt instead of 401 error
- Switch ImageBackground to explicit Image+View layering for reliable rendering
- Add dark mode background image switching in root layout
- Add dark mode override for recent-tag in PWA search view
2026-07-10 16:39:59 +02:00

697 lines
40 KiB
TypeScript

import React, { useState, useEffect } from 'react';
import { View, Text, StyleSheet, TouchableOpacity, Alert, TextInput, ScrollView, Image, ActivityIndicator, Modal, Pressable, useWindowDimensions } from 'react-native';
import { useRouter } from 'expo-router';
import { Ionicons } from '@expo/vector-icons';
import * as ImagePicker from 'expo-image-picker';
import { useAuth } from '../../hooks/useAuth';
import { useThemeContext } from '../../components/ThemeProvider';
import { useThemeStore, ThemeMode } from '../../store/themeStore';
import { spacing, borderRadius, shadows } from '../../constants/theme';
import api from '../../services/api';
const TABLET_MIN_WIDTH = 768;
const AVATARS = [
require('../../assets/avatars/avatar1.png'),
require('../../assets/avatars/avatar2.png'),
require('../../assets/avatars/avatar3.png'),
require('../../assets/avatars/avatar4.png'),
require('../../assets/avatars/avatar5.png'),
require('../../assets/avatars/avatar6.png'),
];
const COLOR_CIRCLES = [
require('../../assets/avatars/color1.png'),
require('../../assets/avatars/color2.png'),
require('../../assets/avatars/color3.png'),
require('../../assets/avatars/color4.png'),
require('../../assets/avatars/color5.png'),
require('../../assets/avatars/color6.png'),
];
function resolveAvatarUrl(url: string | null | undefined): number | null {
if (!url) return null;
const matchPreset = url.match(/^preset_avatar_(\d+)$/);
if (matchPreset) {
const idx = parseInt(matchPreset[1], 10);
if (idx >= 1 && idx <= 6) return AVATARS[idx - 1];
}
const matchColor = url.match(/^color_circle_(\d+)$/);
if (matchColor) {
const idx = parseInt(matchColor[1], 10);
if (idx >= 1 && idx <= 6) return COLOR_CIRCLES[idx - 1];
}
return null;
}
interface SearchHistoryItem {
id: number;
address: string;
latitude: number | null;
longitude: number | null;
created_at: string;
}
interface Address {
id: number;
address: string;
latitude: number | null;
longitude: number | null;
label: string;
is_default: number;
created_at: string;
}
const THEME_OPTIONS: { mode: ThemeMode; label: string; icon: string }[] = [
{ mode: 'system', label: 'Sistema', icon: 'phone-portrait-outline' },
{ mode: 'light', label: 'Claro', icon: 'sunny-outline' },
{ mode: 'dark', label: 'Oscuro', icon: 'moon-outline' },
];
export default function ProfileScreen() {
const router = useRouter();
const { width } = useWindowDimensions();
const isTablet = width >= TABLET_MIN_WIDTH;
const { user, isAuthenticated, isLoading, logout, isAdmin } = useAuth();
const { colors, isDark } = useThemeContext();
const themeMode = useThemeStore((s) => s.mode);
const setThemeMode = useThemeStore((s) => s.setMode);
const initialResolved = resolveAvatarUrl(user?.avatar_url);
const [firstName, setFirstName] = useState(user?.first_name || '');
const [lastName, setLastName] = useState(user?.last_name || '');
const [avatarUrl, setAvatarUrl] = useState(initialResolved ? '' : (user?.avatar_url || ''));
const [avatarLocalSource, setAvatarLocalSource] = useState<number | null>(initialResolved);
const [searchHistory, setSearchHistory] = useState<SearchHistoryItem[]>([]);
const [showAvatarModal, setShowAvatarModal] = useState(false);
const [avatarTab, setAvatarTab] = useState<'presets' | 'colors' | 'upload'>('presets');
const [showConfig, setShowConfig] = useState(false);
const [configFirstName, setConfigFirstName] = useState('');
const [configLastName, setConfigLastName] = useState('');
const [configEmail, setConfigEmail] = useState('');
const [configCity, setConfigCity] = useState('');
const [configAddress, setConfigAddress] = useState('');
const [configSaving, setConfigSaving] = useState(false);
const [configFeedback, setConfigFeedback] = useState<{ type: 'ok' | 'err'; text: string } | null>(null);
const [showAddresses, setShowAddresses] = useState(false);
const [addresses, setAddresses] = useState<Address[]>([]);
const [addressesLoading, setAddressesLoading] = useState(false);
const [showAddressForm, setShowAddressForm] = useState(false);
const [editingAddressId, setEditingAddressId] = useState<number | null>(null);
const [formAddress, setFormAddress] = useState('');
const [formLabel, setFormLabel] = useState('');
const [formDefault, setFormDefault] = useState(false);
const [formSaving, setFormSaving] = useState(false);
const [formError, setFormError] = useState('');
useEffect(() => {
if (isAuthenticated) loadSearchHistory();
}, [isAuthenticated]);
useEffect(() => {
setFirstName(user?.first_name || '');
setLastName(user?.last_name || '');
const resolved = resolveAvatarUrl(user?.avatar_url);
if (resolved) { setAvatarLocalSource(resolved); setAvatarUrl(''); }
else { setAvatarLocalSource(null); setAvatarUrl(user?.avatar_url || ''); }
}, [user]);
async function loadSearchHistory() {
try { const res = await api.get('/search-history'); setSearchHistory(res.data); } catch {}
}
function openConfig() {
setConfigFirstName(user?.first_name || '');
setConfigLastName(user?.last_name || '');
setConfigEmail(user?.email || '');
setConfigCity(user?.city || '');
setConfigAddress(user?.address || '');
setConfigFeedback(null);
setShowConfig(true);
}
async function handleConfigSave() {
setConfigSaving(true);
setConfigFeedback(null);
try {
const res = await api.put('/users/me', {
first_name: configFirstName.trim() || null,
last_name: configLastName.trim() || null,
email: configEmail.trim() || null,
city: configCity.trim() || null,
address: configAddress.trim() || null,
});
setFirstName(res.data.first_name || '');
setLastName(res.data.last_name || '');
setConfigFeedback({ type: 'ok', text: 'Perfil guardado.' });
setTimeout(() => setShowConfig(false), 1200);
} catch (err: any) {
setConfigFeedback({ type: 'err', text: err.message || 'Error al guardar' });
} finally { setConfigSaving(false); }
}
async function handlePickImage() {
const result = await ImagePicker.launchImageLibraryAsync({ mediaTypes: ImagePicker.MediaTypeOptions.Images, allowsEditing: true, aspect: [1, 1], quality: 0.8, base64: true });
if (!result.canceled && result.assets[0]?.base64) {
const dataUri = `data:${result.assets[0].mimeType};base64,${result.assets[0].base64}`;
setAvatarLocalSource(null); setAvatarUrl(dataUri); setShowAvatarModal(false);
try { await api.put('/users/me', { avatar_url: dataUri }); } catch {}
}
}
async function handleTakePhoto() {
const { status } = await ImagePicker.requestCameraPermissionsAsync();
if (status !== 'granted') { Alert.alert('Permiso requerido', 'Necesitamos permiso para acceder a la cámara.'); return; }
const result = await ImagePicker.launchCameraAsync({ allowsEditing: true, aspect: [1, 1], quality: 0.8, base64: true });
if (!result.canceled && result.assets[0]?.base64) {
const dataUri = `data:${result.assets[0].mimeType};base64,${result.assets[0].base64}`;
setAvatarLocalSource(null); setAvatarUrl(dataUri); setShowAvatarModal(false);
try { await api.put('/users/me', { avatar_url: dataUri }); } catch {}
}
}
async function handleSelectPresetAvatar(index: number) {
setAvatarUrl(''); setAvatarLocalSource(AVATARS[index]); setShowAvatarModal(false);
try { await api.put('/users/me', { avatar_url: `preset_avatar_${index + 1}` }); } catch {}
}
async function handleSelectColor(index: number) {
setAvatarUrl(''); setAvatarLocalSource(COLOR_CIRCLES[index]); setShowAvatarModal(false);
try { await api.put('/users/me', { avatar_url: `color_circle_${index + 1}` }); } catch {}
}
async function handleDeleteSearch(id: number) {
try { await api.delete(`/search-history/${id}`); setSearchHistory(prev => prev.filter(i => i.id !== id)); } catch {}
}
async function loadAddresses() {
setAddressesLoading(true);
try { const res = await api.get('/addresses'); setAddresses(res.data); } catch {} finally { setAddressesLoading(false); }
}
function openAddresses() { setShowAddresses(true); loadAddresses(); }
function openAddAddressForm() {
setEditingAddressId(null); setFormAddress(''); setFormLabel(''); setFormDefault(addresses.length === 0); setFormError(''); setShowAddressForm(true);
}
function openEditAddressForm(addr: Address) {
setEditingAddressId(addr.id); setFormAddress(addr.address); setFormLabel(addr.label || ''); setFormDefault(Boolean(addr.is_default)); setFormError(''); setShowAddressForm(true);
}
async function handleAddressSave() {
const addr = formAddress.trim();
if (!addr) { setFormError('La dirección es obligatoria'); return; }
setFormSaving(true); setFormError('');
try {
const url = editingAddressId ? `/api/addresses/${editingAddressId}` : '/api/addresses';
const res = await fetch(url, { method: editingAddressId ? 'PUT' : 'POST', headers: { 'Content-Type': 'application/json' }, credentials: 'include', body: JSON.stringify({ address: addr, label: formLabel.trim(), is_default: formDefault }) });
if (!res.ok) { const err = await res.json().catch(() => ({})); throw new Error(err.error || 'Error al guardar'); }
setShowAddressForm(false); setEditingAddressId(null); loadAddresses();
} catch (err: any) { setFormError(err.message); } finally { setFormSaving(false); }
}
async function handleDeleteAddress(id: number) {
try { const res = await fetch(`/api/addresses/${id}`, { method: 'DELETE', credentials: 'include' }); if (res.ok) setAddresses(prev => prev.filter(a => a.id !== id)); } catch {}
}
async function handleSetDefault(id: number) {
try { const res = await fetch(`/api/addresses/${id}/default`, { method: 'PUT', credentials: 'include' }); if (res.ok) loadAddresses(); } catch {}
}
const handleLogout = () => {
Alert.alert('Cerrar Sesión', '¿Estás seguro que deseas cerrar sesión?', [
{ text: 'Cancelar', style: 'cancel' },
{ text: 'Cerrar Sesión', style: 'destructive', onPress: async () => { await logout(); router.replace('/auth/login'); } },
]);
};
const getThemeModeIcon = (): 'phone-portrait-outline' | 'sunny-outline' | 'moon-outline' => {
if (themeMode === 'light') return 'sunny-outline';
if (themeMode === 'dark') return 'moon-outline';
return 'phone-portrait-outline';
};
if (isLoading) {
return <View style={[styles.container, { backgroundColor: colors.background }]}><ActivityIndicator size="large" color={colors.primary} style={{ marginTop: spacing.xxl }} /></View>;
}
if (!isAuthenticated) {
return (
<View style={[styles.container, { backgroundColor: colors.background }]}>
<View style={styles.authPrompt}>
<View style={[styles.authIconCircle, { backgroundColor: colors.primaryContainer }]}>
<Ionicons name="person-outline" size={isTablet ? 60 : 48} color={colors.primary} />
</View>
<Text style={[styles.authTitle, isTablet && styles.authTitleTablet, { color: colors.text }]}>Inicia Sesión</Text>
<Text style={[styles.authSubtitle, isTablet && styles.authSubtitleTablet, { color: colors.textSecondary }]}>
Inicia sesión para acceder a tu perfil, notificaciones y más
</Text>
<TouchableOpacity style={[styles.authButton, { backgroundColor: colors.primary }]} onPress={() => router.push('/auth/login')}>
<Text style={[styles.authButtonText, { color: colors.onPrimaryContainer }]}>Iniciar Sesión</Text>
</TouchableOpacity>
</View>
</View>
);
}
const displayName = [firstName, lastName].filter(Boolean).join(' ') || user?.username;
return (
<ScrollView style={[styles.container, { backgroundColor: colors.background }]} showsVerticalScrollIndicator={false}>
{/* Header card with avatar + name */}
<View style={[styles.headerCard, { backgroundColor: colors.card }, shadows.card]}>
<TouchableOpacity style={[styles.avatarRing, { borderColor: colors.primary }]} onPress={() => setShowAvatarModal(true)}>
{avatarLocalSource ? (
<Image source={avatarLocalSource} style={styles.avatarImage} />
) : avatarUrl ? (
<Image source={{ uri: avatarUrl }} style={styles.avatarImage} />
) : (
<View style={[styles.avatarFallback, { backgroundColor: colors.primaryContainer }]}>
<Ionicons name="person" size={36} color={colors.primary} />
</View>
)}
<View style={[styles.avatarEditBadge, { backgroundColor: colors.primary }]}>
<Ionicons name="camera" size={14} color="#fff" />
</View>
</TouchableOpacity>
<Text style={[styles.displayName, { color: colors.text }]}>{displayName}</Text>
<Text style={[styles.username, { color: colors.textSecondary }]}>@{user?.username}</Text>
</View>
{/* Info section */}
{(firstName || lastName) && (
<View style={[styles.card, { backgroundColor: colors.card }, shadows.card]}>
<View style={styles.cardHeader}>
<Ionicons name="person-outline" size={18} color={colors.primary} />
<Text style={[styles.cardTitle, { color: colors.text }]}>Datos personales</Text>
</View>
<View style={styles.infoGrid}>
<View style={[styles.infoBox, { backgroundColor: colors.surfaceLow }]}>
<Text style={[styles.infoLabel, { color: colors.textSecondary }]}>Nombre</Text>
<Text style={[styles.infoValue, { color: colors.text }]}>{firstName || '—'}</Text>
</View>
<View style={[styles.infoBox, { backgroundColor: colors.surfaceLow }]}>
<Text style={[styles.infoLabel, { color: colors.textSecondary }]}>Apellidos</Text>
<Text style={[styles.infoValue, { color: colors.text }]}>{lastName || '—'}</Text>
</View>
</View>
</View>
)}
{/* Theme card */}
<View style={[styles.card, { backgroundColor: colors.card }, shadows.card]}>
<View style={styles.cardHeader}>
<Ionicons name={getThemeModeIcon()} size={18} color={colors.primary} />
<Text style={[styles.cardTitle, { color: colors.text }]}>Apariencia</Text>
</View>
<View style={[styles.themePills, { backgroundColor: colors.surfaceLow }]}>
{THEME_OPTIONS.map((opt) => (
<TouchableOpacity
key={opt.mode}
style={[styles.themePill, themeMode === opt.mode && { backgroundColor: colors.primary }]}
onPress={() => setThemeMode(opt.mode)}
>
<Ionicons name={opt.icon as any} size={16} color={themeMode === opt.mode ? colors.onPrimaryContainer : colors.textSecondary} />
<Text style={[styles.themePillText, { color: themeMode === opt.mode ? colors.onPrimaryContainer : colors.textSecondary }]}>{opt.label}</Text>
</TouchableOpacity>
))}
</View>
</View>
{/* Menu card */}
<View style={[styles.card, { backgroundColor: colors.card }, shadows.card]}>
<TouchableOpacity style={styles.menuRow} onPress={openConfig}>
<View style={[styles.menuIconCircle, { backgroundColor: colors.surfaceLow }]}>
<Ionicons name="settings-outline" size={20} color={colors.primary} />
</View>
<Text style={[styles.menuLabel, { color: colors.text }]}>Configuración</Text>
<Ionicons name="chevron-forward" size={18} color={colors.textSecondary} />
</TouchableOpacity>
<View style={[styles.menuDivider, { backgroundColor: colors.surfaceLow }]} />
<TouchableOpacity style={styles.menuRow} onPress={openAddresses}>
<View style={[styles.menuIconCircle, { backgroundColor: colors.surfaceLow }]}>
<Ionicons name="location-outline" size={20} color={colors.primary} />
</View>
<Text style={[styles.menuLabel, { color: colors.text }]}>Mis Direcciones</Text>
<Ionicons name="chevron-forward" size={18} color={colors.textSecondary} />
</TouchableOpacity>
{isAdmin && (
<>
<View style={[styles.menuDivider, { backgroundColor: colors.surfaceLow }]} />
<TouchableOpacity style={styles.menuRow}>
<View style={[styles.menuIconCircle, { backgroundColor: colors.surfaceLow }]}>
<Ionicons name="shield-outline" size={20} color={colors.primary} />
</View>
<Text style={[styles.menuLabel, { color: colors.text }]}>Panel Admin</Text>
<Ionicons name="chevron-forward" size={18} color={colors.textSecondary} />
</TouchableOpacity>
</>
)}
</View>
{/* Search history */}
{searchHistory.length > 0 && (
<View style={[styles.card, { backgroundColor: colors.card }, shadows.card]}>
<View style={styles.cardHeader}>
<Ionicons name="time-outline" size={18} color={colors.primary} />
<Text style={[styles.cardTitle, { color: colors.text }]}>Búsquedas recientes</Text>
</View>
{searchHistory.map((item, i) => (
<React.Fragment key={item.id}>
{i > 0 && <View style={[styles.menuDivider, { backgroundColor: colors.surfaceLow }]} />}
<View style={styles.historyRow}>
<Ionicons name="location-outline" size={16} color={colors.textSecondary} />
<Text style={[styles.historyAddress, { color: colors.text }]} numberOfLines={1}>{item.address}</Text>
<TouchableOpacity onPress={() => handleDeleteSearch(item.id)} hitSlop={{ top: 8, bottom: 8, left: 8, right: 8 }}>
<Ionicons name="close-circle" size={18} color={colors.textSecondary} />
</TouchableOpacity>
</View>
</React.Fragment>
))}
</View>
)}
{/* Logout */}
<TouchableOpacity style={[styles.logoutCard, { backgroundColor: colors.card, borderColor: isDark ? '#5a2020' : '#fecaca' }]} onPress={handleLogout}>
<Ionicons name="log-out-outline" size={20} color={colors.danger} />
<Text style={[styles.logoutText, { color: colors.danger }]}>Cerrar Sesión</Text>
</TouchableOpacity>
<View style={{ height: spacing.xl }} />
{/* ─── Modals ─── */}
{/* Avatar Modal */}
<Modal visible={showAvatarModal} animationType="slide" transparent>
<View style={styles.modalBackdrop}>
<View style={[styles.modalSheet, { backgroundColor: colors.card }]}>
<View style={[styles.modalHandle, { backgroundColor: colors.border }]} />
<View style={[styles.modalHeader, { borderBottomColor: colors.surfaceLow }]}>
<Text style={[styles.modalTitle, { color: colors.text }]}>Cambiar Avatar</Text>
<TouchableOpacity onPress={() => setShowAvatarModal(false)} hitSlop={{ top: 10, bottom: 10, left: 10, right: 10 }}>
<Ionicons name="close" size={24} color={colors.textSecondary} />
</TouchableOpacity>
</View>
<View style={[styles.avatarTabBar, { borderBottomColor: colors.surfaceLow }]}>
{(['presets', 'colors', 'upload'] as const).map((t) => (
<TouchableOpacity key={t} style={[styles.avatarTabBtn, avatarTab === t && { borderBottomColor: colors.primary }]} onPress={() => setAvatarTab(t)}>
<Ionicons name={t === 'presets' ? 'person-outline' : t === 'colors' ? 'color-palette-outline' : 'cloud-upload-outline'} size={18} color={avatarTab === t ? colors.primary : colors.textSecondary} />
<Text style={[styles.avatarTabLabel, { color: avatarTab === t ? colors.primary : colors.textSecondary }]}>{t === 'presets' ? 'Prediseñado' : t === 'colors' ? 'Colores' : 'Subir'}</Text>
</TouchableOpacity>
))}
</View>
<ScrollView style={styles.modalBody} contentContainerStyle={{ paddingBottom: spacing.xl }}>
{avatarTab === 'presets' && (
<View style={styles.avatarGrid}>
{AVATARS.map((av, i) => (
<TouchableOpacity key={i} style={styles.avatarOption} onPress={() => handleSelectPresetAvatar(i)}>
<Image source={av} style={styles.avatarOptionImage} />
</TouchableOpacity>
))}
</View>
)}
{avatarTab === 'colors' && (
<View style={styles.avatarGrid}>
{COLOR_CIRCLES.map((c, i) => (
<TouchableOpacity key={i} style={styles.avatarOption} onPress={() => handleSelectColor(i)}>
<Image source={c} style={styles.avatarOptionImage} />
</TouchableOpacity>
))}
</View>
)}
{avatarTab === 'upload' && (
<View style={{ gap: spacing.md }}>
<TouchableOpacity style={[styles.uploadCard, { backgroundColor: colors.surfaceLow }]} onPress={handleTakePhoto}>
<View style={[styles.uploadIconCircle, { backgroundColor: colors.primaryContainer }]}>
<Ionicons name="camera-outline" size={28} color={colors.primary} />
</View>
<View style={{ flex: 1 }}>
<Text style={[styles.uploadTitle, { color: colors.text }]}>Tomar foto</Text>
<Text style={[styles.uploadSub, { color: colors.textSecondary }]}>Usa la cámara de tu dispositivo</Text>
</View>
<Ionicons name="chevron-forward" size={18} color={colors.textSecondary} />
</TouchableOpacity>
<TouchableOpacity style={[styles.uploadCard, { backgroundColor: colors.surfaceLow }]} onPress={handlePickImage}>
<View style={[styles.uploadIconCircle, { backgroundColor: colors.primaryContainer }]}>
<Ionicons name="images-outline" size={28} color={colors.primary} />
</View>
<View style={{ flex: 1 }}>
<Text style={[styles.uploadTitle, { color: colors.text }]}>Elegir de galería</Text>
<Text style={[styles.uploadSub, { color: colors.textSecondary }]}>Selecciona una imagen existente</Text>
</View>
<Ionicons name="chevron-forward" size={18} color={colors.textSecondary} />
</TouchableOpacity>
</View>
)}
</ScrollView>
</View>
</View>
</Modal>
{/* Config Modal */}
<Modal visible={showConfig} animationType="slide" transparent>
<View style={styles.modalBackdrop}>
<View style={[styles.modalSheet, { backgroundColor: colors.card }]}>
<View style={[styles.modalHandle, { backgroundColor: colors.border }]} />
<View style={[styles.modalHeader, { borderBottomColor: colors.surfaceLow }]}>
<Text style={[styles.modalTitle, { color: colors.text }]}>Configuración</Text>
<TouchableOpacity onPress={() => setShowConfig(false)} hitSlop={{ top: 10, bottom: 10, left: 10, right: 10 }}>
<Ionicons name="close" size={24} color={colors.textSecondary} />
</TouchableOpacity>
</View>
<ScrollView style={styles.modalBody} contentContainerStyle={{ paddingBottom: spacing.xl }}>
{[
{ label: 'Nombre', value: configFirstName, onChange: setConfigFirstName, placeholder: 'Tu nombre', icon: 'person-outline' },
{ label: 'Apellidos', value: configLastName, onChange: setConfigLastName, placeholder: 'Tus apellidos', icon: 'person-outline' },
{ label: 'Correo electrónico', value: configEmail, onChange: setConfigEmail, placeholder: 'tu@email.com', icon: 'mail-outline', keyboard: 'email-address' as const },
{ label: 'Ciudad', value: configCity, onChange: setConfigCity, placeholder: 'Tu ciudad', icon: 'business-outline' },
{ label: 'Dirección', value: configAddress, onChange: setConfigAddress, placeholder: 'Calle Mayor 1, Madrid', icon: 'location-outline' },
].map((field) => (
<View key={field.label} style={styles.modalField}>
<Text style={[styles.modalLabel, { color: colors.textSecondary }]}>{field.label}</Text>
<View style={[styles.modalInputRow, { backgroundColor: colors.surfaceLow, borderColor: colors.border }]}>
<Ionicons name={field.icon as any} size={18} color={colors.textSecondary} style={{ marginRight: spacing.sm }} />
<TextInput
style={[styles.modalInput, { color: colors.text }]}
value={field.value}
onChangeText={field.onChange}
placeholder={field.placeholder}
placeholderTextColor={colors.textSecondary}
keyboardType={field.keyboard}
autoCapitalize="none"
editable={!configSaving}
/>
</View>
</View>
))}
{configFeedback && (
<View style={[styles.modalFeedback, { backgroundColor: configFeedback.type === 'ok' ? (isDark ? '#1a3a1c' : '#eaf7ec') : colors.dangerContainer, borderColor: configFeedback.type === 'ok' ? (isDark ? '#2a5a35' : '#cfead0') : (isDark ? '#5a2020' : '#fecaca') }]}>
<Ionicons name={configFeedback.type === 'ok' ? 'checkmark-circle' : 'alert-circle'} size={18} color={configFeedback.type === 'ok' ? colors.primary : colors.danger} />
<Text style={[styles.modalFeedbackText, { color: colors.text }]}>{configFeedback.text}</Text>
</View>
)}
<View style={styles.modalActions}>
<TouchableOpacity style={[styles.modalCancelBtn, { borderColor: colors.border }]} onPress={() => setShowConfig(false)} disabled={configSaving}>
<Text style={[styles.modalCancelText, { color: colors.text }]}>Cancelar</Text>
</TouchableOpacity>
<TouchableOpacity style={[styles.modalSaveBtn, { backgroundColor: colors.primary }, configSaving && { opacity: 0.6 }]} onPress={handleConfigSave} disabled={configSaving}>
{configSaving ? <ActivityIndicator color={colors.onPrimaryContainer} /> : <Text style={[styles.modalSaveText, { color: colors.onPrimaryContainer }]}>Guardar</Text>}
</TouchableOpacity>
</View>
</ScrollView>
</View>
</View>
</Modal>
{/* Addresses Modal */}
<Modal visible={showAddresses} animationType="slide" transparent>
<View style={styles.modalBackdrop}>
<View style={[styles.modalSheet, { backgroundColor: colors.card }]}>
<View style={[styles.modalHandle, { backgroundColor: colors.border }]} />
<View style={[styles.modalHeader, { borderBottomColor: colors.surfaceLow }]}>
<Text style={[styles.modalTitle, { color: colors.text }]}>Mis Direcciones</Text>
<TouchableOpacity onPress={() => { setShowAddresses(false); setShowAddressForm(false); }} hitSlop={{ top: 10, bottom: 10, left: 10, right: 10 }}>
<Ionicons name="close" size={24} color={colors.textSecondary} />
</TouchableOpacity>
</View>
<ScrollView style={styles.modalBody} contentContainerStyle={{ paddingBottom: spacing.xl }}>
{showAddressForm ? (
<View>
<View style={styles.modalField}>
<Text style={[styles.modalLabel, { color: colors.textSecondary }]}>Dirección</Text>
<View style={[styles.modalInputRow, { backgroundColor: colors.surfaceLow, borderColor: colors.border }]}>
<Ionicons name="location-outline" size={18} color={colors.textSecondary} style={{ marginRight: spacing.sm }} />
<TextInput style={[styles.modalInput, { color: colors.text }]} value={formAddress} onChangeText={setFormAddress} placeholder="Calle Mayor 1, Madrid" placeholderTextColor={colors.textSecondary} editable={!formSaving} />
</View>
</View>
<View style={styles.modalField}>
<Text style={[styles.modalLabel, { color: colors.textSecondary }]}>Etiqueta (opcional)</Text>
<View style={[styles.modalInputRow, { backgroundColor: colors.surfaceLow, borderColor: colors.border }]}>
<Ionicons name="pricetag-outline" size={18} color={colors.textSecondary} style={{ marginRight: spacing.sm }} />
<TextInput style={[styles.modalInput, { color: colors.text }]} value={formLabel} onChangeText={setFormLabel} placeholder="Casa, Trabajo..." placeholderTextColor={colors.textSecondary} editable={!formSaving} />
</View>
</View>
<TouchableOpacity style={styles.checkboxRow} onPress={() => setFormDefault(!formDefault)} disabled={formSaving}>
<Ionicons name={formDefault ? 'checkbox' : 'square-outline'} size={22} color={formDefault ? colors.primary : colors.textSecondary} />
<Text style={[styles.checkboxLabel, { color: colors.text }]}>Dirección predeterminada</Text>
</TouchableOpacity>
{formError ? (
<View style={[styles.modalFeedback, { backgroundColor: colors.dangerContainer, borderColor: isDark ? '#5a2020' : '#fecaca' }]}>
<Text style={[styles.modalFeedbackText, { color: colors.text }]}>{formError}</Text>
</View>
) : null}
<View style={styles.modalActions}>
<TouchableOpacity style={[styles.modalCancelBtn, { borderColor: colors.border }]} onPress={() => { setShowAddressForm(false); setFormError(''); }} disabled={formSaving}>
<Text style={[styles.modalCancelText, { color: colors.text }]}>Cancelar</Text>
</TouchableOpacity>
<TouchableOpacity style={[styles.modalSaveBtn, { backgroundColor: colors.primary }, formSaving && { opacity: 0.6 }]} onPress={handleAddressSave} disabled={formSaving}>
{formSaving ? <ActivityIndicator color={colors.onPrimaryContainer} /> : <Text style={[styles.modalSaveText, { color: colors.onPrimaryContainer }]}>{editingAddressId ? 'Actualizar' : 'Añadir'}</Text>}
</TouchableOpacity>
</View>
</View>
) : (
<View>
{addressesLoading ? (
<ActivityIndicator size="small" color={colors.primary} style={{ paddingVertical: spacing.lg }} />
) : (
<View style={{ gap: spacing.sm }}>
{user?.address && (
<View style={[styles.addrCard, { borderColor: colors.primary, backgroundColor: isDark ? '#1a3a1c' : '#eaf7ec' }]}>
<View style={{ flex: 1 }}>
<Text style={[styles.addrBadge, { color: colors.primary }]}>Principal</Text>
<Text style={[styles.addrText, { color: colors.text }]}>{user.address}</Text>
</View>
<Ionicons name="checkmark-circle" size={20} color={colors.primary} />
</View>
)}
{addresses.map((addr) => (
<View key={addr.id} style={[styles.addrCard, { borderColor: colors.border, backgroundColor: colors.surfaceLow }]}>
<View style={{ flex: 1 }}>
{addr.label ? <Text style={[styles.addrLabel, { color: colors.primary }]}>{addr.label}</Text> : null}
<Text style={[styles.addrText, { color: colors.text }]}>{addr.address}</Text>
{!addr.is_default && (
<TouchableOpacity onPress={() => handleSetDefault(addr.id)}>
<Text style={[styles.addrDefaultLink, { color: colors.primary }]}>Marcar como predeterminada</Text>
</TouchableOpacity>
)}
</View>
<View style={{ gap: spacing.xs, marginLeft: spacing.sm }}>
<TouchableOpacity style={[styles.addrAction, { backgroundColor: colors.card }]} onPress={() => openEditAddressForm(addr)}>
<Ionicons name="pencil-outline" size={16} color={colors.textSecondary} />
</TouchableOpacity>
<TouchableOpacity style={[styles.addrAction, { backgroundColor: colors.card }]} onPress={() => handleDeleteAddress(addr.id)}>
<Ionicons name="trash-outline" size={16} color={colors.danger} />
</TouchableOpacity>
</View>
</View>
))}
<TouchableOpacity style={[styles.addAddrBtn, { borderColor: colors.border }]} onPress={openAddAddressForm}>
<Ionicons name="add-circle-outline" size={20} color={colors.primary} />
<Text style={[styles.addAddrText, { color: colors.primary }]}>Añadir dirección</Text>
</TouchableOpacity>
</View>
)}
</View>
)}
</ScrollView>
</View>
</View>
</Modal>
</ScrollView>
);
}
const styles = StyleSheet.create({
container: { flex: 1 },
// Auth prompt
authPrompt: { flex: 1, justifyContent: 'center', alignItems: 'center', paddingHorizontal: spacing.xl, gap: spacing.md },
authIconCircle: { width: 100, height: 100, borderRadius: 50, justifyContent: 'center', alignItems: 'center' },
authTitle: { fontSize: 24, fontWeight: '700', marginTop: spacing.sm },
authTitleTablet: { fontSize: 32 },
authSubtitle: { fontSize: 15, textAlign: 'center', lineHeight: 22 },
authSubtitleTablet: { fontSize: 17, maxWidth: 400 },
authButton: { paddingHorizontal: spacing.xl, paddingVertical: spacing.md, borderRadius: borderRadius.lg, marginTop: spacing.sm },
authButtonText: { fontSize: 16, fontWeight: '700' },
// Header card
headerCard: { alignItems: 'center', marginHorizontal: spacing.lg, marginTop: spacing.lg, borderRadius: borderRadius.xl, paddingVertical: spacing.xl, gap: spacing.xs },
avatarRing: { width: 104, height: 104, borderRadius: 52, borderWidth: 3, padding: 2, overflow: 'hidden' },
avatarImage: { width: '100%', height: '100%', borderRadius: 50 },
avatarFallback: { width: '100%', height: '100%', borderRadius: 50, justifyContent: 'center', alignItems: 'center' },
avatarEditBadge: { position: 'absolute', bottom: 2, right: 2, width: 28, height: 28, borderRadius: 14, justifyContent: 'center', alignItems: 'center', borderWidth: 2, borderColor: '#fff' },
displayName: { fontSize: 20, fontWeight: '700', marginTop: spacing.sm },
username: { fontSize: 14 },
// Cards
card: { marginHorizontal: spacing.lg, marginTop: spacing.md, borderRadius: borderRadius.xl, padding: spacing.lg },
cardHeader: { flexDirection: 'row', alignItems: 'center', gap: spacing.sm, marginBottom: spacing.md },
cardTitle: { fontSize: 15, fontWeight: '700' },
// Info grid
infoGrid: { flexDirection: 'row', gap: spacing.sm },
infoBox: { flex: 1, borderRadius: borderRadius.md, padding: spacing.md },
infoLabel: { fontSize: 11, fontWeight: '600', textTransform: 'uppercase', letterSpacing: 0.5, marginBottom: 2 },
infoValue: { fontSize: 15, fontWeight: '500' },
// Theme
themePills: { flexDirection: 'row', borderRadius: borderRadius.md, padding: 3 },
themePill: { flex: 1, flexDirection: 'row', alignItems: 'center', justifyContent: 'center', gap: 4, paddingVertical: spacing.sm + 2, borderRadius: borderRadius.sm },
themePillText: { fontSize: 13, fontWeight: '500' },
// Menu
menuRow: { flexDirection: 'row', alignItems: 'center', gap: spacing.md, paddingVertical: spacing.sm + 2 },
menuIconCircle: { width: 36, height: 36, borderRadius: 18, justifyContent: 'center', alignItems: 'center' },
menuLabel: { flex: 1, fontSize: 15, fontWeight: '500' },
menuDivider: { height: 1, marginVertical: spacing.xs },
// History
historyRow: { flexDirection: 'row', alignItems: 'center', gap: spacing.sm, paddingVertical: spacing.sm },
historyAddress: { flex: 1, fontSize: 14 },
// Logout
logoutCard: { flexDirection: 'row', alignItems: 'center', justifyContent: 'center', marginHorizontal: spacing.lg, marginTop: spacing.md, padding: spacing.md, borderRadius: borderRadius.xl, borderWidth: 1, gap: spacing.sm },
logoutText: { fontSize: 15, fontWeight: '600' },
// Modal shared
modalBackdrop: { flex: 1, backgroundColor: 'rgba(0,0,0,0.45)', justifyContent: 'flex-end' },
modalSheet: { borderTopLeftRadius: 20, borderTopRightRadius: 20, maxHeight: '90%' },
modalHandle: { width: 36, height: 4, borderRadius: 2, alignSelf: 'center', marginTop: spacing.sm, marginBottom: spacing.xs },
modalHeader: { flexDirection: 'row', alignItems: 'center', justifyContent: 'space-between', paddingHorizontal: spacing.lg, paddingVertical: spacing.md, borderBottomWidth: 1 },
modalTitle: { fontSize: 17, fontWeight: '700' },
modalBody: { padding: spacing.lg },
modalField: { marginBottom: spacing.md },
modalLabel: { fontSize: 13, fontWeight: '600', marginBottom: spacing.xs },
modalInputRow: { flexDirection: 'row', alignItems: 'center', borderRadius: borderRadius.md, borderWidth: 1, paddingHorizontal: spacing.md },
modalInput: { flex: 1, paddingVertical: spacing.md, fontSize: 16 },
modalFeedback: { flexDirection: 'row', alignItems: 'center', gap: spacing.sm, padding: spacing.md, borderRadius: borderRadius.md, marginBottom: spacing.md, borderWidth: 1 },
modalFeedbackText: { fontSize: 14, flex: 1 },
modalActions: { flexDirection: 'row', justifyContent: 'flex-end', gap: spacing.md, marginTop: spacing.md },
modalCancelBtn: { paddingVertical: spacing.md, paddingHorizontal: spacing.lg, borderRadius: borderRadius.md, borderWidth: 1 },
modalCancelText: { fontSize: 15, fontWeight: '500' },
modalSaveBtn: { paddingVertical: spacing.md, paddingHorizontal: spacing.xl, borderRadius: borderRadius.md, minWidth: 100, alignItems: 'center' },
modalSaveText: { fontSize: 15, fontWeight: '700' },
// Avatar modal
avatarTabBar: { flexDirection: 'row', borderBottomWidth: 1 },
avatarTabBtn: { flex: 1, flexDirection: 'row', alignItems: 'center', justifyContent: 'center', gap: spacing.xs, paddingVertical: spacing.md, borderBottomWidth: 2, borderBottomColor: 'transparent' },
avatarTabLabel: { fontSize: 13, fontWeight: '500' },
avatarGrid: { flexDirection: 'row', flexWrap: 'wrap', gap: spacing.md },
avatarOption: { width: '30%', aspectRatio: 1 },
avatarOptionImage: { width: '100%', height: '100%', borderRadius: borderRadius.lg },
uploadCard: { flexDirection: 'row', alignItems: 'center', padding: spacing.md, borderRadius: borderRadius.lg, gap: spacing.md },
uploadIconCircle: { width: 48, height: 48, borderRadius: 24, justifyContent: 'center', alignItems: 'center' },
uploadTitle: { fontSize: 15, fontWeight: '600' },
uploadSub: { fontSize: 13, marginTop: 1 },
// Addresses
addrCard: { flexDirection: 'row', alignItems: 'flex-start', padding: spacing.md, borderRadius: borderRadius.lg, borderWidth: 1 },
addrLabel: { fontSize: 12, fontWeight: '700', textTransform: 'uppercase', marginBottom: 2 },
addrBadge: { fontSize: 12, fontWeight: '700', textTransform: 'uppercase', marginBottom: 2 },
addrText: { fontSize: 14, lineHeight: 20 },
addrDefaultLink: { fontSize: 12, fontWeight: '600', marginTop: 4, textDecorationLine: 'underline' },
addrAction: { width: 32, height: 32, borderRadius: 8, justifyContent: 'center', alignItems: 'center' },
addAddrBtn: { flexDirection: 'row', alignItems: 'center', justifyContent: 'center', gap: spacing.sm, padding: spacing.md, marginTop: spacing.sm, borderWidth: 2, borderStyle: 'dashed', borderRadius: borderRadius.lg },
addAddrText: { fontSize: 15, fontWeight: '600' },
checkboxRow: { flexDirection: 'row', alignItems: 'center', gap: spacing.sm, marginBottom: spacing.md },
checkboxLabel: { fontSize: 14 },
});