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, + }; +}