Refactor profile screen and organize project structure

- Extract ProfileScreen and components to features/profile
- Move shared hooks and stores to top-level
- Add RLS policies for admin permissions
- Add RELEASING.md guide
- Update .gitignore
This commit is contained in:
Jamie Brunton committed 2026-01-01 23:03:43 +00:00
1 parent df4c4d486b
commit 8f8c6e401d
79 files changed
+6689 -4944

No files matched your search

@@ -0,0 +1,397 @@
import { useState, useEffect, useCallback } from "react";
import { View, StyleSheet, TouchableOpacity, KeyboardAvoidingView, Platform, ActivityIndicator, Modal, Dimensions, Alert } from "react-native";
import { Image } from "expo-image";
import { Video, ResizeMode } from "expo-av";
import { useLocalSearchParams, useRouter } from "expo-router";
import { useFocusEffect } from "@react-navigation/native";
import { useSafeAreaInsets } from "react-native-safe-area-context";
import { Ionicons } from "@expo/vector-icons";
import { LinearGradient } from "expo-linear-gradient";
import Animated, { FadeInDown, useAnimatedStyle } from "react-native-reanimated";
import { InputBar, ChatHeader, ChatMessages } from "./components";
import { useMediaUpload } from "./hooks/useMediaUpload";
import { supabase } from "../../lib/supabase";
import { useAuthStore, useMessageStore } from "../../store";
import { useAmbientOrbAnimation, useMediaPicker, useTypingIndicator, useMessageSubscription } from "../../hooks";
import { Database } from "../../types/supabase";
import { GradientBackground } from "../../components/ui";
import { colors, gradients, spacing } from "../../theme";
import { Events } from "../../lib/analytics";
type Message = Database["public"]["Tables"]["messages"]["Row"];
// Premium color palette for Chat
const ACCENT = colors.premium.gold;
const ROSE_RGBA = 'rgba(232, 164, 174, ';
const ACCENT_RGBA = 'rgba(212, 175, 55, ';
export const ChatScreen: React.FC = () => {
const { id } = useLocalSearchParams();
const matchId = id as string;
const router = useRouter();
const insets = useSafeAreaInsets();
const { user, partner } = useAuthStore();
const { setActiveMatchId, markMatchMessagesAsRead } = useMessageStore();
const [match, setMatch] = useState<any>(null);
const [inputText, setInputText] = useState("");
const { uploading, uploadStatus, uploadMedia } = useMediaUpload(matchId, user?.id);
const [fullScreenImage, setFullScreenImage] = useState<string | null>(null);
const [fullScreenImageLoading, setFullScreenImageLoading] = useState(true);
const [fullScreenVideo, setFullScreenVideo] = useState<string | null>(null);
// Ambient orb breathing animations
const { orbStyle1, orbStyle2 } = useAmbientOrbAnimation({
opacityRange1: [0.2, 0.4],
opacityRange2: [0.15, 0.35],
});
// Media picker hook
const { pickMedia, takePhoto, recordVideo } = useMediaPicker({
imageQuality: 0.7,
libraryVideoMaxDuration: 300, // 5 min max
cameraVideoMaxDuration: 60, // 1 min for camera recording
});
// Typing indicator hook
const { partnerTyping, sendTypingEvent, clearTypingIndicator } = useTypingIndicator({
channelName: `chat:${matchId}`,
userId: user?.id,
});
// Message subscription hook
const { messages, setMessages, isFocusedRef } = useMessageSubscription({
matchId,
userId: user?.id,
onNewMessage: clearTypingIndicator,
});
// Track active chat to prevent notifications for current chat
useFocusEffect(
useCallback(() => {
isFocusedRef.current = true;
if (matchId) {
setActiveMatchId(matchId);
markMatchMessagesAsRead(matchId);
Events.matchViewed();
}
return () => {
isFocusedRef.current = false;
setActiveMatchId(null);
};
}, [matchId, setActiveMatchId, markMatchMessagesAsRead])
);
useEffect(() => {
if (!matchId) return;
const fetchMatch = async () => {
const { data } = await supabase
.from("matches")
.select("*, question:questions(*)")
.eq("id", matchId)
.single();
if (data) {
const { data: responses } = await supabase
.from("responses")
.select("*, profiles(name)")
.eq("question_id", data.question_id)
.eq("couple_id", data.couple_id);
setMatch({ ...data, responses: responses || [] });
}
};
fetchMatch();
}, [matchId]);
const handleTyping = (text: string) => {
setInputText(text);
if (text.length > 0) {
sendTypingEvent();
}
};
const handleSend = async () => {
if (!inputText.trim()) return;
if (!user) return;
const content = inputText.trim();
setInputText("");
const { error } = await supabase.from("messages").insert({
match_id: matchId,
user_id: user.id,
content: content,
});
if (error) {
Alert.alert("Error", "Failed to send message");
setInputText(content);
} else {
Events.messageSent();
}
};
const handlePickMedia = async () => {
const result = await pickMedia();
if (result) {
uploadMedia(result.uri, result.mediaType);
}
};
const handleTakePhoto = async () => {
const result = await takePhoto();
if (result) {
uploadMedia(result.uri, 'image');
}
};
const handleRecordVideo = async () => {
const result = await recordVideo();
if (result) {
uploadMedia(result.uri, 'video');
}
};
const revealMessage = async (messageId: string) => {
const message = messages.find(m => m.id === messageId);
const now = new Date();
// Set expiration 30 days from now for videos only
const expiresAt = message?.media_type === 'video'
? new Date(now.getTime() + 30 * 24 * 60 * 60 * 1000).toISOString()
: null;
setMessages(prev => prev.map(m =>
m.id === messageId ? {
...m,
media_viewed_at: now.toISOString(),
media_expires_at: expiresAt
} : m
));
await supabase
.from("messages")
.update({
media_viewed_at: now.toISOString(),
media_expires_at: expiresAt
})
.eq("id", messageId);
};
return (
<GradientBackground>
{/* Ambient Orbs */}
<Animated.View style={[styles.ambientOrb, styles.orbTopRight, orbStyle1]} pointerEvents="none">
<LinearGradient
colors={[colors.premium.goldGlow, 'transparent']}
style={styles.orbGradient}
start={{ x: 0.5, y: 0.5 }}
end={{ x: 1, y: 1 }}
/>
</Animated.View>
<Animated.View style={[styles.ambientOrb, styles.orbBottomLeft, orbStyle2]} pointerEvents="none">
<LinearGradient
colors={[`${ROSE_RGBA}0.2)`, 'transparent']}
style={styles.orbGradient}
start={{ x: 0.5, y: 0.5 }}
end={{ x: 0, y: 0 }}
/>
</Animated.View>
<KeyboardAvoidingView
style={styles.keyboardAvoiding}
behavior={Platform.OS === "ios" ? "padding" : undefined}
keyboardVerticalOffset={0}
>
<ChatHeader
partner={partner}
user={user}
match={match}
insets={insets}
onBack={() => router.back()}
/>
<ChatMessages
messages={messages}
userId={user?.id}
uploadStatus={uploadStatus}
partnerTyping={partnerTyping}
onImagePress={setFullScreenImage}
onVideoFullScreen={setFullScreenVideo}
revealMessage={revealMessage}
/>
{/* Premium Input Bar */}
<Animated.View
entering={FadeInDown.duration(300)}
style={[styles.inputWrapper, { paddingBottom: insets.bottom || spacing.sm }]}
>
{/* Gradient background */}
<LinearGradient
colors={['rgba(22, 33, 62, 0.9)', 'rgba(13, 13, 26, 0.95)']}
style={StyleSheet.absoluteFill}
start={{ x: 0, y: 0 }}
end={{ x: 0, y: 1 }}
/>
{/* Top border glow */}
<View style={styles.inputWrapperBorder} />
<InputBar
inputText={inputText}
uploading={uploading}
onChangeText={handleTyping}
onSend={handleSend}
onPickMedia={handlePickMedia}
onTakePhoto={handleTakePhoto}
onRecordVideo={handleRecordVideo}
/>
</Animated.View>
</KeyboardAvoidingView>
{/* Full Screen Image Modal */}
<Modal
visible={!!fullScreenImage}
transparent
animationType="fade"
onRequestClose={() => setFullScreenImage(null)}
>
<View style={styles.fullScreenOverlay}>
<TouchableOpacity
style={styles.fullScreenCloseButton}
onPress={() => setFullScreenImage(null)}
activeOpacity={0.8}
>
<LinearGradient
colors={gradients.premiumGold as [string, string]}
style={styles.fullScreenCloseGradient}
start={{ x: 0, y: 0 }}
end={{ x: 1, y: 1 }}
>
<Ionicons name="close" size={24} color={colors.text} />
</LinearGradient>
</TouchableOpacity>
{fullScreenImageLoading && (
<ActivityIndicator size="large" color={ACCENT} style={styles.fullScreenSpinner} />
)}
{fullScreenImage && (
<Image
source={{ uri: fullScreenImage }}
style={styles.fullScreenImage}
contentFit="contain"
cachePolicy="disk"
onLoadStart={() => setFullScreenImageLoading(true)}
onLoadEnd={() => setFullScreenImageLoading(false)}
/>
)}
</View>
</Modal>
{/* Full Screen Video Modal */}
<Modal
visible={!!fullScreenVideo}
transparent
animationType="fade"
onRequestClose={() => setFullScreenVideo(null)}
>
<View style={styles.fullScreenOverlay}>
<TouchableOpacity
style={styles.fullScreenCloseButton}
onPress={() => setFullScreenVideo(null)}
activeOpacity={0.8}
>
<LinearGradient
colors={gradients.premiumGold as [string, string]}
style={styles.fullScreenCloseGradient}
start={{ x: 0, y: 0 }}
end={{ x: 1, y: 1 }}
>
<Ionicons name="close" size={24} color={colors.text} />
</LinearGradient>
</TouchableOpacity>
{fullScreenVideo && (
<Video
source={{ uri: fullScreenVideo }}
style={styles.fullScreenVideo}
resizeMode={ResizeMode.CONTAIN}
useNativeControls
shouldPlay
isLooping={false}
/>
)}
</View>
</Modal>
</GradientBackground>
);
};
const styles = StyleSheet.create({
keyboardAvoiding: {
flex: 1,
},
// Ambient orbs
ambientOrb: {
position: 'absolute',
width: 280,
height: 280,
borderRadius: 140,
},
orbTopRight: {
top: 80,
right: -60,
},
orbBottomLeft: {
bottom: 200,
left: -60,
},
orbGradient: {
width: '100%',
height: '100%',
borderRadius: 140,
},
// Full Screen Modal
fullScreenOverlay: {
flex: 1,
backgroundColor: "rgba(0, 0, 0, 0.95)",
justifyContent: "center",
alignItems: "center",
},
fullScreenCloseButton: {
position: "absolute",
top: 50,
right: 20,
zIndex: 10,
},
fullScreenCloseGradient: {
width: 44,
height: 44,
borderRadius: 22,
justifyContent: 'center',
alignItems: 'center',
},
fullScreenImage: {
width: Dimensions.get("window").width,
height: Dimensions.get("window").height * 0.8,
},
fullScreenVideo: {
width: Dimensions.get("window").width,
height: Dimensions.get("window").height * 0.8,
},
fullScreenSpinner: {
position: "absolute",
},
inputWrapper: {
overflow: 'hidden',
},
inputWrapperBorder: {
position: 'absolute',
top: 0,
left: 0,
right: 0,
height: 1,
backgroundColor: `${ACCENT_RGBA}0.15)`,
},
});
@@ -0,0 +1,383 @@
import React from 'react';
import { View, Text, StyleSheet, TouchableOpacity } from 'react-native';
import { Image } from 'expo-image';
import { Ionicons } from '@expo/vector-icons';
import { LinearGradient } from 'expo-linear-gradient';
import Animated, { FadeIn, FadeInDown } from 'react-native-reanimated';
import { EdgeInsets } from 'react-native-safe-area-context';
import { DecorativeSeparator } from '../../../components/ui';
import { colors, gradients, spacing, radius, typography } from '../../../theme';
import { Profile } from '../../../types';
// Premium color palette for Chat
const ACCENT = colors.premium.gold;
const ACCENT_RGBA = 'rgba(212, 175, 55, ';
interface ChatHeaderProps {
partner: Profile | null;
user: { id: string } | null;
match: any; // Using any for match as it has joined properties (question, responses)
insets: EdgeInsets;
onBack: () => void;
}
export const ChatHeader: React.FC<ChatHeaderProps> = ({
partner,
user,
match,
insets,
onBack,
}) => {
const isYesYes = match?.match_type === 'yes_yes';
return (
<>
<Animated.View
entering={FadeIn.duration(300)}
style={[styles.header, { paddingTop: insets.top + spacing.sm }]}
>
{/* Gradient background */}
<LinearGradient
colors={['rgba(22, 33, 62, 0.8)', 'rgba(13, 13, 26, 0.6)']}
style={StyleSheet.absoluteFill}
start={{ x: 0, y: 0 }}
end={{ x: 0, y: 1 }}
/>
{/* Top silk highlight */}
<LinearGradient
colors={[`${ACCENT_RGBA}0.1)`, 'transparent']}
style={styles.headerSilkHighlight}
start={{ x: 0.5, y: 0 }}
end={{ x: 0.5, y: 1 }}
/>
<TouchableOpacity
onPress={onBack}
style={styles.backButton}
activeOpacity={0.7}
>
<View style={styles.backButtonInner}>
<Ionicons name="chevron-back" size={20} color={ACCENT} />
</View>
</TouchableOpacity>
<View style={styles.headerCenter}>
{partner?.avatar_url ? (
<View style={styles.headerAvatarContainer}>
<Image
source={{ uri: partner.avatar_url }}
style={styles.headerAvatar}
cachePolicy="disk"
transition={200}
/>
{/* Avatar ring */}
<View style={styles.avatarRing} />
</View>
) : (
<LinearGradient
colors={gradients.primary as [string, string]}
style={styles.headerAvatarGradient}
start={{ x: 0, y: 0 }}
end={{ x: 1, y: 1 }}
>
<Text style={styles.headerAvatarText}>
{partner?.name?.[0]?.toUpperCase() || "P"}
</Text>
</LinearGradient>
)}
<View style={styles.headerTextContainer}>
<Text style={styles.headerLabel}>CONVERSATION WITH</Text>
<Text style={styles.headerTitle}>{partner?.name || "Partner"}</Text>
</View>
</View>
<View style={styles.headerSpacer} />
{/* Bottom border */}
<View style={styles.headerBorderBottom} />
</Animated.View>
{/* Premium Match Card */}
{match?.question && (
<Animated.View
entering={FadeInDown.delay(100).duration(400).springify()}
style={styles.matchCard}
>
{/* Card gradient background */}
<LinearGradient
colors={isYesYes
? ['rgba(212, 175, 55, 0.12)', 'rgba(184, 134, 11, 0.08)']
: ['rgba(232, 164, 174, 0.1)', 'rgba(22, 33, 62, 0.6)']
}
style={StyleSheet.absoluteFill}
start={{ x: 0, y: 0 }}
end={{ x: 1, y: 1 }}
/>
{/* Top glow for YES+YES */}
{isYesYes && (
<LinearGradient
colors={[`${ACCENT_RGBA}0.15)`, 'transparent']}
style={styles.matchCardGlow}
start={{ x: 0.5, y: 0 }}
end={{ x: 0.5, y: 1 }}
/>
)}
{/* Match Label */}
<Text style={[styles.matchLabel, isYesYes && styles.matchLabelYesYes]}>
{isYesYes ? "PERFECT MATCH" : "SOFT MATCH"}
</Text>
{/* Decorative Separator */}
<DecorativeSeparator
variant={isYesYes ? "gold" : "rose"}
width="60%"
marginVertical={spacing.md}
/>
{/* Question Text */}
<Text style={styles.matchQuestionText}>
"{match.question.text}"
</Text>
{match.question.partner_text && (
<Text style={styles.matchQuestionPartnerText}>
"{match.question.partner_text}"
</Text>
)}
{/* Response Pills */}
{match.responses && match.responses.length > 0 && (
<View style={styles.responsePillsRow}>
{match.responses.map((response: any) => {
const isUser = response.user_id === user?.id;
const isYes = response.answer === 'yes';
return (
<View key={response.user_id} style={styles.responsePillWrapper}>
{isYes ? (
<LinearGradient
colors={isYesYes
? gradients.premiumGold as [string, string]
: [colors.success, '#27ae60']
}
style={styles.responsePill}
start={{ x: 0, y: 0 }}
end={{ x: 1, y: 1 }}
>
<Ionicons name="checkmark" size={12} color={colors.text} />
<Text style={styles.responsePillText}>
{isUser ? 'You' : (response.profiles?.name?.split(' ')[0] || 'Partner')}
</Text>
</LinearGradient>
) : (
<View style={styles.responsePillMaybe}>
<Ionicons name="help" size={12} color={colors.warning} />
<Text style={styles.responsePillTextMaybe}>
{isUser ? 'You' : (response.profiles?.name?.split(' ')[0] || 'Partner')}
</Text>
</View>
)}
</View>
);
})}
</View>
)}
{/* Premium border */}
<View style={[
styles.matchCardBorder,
isYesYes && styles.matchCardBorderYesYes
]} pointerEvents="none" />
</Animated.View>
)}
</>
);
};
const styles = StyleSheet.create({
header: {
flexDirection: 'row',
alignItems: 'center',
paddingHorizontal: spacing.md,
paddingBottom: spacing.md,
overflow: 'hidden',
},
headerSilkHighlight: {
position: 'absolute',
top: 0,
left: 0,
right: 0,
height: 60,
},
backButton: {
width: 40,
height: 40,
justifyContent: 'center',
alignItems: 'center',
},
backButtonInner: {
width: 36,
height: 36,
borderRadius: 18,
backgroundColor: `${ACCENT_RGBA}0.1)`,
borderWidth: 1,
borderColor: `${ACCENT_RGBA}0.2)`,
justifyContent: 'center',
alignItems: 'center',
},
headerCenter: {
flex: 1,
flexDirection: 'row',
alignItems: 'center',
justifyContent: 'center',
gap: spacing.sm,
},
headerAvatarContainer: {
position: 'relative',
},
headerAvatar: {
width: 40,
height: 40,
borderRadius: 20,
},
avatarRing: {
position: 'absolute',
top: -2,
left: -2,
right: -2,
bottom: -2,
borderRadius: 22,
borderWidth: 2,
borderColor: `${ACCENT_RGBA}0.3)`,
},
headerAvatarGradient: {
width: 40,
height: 40,
borderRadius: 20,
justifyContent: 'center',
alignItems: 'center',
},
headerAvatarText: {
fontSize: 16,
fontWeight: 'bold',
color: colors.text,
},
headerTextContainer: {
alignItems: 'flex-start',
},
headerLabel: {
...typography.caption2,
fontWeight: '600',
letterSpacing: 2,
color: ACCENT,
opacity: 0.8,
},
headerTitle: {
...typography.headline,
color: colors.text,
},
headerSpacer: {
width: 40,
},
headerBorderBottom: {
position: 'absolute',
bottom: 0,
left: 0,
right: 0,
height: 1,
backgroundColor: `${ACCENT_RGBA}0.15)`,
},
matchCard: {
marginHorizontal: spacing.md,
marginTop: spacing.sm,
marginBottom: spacing.xs,
borderRadius: radius.lg,
paddingVertical: spacing.md,
paddingHorizontal: spacing.md,
alignItems: 'center',
overflow: 'hidden',
},
matchCardGlow: {
position: 'absolute',
top: 0,
left: 0,
right: 0,
height: 60,
},
matchLabel: {
...typography.caption2,
fontWeight: '700',
letterSpacing: 2,
color: colors.premium.rose,
marginBottom: spacing.xs,
},
matchLabelYesYes: {
color: ACCENT,
},
matchQuestionText: {
...typography.subhead,
color: colors.text,
textAlign: 'center',
fontStyle: 'italic',
lineHeight: 20,
},
matchQuestionPartnerText: {
...typography.caption1,
color: colors.textSecondary,
textAlign: 'center',
fontStyle: 'italic',
marginTop: 2,
},
responsePillsRow: {
flexDirection: 'row',
justifyContent: 'center',
gap: spacing.sm,
marginTop: spacing.sm,
},
responsePillWrapper: {
overflow: 'hidden',
borderRadius: radius.full,
},
responsePill: {
flexDirection: 'row',
alignItems: 'center',
paddingHorizontal: spacing.sm,
paddingVertical: 4,
gap: 4,
},
responsePillText: {
...typography.caption2,
fontWeight: '600',
color: colors.text,
},
responsePillMaybe: {
flexDirection: 'row',
alignItems: 'center',
paddingHorizontal: spacing.sm,
paddingVertical: 4,
gap: 4,
backgroundColor: `rgba(243, 156, 18, 0.15)`,
borderWidth: 1,
borderColor: `rgba(243, 156, 18, 0.3)`,
borderRadius: radius.full,
},
responsePillTextMaybe: {
...typography.caption2,
fontWeight: '600',
color: colors.warning,
},
matchCardBorder: {
position: 'absolute',
top: 0,
left: 0,
right: 0,
bottom: 0,
borderRadius: radius.lg,
borderWidth: 1,
borderColor: `rgba(232, 164, 174, 0.2)`,
},
matchCardBorderYesYes: {
borderColor: `${ACCENT_RGBA}0.25)`,
},
});
@@ -0,0 +1,132 @@
import React, { useRef } from 'react';
import { View, Text, FlatList, StyleSheet } from 'react-native';
import { Ionicons } from '@expo/vector-icons';
import Animated, { FadeIn } from 'react-native-reanimated';
import { MessageBubble } from './MessageBubble';
import { MessageContent } from './MessageContent';
import { UploadProgress } from './UploadProgress';
import { TypingIndicator } from './TypingIndicator';
import { DecorativeSeparator } from '../../../components/ui';
import { colors, spacing, typography } from '../../../theme';
import { Database } from '../../../types/supabase';
import { UploadStatus } from '../types';
type Message = Database['public']['Tables']['messages']['Row'];
interface ChatMessagesProps {
messages: Message[];
userId: string | undefined;
uploadStatus: UploadStatus | null;
partnerTyping: boolean;
onImagePress: (uri: string) => void;
onVideoFullScreen: (uri: string) => void;
revealMessage: (messageId: string) => void;
}
const ACCENT = colors.premium.gold;
const ACCENT_RGBA = 'rgba(212, 175, 55, ';
export const ChatMessages: React.FC<ChatMessagesProps> = ({
messages,
userId,
uploadStatus,
partnerTyping,
onImagePress,
onVideoFullScreen,
revealMessage,
}) => {
const flatListRef = useRef<FlatList>(null);
const renderMessage = ({ item, index }: { item: Message; index: number }) => {
const isMe = item.user_id === userId;
return (
<MessageBubble isMe={isMe} index={index}>
<MessageContent
item={item}
isMe={isMe}
revealMessage={revealMessage}
onImagePress={onImagePress}
onVideoFullScreen={onVideoFullScreen}
/>
</MessageBubble>
);
};
return (
<FlatList
ref={flatListRef}
data={messages}
renderItem={renderMessage}
keyExtractor={(item) => item.id}
inverted
style={styles.messageList}
contentContainerStyle={styles.listContent}
keyboardShouldPersistTaps="handled"
showsVerticalScrollIndicator={false}
ListHeaderComponent={
<>
{/* Show uploading status when media is being uploaded */}
{uploadStatus && <UploadProgress uploadStatus={uploadStatus} />}
{/* Show typing indicator when partner is typing */}
{partnerTyping && <TypingIndicator />}
</>
}
ListEmptyComponent={
<Animated.View
entering={FadeIn.delay(200).duration(400)}
style={styles.emptyChat}
>
{/* Premium icon */}
<View style={styles.emptyIconContainer}>
<Ionicons name="chatbubbles-outline" size={32} color={ACCENT} />
</View>
{/* Decorative separator */}
<DecorativeSeparator variant="gold" width={100} marginVertical={spacing.md} />
<Text style={styles.emptyChatTitle}>Start the Conversation</Text>
<Text style={styles.emptyChatSubtitle}>
Share your thoughts about this match
</Text>
</Animated.View>
}
/>
);
};
const styles = StyleSheet.create({
messageList: {
flex: 1,
},
listContent: {
flexGrow: 1,
padding: spacing.md,
},
emptyChat: {
alignItems: 'center',
justifyContent: 'center',
paddingVertical: spacing.xxl,
},
emptyIconContainer: {
width: 72,
height: 72,
borderRadius: 36,
backgroundColor: `${ACCENT_RGBA}0.1)`,
borderWidth: 1,
borderColor: `${ACCENT_RGBA}0.2)`,
justifyContent: 'center',
alignItems: 'center',
marginBottom: spacing.md,
},
emptyChatTitle: {
...typography.headline,
color: colors.text,
marginBottom: spacing.xs,
},
emptyChatSubtitle: {
...typography.subhead,
color: colors.textSecondary,
},
});
@@ -0,0 +1,234 @@
import React, { useState, useEffect, useRef, useCallback } from 'react';
import { View, Text, TouchableOpacity, ActivityIndicator, StyleSheet } from 'react-native';
import { Video, ResizeMode, AVPlaybackStatus } from 'expo-av';
import { LinearGradient } from 'expo-linear-gradient';
import { Ionicons } from '@expo/vector-icons';
import { colors, gradients, spacing, radius } from '../../../theme';
import { useVideoCache } from '../hooks';
export interface ChatVideoPlayerProps {
/** Signed URL for the video */
signedUrl: string | null;
/** Storage path for caching */
storagePath: string;
/** Whether there was an error loading the URL */
urlError: boolean;
/** Callback when fullscreen is requested */
onFullScreen: (uri: string) => void;
}
const ACCENT = colors.premium.gold;
/**
* Video player component with native-like tap behavior.
* Supports play/pause, fullscreen, caching, and loading states.
*/
export function ChatVideoPlayer({
signedUrl,
storagePath,
urlError,
onFullScreen,
}: ChatVideoPlayerProps) {
const videoRef = useRef<Video>(null);
const [isPlaying, setIsPlaying] = useState(false);
const [hasEnded, setHasEnded] = useState(false);
const [isLoading, setIsLoading] = useState(true);
// Use video cache hook
const { cachedUri, cacheVideoFile } = useVideoCache(storagePath, signedUrl);
// Handle playback status updates
const handlePlaybackStatusUpdate = useCallback(async (status: AVPlaybackStatus) => {
if (!status.isLoaded) return;
setIsLoading(false);
setIsPlaying(status.isPlaying);
if (status.didJustFinish) {
setHasEnded(true);
setIsPlaying(false);
// Cache in background if not already cached
if (signedUrl && !cachedUri) {
cacheVideoFile();
}
}
}, [signedUrl, storagePath, cachedUri]);
// Toggle play/pause with native-like behavior
const handleTapToPlay = useCallback(async () => {
if (!videoRef.current) return;
if (isPlaying) {
await videoRef.current.pauseAsync();
setIsPlaying(false);
} else {
if (hasEnded) {
await videoRef.current.setPositionAsync(0);
setHasEnded(false);
}
await videoRef.current.playAsync();
setIsPlaying(true);
// Start caching when playback starts
if (signedUrl && !cachedUri) {
cacheVideoFile();
}
}
}, [isPlaying, hasEnded, signedUrl, storagePath, cachedUri]);
// Handle full screen
const handleFullScreen = useCallback(() => {
const uri = cachedUri || signedUrl;
if (uri) {
if (videoRef.current && isPlaying) {
videoRef.current.pauseAsync();
setIsPlaying(false);
}
onFullScreen(uri);
}
}, [cachedUri, signedUrl, isPlaying, onFullScreen]);
const videoSource = cachedUri || signedUrl;
if (urlError || !videoSource) {
return (
<View style={[styles.video, styles.errorContainer]}>
<Ionicons name="videocam-outline" size={32} color={colors.textSecondary} />
<Text style={styles.errorText}>Video unavailable</Text>
</View>
);
}
return (
<TouchableOpacity
activeOpacity={0.9}
onPress={handleTapToPlay}
onLongPress={handleFullScreen}
delayLongPress={300}
style={styles.container}
>
<Video
ref={videoRef}
source={{ uri: videoSource }}
style={styles.video}
resizeMode={ResizeMode.COVER}
isLooping={false}
shouldPlay={false}
positionMillis={0}
onPlaybackStatusUpdate={handlePlaybackStatusUpdate}
/>
{/* Loading indicator */}
{isLoading && (
<View style={styles.loadingOverlay}>
<ActivityIndicator color={ACCENT} size="small" />
</View>
)}
{/* Play/Replay overlay */}
{!isPlaying && !isLoading && (
<View style={styles.playOverlay}>
<LinearGradient
colors={gradients.premiumGold as [string, string]}
style={styles.playButton}
start={{ x: 0, y: 0 }}
end={{ x: 1, y: 1 }}
>
<Ionicons
name={hasEnded ? 'refresh' : 'play'}
size={24}
color={colors.text}
style={hasEnded ? undefined : { marginLeft: 3 }}
/>
</LinearGradient>
</View>
)}
{/* Full screen button */}
{!isPlaying && !isLoading && (
<TouchableOpacity
style={styles.fullScreenButton}
onPress={handleFullScreen}
activeOpacity={0.7}
>
<Ionicons name="expand-outline" size={16} color={colors.text} />
</TouchableOpacity>
)}
{/* Cached indicator */}
{cachedUri && !isPlaying && (
<View style={styles.cachedBadge}>
<Ionicons name="download-outline" size={10} color={colors.textSecondary} />
</View>
)}
</TouchableOpacity>
);
}
const styles = StyleSheet.create({
container: {
width: '100%',
aspectRatio: 16 / 9,
borderRadius: radius.lg,
overflow: 'hidden',
},
video: {
width: '100%',
height: '100%',
backgroundColor: colors.background,
},
errorContainer: {
justifyContent: 'center',
alignItems: 'center',
backgroundColor: 'rgba(0, 0, 0, 0.3)',
},
errorText: {
color: colors.textSecondary,
fontSize: 12,
marginTop: spacing.xs,
},
loadingOverlay: {
...StyleSheet.absoluteFillObject,
justifyContent: 'center',
alignItems: 'center',
backgroundColor: 'rgba(0, 0, 0, 0.3)',
},
playOverlay: {
...StyleSheet.absoluteFillObject,
justifyContent: 'center',
alignItems: 'center',
},
playButton: {
width: 56,
height: 56,
borderRadius: 28,
justifyContent: 'center',
alignItems: 'center',
},
fullScreenButton: {
position: 'absolute',
bottom: spacing.sm,
right: spacing.sm,
width: 32,
height: 32,
borderRadius: 16,
backgroundColor: 'rgba(0, 0, 0, 0.5)',
justifyContent: 'center',
alignItems: 'center',
},
cachedBadge: {
position: 'absolute',
top: spacing.xs,
left: spacing.xs,
width: 20,
height: 20,
borderRadius: 10,
backgroundColor: 'rgba(0, 0, 0, 0.5)',
justifyContent: 'center',
alignItems: 'center',
},
});
export default ChatVideoPlayer;
@@ -0,0 +1,240 @@
import React, { useState } from 'react';
import { View, TextInput, TouchableOpacity, ActivityIndicator, StyleSheet } from 'react-native';
import { LinearGradient } from 'expo-linear-gradient';
import { Ionicons } from '@expo/vector-icons';
import Animated, {
useSharedValue,
useAnimatedStyle,
withTiming,
interpolate,
Easing,
} from 'react-native-reanimated';
import { colors, gradients, spacing, radius, typography } from '../../../theme';
export interface InputBarProps {
inputText: string;
uploading: boolean;
onChangeText: (text: string) => void;
onSend: () => void;
onPickMedia: () => void;
onTakePhoto: () => void;
onRecordVideo: () => void;
}
const ACCENT = colors.premium.gold;
/**
* Chat input bar with expandable media menu.
* Includes text input, send button, and media action buttons.
*/
export function InputBar({
inputText,
uploading,
onChangeText,
onSend,
onPickMedia,
onTakePhoto,
onRecordVideo,
}: InputBarProps) {
const [menuExpanded, setMenuExpanded] = useState(false);
const menuWidth = useSharedValue(0);
const buttonRotation = useSharedValue(0);
const toggleMenu = () => {
const newExpanded = !menuExpanded;
setMenuExpanded(newExpanded);
menuWidth.value = withTiming(newExpanded ? 132 : 0, { duration: 200, easing: Easing.out(Easing.ease) });
buttonRotation.value = withTiming(newExpanded ? 45 : 0, { duration: 200, easing: Easing.out(Easing.ease) });
};
const handleMediaAction = (action: () => void) => {
setMenuExpanded(false);
menuWidth.value = withTiming(0, { duration: 150 });
buttonRotation.value = withTiming(0, { duration: 150 });
action();
};
const menuAnimatedStyle = useAnimatedStyle(() => ({
width: menuWidth.value,
opacity: interpolate(menuWidth.value, [0, 132], [0, 1]),
}));
const plusButtonAnimatedStyle = useAnimatedStyle(() => ({
transform: [{ rotate: `${buttonRotation.value}deg` }],
}));
return (
<View style={styles.container}>
{/* Plus button to toggle media menu */}
<TouchableOpacity onPress={toggleMenu} disabled={uploading} style={styles.attachButton}>
{uploading ? (
<ActivityIndicator color={ACCENT} size="small" />
) : (
<Animated.View style={[styles.attachButtonInner, styles.plusButton, plusButtonAnimatedStyle]}>
<Ionicons name="add" size={24} color={ACCENT} />
</Animated.View>
)}
</TouchableOpacity>
{/* Expandable media options */}
<Animated.View style={[styles.mediaMenuContainer, menuAnimatedStyle]}>
<TouchableOpacity
onPress={() => handleMediaAction(onTakePhoto)}
style={styles.mediaMenuItem}
activeOpacity={0.7}
>
<View style={styles.mediaMenuItemInner}>
<Ionicons name="camera-outline" size={20} color={colors.textSecondary} />
</View>
</TouchableOpacity>
<TouchableOpacity
onPress={() => handleMediaAction(onRecordVideo)}
style={styles.mediaMenuItem}
activeOpacity={0.7}
>
<View style={styles.mediaMenuItemInner}>
<Ionicons name="videocam-outline" size={20} color={colors.textSecondary} />
</View>
</TouchableOpacity>
<TouchableOpacity
onPress={() => handleMediaAction(onPickMedia)}
style={styles.mediaMenuItem}
activeOpacity={0.7}
>
<View style={styles.mediaMenuItemInner}>
<Ionicons name="image-outline" size={20} color={colors.textSecondary} />
</View>
</TouchableOpacity>
</Animated.View>
<View style={styles.inputFieldWrapper}>
{/* Subtle gradient border effect */}
<View style={styles.inputFieldBorder} />
<TextInput
style={styles.input}
value={inputText}
onChangeText={onChangeText}
placeholder="Type a message..."
placeholderTextColor={colors.textTertiary}
multiline
/>
</View>
<TouchableOpacity
onPress={onSend}
disabled={!inputText.trim()}
activeOpacity={0.8}
>
<LinearGradient
colors={inputText.trim() ? gradients.primary as [string, string] : ['rgba(22, 33, 62, 0.6)', 'rgba(22, 33, 62, 0.6)']}
style={styles.sendButton}
start={{ x: 0, y: 0 }}
end={{ x: 1, y: 1 }}
>
{/* Silk highlight */}
{inputText.trim() && (
<LinearGradient
colors={['rgba(255, 255, 255, 0.2)', 'transparent']}
style={styles.sendButtonHighlight}
start={{ x: 0.5, y: 0 }}
end={{ x: 0.5, y: 1 }}
/>
)}
<Ionicons
name="send"
size={18}
color={inputText.trim() ? colors.text : colors.textTertiary}
/>
</LinearGradient>
</TouchableOpacity>
</View>
);
}
const styles = StyleSheet.create({
container: {
flexDirection: 'row',
alignItems: 'flex-end',
paddingHorizontal: spacing.md,
paddingVertical: spacing.sm,
gap: spacing.sm,
},
attachButton: {
width: 40,
height: 40,
justifyContent: 'center',
alignItems: 'center',
},
attachButtonInner: {
width: 36,
height: 36,
borderRadius: 18,
justifyContent: 'center',
alignItems: 'center',
},
plusButton: {
backgroundColor: 'rgba(212, 175, 55, 0.1)',
borderWidth: 1,
borderColor: 'rgba(212, 175, 55, 0.2)',
},
mediaMenuContainer: {
flexDirection: 'row',
overflow: 'hidden',
gap: spacing.xs,
},
mediaMenuItem: {
width: 40,
height: 40,
justifyContent: 'center',
alignItems: 'center',
},
mediaMenuItemInner: {
width: 36,
height: 36,
borderRadius: 18,
backgroundColor: 'rgba(255, 255, 255, 0.05)',
borderWidth: 1,
borderColor: 'rgba(255, 255, 255, 0.1)',
justifyContent: 'center',
alignItems: 'center',
},
inputFieldWrapper: {
flex: 1,
position: 'relative',
},
inputFieldBorder: {
...StyleSheet.absoluteFillObject,
borderRadius: radius.xl,
borderWidth: 1,
borderColor: 'rgba(212, 175, 55, 0.15)',
},
input: {
...typography.body,
color: colors.text,
backgroundColor: 'rgba(22, 33, 62, 0.6)',
borderRadius: radius.xl,
paddingHorizontal: spacing.md,
paddingTop: 12,
paddingBottom: 12,
maxHeight: 100,
},
sendButton: {
width: 40,
height: 40,
borderRadius: 20,
justifyContent: 'center',
alignItems: 'center',
overflow: 'hidden',
},
sendButtonHighlight: {
position: 'absolute',
top: 0,
left: 0,
right: 0,
height: 20,
},
});
export default InputBar;
@@ -0,0 +1,108 @@
/**
* MessageBubble - Wrapper component for chat message bubbles
* Applies gradient styling for sender vs receiver messages.
*/
import React from 'react';
import { View, StyleSheet } from 'react-native';
import { LinearGradient } from 'expo-linear-gradient';
import Animated, { FadeInUp } from 'react-native-reanimated';
import { colors, gradients, spacing, radius } from '../../../theme';
const ACCENT_RGBA = 'rgba(212, 175, 55, ';
export interface MessageBubbleProps {
isMe: boolean;
index: number;
children: React.ReactNode;
}
export function MessageBubble({ isMe, index, children }: MessageBubbleProps) {
return (
<Animated.View
entering={FadeInUp.delay(index * 30).duration(200)}
style={[styles.messageRow, isMe ? styles.myMessageRow : styles.theirMessageRow]}
>
{isMe ? (
<View style={styles.myBubbleContainer}>
<LinearGradient
colors={gradients.primary as [string, string]}
style={[styles.bubble, styles.myBubble]}
start={{ x: 0, y: 0 }}
end={{ x: 1, y: 1 }}
>
{/* Silk highlight */}
<LinearGradient
colors={['rgba(255, 255, 255, 0.15)', 'transparent']}
style={styles.bubbleSilkHighlight}
start={{ x: 0.5, y: 0 }}
end={{ x: 0.5, y: 1 }}
/>
{children}
</LinearGradient>
</View>
) : (
<View style={styles.theirBubbleContainer}>
<View style={[styles.bubble, styles.theirBubble]}>
{/* Subtle gradient background */}
<LinearGradient
colors={['rgba(22, 33, 62, 0.6)', 'rgba(13, 13, 26, 0.8)']}
style={StyleSheet.absoluteFill}
start={{ x: 0, y: 0 }}
end={{ x: 1, y: 1 }}
/>
{/* Silk highlight */}
<LinearGradient
colors={[`${ACCENT_RGBA}0.08)`, 'transparent']}
style={styles.bubbleSilkHighlight}
start={{ x: 0.5, y: 0 }}
end={{ x: 0.5, y: 1 }}
/>
{children}
</View>
</View>
)}
</Animated.View>
);
}
const styles = StyleSheet.create({
messageRow: {
flexDirection: 'row',
marginBottom: spacing.sm,
paddingHorizontal: spacing.md,
},
myMessageRow: {
justifyContent: 'flex-end',
},
theirMessageRow: {
justifyContent: 'flex-start',
},
myBubbleContainer: {
maxWidth: '80%',
},
theirBubbleContainer: {
maxWidth: '80%',
},
bubble: {
padding: spacing.md,
borderRadius: radius.lg,
overflow: 'hidden',
},
myBubble: {
borderTopRightRadius: radius.xs,
},
theirBubble: {
borderTopLeftRadius: radius.xs,
borderWidth: 1,
borderColor: `${ACCENT_RGBA}0.15)`,
},
bubbleSilkHighlight: {
position: 'absolute',
top: 0,
left: 0,
right: 0,
height: 30,
},
});
export default MessageBubble;
@@ -0,0 +1,316 @@
/**
* MessageContent - Renders message content (text, images, videos)
* Handles media reveal logic, expired media, and loading states.
*/
import React, { useState, useEffect } from 'react';
import { View, TouchableOpacity, Text, StyleSheet, ActivityIndicator } from 'react-native';
import { Image } from 'expo-image';
import { LinearGradient } from 'expo-linear-gradient';
import { Ionicons } from '@expo/vector-icons';
import { colors, gradients, spacing, radius } from '../../../theme';
import { getCachedSignedUrl, getStoragePath } from '../../../lib/imageCache';
import { MessageMeta } from './MessageMeta';
import { ChatVideoPlayer } from './ChatVideoPlayer';
import type { Message } from '../types';
const ACCENT = colors.premium.gold;
export interface MessageContentProps {
item: Message;
isMe: boolean;
revealMessage: (id: string) => void;
onImagePress: (uri: string) => void;
onVideoFullScreen: (uri: string) => void;
}
export function MessageContent({
item,
isMe,
revealMessage,
onImagePress,
onVideoFullScreen,
}: MessageContentProps) {
const [signedUrl, setSignedUrl] = useState<string | null>(null);
const [urlError, setUrlError] = useState(false);
useEffect(() => {
if (!item.media_path) return;
let isMounted = true;
const fetchSignedUrl = async () => {
const storagePath = getStoragePath(item.media_path!);
const url = await getCachedSignedUrl(storagePath);
if (!isMounted) return;
if (url) {
setSignedUrl(url);
} else {
setUrlError(true);
}
};
fetchSignedUrl();
return () => {
isMounted = false;
};
}, [item.media_path]);
// Handle expired videos
const isExpired = !!(item as any).media_expired;
const isVideo = (item as any).media_type === 'video';
if (isExpired && isVideo) {
return (
<View>
<View style={styles.expiredMedia}>
<LinearGradient
colors={['rgba(22, 33, 62, 0.8)', 'rgba(13, 13, 26, 0.9)']}
style={StyleSheet.absoluteFill}
start={{ x: 0, y: 0 }}
end={{ x: 1, y: 1 }}
/>
<View style={styles.expiredIconContainer}>
<Ionicons name="videocam-off-outline" size={28} color={colors.textTertiary} />
</View>
<Text style={styles.expiredText}>Video expired</Text>
<Text style={styles.expiredSubtext}>Videos are deleted 30 days after viewing</Text>
</View>
<MessageMeta item={item} isMe={isMe} />
</View>
);
}
// Handle media (images and videos)
if (item.media_path || isExpired) {
const isViewed = !!item.media_viewed_at;
const canOpenFullScreen = (isMe || isViewed) && !isVideo; // Only images can go fullscreen
// Video content
if (isVideo) {
return (
<View>
{(!isMe && !isViewed) ? (
// Blurred placeholder with reveal overlay for unrevealed videos
<View>
<View style={[styles.messageVideo, styles.videoBlurred]}>
<LinearGradient
colors={['rgba(22, 33, 62, 0.9)', 'rgba(13, 13, 26, 0.95)']}
style={StyleSheet.absoluteFill}
start={{ x: 0, y: 0 }}
end={{ x: 1, y: 1 }}
/>
<Ionicons name="videocam" size={40} color={colors.textTertiary} />
</View>
<TouchableOpacity
style={styles.revealOverlay}
activeOpacity={0.8}
onPress={() => revealMessage(item.id)}
>
<View style={styles.revealContent}>
<LinearGradient
colors={gradients.premiumGold as [string, string]}
style={styles.revealIconContainer}
start={{ x: 0, y: 0 }}
end={{ x: 1, y: 1 }}
>
<Ionicons name="eye-off-outline" size={24} color={colors.text} />
</LinearGradient>
<Text style={styles.revealText}>Tap to reveal video</Text>
</View>
</TouchableOpacity>
</View>
) : signedUrl || urlError ? (
<ChatVideoPlayer
signedUrl={signedUrl}
storagePath={item.media_path!}
urlError={urlError}
onFullScreen={onVideoFullScreen}
/>
) : (
<View style={[styles.messageVideo, styles.messageImageLoading]}>
<ActivityIndicator color={ACCENT} />
</View>
)}
{/* Viewed indicator for sender */}
{isMe && isViewed && (
<View style={styles.viewedBadge}>
<Ionicons name="eye" size={12} color={colors.success} />
<Text style={styles.viewedText}>Viewed</Text>
</View>
)}
<MessageMeta item={item} isMe={isMe} />
</View>
);
}
// Image content
return (
<View>
<TouchableOpacity
activeOpacity={canOpenFullScreen ? 0.8 : 1}
onPress={() => canOpenFullScreen && signedUrl && onImagePress(signedUrl)}
disabled={!canOpenFullScreen || !signedUrl}
>
{signedUrl ? (
<Image
source={{ uri: signedUrl }}
style={styles.messageImage}
blurRadius={(!isMe && !isViewed) ? 20 : 0}
cachePolicy="disk"
transition={200}
/>
) : urlError ? (
<View style={[styles.messageImage, styles.messageImageError]}>
<Ionicons name="image-outline" size={32} color={colors.textSecondary} />
<Text style={styles.messageImageErrorText}>Image unavailable</Text>
</View>
) : (
<View style={[styles.messageImage, styles.messageImageLoading]}>
<ActivityIndicator color={ACCENT} />
</View>
)}
</TouchableOpacity>
{/* Reveal overlay for recipient */}
{(!isMe && !isViewed) && (
<TouchableOpacity
style={styles.revealOverlay}
activeOpacity={0.8}
onPress={() => revealMessage(item.id)}
>
<View style={styles.revealContent}>
<LinearGradient
colors={gradients.premiumGold as [string, string]}
style={styles.revealIconContainer}
start={{ x: 0, y: 0 }}
end={{ x: 1, y: 1 }}
>
<Ionicons name="eye-off-outline" size={24} color={colors.text} />
</LinearGradient>
<Text style={styles.revealText}>Tap to reveal</Text>
</View>
</TouchableOpacity>
)}
{/* Viewed indicator for sender */}
{isMe && isViewed && (
<View style={styles.viewedBadge}>
<Ionicons name="eye" size={12} color={colors.success} />
<Text style={styles.viewedText}>Viewed</Text>
</View>
)}
<MessageMeta item={item} isMe={isMe} />
</View>
);
}
// Text message
return (
<>
<Text style={styles.messageText}>{item.content}</Text>
<MessageMeta item={item} isMe={isMe} />
</>
);
}
const styles = StyleSheet.create({
messageText: {
color: colors.text,
fontSize: 16,
lineHeight: 22,
},
messageImage: {
width: 200,
height: 200,
borderRadius: radius.md,
backgroundColor: 'rgba(255, 255, 255, 0.05)',
},
messageImageLoading: {
justifyContent: 'center',
alignItems: 'center',
},
messageImageError: {
justifyContent: 'center',
alignItems: 'center',
gap: spacing.sm,
},
messageImageErrorText: {
color: colors.textSecondary,
fontSize: 12,
},
messageVideo: {
width: 200,
height: 200,
borderRadius: radius.md,
backgroundColor: 'rgba(255, 255, 255, 0.05)',
overflow: 'hidden',
},
videoBlurred: {
justifyContent: 'center',
alignItems: 'center',
},
revealOverlay: {
...StyleSheet.absoluteFillObject,
justifyContent: 'center',
alignItems: 'center',
borderRadius: radius.md,
},
revealContent: {
alignItems: 'center',
gap: spacing.sm,
},
revealIconContainer: {
width: 48,
height: 48,
borderRadius: 24,
justifyContent: 'center',
alignItems: 'center',
},
revealText: {
color: colors.text,
fontSize: 13,
fontWeight: '600',
},
viewedBadge: {
flexDirection: 'row',
alignItems: 'center',
gap: 4,
marginTop: spacing.xs,
},
viewedText: {
color: colors.success,
fontSize: 11,
fontWeight: '500',
},
expiredMedia: {
width: 200,
height: 150,
borderRadius: radius.md,
overflow: 'hidden',
justifyContent: 'center',
alignItems: 'center',
gap: spacing.sm,
},
expiredIconContainer: {
width: 48,
height: 48,
borderRadius: 24,
backgroundColor: 'rgba(255, 255, 255, 0.08)',
justifyContent: 'center',
alignItems: 'center',
},
expiredText: {
color: colors.textSecondary,
fontSize: 14,
fontWeight: '600',
},
expiredSubtext: {
color: colors.textTertiary,
fontSize: 11,
textAlign: 'center',
paddingHorizontal: spacing.md,
},
});
export default MessageContent;
@@ -0,0 +1,47 @@
import React from 'react';
import { View, Text, StyleSheet } from 'react-native';
import { ReadReceipt, getReceiptStatus } from '../../../components/ui';
import { colors, typography, spacing } from '../../../theme';
import { Message } from '../types';
export interface MessageMetaProps {
item: Message;
isMe: boolean;
}
/**
* Message metadata component showing timestamp and read receipts.
*/
export function MessageMeta({ item, isMe }: MessageMetaProps) {
const status = getReceiptStatus(item.delivered_at, item.read_at);
return (
<View style={styles.container}>
<Text style={styles.timestamp}>
{new Date(item.created_at!).toLocaleTimeString([], { hour: '2-digit', minute: '2-digit' })}
</Text>
{isMe && (
<View style={styles.readStatus}>
<ReadReceipt status={status} />
</View>
)}
</View>
);
}
const styles = StyleSheet.create({
container: {
flexDirection: 'row',
alignItems: 'center',
marginTop: spacing.xs,
},
timestamp: {
...typography.caption2,
color: 'rgba(255, 255, 255, 0.5)',
},
readStatus: {
marginLeft: spacing.xs,
},
});
export default MessageMeta;
@@ -0,0 +1,108 @@
import React, { useEffect } from 'react';
import { View, StyleSheet } from 'react-native';
import { LinearGradient } from 'expo-linear-gradient';
import Animated, {
useSharedValue,
useAnimatedStyle,
withRepeat,
withSequence,
withTiming,
FadeIn,
} from 'react-native-reanimated';
import { gradients, colors, radius, spacing } from '../../../theme';
interface TypingDotProps {
delay: number;
}
function TypingDot({ delay }: TypingDotProps) {
const opacity = useSharedValue(0.4);
useEffect(() => {
opacity.value = withRepeat(
withSequence(
withTiming(1, { duration: 300 }),
withTiming(0.4, { duration: 300 })
),
-1,
true
);
}, []);
const animatedStyle = useAnimatedStyle(() => ({
opacity: opacity.value,
}));
return (
<Animated.View style={[styles.typingDot, animatedStyle, { marginLeft: delay > 0 ? 4 : 0 }]}>
<LinearGradient
colors={gradients.premiumGold as [string, string]}
style={styles.typingDotGradient}
start={{ x: 0, y: 0 }}
end={{ x: 1, y: 1 }}
/>
</Animated.View>
);
}
/**
* Typing indicator component showing three animated dots
* when partner is typing.
*/
export function TypingIndicator() {
return (
<Animated.View
entering={FadeIn.duration(200)}
style={styles.container}
>
<View style={styles.bubble}>
{/* Subtle gradient */}
<LinearGradient
colors={['rgba(22, 33, 62, 0.6)', 'rgba(13, 13, 26, 0.8)']}
style={StyleSheet.absoluteFill}
start={{ x: 0, y: 0 }}
end={{ x: 1, y: 1 }}
/>
<View style={styles.dots}>
<TypingDot delay={0} />
<TypingDot delay={150} />
<TypingDot delay={300} />
</View>
</View>
</Animated.View>
);
}
const styles = StyleSheet.create({
container: {
alignItems: 'flex-start',
marginBottom: spacing.sm,
},
bubble: {
flexDirection: 'row',
alignItems: 'center',
paddingHorizontal: spacing.md,
paddingVertical: spacing.sm,
borderRadius: radius.xl,
borderWidth: 1,
borderColor: 'rgba(212, 175, 55, 0.15)',
backgroundColor: 'rgba(22, 33, 62, 0.6)',
overflow: 'hidden',
},
dots: {
flexDirection: 'row',
alignItems: 'center',
},
typingDot: {
width: 8,
height: 8,
borderRadius: 4,
overflow: 'hidden',
},
typingDotGradient: {
flex: 1,
borderRadius: 4,
},
});
export default TypingIndicator;
@@ -0,0 +1,151 @@
import React, { useEffect } from 'react';
import { View, Text, Image, ActivityIndicator, StyleSheet } from 'react-native';
import { LinearGradient } from 'expo-linear-gradient';
import { Ionicons } from '@expo/vector-icons';
import Animated, {
FadeIn,
useSharedValue,
useAnimatedStyle,
withRepeat,
withTiming,
interpolate,
Easing,
} from 'react-native-reanimated';
import { colors, gradients, spacing, radius } from '../../../theme';
import { UploadStatus } from '../types';
export interface UploadProgressProps {
uploadStatus: UploadStatus;
}
const ACCENT = colors.premium.gold;
/**
* Upload progress skeleton component showing when media is being uploaded.
* Displays a shimmer effect with thumbnail preview for images.
*/
export function UploadProgress({ uploadStatus }: UploadProgressProps) {
const shimmer = useSharedValue(0);
useEffect(() => {
shimmer.value = withRepeat(
withTiming(1, { duration: 1500, easing: Easing.inOut(Easing.ease) }),
-1,
false
);
}, []);
const shimmerStyle = useAnimatedStyle(() => ({
opacity: interpolate(shimmer.value, [0, 0.5, 1], [0.3, 0.6, 0.3]),
}));
if (!uploadStatus) return null;
const isVideo = uploadStatus.mediaType === 'video';
const statusText = uploadStatus.status === 'compressing'
? 'Compressing video...'
: `Uploading ${isVideo ? 'video' : 'image'}...`;
return (
<Animated.View
entering={FadeIn.duration(200)}
style={styles.container}
>
<View style={styles.bubbleContainer}>
<LinearGradient
colors={gradients.primary as [string, string]}
style={styles.bubble}
start={{ x: 0, y: 0 }}
end={{ x: 1, y: 1 }}
>
{/* Silk highlight */}
<LinearGradient
colors={['rgba(255, 255, 255, 0.15)', 'transparent']}
style={styles.silkHighlight}
start={{ x: 0.5, y: 0 }}
end={{ x: 0.5, y: 1 }}
/>
{/* Skeleton media placeholder */}
<Animated.View style={[styles.mediaSkeleton, shimmerStyle]}>
{uploadStatus.thumbnailUri && !isVideo ? (
<Image
source={{ uri: uploadStatus.thumbnailUri }}
style={styles.thumbnail}
blurRadius={10}
/>
) : (
<View style={styles.iconContainer}>
<Ionicons
name={isVideo ? 'videocam' : 'image'}
size={32}
color={colors.textTertiary}
/>
</View>
)}
{/* Overlay with spinner and status */}
<View style={styles.overlay}>
<ActivityIndicator color={ACCENT} size="small" />
<Text style={styles.statusText}>{statusText}</Text>
</View>
</Animated.View>
</LinearGradient>
</View>
</Animated.View>
);
}
const styles = StyleSheet.create({
container: {
alignItems: 'flex-end',
marginBottom: spacing.sm,
paddingHorizontal: spacing.md,
},
bubbleContainer: {
maxWidth: '80%',
},
bubble: {
borderRadius: radius.xl,
borderTopRightRadius: radius.sm,
overflow: 'hidden',
minWidth: 200,
},
silkHighlight: {
position: 'absolute',
top: 0,
left: 0,
right: 0,
height: 40,
},
mediaSkeleton: {
height: 180,
justifyContent: 'center',
alignItems: 'center',
backgroundColor: 'rgba(0, 0, 0, 0.2)',
},
thumbnail: {
...StyleSheet.absoluteFillObject,
},
iconContainer: {
width: 64,
height: 64,
borderRadius: 32,
backgroundColor: 'rgba(255, 255, 255, 0.1)',
justifyContent: 'center',
alignItems: 'center',
},
overlay: {
...StyleSheet.absoluteFillObject,
backgroundColor: 'rgba(0, 0, 0, 0.5)',
justifyContent: 'center',
alignItems: 'center',
gap: spacing.sm,
},
statusText: {
color: colors.textSecondary,
fontSize: 13,
},
});
export default UploadProgress;
@@ -0,0 +1,16 @@
// Chat feature components
export { TypingIndicator } from './TypingIndicator';
export { InputBar } from './InputBar';
export type { InputBarProps } from './InputBar';
export { MessageMeta } from './MessageMeta';
export type { MessageMetaProps } from './MessageMeta';
export { UploadProgress } from './UploadProgress';
export type { UploadProgressProps } from './UploadProgress';
export { ChatVideoPlayer } from './ChatVideoPlayer';
export type { ChatVideoPlayerProps } from './ChatVideoPlayer';
export { MessageContent } from './MessageContent';
export type { MessageContentProps } from './MessageContent';
export { MessageBubble } from './MessageBubble';
export type { MessageBubbleProps } from './MessageBubble';
export { ChatHeader } from './ChatHeader';
export { ChatMessages } from './ChatMessages';
@@ -0,0 +1,3 @@
export * from './useMediaUpload';
export * from './useChatMessages';
export * from './useVideoCache';
@@ -0,0 +1 @@
export { useMessageSubscription as useChatMessages } from '../../../hooks';
@@ -0,0 +1,110 @@
import { useState } from 'react';
import { Alert, Platform } from 'react-native';
import * as FileSystem from 'expo-file-system';
import { Video as VideoCompressor } from 'react-native-compressor';
import { decode } from 'base64-arraybuffer';
import { supabase } from '../../../lib/supabase';
import { Events } from '../../../lib/analytics';
import { UploadStatus } from '../types';
export function useMediaUpload(matchId: string, userId: string | undefined) {
const [uploading, setUploading] = useState(false);
const [uploadStatus, setUploadStatus] = useState<UploadStatus>(null);
const uploadMedia = async (uri: string, mediaType: 'image' | 'video') => {
if (!userId) return;
setUploading(true);
setUploadStatus({ mediaType, status: mediaType === 'video' ? 'compressing' : 'uploading', thumbnailUri: uri });
try {
let fileUri = uri;
let fileBody;
let ext = mediaType === 'video' ? 'mp4' : 'jpg';
// Compress videos on native platforms before upload
if (mediaType === 'video' && Platform.OS !== 'web') {
try {
console.log('Compressing video...');
fileUri = await VideoCompressor.compress(uri, {
compressionMethod: 'auto',
maxSize: 720, // Max 720p resolution
minimumFileSizeForCompress: 0, // Always compress
});
console.log('Video compressed successfully');
ext = 'mp4'; // Compressed videos are always mp4
// Update status to uploading after compression
setUploadStatus({ mediaType, status: 'uploading', thumbnailUri: uri });
} catch (compressError) {
console.warn('Video compression failed, uploading original:', compressError);
// Fall back to original if compression fails
fileUri = uri;
setUploadStatus({ mediaType, status: 'uploading', thumbnailUri: uri });
}
}
if (Platform.OS === 'web') {
const response = await fetch(fileUri);
const blob = await response.blob();
fileBody = blob;
// Detect extension from blob type
if (mediaType === 'video') {
if (blob.type === 'video/mp4') ext = 'mp4';
else if (blob.type === 'video/quicktime') ext = 'mov';
else if (blob.type === 'video/webm') ext = 'webm';
} else {
if (blob.type === 'image/png') ext = 'png';
else if (blob.type === 'image/jpeg' || blob.type === 'image/jpg') ext = 'jpg';
else if (blob.type === 'image/gif') ext = 'gif';
else if (blob.type === 'image/webp') ext = 'webp';
}
} else {
const base64 = await FileSystem.readAsStringAsync(fileUri, { encoding: FileSystem.EncodingType.Base64 });
fileBody = decode(base64);
if (mediaType !== 'video') {
// Only use URI extension for non-videos (videos are always mp4 after compression)
const uriExt = fileUri.split('.').pop();
if (uriExt && uriExt !== fileUri) {
ext = uriExt.toLowerCase();
}
}
}
const fileName = `${matchId}/${Date.now()}_${Math.random().toString(36).substring(7)}.${ext}`;
const contentType = mediaType === 'video'
? `video/${ext === 'mov' ? 'quicktime' : ext}`
: `image/${ext === 'jpg' ? 'jpeg' : ext}`;
const { error: uploadError } = await supabase.storage
.from("chat-media")
.upload(fileName, fileBody, {
contentType,
upsert: false
});
if (uploadError) throw uploadError;
// Store just the path, not the full URL (bucket is private)
await supabase.from("messages").insert({
match_id: matchId,
user_id: userId,
content: mediaType === 'video' ? 'Sent a video' : 'Sent an image',
media_path: fileName,
media_type: mediaType,
media_expired: false,
});
Events.mediaUploaded();
} catch (error) {
Alert.alert("Error", `Failed to upload ${mediaType}`);
console.error(error);
} finally {
setUploading(false);
setUploadStatus(null);
}
};
return { uploading, uploadStatus, uploadMedia };
}
@@ -0,0 +1,43 @@
import { useState, useEffect } from 'react';
import { getStoragePath, getVideoCachedUri, cacheVideo } from '../../../lib/imageCache';
export function useVideoCache(storagePath: string, signedUrl: string | null) {
const [cachedUri, setCachedUri] = useState<string | null>(null);
// Check cache on mount or when storagePath changes
useEffect(() => {
let mounted = true;
const checkCache = async () => {
if (!storagePath) return;
const path = getStoragePath(storagePath);
const cached = await getVideoCachedUri(path);
if (mounted && cached) {
setCachedUri(cached);
}
};
checkCache();
return () => {
mounted = false;
};
}, [storagePath]);
// Function to trigger caching securely
const cacheVideoFile = async () => {
if (!signedUrl || cachedUri) return cachedUri;
const path = getStoragePath(storagePath);
const uri = await cacheVideo(path, signedUrl);
if (uri) {
setCachedUri(uri);
}
return uri;
};
return { cachedUri, cacheVideoFile };
}
+5
View File
@@ -0,0 +1,5 @@
// Chat feature exports
export * from './components';
export * from './hooks';
export * from './types';
export { ChatScreen } from './ChatScreen';
+41
View File
@@ -0,0 +1,41 @@
import { Database } from '../../types/supabase';
/** Message type from database */
export type Message = Database['public']['Tables']['messages']['Row'];
/** Upload status for skeleton display */
export type UploadStatus = {
mediaType: 'image' | 'video';
status: 'compressing' | 'uploading';
thumbnailUri?: string;
} | null;
/** Match type with question and responses */
export interface Match {
id: string;
question_id: string;
couple_id: string;
match_type: 'yes_yes' | 'yes_maybe' | 'maybe_maybe';
created_at: string;
question?: {
id: string;
text: string;
partner_text?: string;
};
responses?: Array<{
user_id: string;
answer: string;
profiles?: {
name?: string;
};
}>;
}
/** Chat color palette constants */
export const CHAT_COLORS = {
ACCENT: 'rgb(212, 175, 55)', // colors.premium.gold
ACCENT_DARK: 'rgb(184, 134, 11)',
ACCENT_RGBA: 'rgba(212, 175, 55, ',
ROSE: 'rgb(232, 164, 174)',
ROSE_RGBA: 'rgba(232, 164, 174, ',
} as const;
+3
View File
@@ -0,0 +1,3 @@
// Features exports
export * from './chat';
export * from './profile';
@@ -0,0 +1,533 @@
import React from 'react';
import { View, Text, StyleSheet, ScrollView, TouchableOpacity, useWindowDimensions, Platform, ActivityIndicator, Alert } from 'react-native';
import { Ionicons } from '@expo/vector-icons';
import Animated, { FadeInDown } from 'react-native-reanimated';
import { LinearGradient } from 'expo-linear-gradient';
import { GradientBackground, GlassCard } from '../../components/ui';
import { FeedbackModal } from '../../components/feedback';
import { Paywall } from '../../components/paywall';
import { colors, featureColors, spacing, typography, radius, shadows } from '../../theme';
import { useAuthStore, useSubscriptionStore } from '../../store';
import { resetSwipeTutorial } from '../../lib/swipeTutorialSeen';
import { resetMatchesTutorial } from '../../lib/matchesTutorialSeen';
import { supabase } from '../../lib/supabase'; // Needed for debug reset
// Hooks
import { useProfileSettings, useCoupleManagement } from './hooks';
// Components
import { ProfileHeader, CoupleStatus, PrivacySettings, DangerZone, SettingsSection, MenuItem } from './components';
const MAX_CONTENT_WIDTH = 500;
const ACCENT_GRADIENT = featureColors.profile.gradient as [string, string];
export function ProfileScreen() {
const { width } = useWindowDimensions();
const isWideScreen = width > MAX_CONTENT_WIDTH;
const { user, partner, couple, fetchUser } = useAuthStore();
const { subscription } = useSubscriptionStore();
// Hooks
const settings = useProfileSettings();
const {
handleUnpair,
handleDeleteRelationship,
handleResetProgress,
isResettingProgress,
handleSignOut,
navigateToPairing
} = useCoupleManagement();
const [showFeedbackModal, setShowFeedbackModal] = React.useState(false);
// Derived formatting
const isOwnSubscription = user?.is_premium && subscription.isProUser;
const hasPremiumAccess = user?.is_premium || partner?.is_premium || subscription.isProUser;
const formatExpirationDate = (date: Date | null | string) => {
if (!date) return "Never";
const d = new Date(date);
return d.toLocaleDateString("en-US", {
year: "numeric",
month: "short",
day: "numeric",
});
};
return (
<GradientBackground>
<ScrollView
style={styles.container}
contentContainerStyle={[
styles.contentContainer,
isWideScreen && styles.contentContainerWide,
]}
showsVerticalScrollIndicator={false}
>
{/* Header title */}
<Animated.View
entering={FadeInDown.delay(100).duration(500)}
style={styles.header}
>
<Text style={styles.headerLabel}>YOUR SAUCI</Text>
<Text style={styles.title}>Settings</Text>
<View style={styles.separator}>
<View style={styles.separatorLine} />
<View style={styles.separatorDiamond} />
<View style={styles.separatorLine} />
</View>
</Animated.View>
{/* Profile Header */}
<ProfileHeader
user={user}
isUploadingAvatar={settings.isUploadingAvatar}
isEditingName={settings.isEditingName}
newName={settings.newName}
isUpdatingName={settings.isUpdatingName}
onAvatarPress={settings.handleAvatarPress}
onNewNameChange={settings.setNewName}
onUpdateName={settings.handleUpdateName}
onCancelEditName={settings.handleCancelEditName}
onStartEditingName={() => settings.setIsEditingName(true)}
/>
{/* Partner */}
<CoupleStatus
partner={partner}
couple={couple}
onUnpair={handleUnpair}
onPairingPress={navigateToPairing}
/>
{/* Subscription Section */}
<SettingsSection title="Subscription" delay={350}>
{hasPremiumAccess ? (
<View style={styles.rowContainer}>
<View style={styles.rowLeft}>
<LinearGradient
colors={ACCENT_GRADIENT}
style={styles.partnerIconGradient}
start={{ x: 0, y: 0 }}
end={{ x: 1, y: 1 }}
>
<Ionicons name="star" size={20} color={colors.text} />
</LinearGradient>
<View style={styles.rowTextContainer}>
<Text style={styles.rowValue}>Pro Member</Text>
<Text style={styles.rowLabel}>
{isOwnSubscription
? `Renews ${formatExpirationDate(subscription.expirationDate)}`
: "Via partner's subscription"
}
</Text>
</View>
</View>
{isOwnSubscription && (
<TouchableOpacity
style={styles.manageButton}
onPress={settings.handleManageSubscription}
activeOpacity={0.7}
>
<Text style={styles.manageButtonText}>Manage</Text>
</TouchableOpacity>
)}
</View>
) : (
<TouchableOpacity
style={styles.rowContainer}
onPress={() => settings.setShowPaywall(true)}
activeOpacity={0.7}
>
<View style={styles.rowLeft}>
<View style={styles.emptyPartnerIcon}>
<Ionicons name="star-outline" size={20} color={colors.textTertiary} />
</View>
<View style={styles.rowTextContainer}>
<Text style={styles.rowValueMuted}>Free Plan</Text>
<Text style={styles.rowLabel}>Upgrade to unlock all packs</Text>
</View>
</View>
<LinearGradient
colors={ACCENT_GRADIENT}
style={styles.upgradeButton}
start={{ x: 0, y: 0 }}
end={{ x: 1, y: 0 }}
>
<Text style={styles.upgradeButtonText}>Upgrade</Text>
</LinearGradient>
</TouchableOpacity>
)}
{/* Restore Purchases Link */}
{!hasPremiumAccess && (
<TouchableOpacity
style={styles.restoreLink}
onPress={settings.handleRestorePurchases}
disabled={settings.isPurchasing}
>
<Text style={styles.restoreLinkText}>
{settings.isPurchasing ? "Restoring..." : "Restore Purchases"}
</Text>
</TouchableOpacity>
)}
</SettingsSection>
{/* Preferences */}
<PrivacySettings
showExplicit={settings.showExplicit}
isUpdatingExplicit={settings.isUpdatingExplicit}
onExplicitToggle={settings.handleExplicitToggle}
biometricAvailable={settings.biometricAvailable}
biometricEnabled={settings.biometricEnabled}
biometricType={settings.biometricType}
isUpdatingBiometric={settings.isUpdatingBiometric}
onBiometricToggle={settings.handleBiometricToggle}
/>
{/* Account */}
<SettingsSection title="Account" delay={425}>
<MenuItem
icon="log-out-outline"
label="Sign Out"
onPress={handleSignOut}
variant="danger"
showChevron={true}
/>
</SettingsSection>
{/* Support */}
<SettingsSection title="Support" delay={475}>
<MenuItem
icon="chatbubble-ellipses-outline"
label="Send Feedback"
description="Report bugs or request features"
onPress={() => setShowFeedbackModal(true)}
/>
</SettingsSection>
{/* Reset Progress Layout */}
{couple && (
<SettingsSection title="Reset Progress" delay={500}>
<View style={styles.resetContent}>
<View style={styles.resetInfo}>
<View style={styles.resetIconContainer}>
<Ionicons name="refresh" size={20} color={colors.warning} />
</View>
<View style={styles.resetTextContainer}>
<Text style={styles.resetTitle}>Start Fresh</Text>
<Text style={styles.resetDescription}>
Delete all swipes, matches, and chats while keeping your partner connection.
</Text>
</View>
</View>
<TouchableOpacity
style={[styles.resetButton, isResettingProgress && styles.resetButtonDisabled]}
onPress={handleResetProgress}
disabled={isResettingProgress}
activeOpacity={0.7}
>
{isResettingProgress ? (
<ActivityIndicator size="small" color={colors.warning} />
) : (
<>
<Ionicons name="refresh-outline" size={16} color={colors.warning} />
<Text style={styles.resetButtonText}>Reset</Text>
</>
)}
</TouchableOpacity>
</View>
</SettingsSection>
)}
{/* Debug Section */}
{__DEV__ && (
<SettingsSection title="Debug" delay={550}>
<MenuItem
icon="refresh"
label="Reset Swipe Tutorial"
description="Show the tutorial again"
onPress={async () => {
await resetSwipeTutorial();
Alert.alert("Success", "Swipe tutorial has been reset.");
}}
variant="danger"
// Using danger just for yellow/warning color simulation via props? No, MenuItem uses error color for danger.
// I'll stick to default and standard styling or custom if needed.
// The original used warning color (yellow).
// I will use default for now.
/>
<View style={styles.preferencesDivider} />
<MenuItem
icon="heart"
label="Reset Matches Tutorial"
description="Show the tutorial again"
onPress={async () => {
await resetMatchesTutorial();
Alert.alert("Success", "Matches tutorial has been reset.");
}}
/>
<View style={styles.preferencesDivider} />
<MenuItem
icon="school-outline"
label="Reset Onboarding"
description="Show onboarding flow again"
onPress={async () => {
if (!user?.id) return;
try {
const { error } = await supabase
.from('profiles')
.update({ onboarding_completed: false })
.eq('id', user.id);
if (error) throw error;
await fetchUser();
Alert.alert("Success", "Onboarding has been reset.");
} catch (error) {
Alert.alert("Error", "Failed to reset onboarding.");
}
}}
/>
</SettingsSection>
)}
{/* Danger Zone */}
{couple && (
<DangerZone onDeleteRelationship={handleDeleteRelationship} />
)}
{/* Version */}
<Animated.View
entering={FadeInDown.delay(couple ? 650 : 525).duration(500)}
style={styles.versionContainer}
>
<View style={styles.versionBadge}>
<Ionicons name="heart" size={12} color={featureColors.profile.accent} />
<Text style={styles.version}>Sauci v1.0.0</Text>
</View>
</Animated.View>
<View style={styles.bottomSpacer} />
</ScrollView>
{/* Modals */}
<FeedbackModal
visible={showFeedbackModal}
onClose={() => setShowFeedbackModal(false)}
/>
<Paywall
visible={settings.showPaywall}
onClose={() => settings.setShowPaywall(false)}
/>
</GradientBackground>
);
}
const styles = StyleSheet.create({
container: {
flex: 1,
},
contentContainer: {
paddingBottom: Platform.OS === 'ios' ? 100 : 80,
},
contentContainerWide: {
alignSelf: 'center',
width: '100%',
maxWidth: MAX_CONTENT_WIDTH,
},
header: {
paddingTop: 60,
paddingHorizontal: spacing.lg,
paddingBottom: spacing.md,
alignItems: 'center',
},
headerLabel: {
...typography.caption2,
fontWeight: '600',
letterSpacing: 3,
color: colors.secondary,
marginBottom: spacing.xs,
},
title: {
...typography.largeTitle,
color: colors.text,
textAlign: 'center',
},
separator: {
flexDirection: 'row',
alignItems: 'center',
justifyContent: 'center',
marginVertical: spacing.md,
width: 100,
},
separatorLine: {
flex: 1,
height: 1,
backgroundColor: 'rgba(155, 89, 182, 0.3)',
},
separatorDiamond: {
width: 6,
height: 6,
backgroundColor: colors.secondary,
transform: [{ rotate: '45deg' }],
marginHorizontal: spacing.sm,
opacity: 0.6,
},
// Shared row styles
rowContainer: {
flexDirection: 'row',
alignItems: 'center',
justifyContent: 'space-between',
paddingVertical: spacing.sm,
},
rowLeft: {
flexDirection: 'row',
alignItems: 'center',
flex: 1,
},
partnerIconGradient: {
width: 44,
height: 44,
borderRadius: 22,
justifyContent: 'center',
alignItems: 'center',
},
emptyPartnerIcon: {
width: 44,
height: 44,
borderRadius: 22,
backgroundColor: colors.glass.background,
justifyContent: 'center',
alignItems: 'center',
},
rowTextContainer: {
marginLeft: spacing.md,
flex: 1,
},
rowValue: {
...typography.body,
fontWeight: '600',
color: colors.text,
},
rowValueMuted: {
...typography.body,
fontWeight: '600',
color: colors.textSecondary,
},
rowLabel: {
...typography.caption1,
color: colors.textTertiary,
marginTop: 2,
},
preferencesDivider: {
height: 1,
backgroundColor: colors.glass.border,
marginVertical: spacing.md,
},
// Subscription styles
manageButton: {
paddingHorizontal: spacing.md,
paddingVertical: spacing.sm,
borderRadius: radius.md,
backgroundColor: colors.glass.background,
borderWidth: 1,
borderColor: colors.glass.border,
},
manageButtonText: {
...typography.subhead,
color: colors.secondary,
fontWeight: "600",
},
upgradeButton: {
paddingHorizontal: spacing.md,
paddingVertical: spacing.sm,
borderRadius: radius.md,
},
upgradeButtonText: {
...typography.subhead,
color: colors.text,
fontWeight: "600",
},
restoreLink: {
alignItems: "center",
marginTop: spacing.sm,
padding: spacing.sm,
},
restoreLinkText: {
...typography.caption1,
color: colors.textTertiary,
},
// Reset Progress Styles
resetContent: {
gap: spacing.md,
},
resetInfo: {
flexDirection: "row",
alignItems: "flex-start",
},
resetIconContainer: {
width: 44,
height: 44,
borderRadius: 22,
backgroundColor: colors.warningLight,
justifyContent: "center",
alignItems: "center",
},
resetTextContainer: {
flex: 1,
marginLeft: spacing.md,
},
resetTitle: {
...typography.headline,
color: colors.text,
marginBottom: spacing.xs,
},
resetDescription: {
...typography.subhead,
color: colors.textSecondary,
lineHeight: 20,
},
resetButton: {
flexDirection: "row",
alignItems: "center",
justifyContent: "center",
backgroundColor: colors.warningLight,
paddingVertical: spacing.sm,
paddingHorizontal: spacing.md,
borderRadius: radius.md,
borderWidth: 1,
borderColor: 'rgba(243, 156, 18, 0.3)',
gap: spacing.xs,
alignSelf: "flex-end",
},
resetButtonDisabled: {
opacity: 0.5,
},
resetButtonText: {
...typography.subhead,
color: colors.warning,
fontWeight: "600",
},
// Version Badge
versionContainer: {
alignItems: "center",
marginTop: spacing.lg,
},
versionBadge: {
flexDirection: "row",
alignItems: "center",
backgroundColor: colors.glass.background,
paddingHorizontal: spacing.md,
paddingVertical: spacing.sm,
borderRadius: radius.full,
gap: spacing.xs,
},
version: {
...typography.caption1,
color: colors.textTertiary,
},
bottomSpacer: {
height: spacing.lg,
},
});
@@ -0,0 +1,161 @@
import React from 'react';
import { View, Text, StyleSheet, TouchableOpacity } from 'react-native';
import { Ionicons } from '@expo/vector-icons';
import { LinearGradient } from 'expo-linear-gradient';
import { SettingsSection } from './SettingsSection';
import { colors, featureColors, spacing, typography } from '../../../theme';
import { Profile, Couple } from '../../../types';
const ACCENT_GRADIENT = featureColors.profile.gradient as [string, string];
interface CoupleStatusProps {
partner: Profile | null;
couple: Couple | null;
onUnpair: () => void;
onPairingPress: () => void;
}
export const CoupleStatus: React.FC<CoupleStatusProps> = ({
partner,
couple,
onUnpair,
onPairingPress,
}) => {
return (
<SettingsSection title="Partner" delay={300}>
{partner ? (
<View style={styles.rowContainer}>
<View style={styles.rowLeft}>
<LinearGradient
colors={ACCENT_GRADIENT}
style={styles.partnerIconGradient}
start={{ x: 0, y: 0 }}
end={{ x: 1, y: 1 }}
>
<Ionicons name="heart" size={20} color={colors.text} />
</LinearGradient>
<View style={styles.rowTextContainer}>
<Text style={styles.rowValue}>
{partner.name || partner.email || 'Your partner'}
</Text>
<Text style={styles.rowLabel}>Connected</Text>
</View>
</View>
<TouchableOpacity
onPress={onUnpair}
style={styles.unlinkButton}
activeOpacity={0.7}
>
<Ionicons name="unlink-outline" size={18} color={colors.textTertiary} />
</TouchableOpacity>
</View>
) : couple ? (
<TouchableOpacity
style={styles.rowContainer}
onPress={onPairingPress}
activeOpacity={0.7}
>
<View style={styles.rowLeft}>
<LinearGradient
colors={ACCENT_GRADIENT}
style={styles.partnerIconGradient}
start={{ x: 0, y: 0 }}
end={{ x: 1, y: 1 }}
>
<Ionicons name="hourglass-outline" size={20} color={colors.text} />
</LinearGradient>
<View style={styles.rowTextContainer}>
<Text style={styles.rowValue}>Waiting for partner</Text>
<Text style={styles.rowLabel}>Tap to view invite code</Text>
</View>
</View>
<View style={styles.chevronContainer}>
<Ionicons name="chevron-forward" size={18} color={colors.textTertiary} />
</View>
</TouchableOpacity>
) : (
<TouchableOpacity
style={styles.rowContainer}
onPress={onPairingPress}
activeOpacity={0.7}
>
<View style={styles.rowLeft}>
<View style={styles.emptyPartnerIcon}>
<Ionicons name="heart-outline" size={20} color={colors.textTertiary} />
</View>
<View style={styles.rowTextContainer}>
<Text style={styles.rowValueMuted}>Not paired yet</Text>
<Text style={styles.rowLabel}>Tap to connect</Text>
</View>
</View>
<View style={styles.chevronContainer}>
<Ionicons name="chevron-forward" size={18} color={colors.textTertiary} />
</View>
</TouchableOpacity>
)}
</SettingsSection>
);
};
const styles = StyleSheet.create({
rowContainer: {
flexDirection: 'row',
alignItems: 'center',
justifyContent: 'space-between',
},
rowLeft: {
flexDirection: 'row',
alignItems: 'center',
flex: 1,
},
partnerIconGradient: {
width: 44,
height: 44,
borderRadius: 22,
justifyContent: 'center',
alignItems: 'center',
},
emptyPartnerIcon: {
width: 44,
height: 44,
borderRadius: 22,
backgroundColor: colors.glass.background,
justifyContent: 'center',
alignItems: 'center',
},
rowTextContainer: {
marginLeft: spacing.md,
flex: 1,
},
rowValue: {
...typography.body,
fontWeight: '600',
color: colors.text,
},
rowValueMuted: {
...typography.body,
fontWeight: '600',
color: colors.textSecondary,
},
rowLabel: {
...typography.caption1,
color: colors.textTertiary,
marginTop: 2,
},
unlinkButton: {
width: 36,
height: 36,
borderRadius: 18,
backgroundColor: colors.glass.background,
justifyContent: 'center',
alignItems: 'center',
},
chevronContainer: {
width: 28,
height: 28,
borderRadius: 14,
backgroundColor: colors.glass.background,
justifyContent: 'center',
alignItems: 'center',
},
});
@@ -0,0 +1,336 @@
import React, { useState } from 'react';
import { View, Text, TouchableOpacity, StyleSheet, Modal, TextInput, ActivityIndicator } from 'react-native';
import { Ionicons } from '@expo/vector-icons';
import Animated, { FadeInDown } from 'react-native-reanimated';
import { GlassCard } from '../../../components/ui';
import { colors, spacing, radius, typography } from '../../../theme';
interface DangerZoneProps {
onDeleteRelationship: () => Promise<void>;
}
export const DangerZone: React.FC<DangerZoneProps> = ({ onDeleteRelationship }) => {
const [showDeleteModal, setShowDeleteModal] = useState(false);
const [deleteConfirmText, setDeleteConfirmText] = useState("");
const [isDeleting, setIsDeleting] = useState(false);
const handleDelete = async () => {
if (deleteConfirmText !== "DELETE") return;
setIsDeleting(true);
try {
await onDeleteRelationship();
setShowDeleteModal(false);
setDeleteConfirmText("");
} catch (error) {
// Error handling relies on parent throwing or managing its own alerts
// If parent throws, we stay on modal or close it?
// Usually parent alerts.
} finally {
setIsDeleting(false);
}
};
return (
<>
<Animated.View
entering={FadeInDown.delay(600).duration(500)}
style={styles.section}
>
<Text style={styles.dangerSectionTitle}>Danger Zone</Text>
<GlassCard style={styles.dangerCard}>
<View style={styles.dangerContent}>
<View style={styles.dangerInfo}>
<View style={styles.dangerIconContainer}>
<Ionicons name="warning" size={20} color={colors.error} />
</View>
<View style={styles.dangerTextContainer}>
<Text style={styles.dangerTitle}>Delete All Data</Text>
<Text style={styles.dangerDescription}>
Permanently delete your relationship including all matches, chats, and shared photos.
</Text>
</View>
</View>
<TouchableOpacity
style={styles.dangerButton}
onPress={() => setShowDeleteModal(true)}
activeOpacity={0.7}
>
<Ionicons name="trash-outline" size={16} color={colors.error} />
<Text style={styles.dangerButtonText}>Delete</Text>
</TouchableOpacity>
</View>
</GlassCard>
</Animated.View>
<Modal
visible={showDeleteModal}
transparent
animationType="fade"
onRequestClose={() => !isDeleting && setShowDeleteModal(false)}
>
<View style={styles.modalOverlay}>
<View style={styles.modalContent}>
<View style={styles.modalHeader}>
<View style={styles.modalIconContainer}>
<Ionicons name="warning" size={32} color={colors.error} />
</View>
<Text style={styles.modalTitle}>Delete All Data?</Text>
</View>
<Text style={styles.modalDescription}>
This action cannot be undone. This will permanently delete:
</Text>
<View style={styles.deleteList}>
<View style={styles.deleteListItem}>
<Ionicons name="heart-dislike" size={16} color={colors.error} />
<Text style={styles.deleteListText}>Your couple connection</Text>
</View>
<View style={styles.deleteListItem}>
<Ionicons name="checkmark-circle" size={16} color={colors.error} />
<Text style={styles.deleteListText}>All matches and responses</Text>
</View>
<View style={styles.deleteListItem}>
<Ionicons name="chatbubbles" size={16} color={colors.error} />
<Text style={styles.deleteListText}>All chat messages</Text>
</View>
<View style={styles.deleteListItem}>
<Ionicons name="images" size={16} color={colors.error} />
<Text style={styles.deleteListText}>All shared photos</Text>
</View>
</View>
<Text style={styles.confirmLabel}>
Type <Text style={styles.confirmKeyword}>DELETE</Text> to confirm:
</Text>
<TextInput
style={styles.confirmInput}
value={deleteConfirmText}
onChangeText={setDeleteConfirmText}
placeholder="Type DELETE"
placeholderTextColor={colors.textTertiary}
autoCapitalize="characters"
autoCorrect={false}
editable={!isDeleting}
/>
<View style={styles.modalButtons}>
<TouchableOpacity
style={styles.cancelButton}
onPress={() => {
setShowDeleteModal(false);
setDeleteConfirmText("");
}}
disabled={isDeleting}
activeOpacity={0.7}
>
<Text style={styles.cancelButtonText}>Cancel</Text>
</TouchableOpacity>
<TouchableOpacity
style={[
styles.confirmDeleteButton,
deleteConfirmText !== "DELETE" && styles.confirmDeleteButtonDisabled,
]}
onPress={handleDelete}
disabled={deleteConfirmText !== "DELETE" || isDeleting}
activeOpacity={0.7}
>
{isDeleting ? (
<ActivityIndicator size="small" color={colors.text} />
) : (
<>
<Ionicons name="trash" size={16} color={colors.text} />
<Text style={styles.confirmDeleteButtonText}>Delete</Text>
</>
)}
</TouchableOpacity>
</View>
</View>
</View>
</Modal>
</>
);
};
const styles = StyleSheet.create({
section: {
paddingHorizontal: spacing.lg,
marginBottom: spacing.lg,
},
dangerSectionTitle: {
...typography.caption1,
color: colors.error,
textTransform: "uppercase",
letterSpacing: 1.5,
marginBottom: spacing.sm,
marginLeft: spacing.xs,
},
dangerCard: {
borderColor: 'rgba(231, 76, 60, 0.3)',
borderWidth: 1,
},
dangerContent: {
gap: spacing.md,
},
dangerInfo: {
flexDirection: "row",
alignItems: "flex-start",
},
dangerIconContainer: {
width: 44,
height: 44,
borderRadius: 22,
backgroundColor: colors.errorLight,
justifyContent: "center",
alignItems: "center",
},
dangerTextContainer: {
flex: 1,
marginLeft: spacing.md,
},
dangerTitle: {
...typography.headline,
color: colors.text,
marginBottom: spacing.xs,
},
dangerDescription: {
...typography.subhead,
color: colors.textSecondary,
lineHeight: 20,
},
dangerButton: {
flexDirection: "row",
alignItems: "center",
justifyContent: "center",
backgroundColor: colors.errorLight,
paddingVertical: spacing.sm,
paddingHorizontal: spacing.md,
borderRadius: radius.md,
borderWidth: 1,
borderColor: 'rgba(231, 76, 60, 0.3)',
gap: spacing.xs,
alignSelf: "flex-end",
},
dangerButtonText: {
...typography.subhead,
color: colors.error,
fontWeight: "600",
},
// Modal styles
modalOverlay: {
flex: 1,
backgroundColor: "rgba(0, 0, 0, 0.8)",
justifyContent: "center",
alignItems: "center",
padding: spacing.lg,
},
modalContent: {
backgroundColor: colors.backgroundLight,
borderRadius: radius.xl,
padding: spacing.xl,
width: "100%",
maxWidth: 400,
borderWidth: 1,
borderColor: colors.glass.border,
},
modalHeader: {
alignItems: "center",
marginBottom: spacing.lg,
},
modalIconContainer: {
width: 64,
height: 64,
borderRadius: 32,
backgroundColor: colors.errorLight,
justifyContent: "center",
alignItems: "center",
marginBottom: spacing.md,
},
modalTitle: {
...typography.title2,
color: colors.text,
textAlign: "center",
},
modalDescription: {
...typography.body,
color: colors.textSecondary,
marginBottom: spacing.lg,
textAlign: "center",
},
deleteList: {
backgroundColor: colors.glass.background,
borderRadius: radius.lg,
padding: spacing.md,
gap: spacing.sm,
marginBottom: spacing.xl,
},
deleteListItem: {
flexDirection: "row",
alignItems: "center",
gap: spacing.sm,
},
deleteListText: {
...typography.subhead,
color: colors.text,
},
confirmLabel: {
...typography.subhead,
color: colors.textSecondary,
marginBottom: spacing.xs,
textAlign: "center",
},
confirmKeyword: {
fontWeight: "bold",
color: colors.error,
},
confirmInput: {
backgroundColor: colors.background,
borderRadius: radius.md,
padding: spacing.md,
color: colors.text,
fontSize: 16,
borderWidth: 1,
borderColor: colors.error,
marginBottom: spacing.xl,
textAlign: "center",
},
modalButtons: {
flexDirection: "row",
gap: spacing.md,
},
cancelButton: {
flex: 1,
paddingVertical: spacing.md,
borderRadius: radius.md,
backgroundColor: colors.glass.background,
alignItems: "center",
borderWidth: 1,
borderColor: colors.glass.border,
},
cancelButtonText: {
...typography.body,
color: colors.text,
fontWeight: "600",
},
confirmDeleteButton: {
flex: 1,
flexDirection: "row",
paddingVertical: spacing.md,
borderRadius: radius.md,
backgroundColor: colors.error,
alignItems: "center",
justifyContent: "center",
gap: spacing.xs,
},
confirmDeleteButtonDisabled: {
opacity: 0.5,
backgroundColor: colors.errorLight,
},
confirmDeleteButtonText: {
...typography.body,
color: colors.text,
fontWeight: "600",
},
});
@@ -0,0 +1,130 @@
import React from 'react';
import { View, Text, TouchableOpacity, StyleSheet } from 'react-native';
import { LinearGradient } from 'expo-linear-gradient';
import { Ionicons } from '@expo/vector-icons';
import { colors, spacing, radius, typography, featureColors } from '../../../theme';
export interface MenuItemProps {
/** Icon name (Ionicons) */
icon: string;
/** Main label text */
label: string;
/** Description text below label */
description?: string;
/** Action when pressed */
onPress: () => void;
/** Optional element to show on the right */
rightElement?: React.ReactNode;
/** Whether to show chevron arrow */
showChevron?: boolean;
/** Visual variant */
variant?: 'default' | 'danger';
/** Disable the item */
disabled?: boolean;
}
const ACCENT_GRADIENT = featureColors.profile.gradient as [string, string];
/**
* Reusable menu item row for settings screens.
*/
export function MenuItem({
icon,
label,
description,
onPress,
rightElement,
showChevron = true,
variant = 'default',
disabled = false,
}: MenuItemProps) {
const isDanger = variant === 'danger';
const iconColor = isDanger ? colors.error : colors.text;
const labelColor = isDanger ? colors.error : colors.text;
return (
<TouchableOpacity
style={styles.container}
onPress={onPress}
activeOpacity={0.7}
disabled={disabled}
>
<View style={styles.left}>
{isDanger ? (
<View style={styles.dangerIconContainer}>
<Ionicons name={icon as any} size={20} color={iconColor} />
</View>
) : (
<LinearGradient
colors={ACCENT_GRADIENT}
style={styles.iconContainer}
start={{ x: 0, y: 0 }}
end={{ x: 1, y: 1 }}
>
<Ionicons name={icon as any} size={20} color={iconColor} />
</LinearGradient>
)}
<View style={styles.textContainer}>
<Text style={[styles.label, { color: labelColor }]}>{label}</Text>
{description && (
<Text style={styles.description}>{description}</Text>
)}
</View>
</View>
{rightElement || (showChevron && (
<View style={styles.chevron}>
<Ionicons name="chevron-forward" size={18} color={colors.textTertiary} />
</View>
))}
</TouchableOpacity>
);
}
const styles = StyleSheet.create({
container: {
flexDirection: 'row',
alignItems: 'center',
justifyContent: 'space-between',
paddingVertical: spacing.xs,
},
left: {
flex: 1,
flexDirection: 'row',
alignItems: 'center',
marginRight: spacing.md,
},
iconContainer: {
width: 40,
height: 40,
borderRadius: radius.lg,
justifyContent: 'center',
alignItems: 'center',
marginRight: spacing.md,
},
dangerIconContainer: {
width: 40,
height: 40,
borderRadius: radius.lg,
justifyContent: 'center',
alignItems: 'center',
marginRight: spacing.md,
backgroundColor: 'rgba(239, 68, 68, 0.1)',
},
textContainer: {
flex: 1,
},
label: {
...typography.body,
fontWeight: '500',
},
description: {
...typography.caption1,
color: colors.textSecondary,
marginTop: 2,
},
chevron: {
padding: spacing.xs,
},
});
export default MenuItem;
@@ -0,0 +1,61 @@
import React from 'react';
import { View, StyleSheet } from 'react-native';
import { SettingsSection } from './SettingsSection';
import { SwitchItem } from './SwitchItem';
import { colors, spacing } from '../../../theme';
interface PrivacySettingsProps {
showExplicit: boolean;
isUpdatingExplicit: boolean;
onExplicitToggle: (value: boolean) => void;
biometricAvailable: boolean;
biometricEnabled: boolean;
biometricType: string;
isUpdatingBiometric: boolean;
onBiometricToggle: (value: boolean) => void;
}
export const PrivacySettings: React.FC<PrivacySettingsProps> = ({
showExplicit,
isUpdatingExplicit,
onExplicitToggle,
biometricAvailable,
biometricEnabled,
biometricType,
isUpdatingBiometric,
onBiometricToggle,
}) => {
return (
<SettingsSection title="Preferences" delay={375}>
<SwitchItem
icon="flame"
label="Spicy Content"
description="Show spicy question packs"
value={showExplicit}
onValueChange={onExplicitToggle}
disabled={isUpdatingExplicit}
/>
{biometricAvailable && (
<>
<View style={styles.divider} />
<SwitchItem
icon={biometricType === "Face ID" || biometricType === "Face Recognition" ? "scan-outline" : "finger-print-outline"}
label={biometricType}
description="Require unlock when opening app"
value={biometricEnabled}
onValueChange={onBiometricToggle}
disabled={isUpdatingBiometric}
/>
</>
)}
</SettingsSection>
);
};
const styles = StyleSheet.create({
divider: {
height: 1,
backgroundColor: colors.glass.border,
marginVertical: spacing.md,
},
});
@@ -0,0 +1,263 @@
import React from 'react';
import { View, Text, StyleSheet, TouchableOpacity, TextInput, ActivityIndicator } from 'react-native';
import { Image } from 'expo-image';
import { Ionicons } from '@expo/vector-icons';
import { LinearGradient } from 'expo-linear-gradient';
import Animated, { FadeInDown } from 'react-native-reanimated';
import { GlassCard } from '../../../components/ui';
import { colors, featureColors, spacing, typography } from '../../../theme';
import { Profile } from '../../../types';
const ACCENT_GRADIENT = featureColors.profile.gradient as [string, string];
interface ProfileHeaderProps {
user: Profile | null;
isUploadingAvatar: boolean;
isEditingName: boolean;
newName: string;
isUpdatingName: boolean;
onAvatarPress: () => void;
onNewNameChange: (text: string) => void;
onUpdateName: () => void;
onCancelEditName: () => void;
onStartEditingName: () => void;
}
export const ProfileHeader: React.FC<ProfileHeaderProps> = ({
user,
isUploadingAvatar,
isEditingName,
newName,
isUpdatingName,
onAvatarPress,
onNewNameChange,
onUpdateName,
onCancelEditName,
onStartEditingName,
}) => {
return (
<Animated.View
entering={FadeInDown.delay(200).duration(500)}
style={styles.profileSection}
>
<GlassCard variant="elevated">
<View style={styles.profileContent}>
<TouchableOpacity
onPress={onAvatarPress}
disabled={isUploadingAvatar}
activeOpacity={0.7}
style={styles.avatarTouchable}
>
<LinearGradient
colors={ACCENT_GRADIENT}
style={styles.avatarGradient}
start={{ x: 0, y: 0 }}
end={{ x: 1, y: 1 }}
>
{user?.avatar_url ? (
<Image
source={{ uri: user.avatar_url }}
style={styles.avatarImage}
cachePolicy="disk"
transition={200}
/>
) : (
<View style={styles.avatarInner}>
<Text style={styles.avatarText}>
{user?.name?.[0]?.toUpperCase() || "U"}
</Text>
</View>
)}
</LinearGradient>
{isUploadingAvatar ? (
<View style={styles.avatarOverlay}>
<ActivityIndicator size="large" color={colors.text} />
</View>
) : (
<View style={styles.avatarEditBadge}>
<Ionicons name="camera" size={14} color={colors.text} />
</View>
)}
</TouchableOpacity>
<View style={styles.profileInfo}>
{isEditingName ? (
<View style={styles.editNameContainer}>
<TextInput
style={styles.nameInput}
value={newName}
onChangeText={onNewNameChange}
placeholder="Enter your name"
placeholderTextColor={colors.textTertiary}
autoFocus
editable={!isUpdatingName}
onSubmitEditing={onUpdateName}
returnKeyType="done"
/>
<View style={styles.editNameButtons}>
<TouchableOpacity
style={styles.cancelEditButton}
onPress={onCancelEditName}
disabled={isUpdatingName}
activeOpacity={0.7}
>
<Ionicons name="close" size={20} color={colors.textSecondary} />
</TouchableOpacity>
<TouchableOpacity
style={styles.saveEditButton}
onPress={onUpdateName}
disabled={isUpdatingName}
activeOpacity={0.7}
>
{isUpdatingName ? (
<ActivityIndicator size="small" color={colors.text} />
) : (
<Ionicons name="checkmark" size={20} color={colors.text} />
)}
</TouchableOpacity>
</View>
</View>
) : (
<TouchableOpacity
style={styles.nameContainer}
onPress={onStartEditingName}
activeOpacity={0.7}
>
<Text style={styles.name}>{user?.name || "User"}</Text>
<View style={styles.editNameIcon}>
<Ionicons name="pencil" size={14} color={colors.textTertiary} />
</View>
</TouchableOpacity>
)}
<Text style={styles.email}>{user?.email}</Text>
</View>
</View>
</GlassCard>
</Animated.View>
);
};
const styles = StyleSheet.create({
profileSection: {
marginBottom: spacing.xl,
},
profileContent: {
flexDirection: 'row',
alignItems: 'center',
},
avatarTouchable: {
marginRight: spacing.md,
},
avatarGradient: {
width: 80,
height: 80,
borderRadius: 40,
padding: 3,
justifyContent: 'center',
alignItems: 'center',
shadowColor: featureColors.profile.accent,
shadowOffset: { width: 0, height: 4 },
shadowOpacity: 0.3,
shadowRadius: 8,
elevation: 6,
},
avatarImage: {
width: '100%',
height: '100%',
borderRadius: 40,
backgroundColor: colors.background,
},
avatarInner: {
width: '100%',
height: '100%',
borderRadius: 40,
backgroundColor: colors.background,
justifyContent: 'center',
alignItems: 'center',
},
avatarText: {
...typography.title1,
fontSize: 32,
color: colors.text,
},
avatarOverlay: {
position: 'absolute',
top: 0,
left: 0,
right: 0,
bottom: 0,
borderRadius: 40,
backgroundColor: 'rgba(0,0,0,0.5)',
justifyContent: 'center',
alignItems: 'center',
},
avatarEditBadge: {
position: 'absolute',
bottom: 0,
right: 0,
backgroundColor: colors.surfaceSolid,
width: 28,
height: 28,
borderRadius: 14,
justifyContent: 'center',
alignItems: 'center',
borderWidth: 2,
borderColor: colors.background,
},
profileInfo: {
flex: 1,
justifyContent: 'center',
},
nameContainer: {
flexDirection: 'row',
alignItems: 'center',
marginBottom: spacing.xs,
},
name: {
...typography.title2,
color: colors.text,
marginRight: spacing.xs,
},
editNameIcon: {
padding: 4,
},
email: {
...typography.body,
color: colors.textSecondary,
fontSize: 14,
},
editNameContainer: {
flexDirection: 'row',
alignItems: 'center',
},
nameInput: {
...typography.title2,
color: colors.text,
flex: 1,
paddingVertical: 0,
paddingHorizontal: 0,
marginRight: spacing.sm,
borderBottomWidth: 1,
borderBottomColor: featureColors.profile.accent,
},
editNameButtons: {
flexDirection: 'row',
alignItems: 'center',
},
saveEditButton: {
width: 32,
height: 32,
borderRadius: 16,
backgroundColor: featureColors.profile.accent,
justifyContent: 'center',
alignItems: 'center',
marginLeft: spacing.xs,
},
cancelEditButton: {
width: 32,
height: 32,
borderRadius: 16,
backgroundColor: colors.glass.highlight,
justifyContent: 'center',
alignItems: 'center',
},
});
@@ -0,0 +1,47 @@
import React from 'react';
import { View, Text, StyleSheet } from 'react-native';
import Animated, { FadeInDown } from 'react-native-reanimated';
import { GlassCard } from '../../../components/ui';
import { colors, typography, spacing } from '../../../theme';
export interface SettingsSectionProps {
/** Section title displayed above the card */
title: string;
/** Animation delay in ms */
delay?: number;
/** Content to render inside the card */
children: React.ReactNode;
}
/**
* Reusable settings section with title and glass card container.
*/
export function SettingsSection({ title, delay = 200, children }: SettingsSectionProps) {
return (
<Animated.View
entering={FadeInDown.delay(delay).duration(500)}
style={styles.container}
>
<Text style={styles.title}>{title}</Text>
<GlassCard>
{children}
</GlassCard>
</Animated.View>
);
}
const styles = StyleSheet.create({
container: {
marginBottom: spacing.lg,
},
title: {
...typography.caption1,
fontWeight: '600',
letterSpacing: 2,
color: colors.textTertiary,
marginBottom: spacing.sm,
textTransform: 'uppercase',
},
});
export default SettingsSection;
@@ -0,0 +1,100 @@
import React from 'react';
import { View, Text, Switch, StyleSheet } from 'react-native';
import { LinearGradient } from 'expo-linear-gradient';
import { Ionicons } from '@expo/vector-icons';
import { colors, spacing, radius, typography, featureColors } from '../../../theme';
export interface SwitchItemProps {
/** Icon name (Ionicons) */
icon: string;
/** Main label text */
label: string;
/** Description text below label */
description: string;
/** Switch value */
value: boolean;
/** Whether the switch is disabled */
disabled?: boolean;
/** Callback when value changes */
onValueChange: (value: boolean) => void;
}
const ACCENT_GRADIENT = featureColors.profile.gradient as [string, string];
const ACCENT_COLOR = featureColors.profile.accent;
/**
* Reusable toggle switch row for settings screens.
*/
export function SwitchItem({
icon,
label,
description,
value,
disabled = false,
onValueChange,
}: SwitchItemProps) {
return (
<View style={styles.container}>
<View style={styles.left}>
<LinearGradient
colors={ACCENT_GRADIENT}
style={styles.iconContainer}
start={{ x: 0, y: 0 }}
end={{ x: 1, y: 1 }}
>
<Ionicons name={icon as any} size={20} color={colors.text} />
</LinearGradient>
<View style={styles.textContainer}>
<Text style={styles.label}>{label}</Text>
<Text style={styles.description}>{description}</Text>
</View>
</View>
<Switch
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>
);
}
const styles = StyleSheet.create({
container: {
flexDirection: 'row',
alignItems: 'center',
justifyContent: 'space-between',
paddingVertical: spacing.xs,
},
left: {
flex: 1,
flexDirection: 'row',
alignItems: 'center',
marginRight: spacing.md,
},
iconContainer: {
width: 40,
height: 40,
borderRadius: radius.lg,
justifyContent: 'center',
alignItems: 'center',
marginRight: spacing.md,
},
textContainer: {
flex: 1,
},
label: {
...typography.body,
fontWeight: '500',
color: colors.text,
},
description: {
...typography.caption1,
color: colors.textSecondary,
marginTop: 2,
},
});
export default SwitchItem;
@@ -0,0 +1,12 @@
// Profile feature components
export { ProfileHeader } from './ProfileHeader';
export { CoupleStatus } from './CoupleStatus';
export { PrivacySettings } from './PrivacySettings';
export { DangerZone } from './DangerZone';
export { SettingsSection } from './SettingsSection';
export type { SettingsSectionProps } from './SettingsSection';
export { SwitchItem } from './SwitchItem';
export type { SwitchItemProps } from './SwitchItem';
export { MenuItem } from './MenuItem';
export type { MenuItemProps } from './MenuItem';
@@ -0,0 +1,3 @@
// Profile feature hooks
export { useProfileSettings } from './useProfileSettings';
export { useCoupleManagement } from './useCoupleManagement';
@@ -0,0 +1,129 @@
import { useState } from 'react';
import { Alert } from 'react-native';
import { useRouter } from 'expo-router';
import { useAuthStore, useMatchStore, useMessageStore, usePacksStore } from '../../../store';
import { supabase } from '../../../lib/supabase';
import { Events } from '../../../lib/analytics';
export function useCoupleManagement() {
const router = useRouter();
const { fetchCouple, fetchUser, signOut } = useAuthStore();
const { clearMatches } = useMatchStore();
const { clearMessages } = useMessageStore();
const { clearPacks } = usePacksStore();
const [isResettingProgress, setIsResettingProgress] = useState(false);
const handleUnpair = async () => {
Alert.alert(
"Unpair Partner",
"Are you sure you want to unpair? This cannot be undone.",
[
{ text: "Cancel", style: "cancel" },
{
text: "Unpair",
style: "destructive",
onPress: async () => {
try {
await supabase.functions.invoke("manage-couple", {
method: "DELETE",
});
await fetchCouple();
// Refresh user to update UI state
await fetchUser();
} catch (error) {
Alert.alert("Error", "Failed to unpair");
}
},
},
]
);
};
const handleDeleteRelationship = async () => {
try {
const { error } = await supabase.functions.invoke("delete-relationship", {
method: "DELETE",
});
if (error) throw error;
// Clear local stores
clearMatches();
clearMessages();
clearPacks();
// Refresh user data
await fetchUser();
Events.relationshipEnded();
Alert.alert("Success", "All relationship data has been deleted.");
} catch (error) {
console.error("Delete relationship error:", error);
Alert.alert("Error", "Failed to delete relationship data. Please try again.");
throw error;
}
};
const handleResetProgress = async () => {
Alert.alert(
"Reset Progress",
"This will delete all your swipes, matches, and chat messages. You'll stay connected with your partner and can start fresh. This cannot be undone.",
[
{ text: "Cancel", style: "cancel" },
{
text: "Reset",
style: "destructive",
onPress: async () => {
setIsResettingProgress(true);
try {
const { error } = await supabase.functions.invoke("reset-couple-progress", {
method: "DELETE",
});
if (error) throw error;
// Clear local stores
clearMatches();
clearMessages();
Alert.alert("Success", "Your progress has been reset. You can now start swiping again!");
} catch (error) {
Alert.alert("Error", "Failed to reset progress. Please try again.");
} finally {
setIsResettingProgress(false);
}
},
},
]
);
};
const handleSignOut = () => {
Alert.alert(
"Sign Out",
"Are you sure you want to sign out?",
[
{ text: "Cancel", style: "cancel" },
{
text: "Sign Out",
style: "destructive",
onPress: signOut,
},
]
);
};
const navigateToPairing = () => {
router.push("/(app)/pairing");
};
return {
isResettingProgress,
handleUnpair,
handleDeleteRelationship,
handleResetProgress,
handleSignOut,
navigateToPairing,
};
}
@@ -0,0 +1,334 @@
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 {
isBiometricAvailable,
isBiometricEnabled,
setBiometricEnabled,
getBiometricType,
} from '../../../lib/biometricAuth';
export function useProfileSettings() {
const { user, fetchUser } = useAuthStore();
const { fetchPacks } = usePacksStore();
const { restorePurchases, isPurchasing } = useSubscriptionStore();
// 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 [showExplicit, setShowExplicit] = useState(user?.show_explicit_content ?? true);
const [isUpdatingExplicit, setIsUpdatingExplicit] = 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);
}
if (user?.show_explicit_content !== undefined) {
setShowExplicit(user.show_explicit_content);
}
}, [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 handleExplicitToggle = async (value: boolean) => {
if (!user?.id) return;
setShowExplicit(value);
setIsUpdatingExplicit(true);
try {
const { error } = await supabase
.from('profiles')
.update({
show_explicit_content: value,
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(["show_explicit_content"]);
} catch (error) {
// Revert on error
setShowExplicit(!value);
Alert.alert("Error", "Failed to update preference. Please try again.");
} finally {
setIsUpdatingExplicit(false);
}
};
const handleBiometricToggle = async (value: boolean) => {
setBiometricEnabledState(value);
setIsUpdatingBiometric(true);
try {
await setBiometricEnabled(value);
} 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,
showExplicit,
isUpdatingExplicit,
biometricAvailable,
biometricEnabled: biometricEnabledState,
biometricType,
isUpdatingBiometric,
showPaywall,
setShowPaywall,
isPurchasing,
// Actions
handleUpdateName,
handleCancelEditName,
handleAvatarPress,
handleExplicitToggle,
handleBiometricToggle,
handleRestorePurchases,
handleManageSubscription,
};
}
@@ -0,0 +1,3 @@
export * from './components';
export * from './hooks';
export { ProfileScreen } from './ProfileScreen';
@@ -0,0 +1,7 @@
import { featureColors } from '../../theme';
/** Profile color palette constants */
export const PROFILE_COLORS = {
ACCENT: featureColors.profile.accent,
ACCENT_GRADIENT: featureColors.profile.gradient as [string, string],
} as const;