top-tran/toptran-app/src/app/perfil.tsx
2026-05-04 03:44:30 -03:00

340 lines
10 KiB
TypeScript
Raw Blame History

This file contains invisible Unicode characters

This file contains invisible Unicode characters that are indistinguishable to humans but may be processed differently by a computer. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

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