Add discovery UI, hide NSFW setting, and pack progress

- New discovery components (ContentTile, ContentRow, CircularProgress)
- Refactor home screen with compact header and content tiles
- Add hide_nsfw profile setting for content filtering
- Add color column to categories table
- Track pack progress (answered/total questions)
- Update related stores and tests

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
This commit is contained in:
jjbruntonandClaude Opus 4.5 committed 2026-01-25 10:14:44 +00:00
1 parent bc38e41572
commit 69ac0940d3
29 files changed
+2048 -1174

No files matched your search

@@ -15,7 +15,9 @@ import {
Pressable,
Animated,
Keyboard,
Platform,
} from 'react-native';
import * as Haptics from 'expo-haptics';
import { LinearGradient } from 'expo-linear-gradient';
import { BlurView } from 'expo-blur';
import { Ionicons } from '@expo/vector-icons';
@@ -110,6 +112,15 @@ export const MatchNotificationModal: React.FC<Props> = ({
// Dismiss keyboard so it doesn't block the modal buttons
Keyboard.dismiss();
// Trigger celebration haptic pattern
if (Platform.OS !== 'web') {
// Success notification followed by a heavy impact for extra punch
Haptics.notificationAsync(Haptics.NotificationFeedbackType.Success);
setTimeout(() => {
Haptics.impactAsync(Haptics.ImpactFeedbackStyle.Heavy);
}, 150);
}
// Reset animations
modalScale.setValue(0.8);
modalOpacity.setValue(0);
@@ -0,0 +1,67 @@
import React from 'react';
import { View, StyleSheet } from 'react-native';
import Svg, { Circle } from 'react-native-svg';
interface CircularProgressProps {
progress: number; // 0 to 1
size?: number;
strokeWidth?: number;
backgroundColor?: string;
progressColor?: string;
}
export function CircularProgress({
progress,
size = 28,
strokeWidth = 3,
backgroundColor = 'rgba(255, 255, 255, 0.2)',
progressColor = 'rgba(255, 255, 255, 0.9)',
}: CircularProgressProps) {
const radius = (size - strokeWidth) / 2;
const circumference = radius * 2 * Math.PI;
const clampedProgress = Math.min(Math.max(progress, 0), 1);
const strokeDashoffset = circumference * (1 - clampedProgress);
return (
<View style={[styles.container, { width: size, height: size }]}>
<Svg width={size} height={size} style={styles.svg}>
{/* Background circle */}
<Circle
cx={size / 2}
cy={size / 2}
r={radius}
stroke={backgroundColor}
strokeWidth={strokeWidth}
fill="transparent"
/>
{/* Progress circle */}
{clampedProgress > 0 && (
<Circle
cx={size / 2}
cy={size / 2}
r={radius}
stroke={progressColor}
strokeWidth={strokeWidth}
fill="transparent"
strokeDasharray={`${circumference} ${circumference}`}
strokeDashoffset={strokeDashoffset}
strokeLinecap="round"
transform={`rotate(-90 ${size / 2} ${size / 2})`}
/>
)}
</Svg>
</View>
);
}
const styles = StyleSheet.create({
container: {
justifyContent: 'center',
alignItems: 'center',
},
svg: {
transform: [{ rotateZ: '0deg' }],
},
});
export default CircularProgress;
@@ -0,0 +1,133 @@
import React from 'react';
import { StyleSheet, View, Text, TouchableOpacity } from 'react-native';
import { Ionicons } from '@expo/vector-icons';
import { router } from 'expo-router';
import Animated, { FadeInDown } from 'react-native-reanimated';
import { colors, spacing, typography, radius } from '../../theme';
import type { Profile, Couple } from '@/types';
interface CompactHeaderProps {
user: Profile | null;
partner: Profile | null;
couple: Couple | null;
}
export function CompactHeader({ user, partner, couple }: CompactHeaderProps) {
return (
<Animated.View
entering={FadeInDown.delay(100).duration(500).springify()}
style={styles.container}
>
{/* Greeting */}
<View style={styles.greetingRow}>
<View>
<Text style={styles.welcomeLabel}>Discover</Text>
<Text style={styles.greeting}>
Hey, {user?.name || 'Beautiful'}
</Text>
</View>
{/* Partner badge */}
{partner ? (
<View style={styles.partnerBadge}>
<Ionicons name="heart" size={12} color={colors.primary} />
<Text style={styles.partnerText} numberOfLines={1}>
{partner.name || partner.email?.split('@')[0] || 'Partner'}
</Text>
</View>
) : couple ? (
<TouchableOpacity
style={styles.waitingBadge}
onPress={() => router.push('/(app)/pairing')}
>
<Ionicons name="hourglass-outline" size={12} color={colors.primary} />
<Text style={styles.waitingText}>Waiting</Text>
</TouchableOpacity>
) : (
<TouchableOpacity
style={styles.pairBadge}
onPress={() => router.push('/(app)/pairing')}
>
<Ionicons name="add-circle-outline" size={14} color={colors.textTertiary} />
<Text style={styles.pairText}>Connect</Text>
</TouchableOpacity>
)}
</View>
</Animated.View>
);
}
const styles = StyleSheet.create({
container: {
paddingTop: 60,
paddingHorizontal: spacing.lg,
paddingBottom: spacing.lg,
},
greetingRow: {
flexDirection: 'row',
justifyContent: 'space-between',
alignItems: 'center',
},
welcomeLabel: {
...typography.caption2,
fontWeight: '600',
letterSpacing: 1.5,
color: colors.primary,
textTransform: 'uppercase',
marginBottom: 2,
},
greeting: {
...typography.title2,
color: colors.text,
},
partnerBadge: {
flexDirection: 'row',
alignItems: 'center',
backgroundColor: 'rgba(233, 69, 96, 0.1)',
paddingHorizontal: spacing.md,
paddingVertical: spacing.sm,
borderRadius: radius.full,
borderWidth: 1,
borderColor: 'rgba(233, 69, 96, 0.2)',
gap: spacing.xs,
maxWidth: 140,
},
partnerText: {
...typography.caption1,
color: colors.text,
fontWeight: '500',
},
pairBadge: {
flexDirection: 'row',
alignItems: 'center',
backgroundColor: colors.glass.background,
paddingHorizontal: spacing.md,
paddingVertical: spacing.sm,
borderRadius: radius.full,
borderWidth: 1,
borderColor: colors.glass.border,
gap: spacing.xs,
},
pairText: {
...typography.caption1,
color: colors.textTertiary,
},
waitingBadge: {
flexDirection: 'row',
alignItems: 'center',
backgroundColor: 'rgba(233, 69, 96, 0.1)',
paddingHorizontal: spacing.md,
paddingVertical: spacing.sm,
borderRadius: radius.full,
borderWidth: 1,
borderColor: 'rgba(233, 69, 96, 0.2)',
gap: spacing.xs,
},
waitingText: {
...typography.caption1,
color: colors.text,
fontWeight: '500',
},
});
export default CompactHeader;
@@ -0,0 +1,128 @@
import React from 'react';
import { StyleSheet, View, Text, FlatList, TouchableOpacity } from 'react-native';
import { Ionicons } from '@expo/vector-icons';
import { router } from 'expo-router';
import Animated, { FadeInDown } from 'react-native-reanimated';
import { ContentTile, TILE_WIDTH } from './ContentTile';
import { colors, spacing, typography } from '../../theme';
import type { QuestionPack } from '@/types';
import type { PackProgressData } from '../../store/packsStore';
interface ContentRowProps {
title: string;
packs: QuestionPack[];
isPremiumUser: boolean;
delay?: number;
showSeeAll?: boolean;
categoryId?: string;
getPackProgress?: (packId: string) => PackProgressData | undefined;
onShowPaywall?: () => void;
}
export function ContentRow({
title,
packs,
isPremiumUser,
delay = 0,
showSeeAll = true,
categoryId,
getPackProgress,
onShowPaywall,
}: ContentRowProps) {
const handleSeeAll = () => {
// Navigate to packs screen with category filter
router.push({
pathname: '/(app)/packs',
params: categoryId ? { category: categoryId } : undefined,
});
};
const handleTilePress = (pack: QuestionPack) => {
// Locked packs show paywall immediately
const isLocked = pack.is_premium && !isPremiumUser;
if (isLocked) {
onShowPaywall?.();
return;
}
// Unlocked packs start playing immediately
router.push({
pathname: '/(app)/swipe',
params: { packId: pack.id },
});
};
if (packs.length === 0) return null;
return (
<Animated.View
entering={FadeInDown.delay(delay).duration(500).springify()}
style={styles.container}
>
{/* Header */}
<View style={styles.header}>
<Text style={styles.title}>{title}</Text>
{showSeeAll && (
<TouchableOpacity onPress={handleSeeAll} style={styles.seeAllButton}>
<Text style={styles.seeAllText}>See all</Text>
<Ionicons name="chevron-forward" size={14} color={colors.primary} />
</TouchableOpacity>
)}
</View>
{/* Horizontal scroll */}
<FlatList
data={packs}
keyExtractor={(item) => item.id}
horizontal
showsHorizontalScrollIndicator={false}
contentContainerStyle={styles.listContent}
snapToInterval={TILE_WIDTH + spacing.sm}
decelerationRate="fast"
renderItem={({ item }) => (
<ContentTile
pack={item}
isLocked={item.is_premium && !isPremiumUser}
progress={getPackProgress?.(item.id)}
onPress={() => handleTilePress(item)}
/>
)}
ItemSeparatorComponent={() => <View style={styles.separator} />}
/>
</Animated.View>
);
}
const styles = StyleSheet.create({
container: {
marginBottom: spacing.lg,
},
header: {
flexDirection: 'row',
justifyContent: 'space-between',
alignItems: 'center',
paddingHorizontal: spacing.lg,
marginBottom: spacing.md,
},
title: {
...typography.callout,
color: colors.text,
fontWeight: '600',
},
seeAllButton: {
flexDirection: 'row',
alignItems: 'center',
gap: 2,
},
seeAllText: {
...typography.subhead,
color: colors.primary,
},
listContent: {
paddingHorizontal: spacing.lg,
},
separator: {
width: spacing.sm,
},
});
export default ContentRow;
@@ -0,0 +1,185 @@
import React from 'react';
import { StyleSheet, View, Text, TouchableOpacity, Dimensions } from 'react-native';
import { Ionicons } from '@expo/vector-icons';
import * as Haptics from 'expo-haptics';
import { colors, spacing, radius, typography, shadows, getCategoryColor } from '../../theme';
import { CircularProgress } from './CircularProgress';
import type { QuestionPack } from '@/types';
import type { PackProgressData } from '../../store/packsStore';
const SCREEN_WIDTH = Dimensions.get('window').width;
const TILE_GAP = spacing.sm;
const HORIZONTAL_PADDING = spacing.lg;
// Calculate tile width: 2 tiles per screen with gap and padding, plus peek of next
export const TILE_WIDTH = (SCREEN_WIDTH - HORIZONTAL_PADDING * 2 - TILE_GAP) / 2 + 8;
export const TILE_HEIGHT = 170;
interface ContentTileProps {
pack: QuestionPack;
isLocked: boolean;
isNew?: boolean;
progress?: PackProgressData;
onPress: () => void;
}
export function ContentTile({ pack, isLocked, isNew = false, progress, onPress }: ContentTileProps) {
const handlePress = () => {
Haptics.impactAsync(Haptics.ImpactFeedbackStyle.Light);
onPress();
};
const backgroundColor = getCategoryColor(pack.category);
const icon = pack.icon || pack.category?.icon || 'layers';
const questionCount = pack.questions?.[0]?.count ?? 0;
// Calculate progress percentage
const progressPercent = progress && progress.totalQuestions > 0
? progress.answeredQuestions / progress.totalQuestions
: 0;
// Show progress ring for packs with questions (even at 0%)
const showProgress = progress && progress.totalQuestions > 0;
return (
<TouchableOpacity
onPress={handlePress}
activeOpacity={0.85}
style={styles.container}
>
<View style={[styles.tile, { backgroundColor }, shadows.md]}>
{/* Top row: icon and badges */}
<View style={styles.topRow}>
<View style={styles.iconContainer}>
<Ionicons
name={icon as any}
size={20}
color="rgba(255, 255, 255, 0.9)"
/>
</View>
<View style={styles.badges}>
{isNew && (
<View style={styles.newBadge}>
<Text style={styles.newBadgeText}>NEW</Text>
</View>
)}
{isLocked && (
<View style={styles.lockBadge}>
<Ionicons name="lock-closed" size={12} color={colors.premium.gold} />
</View>
)}
{showProgress && (
<CircularProgress
progress={progressPercent}
size={20}
strokeWidth={2}
/>
)}
</View>
</View>
{/* Content */}
<View style={styles.content}>
{pack.category?.name && (
<Text style={styles.categoryText} numberOfLines={1}>
{pack.category.name}
</Text>
)}
<Text style={styles.titleText} numberOfLines={2}>
{pack.name}
</Text>
</View>
{/* Bottom info */}
<View style={styles.bottomRow}>
<Text style={styles.countText}>
{questionCount} {questionCount === 1 ? 'question' : 'questions'}
</Text>
</View>
{/* Decorative overlay for depth */}
<View style={styles.overlay} />
</View>
</TouchableOpacity>
);
}
const styles = StyleSheet.create({
container: {
width: TILE_WIDTH,
height: TILE_HEIGHT,
},
tile: {
flex: 1,
borderRadius: radius.lg,
padding: spacing.md,
overflow: 'hidden',
position: 'relative',
},
overlay: {
...StyleSheet.absoluteFillObject,
backgroundColor: 'rgba(0, 0, 0, 0.05)',
borderRadius: radius.lg,
pointerEvents: 'none',
},
topRow: {
flexDirection: 'row',
justifyContent: 'space-between',
alignItems: 'flex-start',
},
iconContainer: {
width: 36,
height: 36,
borderRadius: 18,
backgroundColor: 'rgba(255, 255, 255, 0.2)',
justifyContent: 'center',
alignItems: 'center',
},
badges: {
flexDirection: 'row',
gap: spacing.xs,
},
newBadge: {
backgroundColor: 'rgba(255, 255, 255, 0.95)',
paddingHorizontal: spacing.sm,
paddingVertical: 2,
borderRadius: radius.xs,
},
newBadgeText: {
...typography.caption2,
fontWeight: '700',
color: colors.background,
letterSpacing: 0.5,
},
lockBadge: {
width: 24,
height: 24,
borderRadius: 12,
backgroundColor: 'rgba(0, 0, 0, 0.3)',
justifyContent: 'center',
alignItems: 'center',
},
content: {
flex: 1,
justifyContent: 'center',
marginTop: spacing.sm,
},
categoryText: {
...typography.caption2,
color: 'rgba(255, 255, 255, 0.7)',
textTransform: 'uppercase',
letterSpacing: 1,
marginBottom: 2,
},
titleText: {
...typography.headline,
color: colors.text,
fontWeight: '700',
},
bottomRow: {
marginTop: 'auto',
},
countText: {
...typography.caption1,
color: 'rgba(255, 255, 255, 0.7)',
},
});
export default ContentTile;
@@ -0,0 +1,4 @@
export { ContentTile, TILE_WIDTH, TILE_HEIGHT } from './ContentTile';
export { ContentRow } from './ContentRow';
export { CompactHeader } from './CompactHeader';
export { CircularProgress } from './CircularProgress';