This commit is contained in:
Jamie Brunton committed 2026-01-01 17:59:18 +00:00
1 parent 0fa7971cb9
commit 3b445597f0
25 files changed
+757 -101

No files matched your search

+1 -1
View File
@@ -94,7 +94,7 @@ android {
applicationId 'com.sauci.app'
minSdkVersion rootProject.ext.minSdkVersion
targetSdkVersion rootProject.ext.targetSdkVersion
versionCode 3
versionCode 1
versionName "1.0.0"
}
signingConfigs {
Binary file not shown.

Before

Width:  |  Height:  |  Size: 5.0 KiB

After

Width:  |  Height:  |  Size: 2.0 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 17 KiB

After

Width:  |  Height:  |  Size: 7.2 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 5.7 KiB

After

Width:  |  Height:  |  Size: 2.8 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 2.7 KiB

After

Width:  |  Height:  |  Size: 1.1 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 9.2 KiB

After

Width:  |  Height:  |  Size: 4.0 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 3.2 KiB

After

Width:  |  Height:  |  Size: 1.6 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 7.7 KiB

After

Width:  |  Height:  |  Size: 3.0 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 27 KiB

After

Width:  |  Height:  |  Size: 11 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 8.7 KiB

After

Width:  |  Height:  |  Size: 4.1 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 14 KiB

After

Width:  |  Height:  |  Size: 5.3 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 52 KiB

After

Width:  |  Height:  |  Size: 20 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 16 KiB

After

Width:  |  Height:  |  Size: 7.0 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 22 KiB

After

Width:  |  Height:  |  Size: 8.0 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 87 KiB

After

Width:  |  Height:  |  Size: 32 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 24 KiB

After

Width:  |  Height:  |  Size: 10 KiB

+1 -1
View File
@@ -26,7 +26,7 @@
},
"android": {
"adaptiveIcon": {
"foregroundImage": "./assets/splash-icon.png",
"foregroundImage": "./assets/adaptive-icon-foreground.png",
"backgroundColor": "#1a1a2e"
},
"package": "com.sauci.app",
+14 -4
View File
@@ -149,12 +149,10 @@ export default function AppLayout() {
router.push('/(app)/swipe');
break;
case 'dares':
// TODO: Navigate to dares screen
console.log('Dares pressed - not yet implemented');
router.push('/(app)/dares');
break;
case 'quiz':
// TODO: Navigate to quiz screen
console.log('Quiz pressed - not yet implemented');
router.push('/(app)/quiz');
break;
}
}, [router, closeRadialMenu]);
@@ -609,6 +607,18 @@ export default function AppLayout() {
href: null, // Hide from tab bar
}}
/>
<Tabs.Screen
name="dares"
options={{
href: null, // Hide from tab bar
}}
/>
<Tabs.Screen
name="quiz"
options={{
href: null, // Hide from tab bar
}}
/>
</Tabs>
{/* Radial Menu Overlay */}
+170
View File
@@ -0,0 +1,170 @@
import { View, Text, StyleSheet, Platform } from "react-native";
import { Ionicons } from "@expo/vector-icons";
import Animated, { FadeInDown, FadeIn } from "react-native-reanimated";
import { GradientBackground } from "../../src/components/ui";
import { colors, spacing, radius, typography } from "../../src/theme";
const ACCENT_COLOR = colors.premium.gold;
export default function DaresScreen() {
return (
<GradientBackground>
<View style={styles.container}>
<Animated.View
entering={FadeInDown.duration(600).springify()}
style={styles.content}
>
{/* Icon */}
<View style={styles.iconContainer}>
<Ionicons name="flame" size={36} color={ACCENT_COLOR} />
</View>
{/* Title section */}
<Text style={styles.label}>ADVENTURE</Text>
<Text style={styles.title}>Dares</Text>
{/* Decorative separator */}
<View style={styles.separator}>
<View style={styles.separatorLine} />
<View style={styles.separatorDiamond} />
<View style={styles.separatorLine} />
</View>
{/* Coming soon badge */}
<Animated.View
entering={FadeIn.delay(300).duration(400)}
style={styles.badge}
>
<Text style={styles.badgeText}>ARRIVING SOON</Text>
</Animated.View>
{/* Description */}
<Text style={styles.description}>
Push your boundaries together with tantalising challenges designed to ignite passion and create unforgettable moments.
</Text>
{/* Feature hints */}
<View style={styles.features}>
<View style={styles.featureItem}>
<Ionicons name="sparkles" size={16} color={ACCENT_COLOR} />
<Text style={styles.featureText}>Provocative challenges</Text>
</View>
<View style={styles.featureItem}>
<Ionicons name="thermometer-outline" size={16} color={ACCENT_COLOR} />
<Text style={styles.featureText}>Multiple intensity levels</Text>
</View>
<View style={styles.featureItem}>
<Ionicons name="people-outline" size={16} color={ACCENT_COLOR} />
<Text style={styles.featureText}>Designed for two</Text>
</View>
</View>
{/* Bottom teaser */}
<Text style={styles.teaser}>Are you ready to play?</Text>
</Animated.View>
</View>
</GradientBackground>
);
}
const styles = StyleSheet.create({
container: {
flex: 1,
justifyContent: 'center',
alignItems: 'center',
paddingHorizontal: spacing.lg,
paddingTop: Platform.OS === 'ios' ? 60 : 40,
paddingBottom: Platform.OS === 'ios' ? 120 : 100,
},
content: {
width: '100%',
alignItems: 'center',
paddingHorizontal: spacing.md,
},
iconContainer: {
width: 72,
height: 72,
borderRadius: 36,
backgroundColor: 'rgba(212, 175, 55, 0.1)',
borderWidth: 1,
borderColor: 'rgba(212, 175, 55, 0.2)',
justifyContent: 'center',
alignItems: 'center',
marginBottom: spacing.xl,
},
label: {
...typography.caption1,
fontWeight: '600',
letterSpacing: 3,
color: ACCENT_COLOR,
textAlign: 'center',
marginBottom: spacing.xs,
},
title: {
...typography.largeTitle,
color: colors.text,
textAlign: 'center',
},
separator: {
flexDirection: 'row',
alignItems: 'center',
justifyContent: 'center',
marginVertical: spacing.lg,
width: 140,
},
separatorLine: {
flex: 1,
height: 1,
backgroundColor: 'rgba(212, 175, 55, 0.3)',
},
separatorDiamond: {
width: 6,
height: 6,
backgroundColor: ACCENT_COLOR,
transform: [{ rotate: '45deg' }],
marginHorizontal: spacing.md,
opacity: 0.6,
},
badge: {
backgroundColor: 'rgba(212, 175, 55, 0.1)',
paddingHorizontal: spacing.lg,
paddingVertical: spacing.sm,
borderRadius: radius.full,
borderWidth: 1,
borderColor: 'rgba(212, 175, 55, 0.2)',
marginBottom: spacing.xl,
},
badgeText: {
...typography.caption2,
fontWeight: '600',
letterSpacing: 2,
color: ACCENT_COLOR,
},
description: {
...typography.body,
color: colors.textSecondary,
textAlign: 'center',
marginBottom: spacing.xl,
paddingHorizontal: spacing.md,
},
features: {
marginBottom: spacing.xl,
},
featureItem: {
flexDirection: 'row',
alignItems: 'center',
justifyContent: 'center',
marginBottom: spacing.sm,
},
featureText: {
...typography.subhead,
color: colors.text,
marginLeft: spacing.sm,
},
teaser: {
...typography.footnote,
fontStyle: 'italic',
color: colors.textTertiary,
textAlign: 'center',
},
});
+170
View File
@@ -0,0 +1,170 @@
import { View, Text, StyleSheet, Platform } from "react-native";
import { Ionicons } from "@expo/vector-icons";
import Animated, { FadeInDown, FadeIn } from "react-native-reanimated";
import { GradientBackground } from "../../src/components/ui";
import { colors, spacing, radius, typography } from "../../src/theme";
const ACCENT_COLOR = colors.premium.rose;
export default function QuizScreen() {
return (
<GradientBackground>
<View style={styles.container}>
<Animated.View
entering={FadeInDown.duration(600).springify()}
style={styles.content}
>
{/* Icon */}
<View style={styles.iconContainer}>
<Ionicons name="heart" size={36} color={ACCENT_COLOR} />
</View>
{/* Title section */}
<Text style={styles.label}>INTIMACY</Text>
<Text style={styles.title}>Quiz</Text>
{/* Decorative separator */}
<View style={styles.separator}>
<View style={styles.separatorLine} />
<View style={styles.separatorDiamond} />
<View style={styles.separatorLine} />
</View>
{/* Coming soon badge */}
<Animated.View
entering={FadeIn.delay(300).duration(400)}
style={styles.badge}
>
<Text style={styles.badgeText}>ARRIVING SOON</Text>
</Animated.View>
{/* Description */}
<Text style={styles.description}>
Discover the depths of your connection through carefully crafted questions that reveal how well you truly know each other.
</Text>
{/* Feature hints */}
<View style={styles.features}>
<View style={styles.featureItem}>
<Ionicons name="sparkles" size={16} color={ACCENT_COLOR} />
<Text style={styles.featureText}>Uncover hidden desires</Text>
</View>
<View style={styles.featureItem}>
<Ionicons name="flame-outline" size={16} color={ACCENT_COLOR} />
<Text style={styles.featureText}>Deepen your bond</Text>
</View>
<View style={styles.featureItem}>
<Ionicons name="ribbon-outline" size={16} color={ACCENT_COLOR} />
<Text style={styles.featureText}>Track your journey</Text>
</View>
</View>
{/* Bottom teaser */}
<Text style={styles.teaser}>Something special is being crafted for you</Text>
</Animated.View>
</View>
</GradientBackground>
);
}
const styles = StyleSheet.create({
container: {
flex: 1,
justifyContent: 'center',
alignItems: 'center',
paddingHorizontal: spacing.lg,
paddingTop: Platform.OS === 'ios' ? 60 : 40,
paddingBottom: Platform.OS === 'ios' ? 120 : 100,
},
content: {
width: '100%',
alignItems: 'center',
paddingHorizontal: spacing.md,
},
iconContainer: {
width: 72,
height: 72,
borderRadius: 36,
backgroundColor: 'rgba(232, 164, 174, 0.1)',
borderWidth: 1,
borderColor: 'rgba(232, 164, 174, 0.2)',
justifyContent: 'center',
alignItems: 'center',
marginBottom: spacing.xl,
},
label: {
...typography.caption1,
fontWeight: '600',
letterSpacing: 3,
color: ACCENT_COLOR,
textAlign: 'center',
marginBottom: spacing.xs,
},
title: {
...typography.largeTitle,
color: colors.text,
textAlign: 'center',
},
separator: {
flexDirection: 'row',
alignItems: 'center',
justifyContent: 'center',
marginVertical: spacing.lg,
width: 140,
},
separatorLine: {
flex: 1,
height: 1,
backgroundColor: 'rgba(232, 164, 174, 0.3)',
},
separatorDiamond: {
width: 6,
height: 6,
backgroundColor: ACCENT_COLOR,
transform: [{ rotate: '45deg' }],
marginHorizontal: spacing.md,
opacity: 0.6,
},
badge: {
backgroundColor: 'rgba(232, 164, 174, 0.1)',
paddingHorizontal: spacing.lg,
paddingVertical: spacing.sm,
borderRadius: radius.full,
borderWidth: 1,
borderColor: 'rgba(232, 164, 174, 0.2)',
marginBottom: spacing.xl,
},
badgeText: {
...typography.caption2,
fontWeight: '600',
letterSpacing: 2,
color: ACCENT_COLOR,
},
description: {
...typography.body,
color: colors.textSecondary,
textAlign: 'center',
marginBottom: spacing.xl,
paddingHorizontal: spacing.md,
},
features: {
marginBottom: spacing.xl,
},
featureItem: {
flexDirection: 'row',
alignItems: 'center',
justifyContent: 'center',
marginBottom: spacing.sm,
},
featureText: {
...typography.subhead,
color: colors.text,
marginLeft: spacing.sm,
},
teaser: {
...typography.footnote,
fontStyle: 'italic',
color: colors.textTertiary,
textAlign: 'center',
},
});
+308 -90
View File
@@ -270,34 +270,68 @@ export default function SwipeScreen() {
// Show "pair with partner" prompt when user doesn't have a partner yet
if (!partner) {
const PAIR_ACCENT = colors.premium.rose;
return (
<GradientBackground showAccent>
<GradientBackground>
<View style={styles.centerContainer}>
<Animated.View
entering={FadeInUp.duration(600).springify()}
style={styles.emptyContent}
style={styles.waitingContent}
>
<LinearGradient
colors={gradients.primary as [string, string]}
style={styles.pairIconContainer}
start={{ x: 0, y: 0 }}
end={{ x: 1, y: 1 }}
{/* Icon */}
<View style={styles.waitingIconContainer}>
<Ionicons name="heart" size={36} color={PAIR_ACCENT} />
</View>
{/* Title section */}
<Text style={styles.waitingLabel}>{couple ? "ALMOST THERE" : "CONNECT"}</Text>
<Text style={styles.waitingTitle}>{couple ? "Waiting" : "Pair Up"}</Text>
{/* Decorative separator */}
<View style={styles.waitingSeparator}>
<View style={styles.waitingSeparatorLine} />
<View style={styles.waitingSeparatorDiamond} />
<View style={styles.waitingSeparatorLine} />
</View>
{/* Status badge */}
<Animated.View
entering={FadeIn.delay(300).duration(400)}
style={styles.waitingBadge}
>
<Ionicons name="heart" size={48} color={colors.text} />
</LinearGradient>
<Text style={styles.pairTitle}>
{couple ? "Waiting for your partner" : "Pair with your partner"}
</Text>
<Text style={styles.emptySubtitle}>
<Text style={styles.waitingBadgeText}>{couple ? "INVITE SENT" : "MADE FOR TWO"}</Text>
</Animated.View>
{/* Description */}
<Text style={styles.waitingDescription}>
{couple
? "Share your invite code so they can join you. Once paired, you'll both answer questions and discover what you agree on!"
: "Sauci is made for two! Connect with your partner to start answering questions together and discover what you have in common."
}
</Text>
{/* Feature hints */}
<View style={styles.waitingFeatures}>
<View style={styles.waitingFeatureItem}>
<Ionicons name="lock-closed-outline" size={16} color={PAIR_ACCENT} />
<Text style={styles.waitingFeatureText}>Private and secure</Text>
</View>
<View style={styles.waitingFeatureItem}>
<Ionicons name="sparkles" size={16} color={PAIR_ACCENT} />
<Text style={styles.waitingFeatureText}>Discover hidden desires</Text>
</View>
<View style={styles.waitingFeatureItem}>
<Ionicons name="chatbubble-ellipses-outline" size={16} color={PAIR_ACCENT} />
<Text style={styles.waitingFeatureText}>Chat about your matches</Text>
</View>
</View>
{/* Bottom teaser */}
<Text style={styles.waitingTeaser}>{couple ? "Your partner is just a code away" : "Begin your intimate journey together"}</Text>
<GlassButton
onPress={() => router.push("/pairing")}
style={{ marginTop: spacing.xl }}
size="lg"
style={{ marginTop: spacing.lg }}
>
{couple ? "View Invite Code" : "Pair Now"}
</GlassButton>
@@ -309,27 +343,62 @@ export default function SwipeScreen() {
// Show "waiting for partner" message when user is too far ahead
if (isBlocked && gapInfo) {
const WAITING_ACCENT = colors.premium.rose;
return (
<GradientBackground showAccent>
<GradientBackground>
<View style={styles.centerContainer}>
<Animated.View
entering={FadeInUp.duration(600).springify()}
style={styles.emptyContent}
style={styles.waitingContent}
>
<LinearGradient
colors={gradients.warning as [string, string]}
style={styles.pairIconContainer}
start={{ x: 0, y: 0 }}
end={{ x: 1, y: 1 }}
{/* Icon */}
<View style={styles.waitingIconContainer}>
<Ionicons name="hourglass-outline" size={36} color={WAITING_ACCENT} />
</View>
{/* Title section */}
<Text style={styles.waitingLabel}>PATIENCE</Text>
<Text style={styles.waitingTitle}>Waiting</Text>
{/* Decorative separator */}
<View style={styles.waitingSeparator}>
<View style={styles.waitingSeparatorLine} />
<View style={styles.waitingSeparatorDiamond} />
<View style={styles.waitingSeparatorLine} />
</View>
{/* Status badge */}
<Animated.View
entering={FadeIn.delay(300).duration(400)}
style={styles.waitingBadge}
>
<Ionicons name="hourglass-outline" size={48} color={colors.text} />
</LinearGradient>
<Text style={styles.pairTitle}>
Waiting for your partner
</Text>
<Text style={styles.emptySubtitle}>
You've answered {gapInfo.unanswered} questions that your partner hasn't seen yet. Give them some time to catch up!
<Text style={styles.waitingBadgeText}>{gapInfo.unanswered} UNANSWERED</Text>
</Animated.View>
{/* Description */}
<Text style={styles.waitingDescription}>
You're ahead by {gapInfo.unanswered} questions. Give your partner some time to catch up so you can discover matches together.
</Text>
{/* Feature hints */}
<View style={styles.waitingFeatures}>
<View style={styles.waitingFeatureItem}>
<Ionicons name="heart-outline" size={16} color={WAITING_ACCENT} />
<Text style={styles.waitingFeatureText}>Matches happen together</Text>
</View>
<View style={styles.waitingFeatureItem}>
<Ionicons name="notifications-outline" size={16} color={WAITING_ACCENT} />
<Text style={styles.waitingFeatureText}>We'll notify your partner</Text>
</View>
<View style={styles.waitingFeatureItem}>
<Ionicons name="refresh-outline" size={16} color={WAITING_ACCENT} />
<Text style={styles.waitingFeatureText}>Check back anytime</Text>
</View>
</View>
{/* Bottom teaser */}
<Text style={styles.waitingTeaser}>Good things come to those who wait</Text>
<GlassButton
variant="secondary"
onPress={checkAnswerGap}
@@ -345,20 +414,62 @@ export default function SwipeScreen() {
// Show "no packs enabled" message when not viewing a specific pack and no packs are enabled
if (!packId && enabledPackIds.length === 0) {
const NO_PACKS_ACCENT = colors.premium.rose;
return (
<GradientBackground showAccent>
<GradientBackground>
<View style={styles.centerContainer}>
<Animated.View
entering={FadeInUp.duration(600).springify()}
style={styles.emptyContent}
style={styles.waitingContent}
>
<View style={styles.noPacksIconContainer}>
<Ionicons name="layers-outline" size={64} color={colors.textTertiary} />
{/* Icon */}
<View style={styles.waitingIconContainer}>
<Ionicons name="layers-outline" size={36} color={NO_PACKS_ACCENT} />
</View>
<Text style={styles.emptyTitle}>No packs enabled</Text>
<Text style={styles.emptySubtitle}>
Enable at least one question pack to start playing.
{/* Title section */}
<Text style={styles.waitingLabel}>GET STARTED</Text>
<Text style={styles.waitingTitle}>Choose Packs</Text>
{/* Decorative separator */}
<View style={styles.waitingSeparator}>
<View style={styles.waitingSeparatorLine} />
<View style={styles.waitingSeparatorDiamond} />
<View style={styles.waitingSeparatorLine} />
</View>
{/* Status badge */}
<Animated.View
entering={FadeIn.delay(300).duration(400)}
style={styles.waitingBadge}
>
<Text style={styles.waitingBadgeText}>NO PACKS ENABLED</Text>
</Animated.View>
{/* Description */}
<Text style={styles.waitingDescription}>
Select the question packs that interest you and your partner. Each pack explores different aspects of your relationship.
</Text>
{/* Feature hints */}
<View style={styles.waitingFeatures}>
<View style={styles.waitingFeatureItem}>
<Ionicons name="flame-outline" size={16} color={NO_PACKS_ACCENT} />
<Text style={styles.waitingFeatureText}>From playful to passionate</Text>
</View>
<View style={styles.waitingFeatureItem}>
<Ionicons name="shield-checkmark-outline" size={16} color={NO_PACKS_ACCENT} />
<Text style={styles.waitingFeatureText}>Safe space to explore</Text>
</View>
<View style={styles.waitingFeatureItem}>
<Ionicons name="infinite-outline" size={16} color={NO_PACKS_ACCENT} />
<Text style={styles.waitingFeatureText}>New packs added regularly</Text>
</View>
</View>
{/* Bottom teaser */}
<Text style={styles.waitingTeaser}>Your journey of discovery awaits</Text>
<GlassButton
onPress={() => router.push("/packs")}
style={{ marginTop: spacing.lg }}
@@ -372,20 +483,62 @@ export default function SwipeScreen() {
}
if (currentIndex >= questions.length) {
const CAUGHT_UP_ACCENT = colors.premium.rose;
return (
<GradientBackground showAccent>
<GradientBackground>
<View style={styles.centerContainer}>
<Animated.View
entering={FadeInUp.duration(600).springify()}
style={styles.emptyContent}
style={styles.waitingContent}
>
<View style={styles.emptyIconContainer}>
<Ionicons name="checkmark-circle" size={64} color={colors.primary} />
{/* Icon */}
<View style={styles.caughtUpIconContainer}>
<Ionicons name="checkmark" size={36} color={CAUGHT_UP_ACCENT} />
</View>
<Text style={styles.emptyTitle}>All caught up!</Text>
<Text style={styles.emptySubtitle}>
Check back later for more questions or try a different pack.
{/* Title section */}
<Text style={styles.caughtUpLabel}>COMPLETE</Text>
<Text style={styles.waitingTitle}>All Caught Up</Text>
{/* Decorative separator */}
<View style={styles.waitingSeparator}>
<View style={styles.waitingSeparatorLine} />
<View style={styles.waitingSeparatorDiamond} />
<View style={styles.waitingSeparatorLine} />
</View>
{/* Status badge */}
<Animated.View
entering={FadeIn.delay(300).duration(400)}
style={styles.waitingBadge}
>
<Text style={styles.waitingBadgeText}>YOU'RE AHEAD</Text>
</Animated.View>
{/* Description */}
<Text style={styles.waitingDescription}>
You've answered all available questions. New questions are added regularly, or explore different packs.
</Text>
{/* Feature hints */}
<View style={styles.waitingFeatures}>
<View style={styles.waitingFeatureItem}>
<Ionicons name="sparkles" size={16} color={CAUGHT_UP_ACCENT} />
<Text style={styles.waitingFeatureText}>New questions weekly</Text>
</View>
<View style={styles.waitingFeatureItem}>
<Ionicons name="layers-outline" size={16} color={CAUGHT_UP_ACCENT} />
<Text style={styles.waitingFeatureText}>Try different packs</Text>
</View>
<View style={styles.waitingFeatureItem}>
<Ionicons name="chatbubbles-outline" size={16} color={CAUGHT_UP_ACCENT} />
<Text style={styles.waitingFeatureText}>Chat about your matches</Text>
</View>
</View>
{/* Bottom teaser */}
<Text style={styles.waitingTeaser}>More ways to connect are on the way</Text>
<GlassButton
variant="secondary"
onPress={fetchQuestions}
@@ -600,54 +753,119 @@ const styles = StyleSheet.create({
...typography.caption1,
color: colors.textTertiary,
},
emptyContent: {
alignItems: "center",
paddingHorizontal: spacing.xl,
},
emptyIconContainer: {
width: 120,
height: 120,
borderRadius: 60,
backgroundColor: colors.primaryLight,
justifyContent: "center",
alignItems: "center",
marginBottom: spacing.lg,
},
pairIconContainer: {
width: 120,
height: 120,
borderRadius: 60,
justifyContent: "center",
alignItems: "center",
marginBottom: spacing.xl,
...shadows.lg,
},
pairTitle: {
...typography.title1,
color: colors.text,
marginBottom: spacing.md,
textAlign: "center",
},
noPacksIconContainer: {
width: 120,
height: 120,
borderRadius: 60,
backgroundColor: colors.glass.background,
justifyContent: "center",
alignItems: "center",
marginBottom: spacing.lg,
},
emptyTitle: {
...typography.title2,
color: colors.text,
marginBottom: spacing.sm,
},
emptySubtitle: {
...typography.body,
color: colors.textSecondary,
textAlign: "center",
},
bottomSpacer: {
height: Platform.OS === 'ios' ? 100 : 80,
},
// Premium "waiting for partner" styles
waitingContent: {
width: '100%',
alignItems: 'center',
paddingHorizontal: spacing.md,
},
waitingIconContainer: {
width: 72,
height: 72,
borderRadius: 36,
backgroundColor: 'rgba(232, 164, 174, 0.1)',
borderWidth: 1,
borderColor: 'rgba(232, 164, 174, 0.2)',
justifyContent: 'center',
alignItems: 'center',
marginBottom: spacing.xl,
},
waitingLabel: {
...typography.caption1,
fontWeight: '600',
letterSpacing: 3,
color: colors.premium.rose,
textAlign: 'center',
marginBottom: spacing.xs,
},
waitingTitle: {
...typography.largeTitle,
color: colors.text,
textAlign: 'center',
},
waitingSeparator: {
flexDirection: 'row',
alignItems: 'center',
justifyContent: 'center',
marginVertical: spacing.lg,
width: 140,
},
waitingSeparatorLine: {
flex: 1,
height: 1,
backgroundColor: 'rgba(232, 164, 174, 0.3)',
},
waitingSeparatorDiamond: {
width: 6,
height: 6,
backgroundColor: colors.premium.rose,
transform: [{ rotate: '45deg' }],
marginHorizontal: spacing.md,
opacity: 0.6,
},
waitingBadge: {
backgroundColor: 'rgba(232, 164, 174, 0.1)',
paddingHorizontal: spacing.lg,
paddingVertical: spacing.sm,
borderRadius: radius.full,
borderWidth: 1,
borderColor: 'rgba(232, 164, 174, 0.2)',
marginBottom: spacing.xl,
},
waitingBadgeText: {
...typography.caption2,
fontWeight: '600',
letterSpacing: 2,
color: colors.premium.rose,
},
waitingDescription: {
...typography.body,
color: colors.textSecondary,
textAlign: 'center',
marginBottom: spacing.xl,
paddingHorizontal: spacing.md,
},
waitingFeatures: {
marginBottom: spacing.xl,
},
waitingFeatureItem: {
flexDirection: 'row',
alignItems: 'center',
justifyContent: 'center',
marginBottom: spacing.sm,
},
waitingFeatureText: {
...typography.subhead,
color: colors.text,
marginLeft: spacing.sm,
},
waitingTeaser: {
...typography.footnote,
fontStyle: 'italic',
color: colors.textTertiary,
textAlign: 'center',
},
// Caught up specific styles
caughtUpIconContainer: {
width: 72,
height: 72,
borderRadius: 36,
backgroundColor: 'rgba(232, 164, 174, 0.1)',
borderWidth: 1,
borderColor: 'rgba(232, 164, 174, 0.2)',
justifyContent: 'center',
alignItems: 'center',
marginBottom: spacing.xl,
},
caughtUpLabel: {
...typography.caption1,
fontWeight: '600',
letterSpacing: 3,
color: colors.premium.rose,
textAlign: 'center',
marginBottom: spacing.xs,
},
});
Binary file not shown.

After

Width:  |  Height:  |  Size: 147 KiB

+62 -5
View File
@@ -101,6 +101,47 @@ export async function clearPushToken(userId: string): Promise<void> {
}
}
/**
* Get Expo push token with retry logic for Android.
* On Android, Firebase may not be initialized immediately on app start,
* so we retry a few times with increasing delays.
*/
async function getExpoPushTokenWithRetry(
projectId: string,
maxRetries = 5
): Promise<string | null> {
console.log(`[Push] Getting token for project: ${projectId}, platform: ${Platform.OS}`);
for (let attempt = 0; attempt < maxRetries; attempt++) {
try {
console.log(`[Push] Attempt ${attempt + 1}/${maxRetries} to get push token...`);
const tokenData = await Notifications.getExpoPushTokenAsync({ projectId });
console.log(`[Push] Successfully got token: ${tokenData.data}`);
return tokenData.data;
} catch (error) {
const errorMessage = error instanceof Error ? error.message : String(error);
console.log(`[Push] Attempt ${attempt + 1} failed: ${errorMessage}`);
// Retry for any Firebase-related errors on Android
const isFirebaseError =
error instanceof Error &&
(error.message.includes("Firebase") ||
error.message.includes("FCM") ||
error.message.includes("Google Play"));
if (Platform.OS === "android" && attempt < maxRetries - 1) {
const delay = (attempt + 1) * 1000;
console.log(`[Push] Retrying in ${delay}ms...`);
await new Promise((resolve) => setTimeout(resolve, delay));
continue;
}
throw error;
}
}
return null;
}
/**
* Check if current device needs to register for push notifications.
* This handles the case where a user onboarded on one device (e.g., iOS)
@@ -110,32 +151,44 @@ export async function clearPushToken(userId: string): Promise<void> {
* Returns true if a new token was registered.
*/
export async function checkAndRegisterPushToken(userId: string): Promise<boolean> {
console.log(`[Push] checkAndRegisterPushToken called for user: ${userId}`);
if (Platform.OS === "web") {
console.log("[Push] Skipping - web platform");
return false;
}
try {
// First check if permissions are already granted
const { status } = await Notifications.getPermissionsAsync();
console.log(`[Push] Current permission status: ${status}`);
// If permissions not granted, we need to prompt the user
// But only do this if we don't already have a token for this device
if (status !== "granted") {
// Request permissions
console.log("[Push] Requesting permissions...");
const { status: newStatus } = await Notifications.requestPermissionsAsync();
console.log(`[Push] New permission status: ${newStatus}`);
if (newStatus !== "granted") {
console.log("[Push] Permission denied");
return false;
}
}
// Get the current device's push token
const projectId = Constants.expoConfig?.extra?.eas?.projectId;
console.log(`[Push] Project ID: ${projectId}`);
if (!projectId) {
console.log("[Push] No project ID configured");
return false;
}
const tokenData = await Notifications.getExpoPushTokenAsync({ projectId });
const currentToken = tokenData.data;
const currentToken = await getExpoPushTokenWithRetry(projectId);
console.log(`[Push] Current token: ${currentToken}`);
if (!currentToken) {
console.log("[Push] Failed to get token");
return false;
}
// Fetch the stored token from the database
const { data: profile } = await supabase
@@ -144,16 +197,20 @@ export async function checkAndRegisterPushToken(userId: string): Promise<boolean
.eq("id", userId)
.single();
console.log(`[Push] Stored token: ${profile?.push_token}`);
// If the stored token is different (or null), save the new one
if (profile?.push_token !== currentToken) {
console.log("[Push] Token changed, saving new token...");
await savePushToken(userId, currentToken);
console.log("Push token updated for new device");
console.log("[Push] Push token updated for new device");
return true;
}
console.log("[Push] Token unchanged");
return false;
} catch (error) {
console.error("Failed to check/register push token:", error);
console.error("[Push] Failed to check/register push token:", error);
captureError(error as Error, { context: "push_token_check" });
return false;
}