mirror of
https://github.com/jjbrunton/Sauci.git
synced 2026-10-31 18:19:43 +00:00
Add match archiving, chat enhancements, and daily limits
- Match archives with swipe-to-archive and unread filtering - Chat settings sheet and full-screen media modals - Match notification modal for new matches - Admin AI settings with model/provider configuration - Daily response limit feature with recommended questions - New test coverage for stores, hooks, and components - Claude Code agent and command configurations Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
This commit is contained in:
1 parent
595d6bd394
commit
b0f1700e40
57 files changed
+8359
-1661
No files matched your search
@@ -0,0 +1,526 @@
|
||||
/**
|
||||
* MatchNotificationModal - Premium celebration modal for new matches
|
||||
*
|
||||
* Features:
|
||||
* - Glass-morphism styling following boutique design aesthetic
|
||||
* - Animated floating hearts celebration effect
|
||||
* - Shows what each partner answered (You / Partner)
|
||||
* - Uses brand gradients and premium styling
|
||||
*/
|
||||
import React, { useEffect, useRef } from 'react';
|
||||
import {
|
||||
View,
|
||||
Text,
|
||||
StyleSheet,
|
||||
Pressable,
|
||||
Animated,
|
||||
} from 'react-native';
|
||||
import { LinearGradient } from 'expo-linear-gradient';
|
||||
import { BlurView } from 'expo-blur';
|
||||
import { Ionicons } from '@expo/vector-icons';
|
||||
import { colors, gradients, spacing, radius, typography, shadows } from '../theme';
|
||||
import { DecorativeSeparator } from './ui';
|
||||
import type { MatchType, AnswerType } from '../types';
|
||||
|
||||
export interface MatchNotificationData {
|
||||
id: string;
|
||||
match_type: MatchType;
|
||||
question: {
|
||||
id: string;
|
||||
text: string;
|
||||
partner_text?: string | null;
|
||||
};
|
||||
userResponse?: AnswerType;
|
||||
partnerResponse?: AnswerType;
|
||||
partnerName?: string | null;
|
||||
}
|
||||
|
||||
interface Props {
|
||||
visible: boolean;
|
||||
match: MatchNotificationData | null;
|
||||
onDismiss: () => void;
|
||||
onChat: () => void;
|
||||
}
|
||||
|
||||
// Helper to get response styling
|
||||
const getResponseStyle = (response: AnswerType, isPartner: boolean = false) => {
|
||||
switch (response) {
|
||||
case 'yes':
|
||||
return {
|
||||
bg: isPartner ? 'rgba(46, 204, 113, 0.15)' : 'rgba(233, 69, 96, 0.15)',
|
||||
border: isPartner ? 'rgba(46, 204, 113, 0.3)' : 'rgba(233, 69, 96, 0.3)',
|
||||
color: isPartner ? colors.success : colors.primary,
|
||||
text: 'YES',
|
||||
icon: 'heart' as const,
|
||||
};
|
||||
case 'maybe':
|
||||
return {
|
||||
bg: 'rgba(243, 156, 18, 0.15)',
|
||||
border: 'rgba(243, 156, 18, 0.3)',
|
||||
color: colors.warning,
|
||||
text: 'MAYBE',
|
||||
icon: 'help-circle' as const,
|
||||
};
|
||||
default:
|
||||
return {
|
||||
bg: 'rgba(255, 255, 255, 0.1)',
|
||||
border: 'rgba(255, 255, 255, 0.2)',
|
||||
color: colors.textSecondary,
|
||||
text: response.toUpperCase(),
|
||||
icon: 'ellipse' as const,
|
||||
};
|
||||
}
|
||||
};
|
||||
|
||||
// Compact response badge (for two-part questions)
|
||||
const ResponseBadge: React.FC<{
|
||||
response: AnswerType;
|
||||
isPartner?: boolean;
|
||||
}> = ({ response, isPartner = false }) => {
|
||||
const style = getResponseStyle(response, isPartner);
|
||||
|
||||
return (
|
||||
<View
|
||||
style={[
|
||||
styles.responseBadgeCompact,
|
||||
{ backgroundColor: style.bg, borderColor: style.border },
|
||||
]}
|
||||
>
|
||||
<Ionicons name={style.icon} size={14} color={style.color} />
|
||||
<Text style={[styles.responseBadgeTextCompact, { color: style.color }]}>
|
||||
{style.text}
|
||||
</Text>
|
||||
</View>
|
||||
);
|
||||
};
|
||||
|
||||
export const MatchNotificationModal: React.FC<Props> = ({
|
||||
visible,
|
||||
match,
|
||||
onDismiss,
|
||||
onChat,
|
||||
}) => {
|
||||
const modalScale = useRef(new Animated.Value(0.8)).current;
|
||||
const modalOpacity = useRef(new Animated.Value(0)).current;
|
||||
const overlayOpacity = useRef(new Animated.Value(0)).current;
|
||||
|
||||
useEffect(() => {
|
||||
if (visible) {
|
||||
// Reset animations
|
||||
modalScale.setValue(0.8);
|
||||
modalOpacity.setValue(0);
|
||||
overlayOpacity.setValue(0);
|
||||
|
||||
// Animate in
|
||||
Animated.parallel([
|
||||
Animated.timing(overlayOpacity, {
|
||||
toValue: 1,
|
||||
duration: 250,
|
||||
useNativeDriver: true,
|
||||
}),
|
||||
Animated.spring(modalScale, {
|
||||
toValue: 1,
|
||||
tension: 80,
|
||||
friction: 10,
|
||||
useNativeDriver: true,
|
||||
}),
|
||||
Animated.timing(modalOpacity, {
|
||||
toValue: 1,
|
||||
duration: 200,
|
||||
useNativeDriver: true,
|
||||
}),
|
||||
]).start();
|
||||
}
|
||||
}, [visible]);
|
||||
|
||||
const handleDismiss = () => {
|
||||
Animated.parallel([
|
||||
Animated.timing(overlayOpacity, {
|
||||
toValue: 0,
|
||||
duration: 200,
|
||||
useNativeDriver: true,
|
||||
}),
|
||||
Animated.timing(modalScale, {
|
||||
toValue: 0.8,
|
||||
duration: 200,
|
||||
useNativeDriver: true,
|
||||
}),
|
||||
Animated.timing(modalOpacity, {
|
||||
toValue: 0,
|
||||
duration: 150,
|
||||
useNativeDriver: true,
|
||||
}),
|
||||
]).start(() => onDismiss());
|
||||
};
|
||||
|
||||
const handleChat = () => {
|
||||
Animated.parallel([
|
||||
Animated.timing(overlayOpacity, {
|
||||
toValue: 0,
|
||||
duration: 200,
|
||||
useNativeDriver: true,
|
||||
}),
|
||||
Animated.timing(modalScale, {
|
||||
toValue: 0.8,
|
||||
duration: 200,
|
||||
useNativeDriver: true,
|
||||
}),
|
||||
Animated.timing(modalOpacity, {
|
||||
toValue: 0,
|
||||
duration: 150,
|
||||
useNativeDriver: true,
|
||||
}),
|
||||
]).start(() => onChat());
|
||||
};
|
||||
|
||||
if (!visible || !match) return null;
|
||||
|
||||
// Determine responses based on match_type if not explicitly provided
|
||||
const getUserResponse = (): AnswerType => {
|
||||
if (match.userResponse) return match.userResponse;
|
||||
// Infer from match_type (user typically triggers the match)
|
||||
if (match.match_type === 'yes_yes') return 'yes';
|
||||
if (match.match_type === 'maybe_maybe') return 'maybe';
|
||||
// yes_maybe - user could be either
|
||||
return 'yes';
|
||||
};
|
||||
|
||||
const getPartnerResponse = (): AnswerType => {
|
||||
if (match.partnerResponse) return match.partnerResponse;
|
||||
// Infer from match_type
|
||||
if (match.match_type === 'yes_yes') return 'yes';
|
||||
if (match.match_type === 'maybe_maybe') return 'maybe';
|
||||
// yes_maybe - partner could be either
|
||||
return 'maybe';
|
||||
};
|
||||
|
||||
const userResponse = getUserResponse();
|
||||
const partnerResponse = getPartnerResponse();
|
||||
|
||||
// Get match type styling
|
||||
const getMatchTypeLabel = () => {
|
||||
switch (match.match_type) {
|
||||
case 'yes_yes':
|
||||
return { text: 'Perfect Match', color: colors.primary };
|
||||
case 'yes_maybe':
|
||||
return { text: 'Curious Match', color: colors.warning };
|
||||
case 'maybe_maybe':
|
||||
return { text: 'Exploring Together', color: colors.premium.rose };
|
||||
default:
|
||||
return { text: 'Match', color: colors.primary };
|
||||
}
|
||||
};
|
||||
|
||||
const matchTypeInfo = getMatchTypeLabel();
|
||||
|
||||
// Use partner's name or fallback to "Partner"
|
||||
const partnerDisplayName = match.partnerName || 'Partner';
|
||||
|
||||
return (
|
||||
<Animated.View
|
||||
style={[styles.overlay, { opacity: overlayOpacity }]}
|
||||
>
|
||||
{/* Background press to dismiss */}
|
||||
<Pressable style={StyleSheet.absoluteFill} onPress={handleDismiss} />
|
||||
|
||||
{/* Modal card with glow */}
|
||||
<Animated.View
|
||||
style={[
|
||||
styles.glowContainer,
|
||||
{
|
||||
transform: [{ scale: modalScale }],
|
||||
opacity: modalOpacity,
|
||||
},
|
||||
]}
|
||||
>
|
||||
{/* Outer glow effect */}
|
||||
<View style={styles.glowOuter} />
|
||||
|
||||
<View style={styles.modalContainer}>
|
||||
{/* Glass background */}
|
||||
<BlurView intensity={40} tint="dark" style={styles.blurContainer}>
|
||||
<LinearGradient
|
||||
colors={['rgba(22, 33, 62, 0.95)', 'rgba(13, 13, 26, 0.98)']}
|
||||
style={styles.gradientOverlay}
|
||||
/>
|
||||
</BlurView>
|
||||
|
||||
{/* Content */}
|
||||
<View style={styles.content}>
|
||||
{/* Title section */}
|
||||
<Text style={styles.title}>It's a Match!</Text>
|
||||
|
||||
{/* Match type badge */}
|
||||
<View style={[styles.matchTypeBadge, { borderColor: `${matchTypeInfo.color}30` }]}>
|
||||
<Text style={[styles.matchTypeText, { color: matchTypeInfo.color }]}>
|
||||
{matchTypeInfo.text}
|
||||
</Text>
|
||||
</View>
|
||||
|
||||
<DecorativeSeparator variant="muted" width={80} marginVertical={spacing.md} />
|
||||
|
||||
{/* Question text and responses */}
|
||||
{match.question.partner_text ? (
|
||||
// Two-part question - show what each person saw
|
||||
<View style={styles.twoPartContainer}>
|
||||
<View style={styles.responseSection}>
|
||||
<View style={styles.responseLabelRow}>
|
||||
<Text style={styles.responsePersonLabel}>You said</Text>
|
||||
<ResponseBadge response={userResponse} />
|
||||
</View>
|
||||
<Text style={styles.questionTextSmall}>"{match.question.text}"</Text>
|
||||
</View>
|
||||
|
||||
<View style={styles.matchConnector}>
|
||||
<View style={styles.connectorLine} />
|
||||
<View style={styles.connectorHeart}>
|
||||
<Ionicons name="heart" size={16} color={colors.primary} />
|
||||
</View>
|
||||
<View style={styles.connectorLine} />
|
||||
</View>
|
||||
|
||||
<View style={styles.responseSection}>
|
||||
<View style={styles.responseLabelRow}>
|
||||
<Text style={styles.responsePersonLabel}>{partnerDisplayName} said</Text>
|
||||
<ResponseBadge response={partnerResponse} isPartner />
|
||||
</View>
|
||||
<Text style={styles.questionTextSmall}>"{match.question.partner_text}"</Text>
|
||||
</View>
|
||||
</View>
|
||||
) : (
|
||||
// Single question - show shared text with inline responses
|
||||
<View style={styles.questionContainer}>
|
||||
<Text style={styles.questionText}>"{match.question.text}"</Text>
|
||||
<View style={styles.responsesInline}>
|
||||
<Text style={styles.responseInlineText}>You said </Text>
|
||||
<Text style={[styles.responseInlineAnswer, { color: userResponse === 'yes' ? colors.primary : colors.warning }]}>
|
||||
{userResponse.toUpperCase()}
|
||||
</Text>
|
||||
<Text style={styles.responseInlineText}> · {partnerDisplayName} said </Text>
|
||||
<Text style={[styles.responseInlineAnswer, { color: partnerResponse === 'yes' ? colors.success : colors.warning }]}>
|
||||
{partnerResponse.toUpperCase()}
|
||||
</Text>
|
||||
</View>
|
||||
</View>
|
||||
)}
|
||||
|
||||
<DecorativeSeparator variant="muted" width={100} marginVertical={spacing.md} />
|
||||
|
||||
{/* Action buttons */}
|
||||
<View style={styles.buttonRow}>
|
||||
<Pressable onPress={handleChat} style={styles.chatButton}>
|
||||
<LinearGradient
|
||||
colors={gradients.primary as [string, string]}
|
||||
start={{ x: 0, y: 0 }}
|
||||
end={{ x: 1, y: 0 }}
|
||||
style={styles.chatButtonGradient}
|
||||
>
|
||||
<Ionicons name="chatbubble" size={16} color={colors.text} />
|
||||
<Text style={styles.chatButtonText}>Chat</Text>
|
||||
</LinearGradient>
|
||||
</Pressable>
|
||||
|
||||
<Pressable onPress={handleDismiss} style={styles.dismissButton}>
|
||||
<Text style={styles.dismissButtonText}>Continue</Text>
|
||||
</Pressable>
|
||||
</View>
|
||||
</View>
|
||||
|
||||
{/* Border */}
|
||||
<View style={styles.border} pointerEvents="none" />
|
||||
</View>
|
||||
</Animated.View>
|
||||
</Animated.View>
|
||||
);
|
||||
};
|
||||
|
||||
const styles = StyleSheet.create({
|
||||
overlay: {
|
||||
...StyleSheet.absoluteFillObject,
|
||||
backgroundColor: 'rgba(0, 0, 0, 0.75)',
|
||||
justifyContent: 'center',
|
||||
alignItems: 'center',
|
||||
zIndex: 9999,
|
||||
},
|
||||
glowContainer: {
|
||||
width: '88%',
|
||||
maxWidth: 360,
|
||||
alignItems: 'center',
|
||||
justifyContent: 'center',
|
||||
},
|
||||
glowOuter: {
|
||||
position: 'absolute',
|
||||
top: -3,
|
||||
left: -3,
|
||||
right: -3,
|
||||
bottom: -3,
|
||||
borderRadius: radius.xl + 3,
|
||||
backgroundColor: 'transparent',
|
||||
borderWidth: 1,
|
||||
borderColor: 'rgba(233, 69, 96, 0.15)',
|
||||
shadowColor: colors.primary,
|
||||
shadowOffset: { width: 0, height: 0 },
|
||||
shadowOpacity: 0.2,
|
||||
shadowRadius: 20,
|
||||
elevation: 8,
|
||||
},
|
||||
modalContainer: {
|
||||
width: '100%',
|
||||
borderRadius: radius.xl,
|
||||
overflow: 'hidden',
|
||||
},
|
||||
blurContainer: {
|
||||
...StyleSheet.absoluteFillObject,
|
||||
},
|
||||
gradientOverlay: {
|
||||
...StyleSheet.absoluteFillObject,
|
||||
},
|
||||
content: {
|
||||
padding: spacing.xl,
|
||||
paddingTop: spacing.lg,
|
||||
alignItems: 'center',
|
||||
},
|
||||
title: {
|
||||
...typography.title2,
|
||||
color: colors.text,
|
||||
textAlign: 'center',
|
||||
marginBottom: spacing.sm,
|
||||
},
|
||||
matchTypeBadge: {
|
||||
backgroundColor: 'rgba(255, 255, 255, 0.05)',
|
||||
paddingHorizontal: spacing.lg,
|
||||
paddingVertical: spacing.sm,
|
||||
borderRadius: radius.full,
|
||||
borderWidth: 1,
|
||||
marginBottom: spacing.lg,
|
||||
},
|
||||
matchTypeText: {
|
||||
...typography.caption1,
|
||||
fontWeight: '600',
|
||||
letterSpacing: 1,
|
||||
},
|
||||
questionContainer: {
|
||||
alignItems: 'center',
|
||||
},
|
||||
questionText: {
|
||||
...typography.body,
|
||||
color: colors.text,
|
||||
textAlign: 'center',
|
||||
fontStyle: 'italic',
|
||||
lineHeight: 24,
|
||||
paddingHorizontal: spacing.sm,
|
||||
marginBottom: spacing.md,
|
||||
},
|
||||
responsesInline: {
|
||||
flexDirection: 'row',
|
||||
flexWrap: 'wrap',
|
||||
justifyContent: 'center',
|
||||
alignItems: 'center',
|
||||
},
|
||||
responseInlineText: {
|
||||
...typography.caption1,
|
||||
color: colors.textSecondary,
|
||||
},
|
||||
responseInlineAnswer: {
|
||||
...typography.caption1,
|
||||
fontWeight: '700',
|
||||
},
|
||||
responseBadgeCompact: {
|
||||
flexDirection: 'row',
|
||||
alignItems: 'center',
|
||||
gap: spacing.xs,
|
||||
paddingHorizontal: spacing.sm,
|
||||
paddingVertical: spacing.xs,
|
||||
borderRadius: radius.md,
|
||||
borderWidth: 1,
|
||||
},
|
||||
responseBadgeTextCompact: {
|
||||
...typography.caption1,
|
||||
fontWeight: '600',
|
||||
letterSpacing: 0.5,
|
||||
},
|
||||
// Two-part question styles
|
||||
twoPartContainer: {
|
||||
width: '100%',
|
||||
marginBottom: spacing.md,
|
||||
},
|
||||
responseSection: {
|
||||
alignItems: 'center',
|
||||
paddingHorizontal: spacing.sm,
|
||||
},
|
||||
responseLabelRow: {
|
||||
flexDirection: 'row',
|
||||
alignItems: 'center',
|
||||
gap: spacing.sm,
|
||||
marginBottom: spacing.xs,
|
||||
},
|
||||
responsePersonLabel: {
|
||||
...typography.caption1,
|
||||
color: colors.textSecondary,
|
||||
fontWeight: '500',
|
||||
},
|
||||
questionTextSmall: {
|
||||
...typography.subhead,
|
||||
color: colors.text,
|
||||
textAlign: 'center',
|
||||
fontStyle: 'italic',
|
||||
lineHeight: 20,
|
||||
},
|
||||
matchConnector: {
|
||||
flexDirection: 'row',
|
||||
alignItems: 'center',
|
||||
marginVertical: spacing.md,
|
||||
paddingHorizontal: spacing.xl,
|
||||
},
|
||||
connectorLine: {
|
||||
flex: 1,
|
||||
height: 1,
|
||||
backgroundColor: 'rgba(233, 69, 96, 0.2)',
|
||||
},
|
||||
connectorHeart: {
|
||||
marginHorizontal: spacing.sm,
|
||||
opacity: 0.8,
|
||||
},
|
||||
buttonRow: {
|
||||
flexDirection: 'row',
|
||||
gap: spacing.md,
|
||||
width: '100%',
|
||||
},
|
||||
chatButton: {
|
||||
flex: 1,
|
||||
borderRadius: radius.lg,
|
||||
overflow: 'hidden',
|
||||
...shadows.md,
|
||||
},
|
||||
chatButtonGradient: {
|
||||
flexDirection: 'row',
|
||||
alignItems: 'center',
|
||||
justifyContent: 'center',
|
||||
gap: spacing.sm,
|
||||
paddingVertical: spacing.md,
|
||||
},
|
||||
chatButtonText: {
|
||||
...typography.headline,
|
||||
color: colors.text,
|
||||
},
|
||||
dismissButton: {
|
||||
flex: 1,
|
||||
borderRadius: radius.lg,
|
||||
backgroundColor: colors.glass.background,
|
||||
borderWidth: 1,
|
||||
borderColor: colors.glass.border,
|
||||
paddingVertical: spacing.md,
|
||||
alignItems: 'center',
|
||||
justifyContent: 'center',
|
||||
},
|
||||
dismissButtonText: {
|
||||
...typography.headline,
|
||||
color: colors.textSecondary,
|
||||
},
|
||||
border: {
|
||||
...StyleSheet.absoluteFillObject,
|
||||
borderRadius: radius.xl,
|
||||
borderWidth: 1,
|
||||
borderColor: 'rgba(233, 69, 96, 0.2)',
|
||||
},
|
||||
});
|
||||
@@ -0,0 +1,173 @@
|
||||
import React from "react";
|
||||
import { View, Text, StyleSheet, Platform } from "react-native";
|
||||
import { Gesture, GestureDetector } from "react-native-gesture-handler";
|
||||
import Animated, {
|
||||
useSharedValue,
|
||||
useAnimatedStyle,
|
||||
withSpring,
|
||||
runOnJS,
|
||||
interpolate,
|
||||
Extrapolation,
|
||||
} from "react-native-reanimated";
|
||||
import { Ionicons } from "@expo/vector-icons";
|
||||
import { colors, spacing, radius, typography } from "../../theme";
|
||||
|
||||
const SWIPE_THRESHOLD = 80;
|
||||
const ACCENT = colors.premium.gold;
|
||||
|
||||
// Haptics helper
|
||||
const triggerHaptic = async (style: 'light' | 'medium' | 'heavy' = 'medium') => {
|
||||
if (Platform.OS === 'web') return;
|
||||
const Haptics = await import('expo-haptics');
|
||||
const feedbackStyle = style === 'light'
|
||||
? Haptics.ImpactFeedbackStyle.Light
|
||||
: style === 'heavy'
|
||||
? Haptics.ImpactFeedbackStyle.Heavy
|
||||
: Haptics.ImpactFeedbackStyle.Medium;
|
||||
await Haptics.impactAsync(feedbackStyle);
|
||||
};
|
||||
|
||||
interface SwipeableMatchItemProps {
|
||||
children: React.ReactNode;
|
||||
onArchive: () => void;
|
||||
isArchived?: boolean;
|
||||
enabled?: boolean;
|
||||
}
|
||||
|
||||
export function SwipeableMatchItem({
|
||||
children,
|
||||
onArchive,
|
||||
isArchived = false,
|
||||
enabled = true,
|
||||
}: SwipeableMatchItemProps) {
|
||||
const translateX = useSharedValue(0);
|
||||
const hasTriggeredHaptic = useSharedValue(false);
|
||||
|
||||
const panGesture = Gesture.Pan()
|
||||
.enabled(enabled)
|
||||
.activeOffsetX([-20, 20])
|
||||
.onUpdate((event) => {
|
||||
// Only allow left swipe (negative X) for archive
|
||||
translateX.value = Math.min(0, event.translationX);
|
||||
|
||||
// Trigger haptic when crossing threshold
|
||||
if (translateX.value < -SWIPE_THRESHOLD && !hasTriggeredHaptic.value) {
|
||||
hasTriggeredHaptic.value = true;
|
||||
runOnJS(triggerHaptic)('medium');
|
||||
} else if (translateX.value >= -SWIPE_THRESHOLD) {
|
||||
hasTriggeredHaptic.value = false;
|
||||
}
|
||||
})
|
||||
.onEnd(() => {
|
||||
if (translateX.value < -SWIPE_THRESHOLD) {
|
||||
// Animate out then trigger archive
|
||||
translateX.value = withSpring(-200, { damping: 15 }, (finished) => {
|
||||
if (finished) {
|
||||
runOnJS(onArchive)();
|
||||
}
|
||||
});
|
||||
} else {
|
||||
// Spring back to center
|
||||
translateX.value = withSpring(0, { damping: 15 });
|
||||
}
|
||||
hasTriggeredHaptic.value = false;
|
||||
});
|
||||
|
||||
const containerStyle = useAnimatedStyle(() => ({
|
||||
transform: [{ translateX: translateX.value }],
|
||||
}));
|
||||
|
||||
const actionStyle = useAnimatedStyle(() => ({
|
||||
opacity: interpolate(
|
||||
translateX.value,
|
||||
[-SWIPE_THRESHOLD, -20, 0],
|
||||
[1, 0.5, 0],
|
||||
Extrapolation.CLAMP
|
||||
),
|
||||
transform: [
|
||||
{
|
||||
scale: interpolate(
|
||||
translateX.value,
|
||||
[-SWIPE_THRESHOLD, 0],
|
||||
[1, 0.8],
|
||||
Extrapolation.CLAMP
|
||||
),
|
||||
},
|
||||
],
|
||||
}));
|
||||
|
||||
const actionIconStyle = useAnimatedStyle(() => ({
|
||||
transform: [
|
||||
{
|
||||
translateX: interpolate(
|
||||
translateX.value,
|
||||
[-SWIPE_THRESHOLD * 1.5, -SWIPE_THRESHOLD, 0],
|
||||
[0, 10, 30],
|
||||
Extrapolation.CLAMP
|
||||
),
|
||||
},
|
||||
],
|
||||
}));
|
||||
|
||||
return (
|
||||
<View style={styles.container}>
|
||||
{/* Archive action revealed on swipe */}
|
||||
<Animated.View style={[styles.actionContainer, actionStyle]}>
|
||||
<Animated.View style={[styles.actionContent, actionIconStyle]}>
|
||||
<View style={styles.iconContainer}>
|
||||
<Ionicons
|
||||
name={isArchived ? "arrow-undo" : "archive"}
|
||||
size={22}
|
||||
color={ACCENT}
|
||||
/>
|
||||
</View>
|
||||
<Text style={styles.actionText}>
|
||||
{isArchived ? "Restore" : "Archive"}
|
||||
</Text>
|
||||
</Animated.View>
|
||||
</Animated.View>
|
||||
|
||||
<GestureDetector gesture={panGesture}>
|
||||
<Animated.View style={containerStyle}>
|
||||
{children}
|
||||
</Animated.View>
|
||||
</GestureDetector>
|
||||
</View>
|
||||
);
|
||||
}
|
||||
|
||||
const styles = StyleSheet.create({
|
||||
container: {
|
||||
position: "relative",
|
||||
overflow: "hidden",
|
||||
},
|
||||
actionContainer: {
|
||||
position: "absolute",
|
||||
right: spacing.md,
|
||||
top: 0,
|
||||
bottom: 0,
|
||||
justifyContent: "center",
|
||||
alignItems: "flex-end",
|
||||
paddingRight: spacing.sm,
|
||||
},
|
||||
actionContent: {
|
||||
alignItems: "center",
|
||||
gap: spacing.xs,
|
||||
},
|
||||
iconContainer: {
|
||||
width: 44,
|
||||
height: 44,
|
||||
borderRadius: 22,
|
||||
backgroundColor: `rgba(212, 175, 55, 0.15)`,
|
||||
borderWidth: 1,
|
||||
borderColor: `rgba(212, 175, 55, 0.3)`,
|
||||
justifyContent: "center",
|
||||
alignItems: "center",
|
||||
},
|
||||
actionText: {
|
||||
...typography.caption2,
|
||||
color: ACCENT,
|
||||
fontWeight: "600",
|
||||
letterSpacing: 0.5,
|
||||
},
|
||||
});
|
||||
@@ -0,0 +1 @@
|
||||
export { SwipeableMatchItem } from './SwipeableMatchItem';
|
||||
Reference in new issue
Block a user