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
+704 -183

No files matched your search

@@ -42,6 +42,7 @@ import { toast } from 'sonner';
import { AiGeneratorDialog } from '@/components/ai/AiGeneratorDialog';
import { AIPolishButton } from '@/components/ai/AIPolishButton';
import { ReviewQuestionsDialog } from '@/components/content/ReviewQuestionsDialog';
import { IconPreview } from '@/components/ui/icon-picker';
interface QuestionPack {
id: string;
@@ -525,7 +526,7 @@ export function QuestionsPage() {
<h1 className="text-3xl font-bold tracking-tight flex items-center gap-2">
{pack && (
<>
<span className="text-3xl">{pack.icon}</span>
<IconPreview value={pack.icon} fallback="heart-outline" className="text-3xl" />
{pack.name}
</>
)}
+12
View File
@@ -9,6 +9,18 @@ export default defineConfig({
'@': path.resolve(__dirname, './src'),
},
},
optimizeDeps: {
include: ['react-icons/io5'],
},
build: {
rollupOptions: {
output: {
manualChunks: {
'react-icons': ['react-icons/io5'],
},
},
},
},
server: {
host: true,
port: 3001,
@@ -0,0 +1,39 @@
---
name: web-design-guidelines
description: Review UI code for Web Interface Guidelines compliance. Use when asked to "review my UI", "check accessibility", "audit design", "review UX", or "check my site against best practices".
metadata:
author: vercel
version: "1.0.0"
argument-hint: <file-or-pattern>
---
# Web Interface Guidelines
Review files for compliance with Web Interface Guidelines.
## How It Works
1. Fetch the latest guidelines from the source URL below
2. Read the specified files (or prompt user for files/pattern)
3. Check against all rules in the fetched guidelines
4. Output findings in the terse `file:line` format
## Guidelines Source
Fetch fresh guidelines before each review:
```
https://raw.githubusercontent.com/vercel-labs/web-interface-guidelines/main/command.md
```
Use WebFetch to retrieve the latest rules. The fetched content contains all the rules and output format instructions.
## Usage
When a user provides a file or pattern argument:
1. Fetch guidelines from the source URL above
2. Read the specified files
3. Apply all rules from the fetched guidelines
4. Output findings using the format specified in the guidelines
If no files specified, ask the user which files to review.
@@ -0,0 +1,39 @@
---
name: web-design-guidelines
description: Review UI code for Web Interface Guidelines compliance. Use when asked to "review my UI", "check accessibility", "audit design", "review UX", or "check my site against best practices".
metadata:
author: vercel
version: "1.0.0"
argument-hint: <file-or-pattern>
---
# Web Interface Guidelines
Review files for compliance with Web Interface Guidelines.
## How It Works
1. Fetch the latest guidelines from the source URL below
2. Read the specified files (or prompt user for files/pattern)
3. Check against all rules in the fetched guidelines
4. Output findings in the terse `file:line` format
## Guidelines Source
Fetch fresh guidelines before each review:
```
https://raw.githubusercontent.com/vercel-labs/web-interface-guidelines/main/command.md
```
Use WebFetch to retrieve the latest rules. The fetched content contains all the rules and output format instructions.
## Usage
When a user provides a file or pattern argument:
1. Fetch guidelines from the source URL above
2. Read the specified files
3. Apply all rules from the fetched guidelines
4. Output findings using the format specified in the guidelines
If no files specified, ask the user which files to review.
@@ -0,0 +1,39 @@
---
name: web-design-guidelines
description: Review UI code for Web Interface Guidelines compliance. Use when asked to "review my UI", "check accessibility", "audit design", "review UX", or "check my site against best practices".
metadata:
author: vercel
version: "1.0.0"
argument-hint: <file-or-pattern>
---
# Web Interface Guidelines
Review files for compliance with Web Interface Guidelines.
## How It Works
1. Fetch the latest guidelines from the source URL below
2. Read the specified files (or prompt user for files/pattern)
3. Check against all rules in the fetched guidelines
4. Output findings in the terse `file:line` format
## Guidelines Source
Fetch fresh guidelines before each review:
```
https://raw.githubusercontent.com/vercel-labs/web-interface-guidelines/main/command.md
```
Use WebFetch to retrieve the latest rules. The fetched content contains all the rules and output format instructions.
## Usage
When a user provides a file or pattern argument:
1. Fetch guidelines from the source URL above
2. Read the specified files
3. Apply all rules from the fetched guidelines
4. Output findings using the format specified in the guidelines
If no files specified, ask the user which files to review.
+1 -1
View File
@@ -93,7 +93,7 @@ android {
applicationId 'com.sauci.app'
minSdkVersion rootProject.ext.minSdkVersion
targetSdkVersion rootProject.ext.targetSdkVersion
versionCode 31
versionCode 32
versionName "1.0.2"
}
signingConfigs {
+1 -1
View File
@@ -177,7 +177,7 @@
"backgroundColor": "#1a1a2e"
},
"package": "com.sauci.app",
"versionCode": 31,
"versionCode": 32,
"googleServicesFile": "./google-services.json",
"permissions": [
"RECEIVE_BOOT_COMPLETED",
+163 -17
View File
@@ -9,6 +9,7 @@ import {
Platform,
Keyboard,
ScrollView,
Image,
} from 'react-native';
import { useRouter } from 'expo-router';
import { Ionicons } from '@expo/vector-icons';
@@ -17,10 +18,8 @@ import Animated, {
FadeIn,
FadeInUp,
FadeOut,
FadeOutLeft,
SlideInRight,
SlideOutLeft,
Layout,
} from 'react-native-reanimated';
import { GradientBackground } from '../../src/components/ui/GradientBackground';
import { GlassCard } from '../../src/components/ui/GlassCard';
@@ -32,6 +31,7 @@ import { supabase } from '../../src/lib/supabase';
import { getProfileError } from '../../src/lib/errors';
import { registerForPushNotificationsAsync, savePushToken } from '../../src/lib/notifications';
import { Events } from '../../src/lib/analytics';
import { useAvatarPicker } from '../../src/hooks/useAvatarPicker';
import type { Gender, IntensityLevel } from '../../src/types';
const GENDER_OPTIONS: { value: Gender; label: string; icon: string }[] = [
@@ -64,12 +64,12 @@ const getInitialIntensity = (profile?: { max_intensity?: number | null; show_exp
return profile?.show_explicit_content ? 5 : DEFAULT_MAX_INTENSITY;
};
type Stage = 'name' | 'gender' | 'purpose' | 'explicit' | 'notifications';
type Stage = 'avatar' | 'name' | 'gender' | 'purpose' | 'explicit' | 'notifications';
export default function OnboardingScreen() {
const router = useRouter();
const { user, fetchUser } = useAuthStore();
const [stage, setStage] = useState<Stage>('name');
const [stage, setStage] = useState<Stage>('avatar');
const [name, setName] = useState(user?.name || '');
const [gender, setGender] = useState<Gender | null>(user?.gender || null);
const [usageReason, setUsageReason] = useState<UsageReason | null>(null);
@@ -78,10 +78,25 @@ export default function OnboardingScreen() {
const [error, setError] = useState<string | null>(null);
const inputRef = useRef<TextInput>(null);
const {
avatarUri,
isUploading: isUploadingAvatar,
showPicker: handleAvatarPress,
uploadAvatar,
} = useAvatarPicker({
userId: user?.id,
onSelect: () => setError(null),
});
useEffect(() => {
Events.onboardingStart();
}, []);
const handleAvatarContinue = () => {
setStage('name');
Events.onboardingStageComplete('avatar');
};
const handleNameSubmit = () => {
if (!name.trim()) {
setError('Please enter your name');
@@ -153,11 +168,19 @@ export default function OnboardingScreen() {
setError(null);
try {
const showExplicitContent = maxIntensity >= 3;
// Upload avatar if one was selected
let avatarUrl: string | null = null;
if (avatarUri) {
avatarUrl = await uploadAvatar();
if (avatarUrl) {
Events.avatarUploaded();
}
}
const { error: updateError } = await supabase
.from('profiles')
.update({
const showExplicitContent = maxIntensity >= 3;
const updatedFields: string[] = ["name", "gender", "usage_reason", "max_intensity", "show_explicit_content"];
const updateData: Record<string, any> = {
name: name.trim(),
gender,
usage_reason: usageReason,
@@ -165,7 +188,16 @@ export default function OnboardingScreen() {
show_explicit_content: showExplicitContent,
onboarding_completed: true,
updated_at: new Date().toISOString(),
})
};
if (avatarUrl) {
updateData.avatar_url = avatarUrl;
updatedFields.push("avatar_url");
}
const { error: updateError } = await supabase
.from('profiles')
.update(updateData)
.eq('id', user.id);
if (updateError) {
@@ -176,7 +208,7 @@ export default function OnboardingScreen() {
console.log('[Onboarding] Profile updated successfully, fetching user...');
await fetchUser();
console.log('[Onboarding] User fetched, navigating to home...');
Events.profileUpdated(["name", "gender", "usage_reason", "max_intensity", "show_explicit_content"]);
Events.profileUpdated(updatedFields);
Events.onboardingComplete();
router.replace('/');
} catch (err: any) {
@@ -189,10 +221,10 @@ export default function OnboardingScreen() {
const renderStage = () => {
switch (stage) {
case 'name':
case 'avatar':
return (
<Animated.View
key="name"
key="avatar"
entering={FadeInUp.duration(500)}
exiting={SlideOutLeft.duration(300)}
style={styles.stageContainer}
@@ -206,12 +238,73 @@ export default function OnboardingScreen() {
</LinearGradient>
<Text style={styles.title}>Welcome to Sauci</Text>
<Text style={styles.subtitle}>
Let's get to know you
Add a profile photo so your partner knows it's you
</Text>
</View>
<GlassCard style={styles.card}>
<View style={styles.avatarSection}>
<Pressable onPress={handleAvatarPress} style={styles.avatarTouchable}>
{avatarUri ? (
<Image source={{ uri: avatarUri }} style={styles.avatarImage} />
) : (
<LinearGradient
colors={[colors.glass.background, colors.glass.backgroundLight]}
style={styles.avatarPlaceholder}
>
<Ionicons name="camera" size={40} color={colors.textSecondary} />
</LinearGradient>
)}
<View style={styles.avatarBadge}>
<Ionicons name={avatarUri ? "pencil" : "add"} size={16} color={colors.text} />
</View>
</Pressable>
<Text style={styles.avatarHint}>
{avatarUri ? 'Tap to change photo' : 'Tap to add a photo'}
</Text>
</View>
{error && (
<View style={styles.errorContainer}>
<Ionicons name="alert-circle" size={16} color={colors.error} />
<Text style={styles.errorText}>{error}</Text>
</View>
)}
</GlassCard>
<View style={styles.footer}>
<GlassButton onPress={handleAvatarContinue} fullWidth size="lg">
{avatarUri ? 'Continue' : 'Skip for Now'}
</GlassButton>
</View>
</Animated.View>
);
case 'name':
return (
<Animated.View
key="name"
entering={SlideInRight.duration(400)}
exiting={SlideOutLeft.duration(300)}
style={styles.stageContainer}
>
<View style={styles.header}>
{avatarUri ? (
<Image source={{ uri: avatarUri }} style={styles.headerAvatar} />
) : (
<LinearGradient
colors={gradients.primary as [string, string]}
style={styles.iconContainer}
>
<Ionicons name="person" size={40} color={colors.text} />
</LinearGradient>
)}
<Text style={styles.title}>What can we call you?</Text>
<Text style={styles.subtitle}>
This is how your partner will see you
</Text>
</View>
<GlassCard style={styles.card}>
<Text style={styles.label}>What's your name?</Text>
<View style={styles.inputContainer}>
<Ionicons
name="person-outline"
@@ -224,7 +317,7 @@ export default function OnboardingScreen() {
style={styles.input}
value={name}
onChangeText={setName}
placeholder="Enter your name"
placeholder="Your name or nickname"
placeholderTextColor={colors.textTertiary}
autoCapitalize="words"
autoCorrect={false}
@@ -515,14 +608,16 @@ export default function OnboardingScreen() {
>
{/* Header with back button and progress dots */}
<Animated.View entering={FadeIn.duration(600)} style={styles.headerBar}>
{stage !== 'name' ? (
{stage !== 'avatar' ? (
<Pressable
style={styles.backButton}
onPress={() => {
setError(null);
if (stage === 'notifications') setStage('explicit');
else if (stage === 'explicit') setStage('purpose');
else if (stage === 'purpose') setStage('gender');
else setStage('name');
else if (stage === 'gender') setStage('name');
else if (stage === 'name') setStage('avatar');
}}
>
<Ionicons name="chevron-back" size={24} color={colors.text} />
@@ -531,6 +626,7 @@ export default function OnboardingScreen() {
<View style={styles.backButtonPlaceholder} />
)}
<View style={styles.progressContainer}>
<View style={[styles.progressDot, stage === 'avatar' && styles.progressDotActive]} />
<View style={[styles.progressDot, stage === 'name' && styles.progressDotActive]} />
<View style={[styles.progressDot, stage === 'gender' && styles.progressDotActive]} />
<View style={[styles.progressDot, stage === 'purpose' && styles.progressDotActive]} />
@@ -611,6 +707,56 @@ const styles = StyleSheet.create({
marginBottom: spacing.lg,
...shadows.lg,
},
headerAvatar: {
width: 80,
height: 80,
borderRadius: 40,
marginBottom: spacing.lg,
borderWidth: 2,
borderColor: colors.primary,
},
avatarSection: {
alignItems: 'center',
paddingVertical: spacing.lg,
},
avatarTouchable: {
position: 'relative',
},
avatarImage: {
width: 120,
height: 120,
borderRadius: 60,
borderWidth: 3,
borderColor: colors.primary,
},
avatarPlaceholder: {
width: 120,
height: 120,
borderRadius: 60,
alignItems: 'center',
justifyContent: 'center',
borderWidth: 2,
borderColor: colors.glass.border,
borderStyle: 'dashed',
},
avatarBadge: {
position: 'absolute',
bottom: 4,
right: 4,
width: 32,
height: 32,
borderRadius: 16,
backgroundColor: colors.primary,
alignItems: 'center',
justifyContent: 'center',
borderWidth: 2,
borderColor: colors.background,
},
avatarHint: {
...typography.caption1,
color: colors.textSecondary,
marginTop: spacing.md,
},
title: {
...typography.largeTitle,
color: colors.text,
+3 -9
View File
@@ -1,4 +1,4 @@
import { View, Text, StyleSheet, RefreshControl, Platform, Alert, Pressable, Switch } from "react-native";
import { View, Text, StyleSheet, RefreshControl, Platform, Alert, Pressable } from "react-native";
import { usePacksStore, useAuthStore, useSubscriptionStore } from "../../../src/store";
import { Ionicons } from "@expo/vector-icons";
import { router } from "expo-router";
@@ -14,7 +14,7 @@ import Animated, {
Extrapolation,
} from "react-native-reanimated";
import { BlurView } from "expo-blur";
import { GradientBackground } from "../../../src/components/ui";
import { GradientBackground, GlassToggle } from "../../../src/components/ui";
import { Paywall } from "../../../src/components/paywall";
import { PackTeaser } from "../../../src/components/PackTeaser";
import { colors, spacing, radius, typography } from "../../../src/theme";
@@ -461,15 +461,9 @@ export default function QuestionPacksScreen() {
Show all intensities
</Text>
</View>
<Switch
<GlassToggle
value={showAllIntensities}
onValueChange={handleToggleComfortZone}
trackColor={{
false: "rgba(255, 255, 255, 0.1)",
true: "rgba(233, 69, 96, 0.4)"
}}
thumbColor={showAllIntensities ? colors.primary : colors.textTertiary}
ios_backgroundColor="rgba(255, 255, 255, 0.1)"
/>
</Pressable>
</Animated.View>
@@ -1,5 +1,5 @@
import React from 'react';
import { StyleSheet, View, Text, TouchableOpacity, Switch, Platform } from 'react-native';
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';
@@ -10,6 +10,7 @@ import Animated, {
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';
@@ -150,16 +151,10 @@ export function BoutiquePackCard({
<Text style={styles.questionLabel}>questions</Text>
</View>
<Switch
<GlassToggle
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>
@@ -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,
};
}