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

View File

@ -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 (
<ThemedView style={styles.loadingContainer}>
<ActivityIndicator size="large" />
</ThemedView>
);
}
return (
<ThemedView style={styles.container}>
<Avatar
size={50}
url={avatar}
onUpload={updateProfile}
/>
<ThemedView style={styles.formSection}>
<ThemedText style={styles.label}>Full Name</ThemedText>
<ThemedTextInput
value={fullName}
onChangeText={setFullName}
placeholder="Enter your full name"
style={styles.input}
<ScrollView contentContainerStyle={styles.scrollContainer}>
<ThemedView style={styles.container}>
<ProfileAvatar
url={profile.avatar_url}
size={120}
onUpload={handleAvatarUpload}
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}>
<ThemedText type='title' style={styles.label}>Name</ThemedText>
<ThemedTextInput
value={profile.full_name}
onChangeText={(text) => setProfile(prev => ({ ...prev, full_name: text }))}
placeholder="Enter your full name"
style={styles.input}
editable={!updating}
/>
</ThemedView>
<ThemedTextButton
text={updating ? 'Saving...' : 'Save Changes'}
onPress={updateProfile}
disabled={updating || !profile.full_name.trim()}
fontSize={18}
fontWeight='semibold'
width='90%'
style={styles.saveButton}
/>
<LogoutButton
fontSize={18}
fontWeight='semibold'
width='90%'
style={styles.logoutButton}
/>
</ThemedView>
<ThemedTextButton
text='Save Changes'
onPress={updateProfile}
disabled={loading}
fontSize={18}
fontWeight='semibold'
width='90%'
style={styles.saveButton}
/>
<Logout_Button
fontSize={18}
fontWeight='semibold'
width='90%'
style={styles.logoutButton}
/>
</ThemedView>
</ScrollView>
);
}
};
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,
}
});

View File

@ -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<PressableProps, 'children'> & {
@ -38,6 +35,8 @@ const Logout_Button: React.FC<ThemedTextButtonProps> = ({
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<ThemedTextButtonProps> = ({
);
};
export default Logout_Button;
const styles = StyleSheet.create({});

View File

@ -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<string | null>(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<string | null>(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 (
<TouchableOpacity
onPress={uploadAvatar}
style={styles.avatarContainer}
style={[styles.avatarContainer, { opacity: disabled ? 0.7 : 1 }]}
disabled={disabled || uploading}
>
{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}>
<IconSymbol name="person.fill" size={50} color="#999" />
<ThemedView style={[styles.avatarPlaceholder, { width: size, height: size, borderRadius: size / 2 }]}>
<IconSymbol name="person.fill" size={size / 2.5} color="#999" />
</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>
);
}
@ -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,
}
});

View File

@ -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<ThemedButtonProps> = ({
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 (
<ThemedView
style={[
styles.buttonContainer,
{
width: width ?? DEFAULT_WIDTH,
height: height ?? DEFAULT_HEIGHT,
width,
height,
},
containerStyle,
]}
>
<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
>
{children}

View File

@ -15,6 +15,8 @@ type ThemedTextButtonProps = Omit<PressableProps, 'children'> & {
textStyle?: TextStyle;
containerStyle?: object;
buttonStyle?: object;
textColor?: string;
backgroundColor?: string;
};
const ThemedTextButton: React.FC<ThemedTextButtonProps> = ({
@ -26,9 +28,10 @@ const ThemedTextButton: React.FC<ThemedTextButtonProps> = ({
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<ThemedTextButtonProps> = ({
height={height}
containerStyle={containerStyle}
buttonStyle={buttonStyle}
backgroundColor={backgroundColor}
{...restProps}
>
<ThemedText
style={[
{
color: Colors[scheme].background,
color: textColor,
fontSize,
lineHeight: fontSize * 1.5,
fontWeight,