Add streaks, scheduled releases, and notification settings

- Couple streaks with milestone notifications
- Scheduled pack releases with admin UI
- Notification preferences screen
- Refactored profile settings screens
- Partner activity notifications
This commit is contained in:
jjbrunton committed 2026-01-17 16:38:47 +00:00
1 parent 12f312421b
commit 494ed17fe2
82 files changed
+4189 -725

No files matched your search

@@ -0,0 +1,193 @@
import React, { useEffect } from 'react';
import { View, Text, StyleSheet } from 'react-native';
import { Ionicons } from '@expo/vector-icons';
import Animated, { FadeInDown, useSharedValue, useAnimatedStyle, withRepeat, withSequence, withTiming, Easing } from 'react-native-reanimated';
import { LinearGradient } from 'expo-linear-gradient';
import { useStreakStore } from '../store';
import { colors, spacing, radius, typography } from '../theme';
interface StreakDisplayProps {
/** Whether to show the "longest streak" indicator */
showLongest?: boolean;
/** Animation delay for entry animation */
delay?: number;
/** Compact mode for smaller displays */
compact?: boolean;
}
/**
* Displays the couple's current streak with a flame icon.
* The flame pulses when there's an active streak.
*/
export function StreakDisplay({ showLongest = false, delay = 0, compact = false }: StreakDisplayProps) {
const { streak, isLoading, fetchStreak } = useStreakStore();
// Fetch streak on mount
useEffect(() => {
fetchStreak();
}, [fetchStreak]);
// Flame pulse animation
const scale = useSharedValue(1);
useEffect(() => {
if (streak && streak.current_streak > 0) {
scale.value = withRepeat(
withSequence(
withTiming(1.1, { duration: 800, easing: Easing.inOut(Easing.ease) }),
withTiming(1, { duration: 800, easing: Easing.inOut(Easing.ease) })
),
-1,
true
);
} else {
scale.value = 1;
}
}, [streak?.current_streak, scale]);
const flameStyle = useAnimatedStyle(() => ({
transform: [{ scale: scale.value }],
}));
// Don't show if loading or no couple
if (isLoading || !streak) {
return null;
}
const hasStreak = streak.current_streak > 0;
const currentStreak = streak.current_streak;
const longestStreak = streak.longest_streak;
if (compact) {
return (
<Animated.View entering={FadeInDown.delay(delay).duration(400)}>
<View style={styles.compactContainer}>
<Animated.View style={flameStyle}>
<Ionicons
name="flame"
size={16}
color={hasStreak ? colors.warning : colors.textTertiary}
/>
</Animated.View>
<Text style={[styles.compactCount, !hasStreak && styles.inactiveText]}>
{currentStreak}
</Text>
</View>
</Animated.View>
);
}
return (
<Animated.View entering={FadeInDown.delay(delay).duration(500)}>
<View style={styles.container}>
{/* Main streak display */}
<LinearGradient
colors={hasStreak ? ['rgba(243, 156, 18, 0.2)', 'rgba(233, 69, 96, 0.1)'] : ['rgba(255, 255, 255, 0.05)', 'rgba(255, 255, 255, 0.02)']}
style={styles.card}
start={{ x: 0, y: 0 }}
end={{ x: 1, y: 1 }}
>
<View style={styles.content}>
{/* Flame icon */}
<Animated.View style={[styles.iconContainer, flameStyle]}>
<Ionicons
name="flame"
size={32}
color={hasStreak ? colors.warning : colors.textTertiary}
/>
</Animated.View>
{/* Streak info */}
<View style={styles.info}>
<Text style={[styles.count, !hasStreak && styles.inactiveText]}>
{currentStreak}
</Text>
<Text style={styles.label}>
day{currentStreak !== 1 ? 's' : ''} together
</Text>
</View>
</View>
{/* Longest streak */}
{showLongest && longestStreak > 0 && (
<View style={styles.longest}>
<Ionicons name="trophy-outline" size={12} color={colors.textTertiary} />
<Text style={styles.longestText}>
Best: {longestStreak} day{longestStreak !== 1 ? 's' : ''}
</Text>
</View>
)}
</LinearGradient>
</View>
</Animated.View>
);
}
const styles = StyleSheet.create({
container: {
marginVertical: spacing.sm,
},
card: {
borderRadius: radius.lg,
padding: spacing.md,
borderWidth: 1,
borderColor: colors.glass.border,
},
content: {
flexDirection: 'row',
alignItems: 'center',
gap: spacing.md,
},
iconContainer: {
width: 48,
height: 48,
borderRadius: radius.full,
backgroundColor: 'rgba(243, 156, 18, 0.15)',
justifyContent: 'center',
alignItems: 'center',
},
info: {
flex: 1,
},
count: {
...typography.title1,
color: colors.text,
},
label: {
...typography.subhead,
color: colors.textSecondary,
},
inactiveText: {
color: colors.textTertiary,
},
longest: {
flexDirection: 'row',
alignItems: 'center',
gap: spacing.xs,
marginTop: spacing.sm,
paddingTop: spacing.sm,
borderTopWidth: 1,
borderTopColor: colors.glass.border,
},
longestText: {
...typography.caption1,
color: colors.textTertiary,
},
// Compact styles
compactContainer: {
flexDirection: 'row',
alignItems: 'center',
gap: spacing.xs,
backgroundColor: 'rgba(243, 156, 18, 0.15)',
paddingHorizontal: spacing.sm,
paddingVertical: spacing.xs,
borderRadius: radius.full,
},
compactCount: {
...typography.caption1,
fontWeight: '600',
color: colors.warning,
},
});
export default StreakDisplay;
+45 -31
View File
@@ -42,7 +42,11 @@ const FEATURES = [
},
];
export function Paywall({ visible, onClose, onSuccess }: PaywallProps) {
export function Paywall({ visible, onClose, onSuccess }: PaywallProps) {
const handleClose = (reason: "dismiss" | "success" | "restore" | "system") => {
Events.paywallClosed("paywall", reason);
onClose();
};
const {
offerings,
isLoadingOfferings,
@@ -76,26 +80,36 @@ export function Paywall({ visible, onClose, onSuccess }: PaywallProps) {
}
}, [offerings]);
const handlePurchase = async () => {
if (!selectedPackage) return;
const handlePurchase = async () => {
if (!selectedPackage) return;
const packageType = selectedPackage.packageType || "unknown";
Events.purchaseInitiated(packageType);
const result = await purchasePackage(selectedPackage);
if (result.success) {
Events.purchaseCompleted(packageType);
onSuccess?.();
handleClose("success");
return;
}
if (result.cancelled) {
Events.purchaseCancelled(packageType, result.errorCode);
return;
}
Events.purchaseFailed(packageType, result.errorCode, result.errorMessage);
};
Events.purchaseInitiated(selectedPackage.packageType || "unknown");
const success = await purchasePackage(selectedPackage);
if (success) {
Events.purchaseCompleted(selectedPackage.packageType || "unknown");
onSuccess?.();
onClose();
}
};
const handleRestore = async () => {
const restored = await restorePurchases();
if (restored) {
Events.purchaseRestored();
onSuccess?.();
onClose();
}
};
const handleRestore = async () => {
const restored = await restorePurchases();
if (restored) {
Events.purchaseRestored();
onSuccess?.();
handleClose("restore");
}
};
const formatPrice = (pkg: PurchasesPackage) => {
const price = pkg.product.priceString;
@@ -129,12 +143,12 @@ export function Paywall({ visible, onClose, onSuccess }: PaywallProps) {
return (
<Modal
visible={visible}
transparent
animationType="slide"
onRequestClose={onClose}
>
<Modal
visible={visible}
transparent
animationType="slide"
onRequestClose={() => handleClose("system")}
>
<View style={styles.overlay}>
<View style={styles.content}>
{/* Premium gradient background */}
@@ -155,11 +169,11 @@ export function Paywall({ visible, onClose, onSuccess }: PaywallProps) {
contentContainerStyle={styles.scrollContent}
>
{/* Close Button */}
<TouchableOpacity
style={styles.closeButton}
onPress={onClose}
hitSlop={{ top: 10, bottom: 10, left: 10, right: 10 }}
>
<TouchableOpacity
style={styles.closeButton}
onPress={() => handleClose("dismiss")}
hitSlop={{ top: 10, bottom: 10, left: 10, right: 10 }}
>
<Ionicons name="close" size={24} color={colors.textSecondary} />
</TouchableOpacity>