mirror of
https://github.com/jjbrunton/Sauci.git
synced 2026-10-31 18:19:43 +00:00
Add profile photo to onboarding flow
This commit is contained in:
1 parent
349f460e15
commit
b070a6f6a2
15 files changed
+731
-210
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}
|
||||
</>
|
||||
)}
|
||||
|
||||
@@ -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.
|
||||
@@ -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 {
|
||||
|
||||
@@ -177,7 +177,7 @@
|
||||
"backgroundColor": "#1a1a2e"
|
||||
},
|
||||
"package": "com.sauci.app",
|
||||
"versionCode": 31,
|
||||
"versionCode": 32,
|
||||
"googleServicesFile": "./google-services.json",
|
||||
"permissions": [
|
||||
"RECEIVE_BOOT_COMPLETED",
|
||||
|
||||
@@ -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,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<string, any> = {
|
||||
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 (
|
||||
<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,
|
||||
|
||||
@@ -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,17 +1,18 @@
|
||||
import React from 'react';
|
||||
import { StyleSheet, View, Text, TouchableOpacity, Switch, Platform } from 'react-native';
|
||||
import { LinearGradient } from 'expo-linear-gradient';
|
||||
import { BlurView } from 'expo-blur';
|
||||
import { Ionicons } from '@expo/vector-icons';
|
||||
import Animated, {
|
||||
useAnimatedStyle,
|
||||
withSpring,
|
||||
useSharedValue,
|
||||
FadeIn,
|
||||
} from 'react-native-reanimated';
|
||||
import { ShimmerEffect } from './ui/ShimmerEffect';
|
||||
import { colors, gradients, spacing, radius, typography, blur, shadows, animations } from '../theme';
|
||||
import type { QuestionPack } from '../types';
|
||||
import React from 'react';
|
||||
import { StyleSheet, View, Text, TouchableOpacity, Platform } from 'react-native';
|
||||
import { LinearGradient } from 'expo-linear-gradient';
|
||||
import { BlurView } from 'expo-blur';
|
||||
import { Ionicons } from '@expo/vector-icons';
|
||||
import Animated, {
|
||||
useAnimatedStyle,
|
||||
withSpring,
|
||||
useSharedValue,
|
||||
FadeIn,
|
||||
} from 'react-native-reanimated';
|
||||
import { ShimmerEffect } from './ui/ShimmerEffect';
|
||||
import { GlassToggle } from './ui/GlassToggle';
|
||||
import { colors, gradients, spacing, radius, typography, blur, shadows, animations } from '../theme';
|
||||
import type { QuestionPack } from '../types';
|
||||
|
||||
// Boutique gradient palette for pack cards
|
||||
const BOUTIQUE_GRADIENTS: Array<readonly [string, string]> = [
|
||||
@@ -147,23 +148,17 @@ export function BoutiquePackCard({
|
||||
<View style={styles.footer}>
|
||||
<View style={styles.questionBadge}>
|
||||
<Text style={styles.questionCount}>{questionCount}</Text>
|
||||
<Text style={styles.questionLabel}>questions</Text>
|
||||
</View>
|
||||
|
||||
<Switch
|
||||
value={isEnabled}
|
||||
onValueChange={onToggle}
|
||||
disabled={isPremiumLocked}
|
||||
trackColor={{
|
||||
false: 'rgba(255, 255, 255, 0.15)',
|
||||
true: pack.is_premium ? colors.premium.gold : colors.primary,
|
||||
}}
|
||||
thumbColor={colors.text}
|
||||
ios_backgroundColor="rgba(255, 255, 255, 0.15)"
|
||||
style={isPremiumLocked ? { opacity: 0.4 } : undefined}
|
||||
/>
|
||||
</View>
|
||||
</View>
|
||||
<Text style={styles.questionLabel}>questions</Text>
|
||||
</View>
|
||||
|
||||
<GlassToggle
|
||||
value={isEnabled}
|
||||
onValueChange={onToggle}
|
||||
disabled={isPremiumLocked}
|
||||
style={isPremiumLocked ? { opacity: 0.4 } : undefined}
|
||||
/>
|
||||
</View>
|
||||
</View>
|
||||
|
||||
{/* Lock overlay for premium locked packs */}
|
||||
{isPremiumLocked && (
|
||||
|
||||
@@ -0,0 +1,139 @@
|
||||
import React, { useEffect } from 'react';
|
||||
import { StyleSheet, Pressable, ViewStyle, Platform } from 'react-native';
|
||||
import { LinearGradient } from 'expo-linear-gradient';
|
||||
import Animated, {
|
||||
useSharedValue,
|
||||
useAnimatedStyle,
|
||||
withSpring,
|
||||
interpolateColor,
|
||||
} from 'react-native-reanimated';
|
||||
import { colors, gradients, radius, animations } from '../../theme';
|
||||
|
||||
// Haptics is not supported on web
|
||||
const triggerHaptic = async () => {
|
||||
if (Platform.OS === 'web') return;
|
||||
const Haptics = await import('expo-haptics');
|
||||
await Haptics.impactAsync(Haptics.ImpactFeedbackStyle.Light);
|
||||
};
|
||||
|
||||
interface GlassToggleProps {
|
||||
value: boolean;
|
||||
onValueChange: (value: boolean) => void;
|
||||
disabled?: boolean;
|
||||
style?: ViewStyle;
|
||||
}
|
||||
|
||||
const TRACK_WIDTH = 52;
|
||||
const TRACK_HEIGHT = 30;
|
||||
const THUMB_SIZE = 26;
|
||||
const PADDING = 2;
|
||||
|
||||
export function GlassToggle({
|
||||
value,
|
||||
onValueChange,
|
||||
disabled = false,
|
||||
style,
|
||||
}: GlassToggleProps) {
|
||||
// Shared values for animation
|
||||
const progress = useSharedValue(value ? 1 : 0);
|
||||
|
||||
// Sync shared value when prop changes
|
||||
useEffect(() => {
|
||||
progress.value = withSpring(value ? 1 : 0, {
|
||||
mass: 0.8,
|
||||
damping: 15,
|
||||
stiffness: 120,
|
||||
});
|
||||
}, [value]);
|
||||
|
||||
const handlePress = async () => {
|
||||
if (disabled) return;
|
||||
await triggerHaptic();
|
||||
onValueChange(!value);
|
||||
};
|
||||
|
||||
const thumbStyle = useAnimatedStyle(() => {
|
||||
return {
|
||||
transform: [
|
||||
{
|
||||
translateX: progress.value * (TRACK_WIDTH - THUMB_SIZE - (PADDING * 2)),
|
||||
},
|
||||
],
|
||||
};
|
||||
});
|
||||
|
||||
const trackStyle = useAnimatedStyle(() => {
|
||||
const backgroundColor = interpolateColor(
|
||||
progress.value,
|
||||
[0, 1],
|
||||
['rgba(255, 255, 255, 0.1)', 'transparent'] // We use LinearGradient for active state
|
||||
);
|
||||
|
||||
return {
|
||||
backgroundColor,
|
||||
};
|
||||
});
|
||||
|
||||
const activeGradientStyle = useAnimatedStyle(() => {
|
||||
return {
|
||||
opacity: progress.value,
|
||||
};
|
||||
});
|
||||
|
||||
return (
|
||||
<Pressable
|
||||
onPress={handlePress}
|
||||
disabled={disabled}
|
||||
style={[styles.container, style, disabled && styles.disabled]}
|
||||
>
|
||||
<Animated.View style={[styles.track, trackStyle]}>
|
||||
{/* Active Gradient Background (fades in) */}
|
||||
<Animated.View style={[StyleSheet.absoluteFill, activeGradientStyle]}>
|
||||
<LinearGradient
|
||||
colors={gradients.primary as [string, string, ...string[]]}
|
||||
start={{ x: 0, y: 0 }}
|
||||
end={{ x: 1, y: 0 }}
|
||||
style={StyleSheet.absoluteFill}
|
||||
/>
|
||||
</Animated.View>
|
||||
|
||||
{/* Thumb */}
|
||||
<Animated.View style={[styles.thumb, thumbStyle]} />
|
||||
</Animated.View>
|
||||
</Pressable>
|
||||
);
|
||||
}
|
||||
|
||||
const styles = StyleSheet.create({
|
||||
container: {
|
||||
width: TRACK_WIDTH,
|
||||
height: TRACK_HEIGHT,
|
||||
},
|
||||
track: {
|
||||
width: '100%',
|
||||
height: '100%',
|
||||
borderRadius: radius.full,
|
||||
borderWidth: 1,
|
||||
borderColor: 'rgba(255, 255, 255, 0.1)',
|
||||
overflow: 'hidden',
|
||||
justifyContent: 'center',
|
||||
padding: PADDING,
|
||||
},
|
||||
thumb: {
|
||||
width: THUMB_SIZE,
|
||||
height: THUMB_SIZE,
|
||||
borderRadius: radius.full,
|
||||
backgroundColor: '#FFFFFF',
|
||||
shadowColor: '#000',
|
||||
shadowOffset: {
|
||||
width: 0,
|
||||
height: 2,
|
||||
},
|
||||
shadowOpacity: 0.25,
|
||||
shadowRadius: 3.84,
|
||||
elevation: 5,
|
||||
},
|
||||
disabled: {
|
||||
opacity: 0.5,
|
||||
},
|
||||
});
|
||||
@@ -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;
|
||||
|
||||
@@ -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,
|
||||
};
|
||||
}
|
||||
Reference in new issue
Block a user