Files
Sauci/apps/mobile/app/(app)/matches.tsx
T
jjbrunton 994dd2a295 Add lifecycle push notifications and refine UI
Send push notifications when partner disconnects, resets progress, or deletes relationship.
Update Matches screen with new styling and remove legacy premium elements.
Remove message content preview from admin User Activity page.
2026-01-03 18:58:57 +00:00

518 lines
18 KiB
TypeScript
Executable File

import { View, Text, StyleSheet, FlatList, RefreshControl, TouchableOpacity, ActivityIndicator, Platform, useWindowDimensions } from "react-native";
import { useMatchStore, useAuthStore } from "../../src/store";
import { useEffect, useCallback, useState } from "react";
import { useFocusEffect } from "expo-router";
import { Ionicons } from "@expo/vector-icons";
import { useRouter } from "expo-router";
import { LinearGradient } from "expo-linear-gradient";
import Animated, {
FadeIn,
FadeInDown,
FadeInRight,
FadeInUp,
} from "react-native-reanimated";
import { GradientBackground, GlassCard, GlassButton, DecorativeSeparator } from "../../src/components/ui";
import { useAmbientOrbAnimation } from "../../src/hooks";
import { colors, gradients, spacing, typography, radius, shadows } from "../../src/theme";
import { MatchesTutorial } from "../../src/components/tutorials";
import { hasSeenMatchesTutorial, markMatchesTutorialSeen } from "../../src/lib/matchesTutorialSeen";
// Premium color palette
const ACCENT = colors.premium.gold;
const ACCENT_RGBA = 'rgba(212, 175, 55, ';
const ROSE = colors.premium.rose;
const ROSE_RGBA = 'rgba(232, 164, 174, ';
const MAX_CONTENT_WIDTH = 500;
export default function MatchesScreen() {
const { matches, fetchMatches, markAllAsSeen } = useMatchStore();
const { user } = useAuthStore();
const router = useRouter();
const { width } = useWindowDimensions();
const isWideScreen = width > MAX_CONTENT_WIDTH;
const [showTutorial, setShowTutorial] = useState(false);
// Ambient orb breathing animations
const { orbStyle1, orbStyle2 } = useAmbientOrbAnimation();
// Check if tutorial should be shown when screen is focused or matches change
useFocusEffect(
useCallback(() => {
const checkTutorial = async () => {
if (matches.length > 0) {
const seen = await hasSeenMatchesTutorial();
if (!seen) {
setShowTutorial(true);
}
}
};
checkTutorial();
}, [matches.length])
);
const handleTutorialComplete = async () => {
await markMatchesTutorialSeen();
setShowTutorial(false);
};
useFocusEffect(
useCallback(() => {
fetchMatches();
}, [])
);
useEffect(() => {
if (matches.length > 0) {
markAllAsSeen();
}
}, [matches.length]);
const renderItem = ({ item, index }: { item: any; index: number }) => {
const userResponse = item.responses?.find((r: any) => r.user_id === user?.id);
const partnerResponse = item.responses?.find((r: any) => r.user_id !== user?.id);
let userText = item.question.text;
let partnerText = item.question.partner_text;
if (item.question.partner_text && userResponse && partnerResponse) {
const userTime = new Date(userResponse.created_at).getTime();
const partnerTime = new Date(partnerResponse.created_at).getTime();
if (userTime > partnerTime) {
userText = item.question.partner_text;
partnerText = item.question.text;
}
}
const isYesYes = item.match_type === "yes_yes";
return (
<Animated.View entering={FadeInRight.delay(index * 50).duration(300)}>
<TouchableOpacity
onPress={() => router.push(`/chat/${item.id}`)}
activeOpacity={0.8}
>
<View style={styles.matchCardPremium}>
{/* 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 }}
/>
{/* Top silk highlight */}
<LinearGradient
colors={[`${ACCENT_RGBA}0.06)`, 'transparent']}
style={styles.cardSilkHighlight}
start={{ x: 0.5, y: 0 }}
end={{ x: 0.5, y: 1 }}
/>
<View style={styles.matchRow}>
{/* Premium icon container */}
<View style={[
styles.iconContainerPremium,
isYesYes && styles.iconContainerYesYes
]}>
<Ionicons
name={isYesYes ? "heart" : "heart-half"}
size={20}
color={isYesYes ? ACCENT : ROSE}
/>
</View>
<View style={styles.content}>
<Text style={styles.questionTextPremium} numberOfLines={2}>
{userText}
</Text>
{item.question.partner_text && (
<Text style={styles.partnerTextPremium} numberOfLines={1}>
Partner: {partnerText}
</Text>
)}
<View style={styles.metaRow}>
<View style={[
styles.tagPremium,
isYesYes && styles.tagPremiumHighlight
]}>
<Text style={[
styles.tagTextPremium,
isYesYes && styles.tagTextPremiumHighlight
]}>
{isYesYes ? "YES + YES" : "YES + MAYBE"}
</Text>
</View>
<Text style={styles.datePremium}>
{new Date(item.created_at).toLocaleDateString()}
</Text>
</View>
</View>
<View style={styles.rightSection}>
{item.unreadCount > 0 && (
<View style={styles.unreadBadgePremium}>
<Ionicons name="chatbubble" size={10} color={colors.text} />
</View>
)}
<View style={styles.chevronContainerPremium}>
<Ionicons name="chevron-forward" size={16} color={`${ACCENT_RGBA}0.6)`} />
</View>
</View>
</View>
{/* Premium border */}
<View style={styles.cardPremiumBorder} pointerEvents="none" />
</View>
</TouchableOpacity>
</Animated.View>
);
};
if (!user) {
return (
<GradientBackground>
<View style={styles.loadingContainer}>
<ActivityIndicator color={colors.primary} size="large" />
</View>
</GradientBackground>
);
}
return (
<GradientBackground>
{/* Ambient Orbs - Premium gold/rose */}
<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>
<View style={styles.container}>
{/* Premium Header */}
<Animated.View
entering={FadeIn.duration(400)}
style={[styles.header, isWideScreen && styles.headerWide]}
>
<View style={styles.headerContent}>
{/* Premium label */}
<Text style={styles.headerLabel}>DISCOVER</Text>
<Text style={styles.headerTitle}>Matches</Text>
{/* Decorative separator */}
<DecorativeSeparator variant="gold" />
{/* Count badge */}
<View style={styles.countBadgePremium}>
<Ionicons name="heart" size={12} color={ACCENT} />
<Text style={styles.countTextPremium}>{matches.length} {matches.length === 1 ? 'MATCH' : 'MATCHES'}</Text>
</View>
</View>
</Animated.View>
<FlatList
data={matches}
renderItem={renderItem}
keyExtractor={(item) => item.id}
contentContainerStyle={[styles.list, isWideScreen && styles.listWide]}
showsVerticalScrollIndicator={false}
refreshControl={
<RefreshControl
refreshing={false}
onRefresh={fetchMatches}
tintColor={colors.primary}
colors={[colors.primary]}
/>
}
ListEmptyComponent={
<Animated.View
entering={FadeInUp.duration(600).springify()}
style={styles.emptyContent}
>
{/* Description */}
<Text style={styles.emptyDescription}>
Answer questions together to discover what you both enjoy. Matches appear when you agree!
</Text>
{/* Feature hints */}
<View style={styles.emptyFeatures}>
<View style={styles.emptyFeatureItem}>
<Ionicons name="heart" size={16} color={ACCENT} />
<Text style={styles.emptyFeatureText}>Swipe right for yes</Text>
</View>
<View style={styles.emptyFeatureItem}>
<Ionicons name="sparkles" size={16} color={ACCENT} />
<Text style={styles.emptyFeatureText}>Match when you both agree</Text>
</View>
<View style={styles.emptyFeatureItem}>
<Ionicons name="chatbubbles-outline" size={16} color={ACCENT} />
<Text style={styles.emptyFeatureText}>Chat about your matches</Text>
</View>
</View>
{/* Bottom teaser */}
<Text style={styles.emptyTeaser}>Your first match is just a swipe away</Text>
<GlassButton
onPress={() => router.push("/(app)/swipe")}
style={{ marginTop: spacing.lg }}
>
Start Swiping
</GlassButton>
</Animated.View>
}
/>
</View>
{/* Matches Tutorial Overlay */}
{showTutorial && (
<MatchesTutorial onComplete={handleTutorialComplete} />
)}
</GradientBackground>
);
}
const styles = StyleSheet.create({
container: {
flex: 1,
},
loadingContainer: {
flex: 1,
justifyContent: "center",
alignItems: "center",
},
// Ambient orbs
ambientOrb: {
position: 'absolute',
width: 300,
height: 300,
borderRadius: 150,
},
orbTopRight: {
top: 60,
right: -40,
},
orbBottomLeft: {
bottom: 180,
left: -40,
},
orbGradient: {
width: '100%',
height: '100%',
borderRadius: 150,
},
// Premium Header
header: {
paddingTop: 60,
paddingHorizontal: spacing.lg,
paddingBottom: spacing.md,
},
headerWide: {
alignSelf: 'center',
width: '100%',
maxWidth: MAX_CONTENT_WIDTH,
},
headerContent: {
alignItems: 'center',
},
headerLabel: {
...typography.caption2,
fontWeight: '600',
letterSpacing: 3,
color: ACCENT,
marginBottom: spacing.xs,
},
headerTitle: {
...typography.largeTitle,
color: colors.text,
textAlign: 'center',
},
countBadgePremium: {
flexDirection: 'row',
alignItems: 'center',
backgroundColor: `${ACCENT_RGBA}0.1)`,
paddingHorizontal: spacing.md,
paddingVertical: spacing.sm,
borderRadius: radius.full,
borderWidth: 1,
borderColor: `${ACCENT_RGBA}0.2)`,
gap: spacing.xs,
},
countTextPremium: {
...typography.caption2,
fontWeight: '600',
letterSpacing: 2,
color: ACCENT,
},
// List
list: {
padding: spacing.lg,
paddingTop: spacing.sm,
paddingBottom: Platform.OS === 'ios' ? 120 : 100,
},
listWide: {
alignSelf: 'center',
width: '100%',
maxWidth: MAX_CONTENT_WIDTH,
},
// Premium Match Card
matchCardPremium: {
marginBottom: spacing.sm,
borderRadius: radius.lg,
overflow: 'hidden',
padding: spacing.md,
},
cardSilkHighlight: {
position: 'absolute',
top: 0,
left: 0,
right: 0,
height: 60,
},
cardPremiumBorder: {
position: 'absolute',
top: 0,
left: 0,
right: 0,
bottom: 0,
borderRadius: radius.lg,
borderWidth: 1,
borderColor: `${ACCENT_RGBA}0.15)`,
},
matchRow: {
flexDirection: "row",
alignItems: "center",
},
iconContainerPremium: {
width: 44,
height: 44,
borderRadius: 22,
backgroundColor: `${ROSE_RGBA}0.1)`,
borderWidth: 1,
borderColor: `${ROSE_RGBA}0.2)`,
justifyContent: "center",
alignItems: "center",
marginRight: spacing.md,
},
iconContainerYesYes: {
backgroundColor: `${ACCENT_RGBA}0.1)`,
borderColor: `${ACCENT_RGBA}0.2)`,
},
content: {
flex: 1,
},
questionTextPremium: {
...typography.subhead,
color: colors.text,
fontWeight: "600",
marginBottom: spacing.xs,
lineHeight: 20,
},
partnerTextPremium: {
...typography.caption1,
color: colors.textSecondary,
fontStyle: "italic",
marginBottom: spacing.sm,
},
metaRow: {
flexDirection: "row",
alignItems: "center",
justifyContent: "space-between",
},
tagPremium: {
backgroundColor: `${ROSE_RGBA}0.1)`,
paddingHorizontal: spacing.sm,
paddingVertical: 3,
borderRadius: radius.sm,
borderWidth: 1,
borderColor: `${ROSE_RGBA}0.15)`,
},
tagPremiumHighlight: {
backgroundColor: `${ACCENT_RGBA}0.1)`,
borderColor: `${ACCENT_RGBA}0.2)`,
},
tagTextPremium: {
...typography.caption2,
color: ROSE,
fontWeight: "600",
letterSpacing: 1,
},
tagTextPremiumHighlight: {
color: ACCENT,
},
datePremium: {
...typography.caption2,
color: colors.textTertiary,
fontStyle: 'italic',
},
rightSection: {
flexDirection: 'row',
alignItems: 'center',
gap: spacing.sm,
marginLeft: spacing.sm,
},
unreadBadgePremium: {
width: 22,
height: 22,
borderRadius: 11,
backgroundColor: ACCENT,
justifyContent: 'center',
alignItems: 'center',
},
chevronContainerPremium: {
width: 28,
height: 28,
borderRadius: 14,
backgroundColor: `${ACCENT_RGBA}0.08)`,
borderWidth: 1,
borderColor: `${ACCENT_RGBA}0.15)`,
justifyContent: 'center',
alignItems: 'center',
},
// Premium Empty State
emptyContent: {
width: '100%',
alignItems: 'center',
paddingHorizontal: spacing.md,
paddingVertical: spacing.xl,
},
emptyDescription: {
...typography.body,
color: colors.textSecondary,
textAlign: 'center',
marginBottom: spacing.xl,
paddingHorizontal: spacing.md,
},
emptyFeatures: {
marginBottom: spacing.xl,
},
emptyFeatureItem: {
flexDirection: 'row',
alignItems: 'center',
justifyContent: 'center',
marginBottom: spacing.sm,
},
emptyFeatureText: {
...typography.subhead,
color: colors.text,
marginLeft: spacing.sm,
},
emptyTeaser: {
...typography.footnote,
fontStyle: 'italic',
color: colors.textTertiary,
textAlign: 'center',
},
});