diff --git a/apps/admin/src/pages/content/QuestionsPage.tsx b/apps/admin/src/pages/content/QuestionsPage.tsx
index 6af79a5..ca10ad5 100644
--- a/apps/admin/src/pages/content/QuestionsPage.tsx
+++ b/apps/admin/src/pages/content/QuestionsPage.tsx
@@ -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() {
{pack && (
<>
- {pack.icon}
+
{pack.name}
>
)}
diff --git a/apps/admin/vite.config.ts b/apps/admin/vite.config.ts
index 88a74c9..e68c271 100755
--- a/apps/admin/vite.config.ts
+++ b/apps/admin/vite.config.ts
@@ -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,
diff --git a/apps/mobile/.claude/skills/web-design-guidelines/SKILL.md b/apps/mobile/.claude/skills/web-design-guidelines/SKILL.md
new file mode 100644
index 0000000..ceae92a
--- /dev/null
+++ b/apps/mobile/.claude/skills/web-design-guidelines/SKILL.md
@@ -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:
+---
+
+# 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.
diff --git a/apps/mobile/.codex/skills/web-design-guidelines/SKILL.md b/apps/mobile/.codex/skills/web-design-guidelines/SKILL.md
new file mode 100644
index 0000000..ceae92a
--- /dev/null
+++ b/apps/mobile/.codex/skills/web-design-guidelines/SKILL.md
@@ -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:
+---
+
+# 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.
diff --git a/apps/mobile/.gemini/skills/web-design-guidelines/SKILL.md b/apps/mobile/.gemini/skills/web-design-guidelines/SKILL.md
new file mode 100644
index 0000000..ceae92a
--- /dev/null
+++ b/apps/mobile/.gemini/skills/web-design-guidelines/SKILL.md
@@ -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:
+---
+
+# 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.
diff --git a/apps/mobile/android/app/build.gradle b/apps/mobile/android/app/build.gradle
index a81d4cb..469d3c7 100644
--- a/apps/mobile/android/app/build.gradle
+++ b/apps/mobile/android/app/build.gradle
@@ -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 {
diff --git a/apps/mobile/app.json b/apps/mobile/app.json
index 8c30403..ebedee2 100644
--- a/apps/mobile/app.json
+++ b/apps/mobile/app.json
@@ -177,7 +177,7 @@
"backgroundColor": "#1a1a2e"
},
"package": "com.sauci.app",
- "versionCode": 31,
+ "versionCode": 32,
"googleServicesFile": "./google-services.json",
"permissions": [
"RECEIVE_BOOT_COMPLETED",
diff --git a/apps/mobile/app/(app)/onboarding.tsx b/apps/mobile/app/(app)/onboarding.tsx
index 2ddad7b..4f43971 100644
--- a/apps/mobile/app/(app)/onboarding.tsx
+++ b/apps/mobile/app/(app)/onboarding.tsx
@@ -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('name');
+ const [stage, setStage] = useState('avatar');
const [name, setName] = useState(user?.name || '');
const [gender, setGender] = useState(user?.gender || null);
const [usageReason, setUsageReason] = useState(null);
@@ -78,10 +78,25 @@ export default function OnboardingScreen() {
const [error, setError] = useState(null);
const inputRef = useRef(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,19 +168,36 @@ export default function OnboardingScreen() {
setError(null);
try {
+ // Upload avatar if one was selected
+ let avatarUrl: string | null = null;
+ if (avatarUri) {
+ avatarUrl = await uploadAvatar();
+ if (avatarUrl) {
+ Events.avatarUploaded();
+ }
+ }
+
const showExplicitContent = maxIntensity >= 3;
+ const updatedFields: string[] = ["name", "gender", "usage_reason", "max_intensity", "show_explicit_content"];
+
+ const updateData: Record = {
+ name: name.trim(),
+ gender,
+ usage_reason: usageReason,
+ max_intensity: maxIntensity,
+ 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({
- name: name.trim(),
- gender,
- usage_reason: usageReason,
- max_intensity: maxIntensity,
- show_explicit_content: showExplicitContent,
- onboarding_completed: true,
- updated_at: new Date().toISOString(),
- })
+ .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 (
Welcome to Sauci
- Let's get to know you
+ Add a profile photo so your partner knows it's you
+
+
+
+
+
+
+ {avatarUri ? (
+
+ ) : (
+
+
+
+ )}
+
+
+
+
+
+ {avatarUri ? 'Tap to change photo' : 'Tap to add a photo'}
+
+
+ {error && (
+
+
+ {error}
+
+ )}
+
+
+
+
+ {avatarUri ? 'Continue' : 'Skip for Now'}
+
+
+
+ );
+
+ case 'name':
+ return (
+
+
+ {avatarUri ? (
+
+ ) : (
+
+
+
+ )}
+ What can we call you?
+
+ This is how your partner will see you
- What's your name?
{/* Header with back button and progress dots */}
- {stage !== 'name' ? (
+ {stage !== 'avatar' ? (
{
+ 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');
}}
>
@@ -531,6 +626,7 @@ export default function OnboardingScreen() {
)}
+
@@ -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,
diff --git a/apps/mobile/app/(app)/packs/questions.tsx b/apps/mobile/app/(app)/packs/questions.tsx
index aa89094..02758d1 100644
--- a/apps/mobile/app/(app)/packs/questions.tsx
+++ b/apps/mobile/app/(app)/packs/questions.tsx
@@ -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
-
diff --git a/apps/mobile/src/components/BoutiquePackCard.tsx b/apps/mobile/src/components/BoutiquePackCard.tsx
index cc5679c..7be75ed 100644
--- a/apps/mobile/src/components/BoutiquePackCard.tsx
+++ b/apps/mobile/src/components/BoutiquePackCard.tsx
@@ -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 = [
@@ -147,23 +148,17 @@ export function BoutiquePackCard({
{questionCount}
- questions
-
-
-
-
-
+ questions
+
+
+
+
+
{/* Lock overlay for premium locked packs */}
{isPremiumLocked && (
diff --git a/apps/mobile/src/components/ui/GlassToggle.tsx b/apps/mobile/src/components/ui/GlassToggle.tsx
new file mode 100644
index 0000000..5788cf1
--- /dev/null
+++ b/apps/mobile/src/components/ui/GlassToggle.tsx
@@ -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 (
+
+
+ {/* Active Gradient Background (fades in) */}
+
+
+
+
+ {/* Thumb */}
+
+
+
+ );
+}
+
+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,
+ },
+});
diff --git a/apps/mobile/src/components/ui/index.ts b/apps/mobile/src/components/ui/index.ts
index d9fa1ff..731116f 100644
--- a/apps/mobile/src/components/ui/index.ts
+++ b/apps/mobile/src/components/ui/index.ts
@@ -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';
diff --git a/apps/mobile/src/features/profile/components/SwitchItem.tsx b/apps/mobile/src/features/profile/components/SwitchItem.tsx
index c1847dc..6d37060 100644
--- a/apps/mobile/src/features/profile/components/SwitchItem.tsx
+++ b/apps/mobile/src/features/profile/components/SwitchItem.tsx
@@ -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({
{description}
-
);
diff --git a/apps/mobile/src/features/profile/hooks/useProfileSettings.ts b/apps/mobile/src/features/profile/hooks/useProfileSettings.ts
index 60caef6..6baa763 100644
--- a/apps/mobile/src/features/profile/hooks/useProfileSettings.ts
+++ b/apps/mobile/src/features/profile/hooks/useProfileSettings.ts
@@ -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(() => 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;
diff --git a/apps/mobile/src/hooks/useAvatarPicker.ts b/apps/mobile/src/hooks/useAvatarPicker.ts
new file mode 100644
index 0000000..c4064b4
--- /dev/null
+++ b/apps/mobile/src/hooks/useAvatarPicker.ts
@@ -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;
+ /** 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(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 => {
+ 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 => {
+ if (!avatarUri) return null;
+ return uploadAvatarWithUri(avatarUri);
+ }, [avatarUri, uploadAvatarWithUri]);
+
+ const clearAvatar = useCallback(() => {
+ setAvatarUri(null);
+ }, []);
+
+ return {
+ avatarUri,
+ setAvatarUri,
+ isUploading,
+ showPicker,
+ uploadAvatar,
+ clearAvatar,
+ };
+}