More cleanup. More robust themed compononents
This commit is contained in:
@ -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({});
|
||||
|
@ -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,
|
||||
}
|
||||
});
|
||||
|
@ -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}
|
||||
|
@ -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,
|
||||
|
Reference in New Issue
Block a user