mirror of
https://github.com/jjbrunton/Sauci.git
synced 2026-11-01 18:19:43 +00:00
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:
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;
|
||||
@@ -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>
|
||||
|
||||
|
||||
Reference in new issue
Block a user