From cfcf1182752ac42c6c0450d082bf9e9a28f86186 Mon Sep 17 00:00:00 2001 From: gibbyb Date: Tue, 11 Mar 2025 11:33:46 -0500 Subject: [PATCH] More cleanup. More robust themed compononents --- app/(tabs)/settings/index.tsx | 6 +- app/(tabs)/settings/profile.tsx | 217 +++++++++-------- components/auth/Logout_Button.tsx | 11 +- components/auth/Profile_Avatar.tsx | 221 ++++++++++-------- components/theme/buttons/ThemedButton.tsx | 14 +- components/theme/buttons/ThemedTextButton.tsx | 8 +- 6 files changed, 271 insertions(+), 206 deletions(-) diff --git a/app/(tabs)/settings/index.tsx b/app/(tabs)/settings/index.tsx index 8cf6a73..d12e21a 100644 --- a/app/(tabs)/settings/index.tsx +++ b/app/(tabs)/settings/index.tsx @@ -25,8 +25,8 @@ const SettingsScreen = () => { > - Profile - Name, photo, email + Profile Settings + Update profile information or sign out. @@ -59,7 +59,7 @@ const styles = StyleSheet.create({ fontWeight: 'bold', }, section: { - marginVertical: 16, + marginVertical: 8, borderRadius: 10, overflow: 'hidden', }, diff --git a/app/(tabs)/settings/profile.tsx b/app/(tabs)/settings/profile.tsx index 6c4dcd0..b5aa178 100644 --- a/app/(tabs)/settings/profile.tsx +++ b/app/(tabs)/settings/profile.tsx @@ -1,153 +1,177 @@ import React, { useState, useEffect } from 'react'; -import { StyleSheet, TouchableOpacity, Image, Alert, ActivityIndicator } from 'react-native'; -import * as ImagePicker from 'expo-image-picker'; +import { StyleSheet, Alert, ActivityIndicator, ScrollView } from 'react-native'; import { supabase } from '@/lib/supabase'; import { ThemedView, ThemedText, ThemedTextButton, ThemedTextInput } from '@/components/theme'; -import { IconSymbol } from '@/components/ui/IconSymbol'; -import Avatar from '@/components/auth/Profile_Avatar'; -import { Session } from '@supabase/supabase-js' -import Logout_Button from '@/components/auth/Logout_Button'; +import ProfileAvatar from '@/components/auth/Profile_Avatar'; +import LogoutButton from '@/components/auth/Logout_Button'; +import { useFocusEffect } from '@react-navigation/native'; -export default function ProfileScreen() { - const [loading, setLoading] = useState(false); - const [fullName, setFullName] = useState(''); - const [email, setEmail] = useState(''); - const [avatar, setAvatar] = useState(null); - - useEffect(() => { - fetchUserProfile(); - }, []); +const ProfileScreen = () => { + const [loading, setLoading] = useState(true); + const [updating, setUpdating] = useState(false); + const [profile, setProfile] = useState({ + full_name: '', + email: '', + avatar_url: null, + provider: '' + }); + + // Fetch profile when screen comes into focus + useFocusEffect( + React.useCallback(() => { + fetchUserProfile(); + }, []) + ); const fetchUserProfile = async () => { setLoading(true); try { const { data: { user } } = await supabase.auth.getUser(); - if (user) { - const { data, error } = await supabase - .from('profiles') - .select('*') - .eq('id', user.id) - .single(); - - if (data) { - setFullName(data.full_name || ''); - setEmail(data.email || ''); - setAvatar(data.avatar_url || null); - } + if (!user) { + throw new Error('Not authenticated'); + } + + const { data, error } = await supabase + .from('profiles') + .select('*') + .eq('id', user.id) + .single(); + + if (error) throw error; + + if (data) { + setProfile({ + full_name: data.full_name || '', + email: data.email || '', + avatar_url: data.avatar_url, + provider: data.provider || '' + }); } } catch (error) { console.error('Error fetching profile:', error); + Alert.alert('Error', 'Failed to load profile information'); } finally { setLoading(false); } }; const updateProfile = async () => { - setLoading(true); + setUpdating(true); try { const { data: { user } } = await supabase.auth.getUser(); - if (!user) throw new Error('User not found'); + if (!user) throw new Error('Not authenticated'); + + // Validate input + if (!profile.full_name.trim()) { + Alert.alert('Error', 'Please enter your full name'); + return; + } const updates = { id: user.id, - full_name: fullName, - email, + full_name: profile.full_name.trim(), updated_at: new Date(), }; const { error } = await supabase .from('profiles') .upsert(updates); - + if (error) throw error; Alert.alert('Success', 'Profile updated successfully!'); } catch (error) { - Alert.alert('Error updating profile', error.message); + Alert.alert('Error', error instanceof Error ? error.message : 'Failed to update profile'); } finally { - setLoading(false); + setUpdating(false); } }; - // Add image picking functionality here (similar to previous example) + const handleAvatarUpload = () => { + // Refresh profile data after avatar upload + fetchUserProfile(); + }; + + if (loading) { + return ( + + + + ); + } return ( - - - - - - Full Name - + + + {profile.provider && ( + + Signed in with {profile.provider.charAt(0).toUpperCase() + profile.provider.slice(1)} + + )} + + + Name + setProfile(prev => ({ ...prev, full_name: text }))} + placeholder="Enter your full name" + style={styles.input} + editable={!updating} + /> + + + + + - - - - - + ); -} +}; +export default ProfileScreen; const styles = StyleSheet.create({ + scrollContainer: { + flexGrow: 1, + }, container: { flex: 1, padding: 16, alignItems: 'center', }, - avatarContainer: { - alignItems: 'center', - marginTop: 20, - marginBottom: 30, - }, - avatar: { - width: 120, - height: 120, - borderRadius: 60, - }, - avatarPlaceholder: { - width: 120, - height: 120, - borderRadius: 60, - backgroundColor: '#E1E1E1', + loadingContainer: { + flex: 1, justifyContent: 'center', alignItems: 'center', }, - changePhotoText: { - marginTop: 8, - color: '#007AFF', - fontSize: 16, - }, formSection: { - marginBottom: 30, + marginBottom: 20, }, label: { marginBottom: 8, fontSize: 16, + fontWeight: '500', }, input: { fontSize: 16, @@ -155,15 +179,24 @@ const styles = StyleSheet.create({ paddingHorizontal: 10, borderRadius: 8, marginBottom: 20, + width: '100%', + }, + disabledInput: { + opacity: 0.7, }, saveButton: { borderRadius: 8, alignItems: 'center', + marginBottom: 10, }, logoutButton: { - backgroundColor: 'red', - marginTop: 50, + marginTop: 30, borderRadius: 8, alignItems: 'center', }, + providerText: { + marginBottom: 20, + fontSize: 14, + opacity: 0.7, + } }); diff --git a/components/auth/Logout_Button.tsx b/components/auth/Logout_Button.tsx index fd51a3a..52b5524 100644 --- a/components/auth/Logout_Button.tsx +++ b/components/auth/Logout_Button.tsx @@ -1,11 +1,8 @@ import { supabase } from '@/lib/supabase'; -import { ThemedView, ThemedText, ThemedTextButton, ThemedTextInput } from '@/components/theme'; -import { Alert, StyleSheet, AppState } from 'react-native'; +import { ThemedTextButton } from '@/components/theme'; +import { Alert, StyleSheet } from 'react-native'; import React from 'react'; import { TextStyle, PressableProps, DimensionValue } from 'react-native'; -import ThemedButton from '@/components/theme/buttons/ThemedButton'; -import { Colors } from '@/constants/Colors'; -import { useColorScheme } from '@/hooks/useColorScheme'; // Extend ThemedButton props (which already extends PressableProps) type ThemedTextButtonProps = Omit & { @@ -38,6 +35,8 @@ const Logout_Button: React.FC = ({ text='Logout' width={width} height={height} + textColor='white' + backgroundColor='red' fontSize={fontSize} fontWeight={fontWeight} containerStyle={containerStyle} @@ -48,5 +47,3 @@ const Logout_Button: React.FC = ({ ); }; export default Logout_Button; - -const styles = StyleSheet.create({}); diff --git a/components/auth/Profile_Avatar.tsx b/components/auth/Profile_Avatar.tsx index be39af2..82b455d 100644 --- a/components/auth/Profile_Avatar.tsx +++ b/components/auth/Profile_Avatar.tsx @@ -1,124 +1,158 @@ -import { useState, useEffect } from 'react' -import { supabase } from '@/lib/supabase' -import { StyleSheet, Alert, Image, TouchableOpacity } from 'react-native' -import * as ImagePicker from 'expo-image-picker' -//import { ImageManipulator } from 'expo-image-manipulator'; -import { ThemedView, ThemedText, ThemedTextButton } from '../theme'; +import { useState, useEffect } from 'react'; +import { supabase } from '@/lib/supabase'; +import { StyleSheet, Alert, Image, TouchableOpacity, ActivityIndicator } from 'react-native'; +import * as ImagePicker from 'expo-image-picker'; +import * as FileSystem from 'expo-file-system'; +import { manipulateAsync, SaveFormat } from 'expo-image-manipulator'; +import { ThemedView, ThemedText } from '../theme'; import { IconSymbol } from '@/components/ui/IconSymbol'; -interface Props { - size: number - url: string | null - onUpload: (filePath: string) => void +interface AvatarProps { + size?: number; + url: string | null; + onUpload?: (filePath: string) => void; + disabled?: boolean; } -export default function Avatar({ url, size = 150, onUpload }: Props) { - const [uploading, setUploading] = useState(false) - const [avatarUrl, setAvatarUrl] = useState(null) - const avatarSize = { height: size, width: size } - +export default function ProfileAvatar({ + url, + size = 120, + onUpload, + disabled = false +}: AvatarProps) { + const [uploading, setUploading] = useState(false); + const [avatarUrl, setAvatarUrl] = useState(null); + useEffect(() => { - if (url) downloadImage(url) - }, [url]) + if (url) downloadImage(url); + }, [url]); async function downloadImage(path: string) { try { - const { data, error } = await supabase.storage.from('avatars').download(path) - - if (error) { - throw error - } - - const fr = new FileReader() - fr.readAsDataURL(data) + const { data, error } = await supabase.storage.from('avatars').download(path); + + if (error) throw error; + + const fr = new FileReader(); + fr.readAsDataURL(data); fr.onload = () => { - setAvatarUrl(fr.result as string) - } + setAvatarUrl(fr.result as string); + }; } catch (error) { - if (error instanceof Error) { - console.log('Error downloading image: ', error.message) - } + console.log('Error downloading image: ', error instanceof Error ? error.message : error); } - }; + } async function uploadAvatar() { + if (disabled || uploading) return; + try { - setUploading(true) + setUploading(true); + + // Get current user const { data: { user } } = await supabase.auth.getUser(); - - const result = await ImagePicker.launchImageLibraryAsync({ - mediaTypes: ImagePicker.MediaTypeOptions.Images, // Restrict to only images - allowsMultipleSelection: false, // Can only select one image - allowsEditing: true, // Allows the user to crop / rotate their photo before uploading it - quality: 1, - exif: false, // We don't want nor need that data. - }); - - if (result.canceled || !result.assets || result.assets.length === 0) { - console.log('User cancelled image picker.'); + if (!user) throw new Error('User not authenticated'); + + // Request permission if needed + const permissionResult = await ImagePicker.requestMediaLibraryPermissionsAsync(); + if (!permissionResult.granted) { + Alert.alert('Permission Required', 'Please allow access to your photo library to upload an avatar.'); return; } - - //const manipulatedImage = await ImageManipulator.manipulate(result.assets[0].uri) - //.resize({ width: 300 }) - //.renderAsync(); - - //const manipulateResult = await manipulateAsync( - //result.assets[0].uri, - //[{resize: {width: 300, height: 300}}], - //{compress: 0.7, format: SaveFormat.JPEG} - //); - - const image = result.assets[0]; - console.log('Got image', image) - - if (!image.uri) { - throw new Error('No image uri!') // Realistically, this should never happen, but just in case... + + // Launch image picker + const result = await ImagePicker.launchImageLibraryAsync({ + mediaTypes: ImagePicker.MediaTypeOptions.Images, + allowsMultipleSelection: false, + allowsEditing: true, + aspect: [1, 1], + quality: 0.8, + exif: false, + }); + + if (result.canceled || !result.assets || result.assets.length === 0) { + return; } - - const arraybuffer = await fetch(image.uri).then((res) => res.arrayBuffer()) - - const fileExt = image.uri?.split('.').pop()?.toLowerCase() ?? 'jpeg' - const path = `${Date.now()}.${fileExt}` + + const image = result.assets[0]; + + // Compress and resize the image + const compressedImage = await manipulateAsync( + image.uri, + [{ resize: { width: 300, height: 300 } }], + { compress: 0.7, format: SaveFormat.JPEG } + ); + + // Get file info to check size + const fileInfo = await FileSystem.getInfoAsync(compressedImage.uri); + + // Convert to array buffer for upload + const arraybuffer = await fetch(compressedImage.uri).then((res) => res.arrayBuffer()); + + // Generate unique filename + const fileExt = compressedImage.uri.split('.').pop()?.toLowerCase() ?? 'jpg'; + const fileName = `${user.id}_${Date.now()}.${fileExt}`; + + // Upload to Supabase Storage const { data, error: uploadError } = await supabase.storage .from('avatars') - .upload(path, arraybuffer, { - contentType: image.mimeType ?? 'image/jpeg', + .upload(fileName, arraybuffer, { + contentType: `image/${fileExt}`, + upsert: true, }); - const { data: updateData, error: updateError } = await supabase + + if (uploadError) throw uploadError; + + // Update user profile with new avatar URL + const { error: updateError } = await supabase .from('profiles') - .update({ avatar_url: data?.path }) - .eq('id', user?.id); - - if (uploadError) { - throw uploadError - } - - onUpload(data.path) + .update({ + avatar_url: data.path, + updated_at: new Date() + }) + .eq('id', user.id); + + if (updateError) throw updateError; + + // Set the new avatar URL + setAvatarUrl(compressedImage.uri); + + // Call the onUpload callback if provided + if (onUpload) onUpload(data.path); + + Alert.alert('Success', 'Avatar updated successfully!'); + } catch (error) { - if (error instanceof Error) { - Alert.alert(error.message) - } else { - throw error - } + Alert.alert('Error uploading avatar', error instanceof Error ? error.message : 'An unknown error occurred'); } finally { - setUploading(false) + setUploading(false); } - }; + } return ( {avatarUrl ? ( - + ) : ( - - + + )} - Change Photo + + {uploading ? ( + + ) : ( + + {disabled ? 'Avatar' : 'Change Photo'} + + )} ); } @@ -126,18 +160,12 @@ export default function Avatar({ url, size = 150, onUpload }: Props) { const styles = StyleSheet.create({ avatarContainer: { alignItems: 'center', - marginTop: 20, - marginBottom: 30, + marginVertical: 20, }, avatar: { - width: 120, - height: 120, - borderRadius: 60, + backgroundColor: '#E1E1E1', }, avatarPlaceholder: { - width: 120, - height: 120, - borderRadius: 60, backgroundColor: '#E1E1E1', justifyContent: 'center', alignItems: 'center', @@ -147,4 +175,7 @@ const styles = StyleSheet.create({ color: '#007AFF', fontSize: 16, }, + uploadingIndicator: { + marginTop: 8, + } }); diff --git a/components/theme/buttons/ThemedButton.tsx b/components/theme/buttons/ThemedButton.tsx index 24f0726..a3c6ee1 100644 --- a/components/theme/buttons/ThemedButton.tsx +++ b/components/theme/buttons/ThemedButton.tsx @@ -10,34 +10,34 @@ const DEFAULT_HEIGHT = 68; type ThemedButtonProps = PressableProps & { width?: DimensionValue; height?: DimensionValue; + backgroundColor?: string; containerStyle?: object; buttonStyle?: object; }; const ThemedButton: React.FC = ({ - width, - height, + width = DEFAULT_WIDTH, + height = DEFAULT_HEIGHT, + backgroundColor = Colors[useColorScheme() ?? 'dark'].text, children, containerStyle, buttonStyle, style, ...restProps // This now includes onPress automatically }) => { - const scheme = useColorScheme() ?? 'dark'; - return ( {children} diff --git a/components/theme/buttons/ThemedTextButton.tsx b/components/theme/buttons/ThemedTextButton.tsx index 1be37fa..8f9620b 100644 --- a/components/theme/buttons/ThemedTextButton.tsx +++ b/components/theme/buttons/ThemedTextButton.tsx @@ -15,6 +15,8 @@ type ThemedTextButtonProps = Omit & { textStyle?: TextStyle; containerStyle?: object; buttonStyle?: object; + textColor?: string; + backgroundColor?: string; }; const ThemedTextButton: React.FC = ({ @@ -26,9 +28,10 @@ const ThemedTextButton: React.FC = ({ textStyle, containerStyle, buttonStyle, + textColor = Colors[useColorScheme() ?? 'dark'].background, + backgroundColor = Colors[useColorScheme() ?? 'dark'].text, ...restProps // This includes onPress and all other Pressable props }) => { - const scheme = useColorScheme() ?? 'dark'; if (fontWeight === 'semibold') fontWeight = '600'; return ( @@ -37,12 +40,13 @@ const ThemedTextButton: React.FC = ({ height={height} containerStyle={containerStyle} buttonStyle={buttonStyle} + backgroundColor={backgroundColor} {...restProps} >