More cleanup. More robust themed compononents

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

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,