feature/profile-redesign #12

Merged
Ichitux merged 9 commits from feature/profile-redesign into main 2026-07-06 23:54:37 +00:00
2 changed files with 298 additions and 47 deletions
Showing only changes of commit eb3cde67eb - Show all commits
+297 -47
View File
@@ -1,22 +1,128 @@
import React from 'react'; import React, { useState, useEffect } from 'react';
import { View, Text, StyleSheet, TouchableOpacity, Alert } from 'react-native'; import { View, Text, StyleSheet, TouchableOpacity, Alert, TextInput, ScrollView, Image, ActivityIndicator } from 'react-native';
import { useRouter } from 'expo-router'; import { useRouter } from 'expo-router';
import { Ionicons } from '@expo/vector-icons'; import { Ionicons } from '@expo/vector-icons';
import * as ImagePicker from 'expo-image-picker';
import { useAuth } from '../../hooks/useAuth'; import { useAuth } from '../../hooks/useAuth';
import { colors, spacing, borderRadius } from '../../constants/theme'; 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() { export default function ProfileScreen() {
const router = useRouter(); const router = useRouter();
const { user, isAuthenticated, isLoading, logout, isAdmin } = useAuth(); 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 () => { const handleLogout = async () => {
Alert.alert( Alert.alert(
'Cerrar Sesión', 'Cerrar Sesión',
'¿Estás seguro que deseas cerrar sesión?', '¿Estás seguro que deseas cerrar sesión?',
[ [
{ text: 'Cancelar', style: 'cancel' }, { text: 'Cancelar', style: 'cancel' },
{ {
text: 'Cerrar Sesión', text: 'Cerrar Sesión',
style: 'destructive', style: 'destructive',
onPress: async () => { onPress: async () => {
await logout(); await logout();
@@ -62,16 +168,89 @@ export default function ProfileScreen() {
} }
return ( return (
<View style={styles.container}> <ScrollView style={styles.container}>
<View style={styles.header}> {/* Avatar Section */}
<View style={styles.avatar}> <View style={styles.avatarSection}>
<Ionicons name="person" size={40} color={colors.primary} /> <TouchableOpacity style={styles.avatarContainer} onPress={handlePickImage}>
</View> {avatarUrl ? (
<Text style={styles.username}>{user?.username}</Text> <Image source={{ uri: avatarUrl }} style={styles.avatarImage} />
{isAdmin && <Text style={styles.adminBadge}>Administrador</Text>} ) : (
<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> </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}> <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 && ( {isAdmin && (
<TouchableOpacity style={styles.menuItem}> <TouchableOpacity style={styles.menuItem}>
<Ionicons name="settings" size={24} color={colors.text} /> <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} /> <Ionicons name="chevron-forward" size={20} color={colors.textSecondary} />
</TouchableOpacity> </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> </View>
{/* Logout Button */}
<TouchableOpacity style={styles.logoutButton} onPress={handleLogout}> <TouchableOpacity style={styles.logoutButton} onPress={handleLogout}>
<Ionicons name="log-out" size={20} color={colors.danger} /> <Ionicons name="log-out" size={20} color={colors.danger} />
<Text style={styles.logoutText}>Cerrar Sesión</Text> <Text style={styles.logoutText}>Cerrar Sesión</Text>
</TouchableOpacity> </TouchableOpacity>
</View> </ScrollView>
); );
} }
@@ -136,33 +298,94 @@ const styles = StyleSheet.create({
marginTop: spacing.sm, marginTop: spacing.sm,
marginBottom: spacing.xl, marginBottom: spacing.xl,
}, },
header: { avatarSection: {
alignItems: 'center', alignItems: 'center',
padding: spacing.xl, padding: spacing.xl,
backgroundColor: colors.card, backgroundColor: colors.card,
}, },
avatar: { avatarContainer: {
width: 80, width: 100,
height: 80, height: 100,
borderRadius: 40, borderRadius: 50,
backgroundColor: colors.background, backgroundColor: colors.background,
justifyContent: 'center', justifyContent: 'center',
alignItems: 'center', alignItems: 'center',
overflow: 'hidden',
}, },
username: { avatarImage: {
fontSize: 20, width: 100,
fontWeight: 'bold', height: 100,
color: colors.text, 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, marginTop: spacing.md,
}, },
adminBadge: { inputGroup: {
fontSize: 12, marginBottom: spacing.md,
color: colors.primary, },
backgroundColor: '#E3F2FD', label: {
paddingHorizontal: spacing.sm, fontSize: 14,
paddingVertical: spacing.xs, fontWeight: '600',
borderRadius: borderRadius.sm, color: colors.textSecondary,
marginTop: spacing.sm, 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: { menuSection: {
marginTop: spacing.md, marginTop: spacing.md,
@@ -181,6 +404,33 @@ const styles = StyleSheet.create({
fontSize: 16, fontSize: 16,
color: colors.text, 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: { button: {
backgroundColor: colors.primary, backgroundColor: colors.primary,
borderRadius: borderRadius.md, borderRadius: borderRadius.md,
+1
View File
@@ -12,6 +12,7 @@
"expo-constants": "~57.0.3", "expo-constants": "~57.0.3",
"expo-dev-client": "~57.0.5", "expo-dev-client": "~57.0.5",
"expo-device": "~7.0.2", "expo-device": "~7.0.2",
"expo-image-picker": "~57.0.2",
"expo-linking": "~57.0.1", "expo-linking": "~57.0.1",
"expo-local-authentication": "~57.0.0", "expo-local-authentication": "~57.0.0",
"expo-notifications": "~57.0.3", "expo-notifications": "~57.0.3",