feature/profile-redesign #12
@@ -1,22 +1,128 @@
|
||||
import React from 'react';
|
||||
import { View, Text, StyleSheet, TouchableOpacity, Alert } from 'react-native';
|
||||
import React, { useState, useEffect } from 'react';
|
||||
import { View, Text, StyleSheet, TouchableOpacity, Alert, TextInput, ScrollView, Image, ActivityIndicator } 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 { colors, spacing, borderRadius } from '../../constants/theme';
|
||||
|
||||
interface SearchHistoryItem {
|
||||
id: number;
|
||||
address: string;
|
||||
latitude: number | null;
|
||||
longitude: number | null;
|
||||
created_at: string;
|
||||
}
|
||||
|
||||
export default function ProfileScreen() {
|
||||
const router = useRouter();
|
||||
const { user, isAuthenticated, isLoading, logout, isAdmin } = useAuth();
|
||||
|
||||
const [firstName, setFirstName] = useState(user?.first_name || '');
|
||||
const [lastName, setLastName] = useState(user?.last_name || '');
|
||||
const [avatarUrl, setAvatarUrl] = useState(user?.avatar_url || '');
|
||||
const [searchHistory, setSearchHistory] = useState<SearchHistoryItem[]>([]);
|
||||
const [saving, setSaving] = useState(false);
|
||||
const [feedback, setFeedback] = useState<{ type: 'ok' | 'err'; text: string } | null>(null);
|
||||
|
||||
useEffect(() => {
|
||||
if (isAuthenticated) {
|
||||
loadSearchHistory();
|
||||
}
|
||||
}, [isAuthenticated]);
|
||||
|
||||
useEffect(() => {
|
||||
setFirstName(user?.first_name || '');
|
||||
setLastName(user?.last_name || '');
|
||||
setAvatarUrl(user?.avatar_url || '');
|
||||
}, [user]);
|
||||
|
||||
async function loadSearchHistory() {
|
||||
try {
|
||||
const res = await fetch('/api/search-history', { credentials: 'include' });
|
||||
if (res.ok) {
|
||||
const data = await res.json();
|
||||
setSearchHistory(data);
|
||||
}
|
||||
} catch (err) {
|
||||
console.error('Error loading search history:', err);
|
||||
}
|
||||
}
|
||||
|
||||
async function handleSave() {
|
||||
setSaving(true);
|
||||
setFeedback(null);
|
||||
try {
|
||||
const res = await fetch('/api/users/me', {
|
||||
method: 'PUT',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
credentials: 'include',
|
||||
body: JSON.stringify({
|
||||
first_name: firstName.trim() || null,
|
||||
last_name: lastName.trim() || null,
|
||||
avatar_url: avatarUrl || null,
|
||||
}),
|
||||
});
|
||||
if (!res.ok) {
|
||||
const err = await res.json().catch(() => ({}));
|
||||
throw new Error(err.error || 'Error al guardar');
|
||||
}
|
||||
setFeedback({ type: 'ok', text: 'Perfil guardado.' });
|
||||
} catch (err: any) {
|
||||
setFeedback({ type: 'err', text: err.message || 'Error al guardar' });
|
||||
} finally {
|
||||
setSaving(false);
|
||||
}
|
||||
}
|
||||
|
||||
async function handlePickImage() {
|
||||
const result = await ImagePicker.launchImageLibraryAsync({
|
||||
mediaTypes: ImagePicker.MediaTypeOptions.Images,
|
||||
allowsEditing: true,
|
||||
aspect: [1, 1],
|
||||
quality: 0.8,
|
||||
});
|
||||
|
||||
if (!result.canceled && result.assets[0]) {
|
||||
setAvatarUrl(result.assets[0].uri);
|
||||
try {
|
||||
const res = await fetch('/api/users/me', {
|
||||
method: 'PUT',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
credentials: 'include',
|
||||
body: JSON.stringify({ avatar_url: result.assets[0].uri }),
|
||||
});
|
||||
if (res.ok) {
|
||||
setFeedback({ type: 'ok', text: 'Foto de perfil actualizada.' });
|
||||
}
|
||||
} catch (err) {
|
||||
setFeedback({ type: 'err', text: 'Error al guardar la foto' });
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
async function handleDeleteSearch(id: number) {
|
||||
try {
|
||||
const res = await fetch(`/api/search-history/${id}`, {
|
||||
method: 'DELETE',
|
||||
credentials: 'include',
|
||||
});
|
||||
if (res.ok) {
|
||||
setSearchHistory(prev => prev.filter(item => item.id !== id));
|
||||
}
|
||||
} catch (err) {
|
||||
console.error('Error deleting search:', err);
|
||||
}
|
||||
}
|
||||
|
||||
const handleLogout = async () => {
|
||||
Alert.alert(
|
||||
'Cerrar Sesión',
|
||||
'¿Estás seguro que deseas cerrar sesión?',
|
||||
[
|
||||
{ text: 'Cancelar', style: 'cancel' },
|
||||
{
|
||||
text: 'Cerrar Sesión',
|
||||
{
|
||||
text: 'Cerrar Sesión',
|
||||
style: 'destructive',
|
||||
onPress: async () => {
|
||||
await logout();
|
||||
@@ -62,16 +168,89 @@ export default function ProfileScreen() {
|
||||
}
|
||||
|
||||
return (
|
||||
<View style={styles.container}>
|
||||
<View style={styles.header}>
|
||||
<View style={styles.avatar}>
|
||||
<Ionicons name="person" size={40} color={colors.primary} />
|
||||
</View>
|
||||
<Text style={styles.username}>{user?.username}</Text>
|
||||
{isAdmin && <Text style={styles.adminBadge}>Administrador</Text>}
|
||||
<ScrollView style={styles.container}>
|
||||
{/* Avatar Section */}
|
||||
<View style={styles.avatarSection}>
|
||||
<TouchableOpacity style={styles.avatarContainer} onPress={handlePickImage}>
|
||||
{avatarUrl ? (
|
||||
<Image source={{ uri: avatarUrl }} style={styles.avatarImage} />
|
||||
) : (
|
||||
<Ionicons name="person" size={40} color={colors.primary} />
|
||||
)}
|
||||
<View style={styles.avatarOverlay}>
|
||||
<Ionicons name="camera" size={24} color="white" />
|
||||
</View>
|
||||
</TouchableOpacity>
|
||||
<Text style={styles.editAvatarText}>Toca para cambiar foto</Text>
|
||||
</View>
|
||||
|
||||
{/* Editable Fields */}
|
||||
<View style={styles.formSection}>
|
||||
<View style={styles.inputGroup}>
|
||||
<Text style={styles.label}>Nombre</Text>
|
||||
<TextInput
|
||||
style={styles.input}
|
||||
value={firstName}
|
||||
onChangeText={setFirstName}
|
||||
placeholder="Tu nombre"
|
||||
editable={!saving}
|
||||
/>
|
||||
</View>
|
||||
<View style={styles.inputGroup}>
|
||||
<Text style={styles.label}>Apellidos</Text>
|
||||
<TextInput
|
||||
style={styles.input}
|
||||
value={lastName}
|
||||
onChangeText={setLastName}
|
||||
placeholder="Tus apellidos"
|
||||
editable={!saving}
|
||||
/>
|
||||
</View>
|
||||
|
||||
{feedback && (
|
||||
<View style={[styles.feedback, feedback.type === 'ok' ? styles.feedbackOk : styles.feedbackErr]}>
|
||||
<Text style={styles.feedbackText}>{feedback.text}</Text>
|
||||
</View>
|
||||
)}
|
||||
|
||||
<TouchableOpacity
|
||||
style={[styles.saveButton, saving && styles.saveButtonDisabled]}
|
||||
onPress={handleSave}
|
||||
disabled={saving}
|
||||
>
|
||||
{saving ? (
|
||||
<ActivityIndicator color={colors.textInverse} />
|
||||
) : (
|
||||
<Text style={styles.saveButtonText}>Guardar Cambios</Text>
|
||||
)}
|
||||
</TouchableOpacity>
|
||||
</View>
|
||||
|
||||
{/* Menu Section */}
|
||||
<View style={styles.menuSection}>
|
||||
<TouchableOpacity style={styles.menuItem} onPress={() => router.push('/saved')}>
|
||||
<Ionicons name="location" size={24} color={colors.primary} />
|
||||
<Text style={styles.menuText}>Mis Direcciones</Text>
|
||||
<Ionicons name="chevron-forward" size={20} color={colors.textSecondary} />
|
||||
</TouchableOpacity>
|
||||
|
||||
{searchHistory.length > 0 && (
|
||||
<View style={styles.searchHistorySection}>
|
||||
<Text style={styles.searchHistoryTitle}>Tus búsquedas recientes:</Text>
|
||||
{searchHistory.map((item) => (
|
||||
<View key={item.id} style={styles.searchItem}>
|
||||
<Text style={styles.searchAddress}>{item.address}</Text>
|
||||
<TouchableOpacity
|
||||
onPress={() => handleDeleteSearch(item.id)}
|
||||
style={styles.searchDelete}
|
||||
>
|
||||
<Ionicons name="close-circle" size={20} color={colors.textSecondary} />
|
||||
</TouchableOpacity>
|
||||
</View>
|
||||
))}
|
||||
</View>
|
||||
)}
|
||||
|
||||
{isAdmin && (
|
||||
<TouchableOpacity style={styles.menuItem}>
|
||||
<Ionicons name="settings" size={24} color={colors.text} />
|
||||
@@ -79,31 +258,14 @@ export default function ProfileScreen() {
|
||||
<Ionicons name="chevron-forward" size={20} color={colors.textSecondary} />
|
||||
</TouchableOpacity>
|
||||
)}
|
||||
|
||||
<TouchableOpacity style={styles.menuItem}>
|
||||
<Ionicons name="heart" size={24} color={colors.text} />
|
||||
<Text style={styles.menuText}>Favoritos</Text>
|
||||
<Ionicons name="chevron-forward" size={20} color={colors.textSecondary} />
|
||||
</TouchableOpacity>
|
||||
|
||||
<TouchableOpacity style={styles.menuItem}>
|
||||
<Ionicons name="notifications" size={24} color={colors.text} />
|
||||
<Text style={styles.menuText}>Notificaciones</Text>
|
||||
<Ionicons name="chevron-forward" size={20} color={colors.textSecondary} />
|
||||
</TouchableOpacity>
|
||||
|
||||
<TouchableOpacity style={styles.menuItem}>
|
||||
<Ionicons name="help-circle" size={24} color={colors.text} />
|
||||
<Text style={styles.menuText}>Ayuda</Text>
|
||||
<Ionicons name="chevron-forward" size={20} color={colors.textSecondary} />
|
||||
</TouchableOpacity>
|
||||
</View>
|
||||
|
||||
{/* Logout Button */}
|
||||
<TouchableOpacity style={styles.logoutButton} onPress={handleLogout}>
|
||||
<Ionicons name="log-out" size={20} color={colors.danger} />
|
||||
<Text style={styles.logoutText}>Cerrar Sesión</Text>
|
||||
</TouchableOpacity>
|
||||
</View>
|
||||
</ScrollView>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -136,33 +298,94 @@ const styles = StyleSheet.create({
|
||||
marginTop: spacing.sm,
|
||||
marginBottom: spacing.xl,
|
||||
},
|
||||
header: {
|
||||
avatarSection: {
|
||||
alignItems: 'center',
|
||||
padding: spacing.xl,
|
||||
backgroundColor: colors.card,
|
||||
},
|
||||
avatar: {
|
||||
width: 80,
|
||||
height: 80,
|
||||
borderRadius: 40,
|
||||
avatarContainer: {
|
||||
width: 100,
|
||||
height: 100,
|
||||
borderRadius: 50,
|
||||
backgroundColor: colors.background,
|
||||
justifyContent: 'center',
|
||||
alignItems: 'center',
|
||||
overflow: 'hidden',
|
||||
},
|
||||
username: {
|
||||
fontSize: 20,
|
||||
fontWeight: 'bold',
|
||||
color: colors.text,
|
||||
avatarImage: {
|
||||
width: 100,
|
||||
height: 100,
|
||||
borderRadius: 50,
|
||||
},
|
||||
avatarOverlay: {
|
||||
position: 'absolute',
|
||||
top: 0,
|
||||
left: 0,
|
||||
right: 0,
|
||||
bottom: 0,
|
||||
backgroundColor: 'rgba(0, 0, 0, 0.5)',
|
||||
justifyContent: 'center',
|
||||
alignItems: 'center',
|
||||
opacity: 0.8,
|
||||
},
|
||||
editAvatarText: {
|
||||
marginTop: spacing.sm,
|
||||
color: colors.textSecondary,
|
||||
fontSize: 14,
|
||||
},
|
||||
formSection: {
|
||||
padding: spacing.xl,
|
||||
backgroundColor: colors.card,
|
||||
marginTop: spacing.md,
|
||||
},
|
||||
adminBadge: {
|
||||
fontSize: 12,
|
||||
color: colors.primary,
|
||||
backgroundColor: '#E3F2FD',
|
||||
paddingHorizontal: spacing.sm,
|
||||
paddingVertical: spacing.xs,
|
||||
borderRadius: borderRadius.sm,
|
||||
marginTop: spacing.sm,
|
||||
inputGroup: {
|
||||
marginBottom: spacing.md,
|
||||
},
|
||||
label: {
|
||||
fontSize: 14,
|
||||
fontWeight: '600',
|
||||
color: colors.textSecondary,
|
||||
marginBottom: spacing.xs,
|
||||
},
|
||||
input: {
|
||||
borderWidth: 1,
|
||||
borderColor: colors.border || '#E0E0E0',
|
||||
borderRadius: borderRadius.md,
|
||||
padding: spacing.md,
|
||||
fontSize: 16,
|
||||
color: colors.text,
|
||||
},
|
||||
feedback: {
|
||||
padding: spacing.md,
|
||||
borderRadius: borderRadius.md,
|
||||
marginBottom: spacing.md,
|
||||
},
|
||||
feedbackOk: {
|
||||
backgroundColor: 'rgba(0, 69, 13, 0.1)',
|
||||
borderWidth: 1,
|
||||
borderColor: 'rgba(0, 69, 13, 0.2)',
|
||||
},
|
||||
feedbackErr: {
|
||||
backgroundColor: 'rgba(186, 26, 26, 0.1)',
|
||||
borderWidth: 1,
|
||||
borderColor: 'rgba(186, 26, 26, 0.2)',
|
||||
},
|
||||
feedbackText: {
|
||||
fontSize: 14,
|
||||
},
|
||||
saveButton: {
|
||||
backgroundColor: colors.primary,
|
||||
borderRadius: borderRadius.md,
|
||||
padding: spacing.md,
|
||||
alignItems: 'center',
|
||||
},
|
||||
saveButtonDisabled: {
|
||||
opacity: 0.6,
|
||||
},
|
||||
saveButtonText: {
|
||||
color: colors.textInverse,
|
||||
fontSize: 16,
|
||||
fontWeight: '600',
|
||||
},
|
||||
menuSection: {
|
||||
marginTop: spacing.md,
|
||||
@@ -181,6 +404,33 @@ const styles = StyleSheet.create({
|
||||
fontSize: 16,
|
||||
color: colors.text,
|
||||
},
|
||||
searchHistorySection: {
|
||||
padding: spacing.md,
|
||||
backgroundColor: colors.background,
|
||||
borderBottomWidth: 1,
|
||||
borderBottomColor: colors.separator,
|
||||
},
|
||||
searchHistoryTitle: {
|
||||
fontSize: 14,
|
||||
color: colors.textSecondary,
|
||||
marginBottom: spacing.sm,
|
||||
},
|
||||
searchItem: {
|
||||
flexDirection: 'row',
|
||||
alignItems: 'center',
|
||||
justifyContent: 'space-between',
|
||||
paddingVertical: spacing.sm,
|
||||
borderBottomWidth: 1,
|
||||
borderBottomColor: colors.separator,
|
||||
},
|
||||
searchAddress: {
|
||||
flex: 1,
|
||||
fontSize: 14,
|
||||
color: colors.text,
|
||||
},
|
||||
searchDelete: {
|
||||
padding: spacing.xs,
|
||||
},
|
||||
button: {
|
||||
backgroundColor: colors.primary,
|
||||
borderRadius: borderRadius.md,
|
||||
|
||||
@@ -12,6 +12,7 @@
|
||||
"expo-constants": "~57.0.3",
|
||||
"expo-dev-client": "~57.0.5",
|
||||
"expo-device": "~7.0.2",
|
||||
"expo-image-picker": "~57.0.2",
|
||||
"expo-linking": "~57.0.1",
|
||||
"expo-local-authentication": "~57.0.0",
|
||||
"expo-notifications": "~57.0.3",
|
||||
|
||||
Reference in New Issue
Block a user