frontend: introduce avatar upload component

Pedro Lucas Porcellis porcellis@eletrotupi.com 1 month ago 7315aa48399c26dd3cc6e78613bbd578a8e8d2b7
Parents: a5fffad
1 file(s) changed
  • frontend/components/ui/AvatarUpload.tsx +296 -0
frontend/components/ui/AvatarUpload.tsx
@@ -0,0 +1,296 @@
1 + import React, { useState, useRef } from 'react';
2 + import {
3 + View,
4 + Image,
5 + TouchableOpacity,
6 + Text,
7 + Alert,
8 + ActivityIndicator,
9 + Animated,
10 + StyleSheet,
11 + } from 'react-native';
12 + import * as ImagePicker from 'expo-image-picker';
13 + import { Ionicons } from '@expo/vector-icons';
14 + import { apiClient } from '@/lib/api';
15 + import { Colors } from '@/constants/theme';
16 +
17 + interface AvatarUploadProps {
18 + currentAvatar?: string | null;
19 + onUploadSuccess?: () => void;
20 + onRemoveSuccess?: () => void;
21 + }
22 +
23 + const AvatarUpload: React.FC<AvatarUploadProps> = ({
24 + currentAvatar = null,
25 + onUploadSuccess,
26 + onRemoveSuccess,
27 + }) => {
28 + const [preview, setPreview] = useState<string | null>(currentAvatar);
29 + const [isLoading, setIsLoading] = useState<boolean>(false);
30 + const scaleAnim = useRef(new Animated.Value(1)).current;
31 +
32 + const handleImagePick = async (useCamera: boolean = false): Promise<void> => {
33 + try {
34 + const result = useCamera
35 + ? await ImagePicker.launchCameraAsync({
36 + allowsEditing: true,
37 + aspect: [1, 1],
38 + quality: 0.8,
39 + })
40 + : await ImagePicker.launchImageLibraryAsync({
41 + allowsEditing: true,
42 + aspect: [1, 1],
43 + quality: 0.8,
44 + });
45 +
46 + if (!result.canceled && result.assets && result.assets.length > 0) {
47 + const asset = result.assets[0];
48 + setPreview(asset.uri);
49 + await uploadAvatar(asset.fileName, asset.uri, asset.mimeType);
50 + }
51 + } catch (error) {
52 + console.error('Image picker error:', error);
53 + Alert.alert('Error', 'Failed to select image');
54 + }
55 + };
56 +
57 + const uploadAvatar = async (fileName: string, imageUri: string, mimeType: string): Promise<void> => {
58 + setIsLoading(true);
59 +
60 + try {
61 + const formData = new FormData();
62 + formData.append('avatar', {
63 + uri: imageUri,
64 + type: mimeType,
65 + name: fileName,
66 + } as any);
67 +
68 + const response = await apiClient.avatar(formData);
69 +
70 + onUploadSuccess?.(response.user);
71 + } catch (error) {
72 + console.error('Upload error:', error);
73 + Alert.alert('Error', error instanceof Error ? error.message : 'Upload failed');
74 + setPreview(currentAvatar);
75 + } finally {
76 + setIsLoading(false);
77 + }
78 + };
79 +
80 + const handleRemove = (): void => {
81 + Alert.alert('Remover sua foto', 'Tem certeza que quer remover sua foto de perfil?', [
82 + {
83 + text: 'Cancelar',
84 + onPress: () => {},
85 + style: 'cancel',
86 + },
87 + {
88 + text: 'Remover',
89 + onPress: async () => {
90 + setIsLoading(true);
91 +
92 + try {
93 + const response = await apiClient.removeAvatar();
94 +
95 + setPreview(null);
96 + onRemoveSuccess?.(response.user);
97 + } catch (error) {
98 + console.error('Remove error:', error);
99 + Alert.alert('Error', error instanceof Error ? error.message : 'Failed to remove avatar');
100 + } finally {
101 + setIsLoading(false);
102 + }
103 + },
104 + style: 'destructive',
105 + },
106 + ]);
107 + };
108 +
109 + const handlePressIn = (): void => {
110 + Animated.spring(scaleAnim, {
111 + toValue: 0.95,
112 + useNativeDriver: true,
113 + }).start();
114 + };
115 +
116 + const handlePressOut = (): void => {
117 + Animated.spring(scaleAnim, {
118 + toValue: 1,
119 + useNativeDriver: true,
120 + }).start();
121 + };
122 +
123 + const showImageOptions = (): void => {
124 + Alert.alert('Selecionar uma foto de perfil', 'Escolha de onde você quer:', [
125 + {
126 + text: 'Câmera',
127 + onPress: () => handleImagePick(true),
128 + },
129 + {
130 + text: 'Galeria',
131 + onPress: () => handleImagePick(false),
132 + },
133 + {
134 + text: 'Cancelar',
135 + style: 'cancel',
136 + },
137 + ]);
138 + };
139 +
140 + return (
141 + <View style={styles.container}>
142 + <View style={styles.avatarSection}>
143 + {preview ? (
144 + <View style={styles.avatarWrapper}>
145 + <Image source={{ uri: preview }} style={styles.avatarImage} />
146 + {isLoading && (
147 + <View style={styles.loadingOverlay}>
148 + <ActivityIndicator color="#fff" size="large" />
149 + </View>
150 + )}
151 + </View>
152 + ) : (
153 + <TouchableOpacity
154 + style={styles.avatarPlaceholder}
155 + onPress={showImageOptions}
156 + activeOpacity={0.7}
157 + disabled={isLoading}
158 + >
159 + <Ionicons name="cloud-upload-outline" size={32} color="#666" />
160 + <Text style={styles.placeholderText}>Adicione uma foto</Text>
161 + </TouchableOpacity>
162 + )}
163 + </View>
164 +
165 + <View style={styles.avatarActions}>
166 + <Animated.View style={{ transform: [{ scale: scaleAnim }] }}>
167 + <TouchableOpacity
168 + style={[styles.actionBtn, styles.editBtn]}
169 + onPress={showImageOptions}
170 + onPressIn={handlePressIn}
171 + onPressOut={handlePressOut}
172 + disabled={isLoading}
173 + activeOpacity={0.8}
174 + >
175 + {isLoading ? (
176 + <ActivityIndicator color="#fff" size="small" />
177 + ) : (
178 + <Text style={styles.editBtnText}>ALTERAR FOTO</Text>
179 + )}
180 + </TouchableOpacity>
181 + </Animated.View>
182 +
183 + {preview && (
184 + <Animated.View style={{ transform: [{ scale: scaleAnim }] }}>
185 + <TouchableOpacity
186 + style={[styles.actionBtn, styles.removeBtn]}
187 + onPress={handleRemove}
188 + onPressIn={handlePressIn}
189 + onPressOut={handlePressOut}
190 + disabled={isLoading}
191 + activeOpacity={0.8}
192 + >
193 + <Text style={styles.removeBtnText}>REMOVER FOTO</Text>
194 + </TouchableOpacity>
195 + </Animated.View>
196 + )}
197 + </View>
198 + </View>
199 + );
200 + };
201 +
202 + const styles = StyleSheet.create({
203 + container: {
204 + alignItems: 'center',
205 + gap: 32,
206 + paddingVertical: 32,
207 + },
208 + avatarSection: {
209 + position: 'relative',
210 + width: 160,
211 + height: 160,
212 + },
213 + avatarWrapper: {
214 + width: '100%',
215 + height: '100%',
216 + borderRadius: 80,
217 + overflow: 'hidden',
218 + backgroundColor: '#f0f0f0',
219 + shadowColor: '#000',
220 + shadowOffset: { width: 0, height: 8 },
221 + shadowOpacity: 0.15,
222 + shadowRadius: 12,
223 + elevation: 8,
224 + },
225 + avatarImage: {
226 + width: '100%',
227 + height: '100%',
228 + resizeMode: 'cover',
229 + },
230 + loadingOverlay: {
231 + position: 'absolute',
232 + top: 0,
233 + left: 0,
234 + right: 0,
235 + bottom: 0,
236 + backgroundColor: 'rgba(0, 0, 0, 0.4)',
237 + alignItems: 'center',
238 + justifyContent: 'center',
239 + },
240 + avatarPlaceholder: {
241 + width: '100%',
242 + height: '100%',
243 + borderRadius: 80,
244 + borderWidth: 2,
245 + borderStyle: 'dashed',
246 + borderColor: '#d0d0d0',
247 + display: 'flex',
248 + alignItems: 'center',
249 + justifyContent: 'center',
250 + backgroundColor: '#fafafa',
251 + },
252 + placeholderText: {
253 + marginTop: 8,
254 + fontSize: 12,
255 + fontWeight: '500',
256 + color: '#666',
257 + textAlign: 'center',
258 + },
259 + avatarActions: {
260 + display: 'flex',
261 + flexDirection: 'row',
262 + gap: 16,
263 + width: '100%',
264 + maxWidth: 320,
265 + justifyContent: 'center',
266 + flexWrap: 'wrap',
267 + },
268 + actionBtn: {
269 + paddingVertical: 12,
270 + paddingHorizontal: 24,
271 + borderRadius: 6,
272 + justifyContent: 'center',
273 + alignItems: 'center',
274 + minWidth: 140,
275 + },
276 + editBtn: {
277 + },
278 + editBtnText: {
279 + fontSize: 12,
280 + fontWeight: '600',
281 + color: Colors.light.textSecondary,
282 + letterSpacing: 0.5,
283 + textTransform: 'uppercase',
284 + },
285 + removeBtn: {
286 + },
287 + removeBtnText: {
288 + fontSize: 12,
289 + fontWeight: '600',
290 + color: Colors.light.danger,
291 + letterSpacing: 0.5,
292 + textTransform: 'uppercase',
293 + },
294 + });
295 +
296 + export default AvatarUpload;