Add question types, nudge feature, and share modal

- Add new question card components (photo, audio, text, who-likely)
- Add share preview modal for matches
- Add nudge notification system with rate limiting
- Add session-based notification improvements
- Add match confetti celebration animation
- Update onboarding flow with paywall tracking
- Remove deprecated swipe card and tutorial components
- Update GlassCard/GlassInput to use solid backgrounds
- Fix routing issues after packs screen removal
This commit is contained in:
jjbrunton committed 2026-01-25 19:17:39 +00:00
1 parent 8612b16b93
commit 1daf386765
114 files changed
+14877 -7911

No files matched your search

@@ -2,10 +2,10 @@ import React from 'react';
import { View, StyleSheet, ViewStyle, DimensionValue } from 'react-native';
import { colors, spacing } from '../../theme';
export type SeparatorVariant = 'rose' | 'gold' | 'muted';
export type SeparatorVariant = 'primary' | 'rose' | 'gold' | 'muted';
export interface DecorativeSeparatorProps {
/** Color variant: 'rose' (default), 'gold' (premium/yes-yes), 'muted' */
/** Color variant: 'primary' (default), 'rose' (quiz), 'gold' (premium/dares), 'muted' */
variant?: SeparatorVariant;
/** Width of the separator. Default: 140 */
width?: DimensionValue;
@@ -16,6 +16,10 @@ export interface DecorativeSeparatorProps {
}
const VARIANT_COLORS: Record<SeparatorVariant, { line: string; diamond: string }> = {
primary: {
line: 'rgba(225, 48, 108, 0.3)',
diamond: colors.primary,
},
rose: {
line: 'rgba(232, 164, 174, 0.3)',
diamond: colors.premium.rose,
@@ -35,7 +39,7 @@ const VARIANT_COLORS: Record<SeparatorVariant, { line: string; diamond: string }
* Used for visual separation in waiting states, headers, and empty states.
*/
export const DecorativeSeparator: React.FC<DecorativeSeparatorProps> = ({
variant = 'rose',
variant = 'primary',
width = 140,
marginVertical = spacing.lg,
style,
+52 -98
View File
@@ -1,98 +1,52 @@
import React from 'react';
import { StyleSheet, View, ViewStyle, Platform } from 'react-native';
import { BlurView } from 'expo-blur';
import { LinearGradient } from 'expo-linear-gradient';
import { colors, radius, shadows, blur } from '../../theme';
interface GlassCardProps {
children: React.ReactNode;
style?: ViewStyle;
intensity?: 'light' | 'medium' | 'heavy';
variant?: 'default' | 'elevated' | 'subtle';
noPadding?: boolean;
}
export function GlassCard({
children,
style,
intensity = 'medium',
variant = 'default',
noPadding = false,
}: GlassCardProps) {
const blurIntensity = blur[intensity];
const shadowStyle = variant === 'elevated' ? shadows.lg : variant === 'subtle' ? shadows.sm : shadows.md;
const borderColor = variant === 'elevated' ? colors.glass.borderLight : colors.glass.border;
// Use BlurView on iOS, fallback to solid background on Android for performance
const useBlur = Platform.OS === 'ios';
return (
<View style={[styles.container, shadowStyle, style]}>
{useBlur ? (
<BlurView
intensity={blurIntensity}
tint="dark"
style={[styles.blurContainer, { borderColor }]}
>
{/* Top highlight gradient */}
<LinearGradient
colors={[colors.glass.highlight, 'transparent']}
style={styles.highlight}
start={{ x: 0.5, y: 0 }}
end={{ x: 0.5, y: 1 }}
/>
<View style={[styles.content, noPadding && styles.noPadding]}>
{children}
</View>
</BlurView>
) : (
// Android fallback with solid background
<View style={[styles.fallbackContainer, { borderColor }]}>
<LinearGradient
colors={[colors.glass.highlight, 'transparent']}
style={styles.highlight}
start={{ x: 0.5, y: 0 }}
end={{ x: 0.5, y: 1 }}
/>
<View style={[styles.content, noPadding && styles.noPadding]}>
{children}
</View>
</View>
)}
</View>
);
}
const styles = StyleSheet.create({
container: {
borderRadius: radius.lg,
overflow: 'hidden',
},
blurContainer: {
overflow: 'hidden',
borderRadius: radius.lg,
borderWidth: 1,
backgroundColor: colors.glass.background,
},
fallbackContainer: {
overflow: 'hidden',
borderRadius: radius.lg,
borderWidth: 1,
backgroundColor: colors.glass.backgroundLight,
},
highlight: {
position: 'absolute',
top: 0,
left: 0,
right: 0,
height: 60,
},
content: {
padding: 16,
},
noPadding: {
padding: 0,
},
});
export default GlassCard;
import React from 'react';
import { StyleSheet, View, ViewStyle } from 'react-native';
import { colors, radius, shadows } from '../../theme';
interface GlassCardProps {
children: React.ReactNode;
style?: ViewStyle;
intensity?: 'light' | 'medium' | 'heavy'; // Kept for API compatibility
variant?: 'default' | 'elevated' | 'subtle';
noPadding?: boolean;
}
export function GlassCard({
children,
style,
variant = 'default',
noPadding = false,
}: GlassCardProps) {
const shadowStyle = variant === 'elevated' ? shadows.lg : variant === 'subtle' ? shadows.sm : shadows.md;
const borderColor = variant === 'elevated' ? colors.glass.borderLight : colors.glass.border;
return (
<View style={[styles.container, shadowStyle, style]}>
<View style={[styles.flatContainer, { borderColor }]}>
<View style={[styles.content, noPadding && styles.noPadding]}>
{children}
</View>
</View>
</View>
);
}
const styles = StyleSheet.create({
container: {
borderRadius: radius.lg,
overflow: 'hidden',
},
flatContainer: {
overflow: 'hidden',
borderRadius: radius.lg,
backgroundColor: colors.backgroundLight, // Use solid card color
// Removed border for flatter look
},
content: {
padding: 16,
},
noPadding: {
padding: 0,
},
});
export default GlassCard;
+117 -140
View File
@@ -1,140 +1,117 @@
import React, { useState } from 'react';
import { StyleSheet, TextInput, View, Text, TextInputProps, ViewStyle, Platform } from 'react-native';
import { BlurView } from 'expo-blur';
import Animated, {
useSharedValue,
useAnimatedStyle,
withTiming,
interpolateColor,
} from 'react-native-reanimated';
import { colors, radius, blur, animations, typography } from '../../theme';
interface GlassInputProps extends Omit<TextInputProps, 'style'> {
label?: string;
error?: string;
containerStyle?: ViewStyle;
icon?: React.ReactNode;
}
const AnimatedBlurView = Animated.createAnimatedComponent(BlurView);
const AnimatedView = Animated.View;
export function GlassInput({
label,
error,
containerStyle,
icon,
...props
}: GlassInputProps) {
const [isFocused, setIsFocused] = useState(false);
const focusAnim = useSharedValue(0);
const handleFocus = (e: any) => {
setIsFocused(true);
focusAnim.value = withTiming(1, { duration: animations.timing.fast });
props.onFocus?.(e);
};
const handleBlur = (e: any) => {
setIsFocused(false);
focusAnim.value = withTiming(0, { duration: animations.timing.fast });
props.onBlur?.(e);
};
const animatedBorderStyle = useAnimatedStyle(() => ({
borderColor: interpolateColor(
focusAnim.value,
[0, 1],
[error ? colors.error : colors.glass.border, error ? colors.error : colors.primary]
),
}));
const useBlur = Platform.OS === 'ios';
const inputContent = (
<View style={styles.inputWrapper}>
{icon && <View style={styles.icon}>{icon}</View>}
<TextInput
{...props}
style={[styles.input, icon ? styles.inputWithIcon : undefined]}
placeholderTextColor={colors.textTertiary}
onFocus={handleFocus}
onBlur={handleBlur}
selectionColor={colors.primary}
/>
</View>
);
return (
<View style={[styles.container, containerStyle]}>
{label && <Text style={styles.label}>{label}</Text>}
{useBlur ? (
<AnimatedView style={[styles.blurWrapper, animatedBorderStyle]}>
<BlurView
intensity={blur.light}
tint="dark"
style={styles.blurContainer}
>
{inputContent}
</BlurView>
</AnimatedView>
) : (
<AnimatedView style={[styles.fallbackContainer, animatedBorderStyle]}>
{inputContent}
</AnimatedView>
)}
{error && <Text style={styles.error}>{error}</Text>}
</View>
);
}
const styles = StyleSheet.create({
container: {
marginBottom: 16,
},
label: {
...typography.subhead,
color: colors.textSecondary,
marginBottom: 8,
marginLeft: 4,
},
blurWrapper: {
borderRadius: radius.md,
borderWidth: 1,
overflow: 'hidden',
},
blurContainer: {
backgroundColor: colors.glass.background,
},
fallbackContainer: {
borderRadius: radius.md,
borderWidth: 1,
backgroundColor: colors.glass.backgroundLight,
},
inputWrapper: {
flexDirection: 'row',
alignItems: 'center',
},
icon: {
paddingLeft: 16,
},
input: {
flex: 1,
padding: 16,
fontSize: 16,
color: colors.text,
},
inputWithIcon: {
paddingLeft: 12,
},
error: {
...typography.caption1,
color: colors.error,
marginTop: 6,
marginLeft: 4,
},
});
export default GlassInput;
import React, { useState } from 'react';
import { StyleSheet, TextInput, View, Text, TextInputProps, ViewStyle, Platform } from 'react-native';
import Animated, {
useSharedValue,
useAnimatedStyle,
withTiming,
interpolateColor,
} from 'react-native-reanimated';
import { colors, radius, animations, typography } from '../../theme';
interface GlassInputProps extends Omit<TextInputProps, 'style'> {
label?: string;
error?: string;
containerStyle?: ViewStyle;
icon?: React.ReactNode;
}
const AnimatedView = Animated.View;
export function GlassInput({
label,
error,
containerStyle,
icon,
...props
}: GlassInputProps) {
const [isFocused, setIsFocused] = useState(false);
const focusAnim = useSharedValue(0);
const handleFocus = (e: any) => {
setIsFocused(true);
focusAnim.value = withTiming(1, { duration: animations.timing.fast });
props.onFocus?.(e);
};
const handleBlur = (e: any) => {
setIsFocused(false);
focusAnim.value = withTiming(0, { duration: animations.timing.fast });
props.onBlur?.(e);
};
const animatedContainerStyle = useAnimatedStyle(() => ({
backgroundColor: interpolateColor(
focusAnim.value,
[0, 1],
[colors.backgroundLight, colors.backgroundLight] // Consistent flat background
),
borderColor: interpolateColor(
focusAnim.value,
[0, 1],
[error ? colors.error : colors.border, error ? colors.error : colors.primary]
),
}));
return (
<View style={[styles.container, containerStyle]}>
{label && <Text style={styles.label}>{label}</Text>}
<AnimatedView style={[styles.inputContainer, animatedContainerStyle]}>
<View style={styles.inputWrapper}>
{icon && <View style={styles.icon}>{icon}</View>}
<TextInput
{...props}
style={[styles.input, icon ? styles.inputWithIcon : undefined]}
placeholderTextColor={colors.textTertiary}
onFocus={handleFocus}
onBlur={handleBlur}
selectionColor={colors.primary}
/>
</View>
</AnimatedView>
{error && <Text style={styles.error}>{error}</Text>}
</View>
);
}
const styles = StyleSheet.create({
container: {
marginBottom: 16,
},
label: {
...typography.subhead,
color: colors.textSecondary,
marginBottom: 8,
marginLeft: 4,
},
inputContainer: {
borderRadius: radius.lg,
borderWidth: 1,
overflow: 'hidden',
},
inputWrapper: {
flexDirection: 'row',
alignItems: 'center',
},
icon: {
paddingLeft: 16,
},
input: {
flex: 1,
padding: 16,
fontSize: 16,
color: colors.text,
},
inputWithIcon: {
paddingLeft: 12,
},
error: {
...typography.caption1,
color: colors.error,
marginTop: 6,
marginLeft: 4,
},
});
export default GlassInput;
@@ -66,11 +66,18 @@ export function GlassToggle({
const backgroundColor = interpolateColor(
progress.value,
[0, 1],
['rgba(255, 255, 255, 0.1)', 'transparent'] // We use LinearGradient for active state
[colors.backgroundLight, 'transparent'] // Use backgroundLight for inactive
);
const borderColor = interpolateColor(
progress.value,
[0, 1],
[colors.border, 'transparent'] // Use standard border for inactive
);
return {
backgroundColor,
borderColor,
};
});
@@ -114,7 +121,7 @@ const styles = StyleSheet.create({
height: '100%',
borderRadius: radius.full,
borderWidth: 1,
borderColor: 'rgba(255, 255, 255, 0.1)',
borderColor: colors.border, // Use standard border
overflow: 'hidden',
justifyContent: 'center',
padding: PADDING,
@@ -1,8 +1,9 @@
import React, { useEffect, useState } from 'react';
import { View, Text, StyleSheet, Pressable, Platform, Image, Modal, TouchableOpacity } from 'react-native';
import { View, Text, StyleSheet, Pressable, Platform, Modal, TouchableOpacity } from 'react-native';
import { Ionicons } from '@expo/vector-icons';
import { LinearGradient } from 'expo-linear-gradient';
import { BlurView } from 'expo-blur';
import { Image } from 'expo-image';
import Animated, {
useAnimatedStyle,
useSharedValue,
@@ -126,6 +127,9 @@ export function IntensitySlider({
<Image
source={{ uri: partnerAvatar }}
style={styles.partnerAvatarSmall}
cachePolicy="memory-disk"
contentFit="cover"
transition={150}
/>
) : (
<Ionicons name="heart" size={8} color={colors.secondary} />
@@ -189,7 +193,13 @@ export function IntensitySlider({
onPress={() => setShowInfoModal(true)}
>
{partnerAvatar ? (
<Image source={{ uri: partnerAvatar }} style={styles.partnerAvatarTiny} />
<Image
source={{ uri: partnerAvatar }}
style={styles.partnerAvatarTiny}
cachePolicy="memory-disk"
contentFit="cover"
transition={150}
/>
) : (
<View style={styles.partnerIconTiny}>
<Ionicons name="person" size={10} color={colors.secondary} />
@@ -276,7 +286,13 @@ export function IntensitySlider({
<View style={styles.comparisonPerson}>
{partnerAvatar ? (
<Image source={{ uri: partnerAvatar }} style={styles.comparisonAvatarImage} />
<Image
source={{ uri: partnerAvatar }}
style={styles.comparisonAvatarImage}
cachePolicy="memory-disk"
contentFit="cover"
transition={150}
/>
) : (
<View style={[styles.comparisonAvatar, { backgroundColor: HEAT_COLORS[(partnerValue ?? 1) - 1] }]}>
<Text style={styles.comparisonEmoji}>{partnerLevel?.emoji}</Text>
@@ -388,13 +404,13 @@ const styles = StyleSheet.create({
borderRadius: 18,
alignItems: 'center',
justifyContent: 'center',
backgroundColor: 'rgba(255, 255, 255, 0.05)',
backgroundColor: colors.backgroundLight, // Flat
borderWidth: 2,
borderColor: 'rgba(255, 255, 255, 0.1)',
borderColor: colors.border,
},
levelIndicatorActive: {
backgroundColor: 'rgba(233, 69, 96, 0.15)',
borderColor: 'rgba(233, 69, 96, 0.3)',
backgroundColor: colors.background, // Nested
borderColor: colors.primary,
},
levelIndicatorPartner: {
borderColor: colors.secondary,
@@ -435,15 +451,15 @@ const styles = StyleSheet.create({
color: colors.secondary,
},
currentCard: {
backgroundColor: 'rgba(255, 255, 255, 0.05)',
backgroundColor: colors.backgroundLight, // Flat
borderRadius: radius.md,
padding: spacing.md,
borderWidth: 1,
borderColor: 'rgba(255, 255, 255, 0.08)',
borderColor: colors.border,
},
currentCardWarning: {
borderColor: 'rgba(243, 156, 18, 0.3)',
backgroundColor: 'rgba(243, 156, 18, 0.05)',
borderColor: colors.warning,
backgroundColor: colors.background, // Use background for warning state to differentiate
},
currentHeader: {
flexDirection: 'row',
@@ -486,7 +502,7 @@ const styles = StyleSheet.create({
marginTop: spacing.sm,
paddingTop: spacing.sm,
borderTopWidth: 1,
borderTopColor: 'rgba(255, 255, 255, 0.08)',
borderTopColor: colors.border,
},
partnerAvatarTiny: {
width: 20,
@@ -509,7 +525,6 @@ const styles = StyleSheet.create({
mismatchTextWarning: {
color: colors.warning,
},
// Modal styles
modalOverlay: {
flex: 1,
backgroundColor: 'rgba(0, 0, 0, 0.7)',
@@ -522,13 +537,13 @@ const styles = StyleSheet.create({
maxWidth: 360,
borderRadius: radius.xl,
overflow: 'hidden',
backgroundColor: Platform.OS === 'ios' ? 'transparent' : colors.backgroundLight,
backgroundColor: colors.backgroundLight, // Flat
borderWidth: 1,
borderColor: 'rgba(255, 255, 255, 0.1)',
borderColor: colors.border,
},
modalInner: {
padding: spacing.lg,
backgroundColor: Platform.OS === 'ios' ? 'rgba(26, 26, 46, 0.85)' : 'transparent',
backgroundColor: colors.backgroundLight,
},
modalHeader: {
alignItems: 'center',
@@ -551,7 +566,7 @@ const styles = StyleSheet.create({
flexDirection: 'row',
alignItems: 'center',
justifyContent: 'center',
backgroundColor: 'rgba(255, 255, 255, 0.05)',
backgroundColor: colors.background,
borderRadius: radius.lg,
padding: spacing.md,
marginBottom: spacing.lg,
@@ -627,12 +642,12 @@ const styles = StyleSheet.create({
color: colors.warning,
},
modalCloseButton: {
backgroundColor: colors.glass.background,
backgroundColor: colors.background,
borderRadius: radius.md,
paddingVertical: spacing.md,
alignItems: 'center',
borderWidth: 1,
borderColor: colors.glass.border,
borderColor: colors.border,
},
modalCloseText: {
...typography.subhead,