Add profile photo to onboarding flow

This commit is contained in:
jjbrunton committed 2026-01-17 19:22:36 +00:00
1 parent 349f460e15
commit b070a6f6a2
15 files changed
+731 -210

No files matched your search

+26 -31
View File
@@ -1,17 +1,18 @@
import React from 'react';
import { StyleSheet, View, Text, TouchableOpacity, Switch, Platform } from 'react-native';
import { LinearGradient } from 'expo-linear-gradient';
import { BlurView } from 'expo-blur';
import { Ionicons } from '@expo/vector-icons';
import Animated, {
useAnimatedStyle,
withSpring,
useSharedValue,
FadeIn,
} from 'react-native-reanimated';
import { ShimmerEffect } from './ui/ShimmerEffect';
import { colors, gradients, spacing, radius, typography, blur, shadows, animations } from '../theme';
import type { QuestionPack } from '../types';
import React from 'react';
import { StyleSheet, View, Text, TouchableOpacity, Platform } from 'react-native';
import { LinearGradient } from 'expo-linear-gradient';
import { BlurView } from 'expo-blur';
import { Ionicons } from '@expo/vector-icons';
import Animated, {
useAnimatedStyle,
withSpring,
useSharedValue,
FadeIn,
} from 'react-native-reanimated';
import { ShimmerEffect } from './ui/ShimmerEffect';
import { GlassToggle } from './ui/GlassToggle';
import { colors, gradients, spacing, radius, typography, blur, shadows, animations } from '../theme';
import type { QuestionPack } from '../types';
// Boutique gradient palette for pack cards
const BOUTIQUE_GRADIENTS: Array<readonly [string, string]> = [
@@ -147,23 +148,17 @@ export function BoutiquePackCard({
<View style={styles.footer}>
<View style={styles.questionBadge}>
<Text style={styles.questionCount}>{questionCount}</Text>
<Text style={styles.questionLabel}>questions</Text>
</View>
<Switch
value={isEnabled}
onValueChange={onToggle}
disabled={isPremiumLocked}
trackColor={{
false: 'rgba(255, 255, 255, 0.15)',
true: pack.is_premium ? colors.premium.gold : colors.primary,
}}
thumbColor={colors.text}
ios_backgroundColor="rgba(255, 255, 255, 0.15)"
style={isPremiumLocked ? { opacity: 0.4 } : undefined}
/>
</View>
</View>
<Text style={styles.questionLabel}>questions</Text>
</View>
<GlassToggle
value={isEnabled}
onValueChange={onToggle}
disabled={isPremiumLocked}
style={isPremiumLocked ? { opacity: 0.4 } : undefined}
/>
</View>
</View>
{/* Lock overlay for premium locked packs */}
{isPremiumLocked && (
@@ -0,0 +1,139 @@
import React, { useEffect } from 'react';
import { StyleSheet, Pressable, ViewStyle, Platform } from 'react-native';
import { LinearGradient } from 'expo-linear-gradient';
import Animated, {
useSharedValue,
useAnimatedStyle,
withSpring,
interpolateColor,
} from 'react-native-reanimated';
import { colors, gradients, radius, animations } from '../../theme';
// Haptics is not supported on web
const triggerHaptic = async () => {
if (Platform.OS === 'web') return;
const Haptics = await import('expo-haptics');
await Haptics.impactAsync(Haptics.ImpactFeedbackStyle.Light);
};
interface GlassToggleProps {
value: boolean;
onValueChange: (value: boolean) => void;
disabled?: boolean;
style?: ViewStyle;
}
const TRACK_WIDTH = 52;
const TRACK_HEIGHT = 30;
const THUMB_SIZE = 26;
const PADDING = 2;
export function GlassToggle({
value,
onValueChange,
disabled = false,
style,
}: GlassToggleProps) {
// Shared values for animation
const progress = useSharedValue(value ? 1 : 0);
// Sync shared value when prop changes
useEffect(() => {
progress.value = withSpring(value ? 1 : 0, {
mass: 0.8,
damping: 15,
stiffness: 120,
});
}, [value]);
const handlePress = async () => {
if (disabled) return;
await triggerHaptic();
onValueChange(!value);
};
const thumbStyle = useAnimatedStyle(() => {
return {
transform: [
{
translateX: progress.value * (TRACK_WIDTH - THUMB_SIZE - (PADDING * 2)),
},
],
};
});
const trackStyle = useAnimatedStyle(() => {
const backgroundColor = interpolateColor(
progress.value,
[0, 1],
['rgba(255, 255, 255, 0.1)', 'transparent'] // We use LinearGradient for active state
);
return {
backgroundColor,
};
});
const activeGradientStyle = useAnimatedStyle(() => {
return {
opacity: progress.value,
};
});
return (
<Pressable
onPress={handlePress}
disabled={disabled}
style={[styles.container, style, disabled && styles.disabled]}
>
<Animated.View style={[styles.track, trackStyle]}>
{/* Active Gradient Background (fades in) */}
<Animated.View style={[StyleSheet.absoluteFill, activeGradientStyle]}>
<LinearGradient
colors={gradients.primary as [string, string, ...string[]]}
start={{ x: 0, y: 0 }}
end={{ x: 1, y: 0 }}
style={StyleSheet.absoluteFill}
/>
</Animated.View>
{/* Thumb */}
<Animated.View style={[styles.thumb, thumbStyle]} />
</Animated.View>
</Pressable>
);
}
const styles = StyleSheet.create({
container: {
width: TRACK_WIDTH,
height: TRACK_HEIGHT,
},
track: {
width: '100%',
height: '100%',
borderRadius: radius.full,
borderWidth: 1,
borderColor: 'rgba(255, 255, 255, 0.1)',
overflow: 'hidden',
justifyContent: 'center',
padding: PADDING,
},
thumb: {
width: THUMB_SIZE,
height: THUMB_SIZE,
borderRadius: radius.full,
backgroundColor: '#FFFFFF',
shadowColor: '#000',
shadowOffset: {
width: 0,
height: 2,
},
shadowOpacity: 0.25,
shadowRadius: 3.84,
elevation: 5,
},
disabled: {
opacity: 0.5,
},
});
+1
View File
@@ -10,5 +10,6 @@ export { LoadingOverlay } from './LoadingOverlay';
export type { LoadingOverlayProps, LoadingVariant } from './LoadingOverlay';
export { ReadReceipt, getReceiptStatus } from './ReadReceipt';
export type { ReadReceiptProps, ReceiptStatus } from './ReadReceipt';
export { GlassToggle } from './GlassToggle';
export { AmbientOrbs } from './AmbientOrbs';
export type { AmbientOrbsProps, OrbVariant, OrbPosition } from './AmbientOrbs';
@@ -1,8 +1,9 @@
import React from 'react';
import { View, Text, Switch, StyleSheet } from 'react-native';
import { View, Text, StyleSheet } from 'react-native';
import { LinearGradient } from 'expo-linear-gradient';
import { Ionicons } from '@expo/vector-icons';
import { colors, spacing, radius, typography, featureColors } from '../../../theme';
import { GlassToggle } from '../../../components/ui';
export interface SwitchItemProps {
/** Icon name (Ionicons) */
@@ -20,7 +21,6 @@ export interface SwitchItemProps {
}
const ACCENT_GRADIENT = featureColors.profile.gradient as [string, string];
const ACCENT_COLOR = featureColors.profile.accent;
/**
* Reusable toggle switch row for settings screens.
@@ -49,13 +49,10 @@ export function SwitchItem({
<Text style={styles.description}>{description}</Text>
</View>
</View>
<Switch
<GlassToggle
value={value}
onValueChange={onValueChange}
disabled={disabled}
trackColor={{ false: colors.glass.border, true: colors.secondaryLight }}
thumbColor={value ? ACCENT_COLOR : colors.textTertiary}
ios_backgroundColor={colors.glass.border}
/>
</View>
);
@@ -1,11 +1,9 @@
import { useState, useEffect, useCallback } from 'react';
import { Alert, Platform, ActionSheetIOS, Linking } from 'react-native';
import * as ImagePicker from 'expo-image-picker';
import * as FileSystem from 'expo-file-system';
import { decode } from 'base64-arraybuffer';
import { Alert, Linking, Platform } from 'react-native';
import { useAuthStore, useSubscriptionStore, usePacksStore } from '../../../store';
import { supabase } from '../../../lib/supabase';
import { Events } from '../../../lib/analytics';
import { useAvatarPicker } from '../../../hooks/useAvatarPicker';
import type { IntensityLevel } from '../../../types';
import {
registerForPushNotificationsAsync,
@@ -45,8 +43,42 @@ export function useProfileSettings() {
const [newName, setNewName] = useState(user?.name || "");
const [isUpdatingName, setIsUpdatingName] = useState(false);
// Avatar
const [isUploadingAvatar, setIsUploadingAvatar] = useState(false);
// Avatar - using shared hook with immediate upload on select
const handleAvatarUploadComplete = useCallback(async (publicUrl: string) => {
if (!user?.id) return;
try {
const { error: updateError } = await supabase
.from('profiles')
.update({
avatar_url: publicUrl,
updated_at: new Date().toISOString(),
})
.eq('id', user.id);
if (updateError) throw updateError;
await fetchUser();
Events.avatarUploaded();
} catch (error) {
console.error('Error updating profile with avatar:', error);
Alert.alert('Error', 'Failed to save photo. Please try again.');
}
}, [user?.id, fetchUser]);
const handleAvatarUploadError = useCallback(() => {
Alert.alert('Error', 'Failed to upload photo. Please try again.');
}, []);
const {
isUploading: isUploadingAvatar,
showPicker: handleAvatarPress,
} = useAvatarPicker({
userId: user?.id,
autoUpload: true,
onUploadComplete: handleAvatarUploadComplete,
onUploadError: handleAvatarUploadError,
});
// Preferences
const [maxIntensity, setMaxIntensity] = useState<IntensityLevel>(() => getProfileMaxIntensity(user));
@@ -136,140 +168,6 @@ export function useProfileSettings() {
setIsEditingName(false);
}, [user?.name]);
// Avatar Actions
const handleAvatarPress = () => {
if (Platform.OS === 'ios') {
ActionSheetIOS.showActionSheetWithOptions(
{
options: ['Cancel', 'Take Photo', 'Choose from Library'],
cancelButtonIndex: 0,
},
(buttonIndex) => {
if (buttonIndex === 1) {
pickAvatar('camera');
} else if (buttonIndex === 2) {
pickAvatar('library');
}
}
);
} else {
Alert.alert(
'Change Profile Photo',
'Choose an option',
[
{ text: 'Cancel', style: 'cancel' },
{ text: 'Take Photo', onPress: () => pickAvatar('camera') },
{ text: 'Choose from Library', onPress: () => pickAvatar('library') },
]
);
}
};
const pickAvatar = async (source: 'camera' | 'library') => {
try {
let result: ImagePicker.ImagePickerResult;
if (source === 'camera') {
const { status } = await ImagePicker.requestCameraPermissionsAsync();
if (status !== 'granted') {
Alert.alert('Permission Denied', 'Camera access is required to take a photo.');
return;
}
result = await ImagePicker.launchCameraAsync({
mediaTypes: ['images'],
allowsEditing: true,
aspect: [1, 1],
quality: 0.8,
});
} else {
const { status } = await ImagePicker.requestMediaLibraryPermissionsAsync();
if (status !== 'granted') {
Alert.alert('Permission Denied', 'Photo library access is required to choose a photo.');
return;
}
result = await ImagePicker.launchImageLibraryAsync({
mediaTypes: ['images'],
allowsEditing: true,
aspect: [1, 1],
quality: 0.8,
});
}
if (!result.canceled && result.assets[0]) {
await uploadAvatar(result.assets[0].uri);
}
} catch (error) {
console.error('Error picking avatar:', error);
Alert.alert('Error', 'Failed to pick image. Please try again.');
}
};
const uploadAvatar = async (uri: string) => {
if (!user?.id) return;
setIsUploadingAvatar(true);
try {
let fileBody;
let ext = 'jpg';
if (Platform.OS === 'web') {
const response = await fetch(uri);
const blob = await response.blob();
fileBody = blob;
if (blob.type === 'image/png') ext = 'png';
else if (blob.type === 'image/webp') ext = 'webp';
} else {
const base64 = await FileSystem.readAsStringAsync(uri, { encoding: FileSystem.EncodingType.Base64 });
fileBody = decode(base64);
const uriExt = uri.split('.').pop()?.toLowerCase();
if (uriExt && ['jpg', 'jpeg', 'png', 'webp'].includes(uriExt)) {
ext = uriExt === 'jpeg' ? 'jpg' : uriExt;
}
}
const fileName = `${user.id}/${Date.now()}.${ext}`;
const contentType = `image/${ext === 'jpg' ? 'jpeg' : ext}`;
// Upload to storage
const { error: uploadError } = await supabase.storage
.from('avatars')
.upload(fileName, fileBody, {
contentType,
upsert: true,
});
if (uploadError) throw uploadError;
// Get public URL
const { data: { publicUrl } } = supabase.storage
.from('avatars')
.getPublicUrl(fileName);
// Update profile with avatar URL
const { error: updateError } = await supabase
.from('profiles')
.update({
avatar_url: publicUrl,
updated_at: new Date().toISOString(),
})
.eq('id', user.id);
if (updateError) throw updateError;
// Refresh user data
await fetchUser();
Events.avatarUploaded();
} catch (error) {
console.error('Error uploading avatar:', error);
Alert.alert('Error', 'Failed to upload photo. Please try again.');
} finally {
setIsUploadingAvatar(false);
}
};
// Preference Actions
const handleIntensityChange = async (value: IntensityLevel) => {
if (!user?.id) return;
+221
View File
@@ -0,0 +1,221 @@
import { useState, useCallback, useRef } from 'react';
import { Alert, Platform, ActionSheetIOS } from 'react-native';
import * as ImagePicker from 'expo-image-picker';
import * as FileSystem from 'expo-file-system';
import { decode } from 'base64-arraybuffer';
import { supabase } from '../lib/supabase';
export interface UseAvatarPickerOptions {
/** User ID for storage path. Required for upload. */
userId?: string;
/** If true, automatically uploads after selection. Default: false */
autoUpload?: boolean;
/** Called when an image is selected (before upload) */
onSelect?: (uri: string) => void;
/** Called after successful upload */
onUploadComplete?: (publicUrl: string) => void;
/** Called on upload error */
onUploadError?: (error: Error) => void;
}
export interface UseAvatarPickerReturn {
/** Local URI of selected image (before upload) */
avatarUri: string | null;
/** Set avatar URI directly */
setAvatarUri: (uri: string | null) => void;
/** Whether an upload is in progress */
isUploading: boolean;
/** Show the picker action sheet/dialog */
showPicker: () => void;
/** Upload the currently selected image to storage */
uploadAvatar: () => Promise<string | null>;
/** Clear the selected avatar */
clearAvatar: () => void;
}
/**
* Hook for avatar image picking and uploading.
* Handles cross-platform image selection (camera/library) and Supabase storage upload.
*/
export function useAvatarPicker(options?: UseAvatarPickerOptions): UseAvatarPickerReturn {
const [avatarUri, setAvatarUri] = useState<string | null>(null);
const [isUploading, setIsUploading] = useState(false);
// Use ref for options to avoid stale closures in callbacks
const optionsRef = useRef(options);
optionsRef.current = options;
// Internal upload function that takes a URI parameter
const uploadAvatarWithUri = useCallback(async (uri: string): Promise<string | null> => {
const currentOptions = optionsRef.current;
if (!uri || !currentOptions?.userId) {
return null;
}
setIsUploading(true);
try {
let fileBody;
let ext = 'jpg';
if (Platform.OS === 'web') {
const response = await fetch(uri);
const blob = await response.blob();
fileBody = blob;
if (blob.type === 'image/png') ext = 'png';
else if (blob.type === 'image/webp') ext = 'webp';
} else {
const base64 = await FileSystem.readAsStringAsync(uri, {
encoding: FileSystem.EncodingType.Base64
});
fileBody = decode(base64);
const uriExt = uri.split('.').pop()?.toLowerCase();
if (uriExt && ['jpg', 'jpeg', 'png', 'webp'].includes(uriExt)) {
ext = uriExt === 'jpeg' ? 'jpg' : uriExt;
}
}
const fileName = `${currentOptions.userId}/${Date.now()}.${ext}`;
const contentType = `image/${ext === 'jpg' ? 'jpeg' : ext}`;
const { error: uploadError } = await supabase.storage
.from('avatars')
.upload(fileName, fileBody, {
contentType,
upsert: true,
});
if (uploadError) throw uploadError;
const { data: { publicUrl } } = supabase.storage
.from('avatars')
.getPublicUrl(fileName);
currentOptions?.onUploadComplete?.(publicUrl);
return publicUrl;
} catch (error) {
console.error('Error uploading avatar:', error);
currentOptions?.onUploadError?.(error as Error);
return null;
} finally {
setIsUploading(false);
}
}, []);
const pickImage = useCallback(async (source: 'camera' | 'library') => {
const currentOptions = optionsRef.current;
try {
let result: ImagePicker.ImagePickerResult;
if (source === 'camera') {
const { status } = await ImagePicker.requestCameraPermissionsAsync();
if (status !== 'granted') {
Alert.alert('Permission Denied', 'Camera access is required to take a photo.');
return;
}
result = await ImagePicker.launchCameraAsync({
mediaTypes: ['images'],
allowsEditing: true,
aspect: [1, 1],
quality: 0.8,
exif: false,
});
} else {
const { status } = await ImagePicker.requestMediaLibraryPermissionsAsync();
if (status !== 'granted') {
Alert.alert('Permission Denied', 'Photo library access is required to choose a photo.');
return;
}
result = await ImagePicker.launchImageLibraryAsync({
mediaTypes: ['images'],
allowsEditing: true,
aspect: [1, 1],
quality: 0.8,
exif: false,
});
}
if (!result.canceled && result.assets[0]) {
const uri = result.assets[0].uri;
// Validate image is accessible on native platforms
if (Platform.OS !== 'web') {
try {
const info = await FileSystem.getInfoAsync(uri);
if (!info.exists) {
Alert.alert('Error', 'Selected image could not be accessed.');
return;
}
// Warn if file is very large (> 10MB)
if (info.size && info.size > 10 * 1024 * 1024) {
Alert.alert('Large Image', 'This image is quite large and may take longer to upload.');
}
} catch (e) {
console.warn('Could not validate image:', e);
}
}
setAvatarUri(uri);
currentOptions?.onSelect?.(uri);
// Auto-upload if enabled
if (currentOptions?.autoUpload) {
await uploadAvatarWithUri(uri);
}
}
} catch (error) {
console.error('Error picking avatar:', error);
Alert.alert('Error', 'Failed to pick image. Please try again.');
}
}, [uploadAvatarWithUri]);
const showPicker = useCallback(() => {
if (Platform.OS === 'ios') {
ActionSheetIOS.showActionSheetWithOptions(
{
options: ['Cancel', 'Take Photo', 'Choose from Library'],
cancelButtonIndex: 0,
},
(buttonIndex) => {
if (buttonIndex === 1) {
pickImage('camera');
} else if (buttonIndex === 2) {
pickImage('library');
}
}
);
} else {
Alert.alert(
'Add Profile Photo',
'Choose an option',
[
{ text: 'Cancel', style: 'cancel' },
{ text: 'Take Photo', onPress: () => pickImage('camera') },
{ text: 'Choose from Library', onPress: () => pickImage('library') },
]
);
}
}, [pickImage]);
// Public upload function uses stored avatarUri
const uploadAvatar = useCallback(async (): Promise<string | null> => {
if (!avatarUri) return null;
return uploadAvatarWithUri(avatarUri);
}, [avatarUri, uploadAvatarWithUri]);
const clearAvatar = useCallback(() => {
setAvatarUri(null);
}, []);
return {
avatarUri,
setAvatarUri,
isUploading,
showPicker,
uploadAvatar,
clearAvatar,
};
}