frontend: introduce avatar upload component
Pedro Lucas Porcellis porcellis@eletrotupi.com · 5 months ago
7315aa48399c26dd3cc6e78613bbd578a8e8d2b7 Browse filesParents
a5fffadChanged files
1
Additions
+296
Deletions
0
/dev/null → frontend/components/ui/AvatarUpload.tsx
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;