341 lines
10 KiB
TypeScript
341 lines
10 KiB
TypeScript
|
|
import { Input } from "@/components/Input";
|
|||
|
|
import { BORDER_RADIUS, COLORS, SPACING } from "@/constants/theme";
|
|||
|
|
import { useAuth } from "@/contexts/AuthContext";
|
|||
|
|
import { getSetting, setSetting } from "@/services/db";
|
|||
|
|
import { api } from "@/server/api";
|
|||
|
|
import { File, Paths } from "expo-file-system";
|
|||
|
|
import * as ImagePicker from "expo-image-picker";
|
|||
|
|
import { router } from "expo-router";
|
|||
|
|
import React, { useEffect, useState } from "react";
|
|||
|
|
import {
|
|||
|
|
ActivityIndicator,
|
|||
|
|
Alert,
|
|||
|
|
Image,
|
|||
|
|
KeyboardAvoidingView,
|
|||
|
|
Platform,
|
|||
|
|
ScrollView,
|
|||
|
|
StyleSheet,
|
|||
|
|
Text,
|
|||
|
|
TextInput,
|
|||
|
|
TouchableOpacity,
|
|||
|
|
View,
|
|||
|
|
} from "react-native";
|
|||
|
|
import { SafeAreaView } from "react-native-safe-area-context";
|
|||
|
|
|
|||
|
|
export default function PerfilPage() {
|
|||
|
|
const { user, updateUser } = useAuth();
|
|||
|
|
|
|||
|
|
const [name, setName] = useState(user?.name ?? "");
|
|||
|
|
const [bio, setBio] = useState("");
|
|||
|
|
const [photoUri, setPhotoUri] = useState<string | null>(null);
|
|||
|
|
const [loading, setLoading] = useState(true);
|
|||
|
|
const [saving, setSaving] = useState(false);
|
|||
|
|
|
|||
|
|
useEffect(() => {
|
|||
|
|
async function loadProfile() {
|
|||
|
|
try {
|
|||
|
|
// Carrega local primeiro (rápido, offline)
|
|||
|
|
const [savedPhoto, savedBio] = await Promise.all([
|
|||
|
|
getSetting("profilePhoto"),
|
|||
|
|
getSetting("profileBio"),
|
|||
|
|
]);
|
|||
|
|
if (savedPhoto) setPhotoUri(savedPhoto);
|
|||
|
|
if (savedBio) setBio(savedBio);
|
|||
|
|
|
|||
|
|
// Atualiza com dados do backend
|
|||
|
|
const { data } = await api.get("/users/profile");
|
|||
|
|
const profile = data.data ?? data;
|
|||
|
|
if (profile.name) setName(profile.name);
|
|||
|
|
if (profile.bio) setBio(profile.bio);
|
|||
|
|
if (profile.profilePhoto) setPhotoUri(profile.profilePhoto);
|
|||
|
|
} catch {
|
|||
|
|
// fica com os dados locais se offline
|
|||
|
|
} finally {
|
|||
|
|
setLoading(false);
|
|||
|
|
}
|
|||
|
|
}
|
|||
|
|
loadProfile();
|
|||
|
|
}, []);
|
|||
|
|
|
|||
|
|
const handlePickPhoto = async () => {
|
|||
|
|
const { status } = await ImagePicker.requestMediaLibraryPermissionsAsync();
|
|||
|
|
if (status !== "granted") {
|
|||
|
|
Alert.alert("Permissão negada", "Permita o acesso à galeria nas configurações do celular.");
|
|||
|
|
return;
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
const result = await ImagePicker.launchImageLibraryAsync({
|
|||
|
|
mediaTypes: "images",
|
|||
|
|
allowsEditing: true,
|
|||
|
|
aspect: [1, 1],
|
|||
|
|
quality: 0.8,
|
|||
|
|
});
|
|||
|
|
|
|||
|
|
if (result.canceled || !result.assets[0]) return;
|
|||
|
|
|
|||
|
|
const picked = result.assets[0].uri;
|
|||
|
|
try {
|
|||
|
|
const dest = new File(Paths.document, `avatar_${user?.id ?? "user"}.jpg`);
|
|||
|
|
new File(picked).copy(dest);
|
|||
|
|
setPhotoUri(dest.uri);
|
|||
|
|
} catch {
|
|||
|
|
setPhotoUri(picked);
|
|||
|
|
}
|
|||
|
|
};
|
|||
|
|
|
|||
|
|
const handleSave = async () => {
|
|||
|
|
const nameTrimmed = name.trim();
|
|||
|
|
if (!nameTrimmed) {
|
|||
|
|
Alert.alert("Nome inválido", "O nome não pode estar vazio.");
|
|||
|
|
return;
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
try {
|
|||
|
|
setSaving(true);
|
|||
|
|
|
|||
|
|
// Persiste localmente
|
|||
|
|
await Promise.all([
|
|||
|
|
updateUser({ name: nameTrimmed }),
|
|||
|
|
setSetting("profileBio", bio.trim()),
|
|||
|
|
photoUri ? setSetting("profilePhoto", photoUri) : Promise.resolve(),
|
|||
|
|
]);
|
|||
|
|
|
|||
|
|
// Envia ao backend
|
|||
|
|
await api.patch("/users/profile", {
|
|||
|
|
name: nameTrimmed,
|
|||
|
|
bio: bio.trim(),
|
|||
|
|
profilePhoto: photoUri ?? undefined,
|
|||
|
|
});
|
|||
|
|
|
|||
|
|
Alert.alert("Salvo", "Perfil atualizado com sucesso.", [
|
|||
|
|
{ text: "OK", onPress: () => router.back() },
|
|||
|
|
]);
|
|||
|
|
} catch (e: any) {
|
|||
|
|
const msg = e?.response?.data?.error ?? e?.message ?? "Não foi possível salvar.";
|
|||
|
|
Alert.alert("Erro", msg);
|
|||
|
|
} finally {
|
|||
|
|
setSaving(false);
|
|||
|
|
}
|
|||
|
|
};
|
|||
|
|
|
|||
|
|
const initials = (user?.name ?? "U")
|
|||
|
|
.split(" ")
|
|||
|
|
.slice(0, 2)
|
|||
|
|
.map((w) => w.charAt(0).toUpperCase())
|
|||
|
|
.join("");
|
|||
|
|
|
|||
|
|
if (loading) {
|
|||
|
|
return (
|
|||
|
|
<SafeAreaView style={styles.container}>
|
|||
|
|
<View style={styles.loadingContainer}>
|
|||
|
|
<ActivityIndicator color={COLORS.text} size="large" />
|
|||
|
|
</View>
|
|||
|
|
</SafeAreaView>
|
|||
|
|
);
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
return (
|
|||
|
|
<SafeAreaView style={styles.container}>
|
|||
|
|
<View style={styles.header}>
|
|||
|
|
<TouchableOpacity onPress={() => router.back()} style={styles.backButton} activeOpacity={0.7}>
|
|||
|
|
<Text style={styles.backIcon}>←</Text>
|
|||
|
|
<Text style={styles.backLabel}>Voltar</Text>
|
|||
|
|
</TouchableOpacity>
|
|||
|
|
<Text style={styles.headerTitle}>Editar Perfil</Text>
|
|||
|
|
<View style={{ width: 70 }} />
|
|||
|
|
</View>
|
|||
|
|
|
|||
|
|
<KeyboardAvoidingView
|
|||
|
|
style={{ flex: 1 }}
|
|||
|
|
behavior={Platform.OS === "ios" ? "padding" : "height"}
|
|||
|
|
>
|
|||
|
|
<ScrollView
|
|||
|
|
contentContainerStyle={styles.scroll}
|
|||
|
|
showsVerticalScrollIndicator={false}
|
|||
|
|
keyboardShouldPersistTaps="handled"
|
|||
|
|
>
|
|||
|
|
{/* Avatar */}
|
|||
|
|
<View style={styles.avatarSection}>
|
|||
|
|
<TouchableOpacity onPress={handlePickPhoto} activeOpacity={0.8} style={styles.avatarWrapper}>
|
|||
|
|
{photoUri ? (
|
|||
|
|
<Image source={{ uri: photoUri }} style={styles.avatarImage} />
|
|||
|
|
) : (
|
|||
|
|
<View style={styles.avatarPlaceholder}>
|
|||
|
|
<Text style={styles.avatarInitials}>{initials}</Text>
|
|||
|
|
</View>
|
|||
|
|
)}
|
|||
|
|
<View style={styles.avatarEditBadge}>
|
|||
|
|
<Text style={styles.avatarEditIcon}>✏️</Text>
|
|||
|
|
</View>
|
|||
|
|
</TouchableOpacity>
|
|||
|
|
<Text style={styles.avatarHint}>Toque para alterar a foto</Text>
|
|||
|
|
</View>
|
|||
|
|
|
|||
|
|
{/* Campos */}
|
|||
|
|
<View style={styles.form}>
|
|||
|
|
<View style={styles.fieldGroup}>
|
|||
|
|
<Text style={styles.fieldLabel}>Nome</Text>
|
|||
|
|
<Input
|
|||
|
|
value={name}
|
|||
|
|
onChangeText={setName}
|
|||
|
|
placeholder="Seu nome completo"
|
|||
|
|
autoCapitalize="words"
|
|||
|
|
/>
|
|||
|
|
</View>
|
|||
|
|
|
|||
|
|
<View style={styles.fieldGroup}>
|
|||
|
|
<Text style={styles.fieldLabel}>E-mail</Text>
|
|||
|
|
<View style={styles.readonlyField}>
|
|||
|
|
<Text style={styles.readonlyText}>{user?.email}</Text>
|
|||
|
|
<Text style={styles.lockIcon}>🔒</Text>
|
|||
|
|
</View>
|
|||
|
|
<Text style={styles.fieldHint}>O e-mail não pode ser alterado.</Text>
|
|||
|
|
</View>
|
|||
|
|
|
|||
|
|
<View style={styles.fieldGroup}>
|
|||
|
|
<Text style={styles.fieldLabel}>Bio</Text>
|
|||
|
|
<TextInput
|
|||
|
|
style={styles.bioInput}
|
|||
|
|
value={bio}
|
|||
|
|
onChangeText={setBio}
|
|||
|
|
placeholder="Fale um pouco sobre você..."
|
|||
|
|
placeholderTextColor={COLORS.inputPlaceholder}
|
|||
|
|
multiline
|
|||
|
|
numberOfLines={4}
|
|||
|
|
textAlignVertical="top"
|
|||
|
|
maxLength={200}
|
|||
|
|
/>
|
|||
|
|
<Text style={styles.fieldHint}>{bio.length}/200 caracteres</Text>
|
|||
|
|
</View>
|
|||
|
|
</View>
|
|||
|
|
|
|||
|
|
<TouchableOpacity
|
|||
|
|
style={[styles.saveButton, saving && styles.saveButtonDisabled]}
|
|||
|
|
onPress={handleSave}
|
|||
|
|
disabled={saving}
|
|||
|
|
activeOpacity={0.85}
|
|||
|
|
>
|
|||
|
|
{saving ? (
|
|||
|
|
<ActivityIndicator color={COLORS.background} size="small" />
|
|||
|
|
) : (
|
|||
|
|
<Text style={styles.saveButtonText}>Salvar Alterações</Text>
|
|||
|
|
)}
|
|||
|
|
</TouchableOpacity>
|
|||
|
|
</ScrollView>
|
|||
|
|
</KeyboardAvoidingView>
|
|||
|
|
</SafeAreaView>
|
|||
|
|
);
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
const AVATAR_SIZE = 110;
|
|||
|
|
|
|||
|
|
const styles = StyleSheet.create({
|
|||
|
|
container: { flex: 1, backgroundColor: COLORS.background },
|
|||
|
|
|
|||
|
|
loadingContainer: { flex: 1, justifyContent: "center", alignItems: "center" },
|
|||
|
|
|
|||
|
|
header: {
|
|||
|
|
flexDirection: "row",
|
|||
|
|
alignItems: "center",
|
|||
|
|
justifyContent: "space-between",
|
|||
|
|
paddingHorizontal: SPACING.lg,
|
|||
|
|
paddingVertical: SPACING.md,
|
|||
|
|
borderBottomWidth: 1,
|
|||
|
|
borderBottomColor: COLORS.border,
|
|||
|
|
},
|
|||
|
|
backButton: {
|
|||
|
|
flexDirection: "row",
|
|||
|
|
alignItems: "center",
|
|||
|
|
gap: SPACING.xs,
|
|||
|
|
width: 70,
|
|||
|
|
},
|
|||
|
|
backIcon: { fontSize: 18, color: COLORS.text },
|
|||
|
|
backLabel: { fontSize: 15, color: COLORS.textSecondary, fontWeight: "500" },
|
|||
|
|
headerTitle: { fontSize: 16, fontWeight: "700", color: COLORS.text },
|
|||
|
|
|
|||
|
|
scroll: { padding: SPACING.lg, paddingBottom: 48 },
|
|||
|
|
|
|||
|
|
avatarSection: { alignItems: "center", marginTop: SPACING.xl, marginBottom: SPACING.xxl },
|
|||
|
|
avatarWrapper: { position: "relative" },
|
|||
|
|
avatarImage: {
|
|||
|
|
width: AVATAR_SIZE,
|
|||
|
|
height: AVATAR_SIZE,
|
|||
|
|
borderRadius: AVATAR_SIZE / 2,
|
|||
|
|
borderWidth: 3,
|
|||
|
|
borderColor: COLORS.borderLight,
|
|||
|
|
},
|
|||
|
|
avatarPlaceholder: {
|
|||
|
|
width: AVATAR_SIZE,
|
|||
|
|
height: AVATAR_SIZE,
|
|||
|
|
borderRadius: AVATAR_SIZE / 2,
|
|||
|
|
backgroundColor: COLORS.surfaceLight,
|
|||
|
|
borderWidth: 3,
|
|||
|
|
borderColor: COLORS.borderLight,
|
|||
|
|
justifyContent: "center",
|
|||
|
|
alignItems: "center",
|
|||
|
|
},
|
|||
|
|
avatarInitials: { fontSize: 38, fontWeight: "800", color: COLORS.text },
|
|||
|
|
avatarEditBadge: {
|
|||
|
|
position: "absolute",
|
|||
|
|
bottom: 4,
|
|||
|
|
right: 4,
|
|||
|
|
width: 32,
|
|||
|
|
height: 32,
|
|||
|
|
borderRadius: 16,
|
|||
|
|
backgroundColor: COLORS.text,
|
|||
|
|
justifyContent: "center",
|
|||
|
|
alignItems: "center",
|
|||
|
|
borderWidth: 2,
|
|||
|
|
borderColor: COLORS.background,
|
|||
|
|
},
|
|||
|
|
avatarEditIcon: { fontSize: 14 },
|
|||
|
|
avatarHint: { marginTop: SPACING.md, fontSize: 13, color: COLORS.textTertiary },
|
|||
|
|
|
|||
|
|
form: { gap: SPACING.lg },
|
|||
|
|
fieldGroup: { gap: SPACING.sm },
|
|||
|
|
fieldLabel: {
|
|||
|
|
fontSize: 11,
|
|||
|
|
fontWeight: "700",
|
|||
|
|
color: COLORS.textTertiary,
|
|||
|
|
textTransform: "uppercase",
|
|||
|
|
letterSpacing: 0.8,
|
|||
|
|
},
|
|||
|
|
|
|||
|
|
readonlyField: {
|
|||
|
|
flexDirection: "row",
|
|||
|
|
alignItems: "center",
|
|||
|
|
justifyContent: "space-between",
|
|||
|
|
height: 48,
|
|||
|
|
borderWidth: 1,
|
|||
|
|
borderColor: COLORS.border,
|
|||
|
|
backgroundColor: COLORS.surface,
|
|||
|
|
borderRadius: BORDER_RADIUS.md,
|
|||
|
|
paddingHorizontal: SPACING.md,
|
|||
|
|
},
|
|||
|
|
readonlyText: { fontSize: 15, color: COLORS.textSecondary, flex: 1 },
|
|||
|
|
lockIcon: { fontSize: 14 },
|
|||
|
|
|
|||
|
|
bioInput: {
|
|||
|
|
borderWidth: 1,
|
|||
|
|
borderColor: COLORS.inputBorder,
|
|||
|
|
backgroundColor: COLORS.inputBackground,
|
|||
|
|
borderRadius: BORDER_RADIUS.md,
|
|||
|
|
color: COLORS.inputText,
|
|||
|
|
fontSize: 15,
|
|||
|
|
paddingHorizontal: SPACING.md,
|
|||
|
|
paddingTop: SPACING.md,
|
|||
|
|
minHeight: 100,
|
|||
|
|
},
|
|||
|
|
fieldHint: { fontSize: 11, color: COLORS.textTertiary },
|
|||
|
|
|
|||
|
|
saveButton: {
|
|||
|
|
marginTop: SPACING.xxl,
|
|||
|
|
backgroundColor: COLORS.text,
|
|||
|
|
borderRadius: BORDER_RADIUS.lg,
|
|||
|
|
paddingVertical: SPACING.lg,
|
|||
|
|
alignItems: "center",
|
|||
|
|
},
|
|||
|
|
saveButtonDisabled: { backgroundColor: COLORS.borderLight },
|
|||
|
|
saveButtonText: { fontSize: 15, fontWeight: "700", color: COLORS.background },
|
|||
|
|
});
|