mirror of
https://github.com/jjbrunton/Sauci.git
synced 2026-11-02 18:19:44 +00:00
430 lines
14 KiB
TypeScript
430 lines
14 KiB
TypeScript
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 { useAuthStore, useSubscriptionStore, usePacksStore } from '../../../store';
|
|
import { supabase } from '../../../lib/supabase';
|
|
import { Events } from '../../../lib/analytics';
|
|
import type { IntensityLevel } from '../../../types';
|
|
import {
|
|
registerForPushNotificationsAsync,
|
|
savePushToken,
|
|
clearPushToken,
|
|
} from '../../../lib/notifications';
|
|
import {
|
|
isBiometricAvailable,
|
|
isBiometricEnabled,
|
|
setBiometricEnabled,
|
|
getBiometricType,
|
|
} from '../../../lib/biometricAuth';
|
|
|
|
const DEFAULT_MAX_INTENSITY: IntensityLevel = 2;
|
|
|
|
const normalizeIntensity = (value: number | null | undefined): IntensityLevel | null => {
|
|
if (value === 1 || value === 2 || value === 3 || value === 4 || value === 5) return value;
|
|
return null;
|
|
};
|
|
|
|
const getProfileMaxIntensity = (profile?: { max_intensity?: number | null; show_explicit_content?: boolean | null } | null): IntensityLevel => {
|
|
const normalized = normalizeIntensity(profile?.max_intensity);
|
|
if (normalized) return normalized;
|
|
return profile?.show_explicit_content ? 5 : DEFAULT_MAX_INTENSITY;
|
|
};
|
|
|
|
export function useProfileSettings() {
|
|
const { user, partner, fetchUser } = useAuthStore();
|
|
const { fetchPacks } = usePacksStore();
|
|
const { restorePurchases, isPurchasing } = useSubscriptionStore();
|
|
|
|
// Partner's intensity level (for showing on slider)
|
|
const partnerIntensity = partner ? getProfileMaxIntensity(partner) : null;
|
|
|
|
// Name Editing
|
|
const [isEditingName, setIsEditingName] = useState(false);
|
|
const [newName, setNewName] = useState(user?.name || "");
|
|
const [isUpdatingName, setIsUpdatingName] = useState(false);
|
|
|
|
// Avatar
|
|
const [isUploadingAvatar, setIsUploadingAvatar] = useState(false);
|
|
|
|
// Preferences
|
|
const [maxIntensity, setMaxIntensity] = useState<IntensityLevel>(() => getProfileMaxIntensity(user));
|
|
const [isUpdatingIntensity, setIsUpdatingIntensity] = useState(false);
|
|
|
|
// Notifications
|
|
const [pushEnabled, setPushEnabled] = useState(!!user?.push_token);
|
|
const [isUpdatingPush, setIsUpdatingPush] = useState(false);
|
|
|
|
// Biometrics
|
|
const [biometricAvailable, setBiometricAvailable] = useState(false);
|
|
const [biometricEnabledState, setBiometricEnabledState] = useState(false);
|
|
const [biometricType, setBiometricType] = useState("Face ID");
|
|
const [isUpdatingBiometric, setIsUpdatingBiometric] = useState(false);
|
|
|
|
// Paywall
|
|
const [showPaywall, setShowPaywall] = useState(false);
|
|
|
|
// Initialize state from user object
|
|
useEffect(() => {
|
|
if (user?.name) {
|
|
setNewName(user.name);
|
|
}
|
|
setMaxIntensity(getProfileMaxIntensity(user));
|
|
setPushEnabled(!!user?.push_token);
|
|
}, [user]);
|
|
|
|
// Check biometric availability
|
|
useEffect(() => {
|
|
const checkBiometric = async () => {
|
|
const available = await isBiometricAvailable();
|
|
setBiometricAvailable(available);
|
|
|
|
if (available) {
|
|
const enabled = await isBiometricEnabled();
|
|
setBiometricEnabledState(enabled);
|
|
|
|
const type = await getBiometricType();
|
|
setBiometricType(type);
|
|
}
|
|
};
|
|
|
|
checkBiometric();
|
|
}, []);
|
|
|
|
// Name Actions
|
|
const handleUpdateName = async () => {
|
|
if (!user?.id) return;
|
|
|
|
const trimmedName = newName.trim();
|
|
if (!trimmedName) {
|
|
Alert.alert("Error", "Name cannot be empty");
|
|
return;
|
|
}
|
|
|
|
if (trimmedName === user.name) {
|
|
setIsEditingName(false);
|
|
return;
|
|
}
|
|
|
|
setIsUpdatingName(true);
|
|
|
|
try {
|
|
const { error } = await supabase
|
|
.from('profiles')
|
|
.update({
|
|
name: trimmedName,
|
|
updated_at: new Date().toISOString(),
|
|
})
|
|
.eq('id', user.id);
|
|
|
|
if (error) throw error;
|
|
|
|
await fetchUser();
|
|
setIsEditingName(false);
|
|
Events.profileUpdated(["name"]);
|
|
} catch (error) {
|
|
Alert.alert("Error", "Failed to update name. Please try again.");
|
|
setNewName(user.name || "");
|
|
} finally {
|
|
setIsUpdatingName(false);
|
|
}
|
|
};
|
|
|
|
const handleCancelEditName = useCallback(() => {
|
|
setNewName(user?.name || "");
|
|
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;
|
|
|
|
const previous = maxIntensity;
|
|
setMaxIntensity(value);
|
|
setIsUpdatingIntensity(true);
|
|
|
|
const showExplicitContent = value >= 3;
|
|
|
|
try {
|
|
const { error } = await supabase
|
|
.from('profiles')
|
|
.update({
|
|
max_intensity: value,
|
|
show_explicit_content: showExplicitContent,
|
|
updated_at: new Date().toISOString(),
|
|
})
|
|
.eq('id', user.id);
|
|
|
|
if (error) throw error;
|
|
|
|
// Refresh user data and packs to reflect the change
|
|
await fetchUser();
|
|
await fetchPacks();
|
|
Events.profileUpdated(["max_intensity", "show_explicit_content"]);
|
|
} catch (error) {
|
|
// Revert on error
|
|
setMaxIntensity(previous);
|
|
Alert.alert("Error", "Failed to update preference. Please try again.");
|
|
} finally {
|
|
setIsUpdatingIntensity(false);
|
|
}
|
|
};
|
|
|
|
const handlePushToggle = async (value: boolean) => {
|
|
if (!user?.id) return;
|
|
|
|
if (Platform.OS === 'web') {
|
|
setPushEnabled(false);
|
|
Alert.alert("Not Supported", "Push notifications aren't available on web.");
|
|
return;
|
|
}
|
|
|
|
setPushEnabled(value);
|
|
setIsUpdatingPush(true);
|
|
|
|
try {
|
|
if (value) {
|
|
const token = await registerForPushNotificationsAsync();
|
|
if (!token) {
|
|
setPushEnabled(false);
|
|
Alert.alert(
|
|
"Enable Notifications",
|
|
"Allow notifications in system settings to receive alerts.",
|
|
[
|
|
{ text: "Not Now", style: "cancel" },
|
|
{ text: "Open Settings", onPress: () => Linking.openSettings() },
|
|
]
|
|
);
|
|
return;
|
|
}
|
|
|
|
await savePushToken(user.id, token);
|
|
} else {
|
|
await clearPushToken(user.id);
|
|
}
|
|
|
|
await fetchUser();
|
|
} catch (error) {
|
|
setPushEnabled(!value);
|
|
Alert.alert("Error", "Failed to update notification settings. Please try again.");
|
|
} finally {
|
|
setIsUpdatingPush(false);
|
|
}
|
|
};
|
|
|
|
const handleBiometricToggle = async (value: boolean) => {
|
|
setIsUpdatingBiometric(true);
|
|
|
|
try {
|
|
if (value) {
|
|
const { authenticateWithBiometric } = await import('../../../lib/biometricAuth');
|
|
const success = await authenticateWithBiometric();
|
|
|
|
if (!success) {
|
|
Alert.alert(
|
|
"Authentication Failed",
|
|
`Could not verify your ${biometricType}. Please try again.`
|
|
);
|
|
return;
|
|
}
|
|
|
|
await setBiometricEnabled(true);
|
|
setBiometricEnabledState(true);
|
|
} else {
|
|
await setBiometricEnabled(false);
|
|
setBiometricEnabledState(false);
|
|
}
|
|
} catch (error) {
|
|
setBiometricEnabledState(!value);
|
|
Alert.alert("Error", `Failed to ${value ? "enable" : "disable"} ${biometricType}. Please try again.`);
|
|
} finally {
|
|
setIsUpdatingBiometric(false);
|
|
}
|
|
};
|
|
|
|
// Subscription Actions
|
|
const handleRestorePurchases = async () => {
|
|
const restored = await restorePurchases();
|
|
if (restored) {
|
|
Alert.alert("Success", "Your purchases have been restored!");
|
|
} else {
|
|
Alert.alert("No Purchases Found", "No previous purchases found to restore.");
|
|
}
|
|
};
|
|
|
|
const handleManageSubscription = () => {
|
|
Linking.openURL("https://apps.apple.com/account/subscriptions");
|
|
};
|
|
|
|
return {
|
|
// State
|
|
isEditingName,
|
|
setIsEditingName,
|
|
newName,
|
|
setNewName,
|
|
isUpdatingName,
|
|
isUploadingAvatar,
|
|
maxIntensity,
|
|
isUpdatingIntensity,
|
|
pushEnabled,
|
|
isUpdatingPush,
|
|
biometricAvailable,
|
|
biometricEnabled: biometricEnabledState,
|
|
biometricType,
|
|
isUpdatingBiometric,
|
|
showPaywall,
|
|
setShowPaywall,
|
|
isPurchasing,
|
|
|
|
// Partner info for comfort zone comparison
|
|
partnerIntensity,
|
|
partnerName: partner?.name ?? undefined,
|
|
partnerAvatar: partner?.avatar_url ?? undefined,
|
|
|
|
// Actions
|
|
handleUpdateName,
|
|
handleCancelEditName,
|
|
handleAvatarPress,
|
|
handleIntensityChange,
|
|
handlePushToggle,
|
|
handleBiometricToggle,
|
|
handleRestorePurchases,
|
|
handleManageSubscription,
|
|
};
|
|
}
|