More cleanup. More robust themed compononents

This commit is contained in:
Gabriel Brown 2025-03-11 11:33:46 -05:00
parent 86d1df3558
commit cfcf118275
6 changed files with 271 additions and 206 deletions

View File

@ -25,8 +25,8 @@ const SettingsScreen = () => {
> >
<IconSymbol name="person.fill" size={24} color="#007AFF" style={styles.icon} /> <IconSymbol name="person.fill" size={24} color="#007AFF" style={styles.icon} />
<ThemedView style={styles.settingContent}> <ThemedView style={styles.settingContent}>
<ThemedText style={styles.settingTitle}>Profile</ThemedText> <ThemedText style={styles.settingTitle}>Profile Settings</ThemedText>
<ThemedText style={styles.settingSubtitle}>Name, photo, email</ThemedText> <ThemedText style={styles.settingSubtitle}>Update profile information or sign out.</ThemedText>
</ThemedView> </ThemedView>
<IconSymbol name="chevron.right" size={20} color="#C7C7CC" /> <IconSymbol name="chevron.right" size={20} color="#C7C7CC" />
</TouchableOpacity> </TouchableOpacity>
@ -59,7 +59,7 @@ const styles = StyleSheet.create({
fontWeight: 'bold', fontWeight: 'bold',
}, },
section: { section: {
marginVertical: 16, marginVertical: 8,
borderRadius: 10, borderRadius: 10,
overflow: 'hidden', overflow: 'hidden',
}, },

View File

@ -1,59 +1,77 @@
import React, { useState, useEffect } from 'react'; import React, { useState, useEffect } from 'react';
import { StyleSheet, TouchableOpacity, Image, Alert, ActivityIndicator } from 'react-native'; import { StyleSheet, Alert, ActivityIndicator, ScrollView } from 'react-native';
import * as ImagePicker from 'expo-image-picker';
import { supabase } from '@/lib/supabase'; import { supabase } from '@/lib/supabase';
import { ThemedView, ThemedText, ThemedTextButton, ThemedTextInput } from '@/components/theme'; import { ThemedView, ThemedText, ThemedTextButton, ThemedTextInput } from '@/components/theme';
import { IconSymbol } from '@/components/ui/IconSymbol'; import ProfileAvatar from '@/components/auth/Profile_Avatar';
import Avatar from '@/components/auth/Profile_Avatar'; import LogoutButton from '@/components/auth/Logout_Button';
import { Session } from '@supabase/supabase-js' import { useFocusEffect } from '@react-navigation/native';
import Logout_Button from '@/components/auth/Logout_Button';
export default function ProfileScreen() { const ProfileScreen = () => {
const [loading, setLoading] = useState(false); const [loading, setLoading] = useState(true);
const [fullName, setFullName] = useState(''); const [updating, setUpdating] = useState(false);
const [email, setEmail] = useState(''); const [profile, setProfile] = useState({
const [avatar, setAvatar] = useState(null); full_name: '',
email: '',
avatar_url: null,
provider: ''
});
useEffect(() => { // Fetch profile when screen comes into focus
useFocusEffect(
React.useCallback(() => {
fetchUserProfile(); fetchUserProfile();
}, []); }, [])
);
const fetchUserProfile = async () => { const fetchUserProfile = async () => {
setLoading(true); setLoading(true);
try { try {
const { data: { user } } = await supabase.auth.getUser(); const { data: { user } } = await supabase.auth.getUser();
if (user) { if (!user) {
throw new Error('Not authenticated');
}
const { data, error } = await supabase const { data, error } = await supabase
.from('profiles') .from('profiles')
.select('*') .select('*')
.eq('id', user.id) .eq('id', user.id)
.single(); .single();
if (error) throw error;
if (data) { if (data) {
setFullName(data.full_name || ''); setProfile({
setEmail(data.email || ''); full_name: data.full_name || '',
setAvatar(data.avatar_url || null); email: data.email || '',
} avatar_url: data.avatar_url,
provider: data.provider || ''
});
} }
} catch (error) { } catch (error) {
console.error('Error fetching profile:', error); console.error('Error fetching profile:', error);
Alert.alert('Error', 'Failed to load profile information');
} finally { } finally {
setLoading(false); setLoading(false);
} }
}; };
const updateProfile = async () => { const updateProfile = async () => {
setLoading(true); setUpdating(true);
try { try {
const { data: { user } } = await supabase.auth.getUser(); 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 = { const updates = {
id: user.id, id: user.id,
full_name: fullName, full_name: profile.full_name.trim(),
email,
updated_at: new Date(), updated_at: new Date(),
}; };
@ -65,89 +83,95 @@ export default function ProfileScreen() {
Alert.alert('Success', 'Profile updated successfully!'); Alert.alert('Success', 'Profile updated successfully!');
} catch (error) { } catch (error) {
Alert.alert('Error updating profile', error.message); Alert.alert('Error', error instanceof Error ? error.message : 'Failed to update profile');
} finally { } 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 (
<ThemedView style={styles.loadingContainer}>
<ActivityIndicator size="large" />
</ThemedView>
);
}
return ( return (
<ScrollView contentContainerStyle={styles.scrollContainer}>
<ThemedView style={styles.container}> <ThemedView style={styles.container}>
<ProfileAvatar
<Avatar url={profile.avatar_url}
size={50} size={120}
url={avatar} onUpload={handleAvatarUpload}
onUpload={updateProfile} disabled={updating}
/> />
{profile.provider && (
<ThemedText style={styles.providerText}>
Signed in with {profile.provider.charAt(0).toUpperCase() + profile.provider.slice(1)}
</ThemedText>
)}
<ThemedView style={styles.formSection}> <ThemedView style={styles.formSection}>
<ThemedText style={styles.label}>Full Name</ThemedText> <ThemedText type='title' style={styles.label}>Name</ThemedText>
<ThemedTextInput <ThemedTextInput
value={fullName} value={profile.full_name}
onChangeText={setFullName} onChangeText={(text) => setProfile(prev => ({ ...prev, full_name: text }))}
placeholder="Enter your full name" placeholder="Enter your full name"
style={styles.input} style={styles.input}
editable={!updating}
/> />
</ThemedView> </ThemedView>
<ThemedTextButton <ThemedTextButton
text='Save Changes' text={updating ? 'Saving...' : 'Save Changes'}
onPress={updateProfile} onPress={updateProfile}
disabled={loading} disabled={updating || !profile.full_name.trim()}
fontSize={18} fontSize={18}
fontWeight='semibold' fontWeight='semibold'
width='90%' width='90%'
style={styles.saveButton} style={styles.saveButton}
/> />
<Logout_Button
<LogoutButton
fontSize={18} fontSize={18}
fontWeight='semibold' fontWeight='semibold'
width='90%' width='90%'
style={styles.logoutButton} style={styles.logoutButton}
/> />
</ThemedView> </ThemedView>
</ScrollView>
); );
} };
export default ProfileScreen;
const styles = StyleSheet.create({ const styles = StyleSheet.create({
scrollContainer: {
flexGrow: 1,
},
container: { container: {
flex: 1, flex: 1,
padding: 16, padding: 16,
alignItems: 'center', alignItems: 'center',
}, },
avatarContainer: { loadingContainer: {
alignItems: 'center', flex: 1,
marginTop: 20,
marginBottom: 30,
},
avatar: {
width: 120,
height: 120,
borderRadius: 60,
},
avatarPlaceholder: {
width: 120,
height: 120,
borderRadius: 60,
backgroundColor: '#E1E1E1',
justifyContent: 'center', justifyContent: 'center',
alignItems: 'center', alignItems: 'center',
}, },
changePhotoText: {
marginTop: 8,
color: '#007AFF',
fontSize: 16,
},
formSection: { formSection: {
marginBottom: 30, marginBottom: 20,
}, },
label: { label: {
marginBottom: 8, marginBottom: 8,
fontSize: 16, fontSize: 16,
fontWeight: '500',
}, },
input: { input: {
fontSize: 16, fontSize: 16,
@ -155,15 +179,24 @@ const styles = StyleSheet.create({
paddingHorizontal: 10, paddingHorizontal: 10,
borderRadius: 8, borderRadius: 8,
marginBottom: 20, marginBottom: 20,
width: '100%',
},
disabledInput: {
opacity: 0.7,
}, },
saveButton: { saveButton: {
borderRadius: 8, borderRadius: 8,
alignItems: 'center', alignItems: 'center',
marginBottom: 10,
}, },
logoutButton: { logoutButton: {
backgroundColor: 'red', marginTop: 30,
marginTop: 50,
borderRadius: 8, borderRadius: 8,
alignItems: 'center', alignItems: 'center',
}, },
providerText: {
marginBottom: 20,
fontSize: 14,
opacity: 0.7,
}
}); });

View File

@ -1,11 +1,8 @@
import { supabase } from '@/lib/supabase'; import { supabase } from '@/lib/supabase';
import { ThemedView, ThemedText, ThemedTextButton, ThemedTextInput } from '@/components/theme'; import { ThemedTextButton } from '@/components/theme';
import { Alert, StyleSheet, AppState } from 'react-native'; import { Alert, StyleSheet } from 'react-native';
import React from 'react'; import React from 'react';
import { TextStyle, PressableProps, DimensionValue } from 'react-native'; 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) // Extend ThemedButton props (which already extends PressableProps)
type ThemedTextButtonProps = Omit<PressableProps, 'children'> & { type ThemedTextButtonProps = Omit<PressableProps, 'children'> & {
@ -38,6 +35,8 @@ const Logout_Button: React.FC<ThemedTextButtonProps> = ({
text='Logout' text='Logout'
width={width} width={width}
height={height} height={height}
textColor='white'
backgroundColor='red'
fontSize={fontSize} fontSize={fontSize}
fontWeight={fontWeight} fontWeight={fontWeight}
containerStyle={containerStyle} containerStyle={containerStyle}
@ -48,5 +47,3 @@ const Logout_Button: React.FC<ThemedTextButtonProps> = ({
); );
}; };
export default Logout_Button; export default Logout_Button;
const styles = StyleSheet.create({});

View File

@ -1,124 +1,158 @@
import { useState, useEffect } from 'react' import { useState, useEffect } from 'react';
import { supabase } from '@/lib/supabase' import { supabase } from '@/lib/supabase';
import { StyleSheet, Alert, Image, TouchableOpacity } from 'react-native' import { StyleSheet, Alert, Image, TouchableOpacity, ActivityIndicator } from 'react-native';
import * as ImagePicker from 'expo-image-picker' import * as ImagePicker from 'expo-image-picker';
//import { ImageManipulator } from 'expo-image-manipulator'; import * as FileSystem from 'expo-file-system';
import { ThemedView, ThemedText, ThemedTextButton } from '../theme'; import { manipulateAsync, SaveFormat } from 'expo-image-manipulator';
import { ThemedView, ThemedText } from '../theme';
import { IconSymbol } from '@/components/ui/IconSymbol'; import { IconSymbol } from '@/components/ui/IconSymbol';
interface Props { interface AvatarProps {
size: number size?: number;
url: string | null url: string | null;
onUpload: (filePath: string) => void onUpload?: (filePath: string) => void;
disabled?: boolean;
} }
export default function Avatar({ url, size = 150, onUpload }: Props) { export default function ProfileAvatar({
const [uploading, setUploading] = useState(false) url,
const [avatarUrl, setAvatarUrl] = useState<string | null>(null) size = 120,
const avatarSize = { height: size, width: size } onUpload,
disabled = false
}: AvatarProps) {
const [uploading, setUploading] = useState(false);
const [avatarUrl, setAvatarUrl] = useState<string | null>(null);
useEffect(() => { useEffect(() => {
if (url) downloadImage(url) if (url) downloadImage(url);
}, [url]) }, [url]);
async function downloadImage(path: string) { async function downloadImage(path: string) {
try { try {
const { data, error } = await supabase.storage.from('avatars').download(path) const { data, error } = await supabase.storage.from('avatars').download(path);
if (error) { if (error) throw error;
throw error
}
const fr = new FileReader() const fr = new FileReader();
fr.readAsDataURL(data) fr.readAsDataURL(data);
fr.onload = () => { 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)
}
}
}; };
} catch (error) {
console.log('Error downloading image: ', error instanceof Error ? error.message : error);
}
}
async function uploadAvatar() { async function uploadAvatar() {
if (disabled || uploading) return;
try { try {
setUploading(true) setUploading(true);
// Get current user
const { data: { user } } = await supabase.auth.getUser(); const { data: { user } } = await supabase.auth.getUser();
if (!user) throw new Error('User not authenticated');
const result = await ImagePicker.launchImageLibraryAsync({ // Request permission if needed
mediaTypes: ImagePicker.MediaTypeOptions.Images, // Restrict to only images const permissionResult = await ImagePicker.requestMediaLibraryPermissionsAsync();
allowsMultipleSelection: false, // Can only select one image if (!permissionResult.granted) {
allowsEditing: true, // Allows the user to crop / rotate their photo before uploading it Alert.alert('Permission Required', 'Please allow access to your photo library to upload an avatar.');
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.');
return; return;
} }
//const manipulatedImage = await ImageManipulator.manipulate(result.assets[0].uri) // Launch image picker
//.resize({ width: 300 }) const result = await ImagePicker.launchImageLibraryAsync({
//.renderAsync(); mediaTypes: ImagePicker.MediaTypeOptions.Images,
allowsMultipleSelection: false,
allowsEditing: true,
aspect: [1, 1],
quality: 0.8,
exif: false,
});
//const manipulateResult = await manipulateAsync( if (result.canceled || !result.assets || result.assets.length === 0) {
//result.assets[0].uri, return;
//[{resize: {width: 300, height: 300}}], }
//{compress: 0.7, format: SaveFormat.JPEG}
//);
const image = result.assets[0]; const image = result.assets[0];
console.log('Got image', image)
if (!image.uri) { // Compress and resize the image
throw new Error('No image uri!') // Realistically, this should never happen, but just in case... const compressedImage = await manipulateAsync(
} image.uri,
[{ resize: { width: 300, height: 300 } }],
{ compress: 0.7, format: SaveFormat.JPEG }
);
const arraybuffer = await fetch(image.uri).then((res) => res.arrayBuffer()) // Get file info to check size
const fileInfo = await FileSystem.getInfoAsync(compressedImage.uri);
const fileExt = image.uri?.split('.').pop()?.toLowerCase() ?? 'jpeg' // Convert to array buffer for upload
const path = `${Date.now()}.${fileExt}` 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 const { data, error: uploadError } = await supabase.storage
.from('avatars') .from('avatars')
.upload(path, arraybuffer, { .upload(fileName, arraybuffer, {
contentType: image.mimeType ?? 'image/jpeg', 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') .from('profiles')
.update({ avatar_url: data?.path }) .update({
.eq('id', user?.id); avatar_url: data.path,
updated_at: new Date()
})
.eq('id', user.id);
if (uploadError) { if (updateError) throw updateError;
throw uploadError
} // 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!');
onUpload(data.path)
} catch (error) { } catch (error) {
if (error instanceof Error) { Alert.alert('Error uploading avatar', error instanceof Error ? error.message : 'An unknown error occurred');
Alert.alert(error.message)
} else {
throw error
}
} finally { } finally {
setUploading(false) setUploading(false);
}
} }
};
return ( return (
<TouchableOpacity <TouchableOpacity
onPress={uploadAvatar} onPress={uploadAvatar}
style={styles.avatarContainer} style={[styles.avatarContainer, { opacity: disabled ? 0.7 : 1 }]}
disabled={disabled || uploading}
> >
{avatarUrl ? ( {avatarUrl ? (
<Image source={{ uri: avatarUrl }} style={styles.avatar} /> <Image
source={{ uri: avatarUrl }}
style={[styles.avatar, { width: size, height: size, borderRadius: size / 2 }]}
/>
) : ( ) : (
<ThemedView style={styles.avatarPlaceholder}> <ThemedView style={[styles.avatarPlaceholder, { width: size, height: size, borderRadius: size / 2 }]}>
<IconSymbol name="person.fill" size={50} color="#999" /> <IconSymbol name="person.fill" size={size / 2.5} color="#999" />
</ThemedView> </ThemedView>
)} )}
<ThemedText style={styles.changePhotoText}>Change Photo</ThemedText>
{uploading ? (
<ActivityIndicator style={styles.uploadingIndicator} size="small" color="#007AFF" />
) : (
<ThemedText style={styles.changePhotoText}>
{disabled ? 'Avatar' : 'Change Photo'}
</ThemedText>
)}
</TouchableOpacity> </TouchableOpacity>
); );
} }
@ -126,18 +160,12 @@ export default function Avatar({ url, size = 150, onUpload }: Props) {
const styles = StyleSheet.create({ const styles = StyleSheet.create({
avatarContainer: { avatarContainer: {
alignItems: 'center', alignItems: 'center',
marginTop: 20, marginVertical: 20,
marginBottom: 30,
}, },
avatar: { avatar: {
width: 120, backgroundColor: '#E1E1E1',
height: 120,
borderRadius: 60,
}, },
avatarPlaceholder: { avatarPlaceholder: {
width: 120,
height: 120,
borderRadius: 60,
backgroundColor: '#E1E1E1', backgroundColor: '#E1E1E1',
justifyContent: 'center', justifyContent: 'center',
alignItems: 'center', alignItems: 'center',
@ -147,4 +175,7 @@ const styles = StyleSheet.create({
color: '#007AFF', color: '#007AFF',
fontSize: 16, fontSize: 16,
}, },
uploadingIndicator: {
marginTop: 8,
}
}); });

View File

@ -10,34 +10,34 @@ const DEFAULT_HEIGHT = 68;
type ThemedButtonProps = PressableProps & { type ThemedButtonProps = PressableProps & {
width?: DimensionValue; width?: DimensionValue;
height?: DimensionValue; height?: DimensionValue;
backgroundColor?: string;
containerStyle?: object; containerStyle?: object;
buttonStyle?: object; buttonStyle?: object;
}; };
const ThemedButton: React.FC<ThemedButtonProps> = ({ const ThemedButton: React.FC<ThemedButtonProps> = ({
width, width = DEFAULT_WIDTH,
height, height = DEFAULT_HEIGHT,
backgroundColor = Colors[useColorScheme() ?? 'dark'].text,
children, children,
containerStyle, containerStyle,
buttonStyle, buttonStyle,
style, style,
...restProps // This now includes onPress automatically ...restProps // This now includes onPress automatically
}) => { }) => {
const scheme = useColorScheme() ?? 'dark';
return ( return (
<ThemedView <ThemedView
style={[ style={[
styles.buttonContainer, styles.buttonContainer,
{ {
width: width ?? DEFAULT_WIDTH, width,
height: height ?? DEFAULT_HEIGHT, height,
}, },
containerStyle, containerStyle,
]} ]}
> >
<Pressable <Pressable
style={[styles.button, { backgroundColor: Colors[scheme].text }, buttonStyle, style]} style={[styles.button, { backgroundColor }, buttonStyle, style]}
{...restProps} // This passes onPress and all other Pressable props {...restProps} // This passes onPress and all other Pressable props
> >
{children} {children}

View File

@ -15,6 +15,8 @@ type ThemedTextButtonProps = Omit<PressableProps, 'children'> & {
textStyle?: TextStyle; textStyle?: TextStyle;
containerStyle?: object; containerStyle?: object;
buttonStyle?: object; buttonStyle?: object;
textColor?: string;
backgroundColor?: string;
}; };
const ThemedTextButton: React.FC<ThemedTextButtonProps> = ({ const ThemedTextButton: React.FC<ThemedTextButtonProps> = ({
@ -26,9 +28,10 @@ const ThemedTextButton: React.FC<ThemedTextButtonProps> = ({
textStyle, textStyle,
containerStyle, containerStyle,
buttonStyle, buttonStyle,
textColor = Colors[useColorScheme() ?? 'dark'].background,
backgroundColor = Colors[useColorScheme() ?? 'dark'].text,
...restProps // This includes onPress and all other Pressable props ...restProps // This includes onPress and all other Pressable props
}) => { }) => {
const scheme = useColorScheme() ?? 'dark';
if (fontWeight === 'semibold') fontWeight = '600'; if (fontWeight === 'semibold') fontWeight = '600';
return ( return (
@ -37,12 +40,13 @@ const ThemedTextButton: React.FC<ThemedTextButtonProps> = ({
height={height} height={height}
containerStyle={containerStyle} containerStyle={containerStyle}
buttonStyle={buttonStyle} buttonStyle={buttonStyle}
backgroundColor={backgroundColor}
{...restProps} {...restProps}
> >
<ThemedText <ThemedText
style={[ style={[
{ {
color: Colors[scheme].background, color: textColor,
fontSize, fontSize,
lineHeight: fontSize * 1.5, lineHeight: fontSize * 1.5,
fontWeight, fontWeight,