mirror of
https://github.com/jjbrunton/Sauci.git
synced 2026-10-31 18:19:43 +00:00
Improve mobile data retrieval and state management
- Add race protection and packId normalization to swipe screen - Add loading/error states to matchStore with couple_id filter - Sync per-match unread counts in realtime - Use useFocusEffect for data freshness on Home screen - Wire RefreshControl to actual loading state on Matches 🤖 Generated with [Claude Code](https://claude.com/claude-code)
This commit is contained in:
1 parent
994dd2a295
commit
ae91602921
21 files changed
+2453
-237
No files matched your search
@@ -50,21 +50,21 @@ function MessageToastPreview({
|
||||
currentUserId,
|
||||
});
|
||||
|
||||
if (isDecrypting) {
|
||||
return (
|
||||
<Text style={styles.messageToastBody} numberOfLines={1}>
|
||||
Waiting for message...
|
||||
</Text>
|
||||
);
|
||||
}
|
||||
|
||||
if (error) {
|
||||
return (
|
||||
<Text style={styles.messageToastBody} numberOfLines={1}>
|
||||
Encrypted message
|
||||
</Text>
|
||||
);
|
||||
}
|
||||
if (isDecrypting) {
|
||||
return (
|
||||
<Text style={styles.messageToastBody} numberOfLines={1}>
|
||||
Waiting for message...
|
||||
</Text>
|
||||
);
|
||||
}
|
||||
|
||||
if (error) {
|
||||
return (
|
||||
<Text style={styles.messageToastBody} numberOfLines={1}>
|
||||
Encrypted message
|
||||
</Text>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<Text style={styles.messageToastBody} numberOfLines={1}>
|
||||
@@ -175,7 +175,7 @@ export default function AppLayout() {
|
||||
const router = useRouter();
|
||||
const segments = useSegments();
|
||||
const { isAuthenticated, isLoading, user, signOut } = useAuthStore();
|
||||
const { matches, newMatchesCount, addMatch } = useMatchStore();
|
||||
const { matches, newMatchesCount, addMatch, updateMatchUnreadCount } = useMatchStore();
|
||||
const { unreadCount, lastMessage, fetchUnreadCount, addMessage, clearLastMessage } = useMessageStore();
|
||||
const { fetchEnabledPacks } = usePacksStore();
|
||||
const { initializeRevenueCat } = useSubscriptionStore();
|
||||
@@ -477,6 +477,9 @@ export default function AppLayout() {
|
||||
delivered_at: newMessage.delivered_at || new Date().toISOString(),
|
||||
match: { id: match.id, question: { text: questionText || "" } },
|
||||
});
|
||||
|
||||
// Sync per-match unread count for realtime updates
|
||||
updateMatchUnreadCount(match.id, 1);
|
||||
}
|
||||
}
|
||||
)
|
||||
@@ -697,6 +700,12 @@ export default function AppLayout() {
|
||||
href: null, // Hide from tab bar
|
||||
}}
|
||||
/>
|
||||
<Tabs.Screen
|
||||
name="my-answers"
|
||||
options={{
|
||||
href: null, // Hide from tab bar
|
||||
}}
|
||||
/>
|
||||
</Tabs>
|
||||
|
||||
{/* Radial Menu Overlay */}
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { useEffect } from "react";
|
||||
import { useEffect, useCallback } from "react";
|
||||
import { View, Text, StyleSheet, TouchableOpacity, ScrollView, Platform, useWindowDimensions } from "react-native";
|
||||
import Animated, {
|
||||
FadeInDown,
|
||||
@@ -13,7 +13,7 @@ import Animated, {
|
||||
import { LinearGradient } from "expo-linear-gradient";
|
||||
import { useAuthStore, useMatchStore, usePacksStore } from "../../src/store";
|
||||
import { Ionicons } from "@expo/vector-icons";
|
||||
import { router } from "expo-router";
|
||||
import { router, useFocusEffect } from "expo-router";
|
||||
import { GradientBackground, GlassCard } from "../../src/components/ui";
|
||||
import { colors, gradients, spacing, radius, typography, shadows } from "../../src/theme";
|
||||
|
||||
@@ -23,17 +23,20 @@ const ACCENT_COLOR = colors.primary;
|
||||
export default function HomeScreen() {
|
||||
const { user, partner, couple } = useAuthStore();
|
||||
const { matches, newMatchesCount, fetchMatches } = useMatchStore();
|
||||
const { packs, fetchPacks } = usePacksStore();
|
||||
const { enabledPackIds, fetchPacks } = usePacksStore();
|
||||
const { width } = useWindowDimensions();
|
||||
|
||||
useEffect(() => {
|
||||
fetchMatches();
|
||||
fetchPacks();
|
||||
}, []);
|
||||
// Refresh data when screen gains focus for freshness
|
||||
useFocusEffect(
|
||||
useCallback(() => {
|
||||
fetchMatches();
|
||||
fetchPacks();
|
||||
}, [])
|
||||
);
|
||||
|
||||
const recentMatches = matches.slice(0, 3);
|
||||
const topMatches = matches.slice(0, 3);
|
||||
const isWideScreen = width > MAX_CONTENT_WIDTH;
|
||||
const enabledPacksCount = packs.length;
|
||||
const enabledPacksCount = enabledPackIds.length;
|
||||
|
||||
// Subtle pulsating animation for the CTA icon
|
||||
const pulseScale = useSharedValue(1);
|
||||
@@ -219,8 +222,8 @@ export default function HomeScreen() {
|
||||
</TouchableOpacity>
|
||||
</Animated.View>
|
||||
|
||||
{/* Recent Matches */}
|
||||
{recentMatches.length > 0 && (
|
||||
{/* Top Matches (sorted by unread first, then recent) */}
|
||||
{topMatches.length > 0 && (
|
||||
<Animated.View
|
||||
entering={FadeInDown.delay(400).duration(500)}
|
||||
style={styles.section}
|
||||
@@ -230,7 +233,7 @@ export default function HomeScreen() {
|
||||
<View style={styles.sectionIcon}>
|
||||
<Ionicons name="heart" size={14} color={ACCENT_COLOR} />
|
||||
</View>
|
||||
<Text style={styles.sectionTitle}>Recent Matches</Text>
|
||||
<Text style={styles.sectionTitle}>Top Matches</Text>
|
||||
</View>
|
||||
<TouchableOpacity
|
||||
onPress={() => router.push("/(app)/matches")}
|
||||
@@ -240,7 +243,7 @@ export default function HomeScreen() {
|
||||
<Ionicons name="chevron-forward" size={14} color={ACCENT_COLOR} />
|
||||
</TouchableOpacity>
|
||||
</View>
|
||||
{recentMatches.map((match, index) => (
|
||||
{topMatches.map((match, index) => (
|
||||
<Animated.View
|
||||
key={match.id}
|
||||
entering={FadeInRight.delay(450 + index * 100).duration(400)}
|
||||
@@ -272,7 +275,7 @@ export default function HomeScreen() {
|
||||
)}
|
||||
|
||||
{/* Tips when no matches */}
|
||||
{recentMatches.length === 0 && (
|
||||
{topMatches.length === 0 && (
|
||||
<Animated.View
|
||||
entering={FadeInDown.delay(400).duration(500)}
|
||||
style={styles.section}
|
||||
|
||||
@@ -26,7 +26,7 @@ const ROSE_RGBA = 'rgba(232, 164, 174, ';
|
||||
const MAX_CONTENT_WIDTH = 500;
|
||||
|
||||
export default function MatchesScreen() {
|
||||
const { matches, fetchMatches, markAllAsSeen } = useMatchStore();
|
||||
const { matches, fetchMatches, markAllAsSeen, isLoading } = useMatchStore();
|
||||
const { user } = useAuthStore();
|
||||
const router = useRouter();
|
||||
const { width } = useWindowDimensions();
|
||||
@@ -205,6 +205,14 @@ export default function MatchesScreen() {
|
||||
entering={FadeIn.duration(400)}
|
||||
style={[styles.header, isWideScreen && styles.headerWide]}
|
||||
>
|
||||
{/* My Answers button */}
|
||||
<TouchableOpacity
|
||||
style={styles.myAnswersButton}
|
||||
onPress={() => router.push("/(app)/my-answers")}
|
||||
>
|
||||
<Ionicons name="list-outline" size={20} color={ACCENT} />
|
||||
</TouchableOpacity>
|
||||
|
||||
<View style={styles.headerContent}>
|
||||
{/* Premium label */}
|
||||
<Text style={styles.headerLabel}>DISCOVER</Text>
|
||||
@@ -229,7 +237,7 @@ export default function MatchesScreen() {
|
||||
showsVerticalScrollIndicator={false}
|
||||
refreshControl={
|
||||
<RefreshControl
|
||||
refreshing={false}
|
||||
refreshing={isLoading}
|
||||
onRefresh={fetchMatches}
|
||||
tintColor={colors.primary}
|
||||
colors={[colors.primary]}
|
||||
@@ -323,6 +331,20 @@ const styles = StyleSheet.create({
|
||||
width: '100%',
|
||||
maxWidth: MAX_CONTENT_WIDTH,
|
||||
},
|
||||
myAnswersButton: {
|
||||
position: 'absolute',
|
||||
top: 60,
|
||||
right: spacing.md,
|
||||
zIndex: 10,
|
||||
width: 40,
|
||||
height: 40,
|
||||
borderRadius: 20,
|
||||
backgroundColor: `${ACCENT_RGBA}0.1)`,
|
||||
borderWidth: 1,
|
||||
borderColor: `${ACCENT_RGBA}0.2)`,
|
||||
justifyContent: 'center',
|
||||
alignItems: 'center',
|
||||
},
|
||||
headerContent: {
|
||||
alignItems: 'center',
|
||||
},
|
||||
|
||||
@@ -0,0 +1,386 @@
|
||||
import {
|
||||
View,
|
||||
Text,
|
||||
StyleSheet,
|
||||
SectionList,
|
||||
RefreshControl,
|
||||
TouchableOpacity,
|
||||
ActivityIndicator,
|
||||
Platform,
|
||||
useWindowDimensions,
|
||||
} from "react-native";
|
||||
import { useResponsesStore, groupResponses, useAuthStore } from "../../src/store";
|
||||
import type { ResponseWithQuestion } from "../../src/store";
|
||||
import { useCallback, useState } from "react";
|
||||
import { useFocusEffect, useRouter } from "expo-router";
|
||||
import { Ionicons } from "@expo/vector-icons";
|
||||
import { LinearGradient } from "expo-linear-gradient";
|
||||
import Animated, { FadeIn, FadeInUp } from "react-native-reanimated";
|
||||
import { GradientBackground, GlassButton, DecorativeSeparator } from "../../src/components/ui";
|
||||
import { useAmbientOrbAnimation } from "../../src/hooks";
|
||||
import { colors, spacing, typography, radius } from "../../src/theme";
|
||||
import { ResponseCard } from "../../src/components/responses/ResponseCard";
|
||||
import { EditResponseSheet } from "../../src/components/responses/EditResponseSheet";
|
||||
|
||||
// Premium color palette
|
||||
const ACCENT = colors.premium.gold;
|
||||
const ACCENT_RGBA = "rgba(212, 175, 55, ";
|
||||
const ROSE = colors.premium.rose;
|
||||
const ROSE_RGBA = "rgba(232, 164, 174, ";
|
||||
|
||||
const MAX_CONTENT_WIDTH = 500;
|
||||
|
||||
type GroupByOption = "pack" | "date" | "answer";
|
||||
|
||||
export default function MyAnswersScreen() {
|
||||
const { responses, isLoading, groupBy, fetchResponses, setGroupBy } = useResponsesStore();
|
||||
const { user } = useAuthStore();
|
||||
const router = useRouter();
|
||||
const { width } = useWindowDimensions();
|
||||
const isWideScreen = width > MAX_CONTENT_WIDTH;
|
||||
|
||||
// State for edit sheet
|
||||
const [selectedResponse, setSelectedResponse] = useState<ResponseWithQuestion | null>(null);
|
||||
const [isEditSheetVisible, setIsEditSheetVisible] = useState(false);
|
||||
|
||||
// Ambient orb breathing animations
|
||||
const { orbStyle1, orbStyle2 } = useAmbientOrbAnimation();
|
||||
|
||||
// Fetch responses on focus
|
||||
useFocusEffect(
|
||||
useCallback(() => {
|
||||
fetchResponses();
|
||||
}, [])
|
||||
);
|
||||
|
||||
const sections = groupResponses(responses, groupBy);
|
||||
|
||||
const handleEditPress = (response: ResponseWithQuestion) => {
|
||||
setSelectedResponse(response);
|
||||
setIsEditSheetVisible(true);
|
||||
};
|
||||
|
||||
const handleEditClose = () => {
|
||||
setIsEditSheetVisible(false);
|
||||
setSelectedResponse(null);
|
||||
};
|
||||
|
||||
const handleEditSuccess = () => {
|
||||
// Refetch responses to get updated data
|
||||
fetchResponses();
|
||||
};
|
||||
|
||||
const renderSectionHeader = ({ section }: { section: { title: string } }) => (
|
||||
<View style={styles.sectionHeader}>
|
||||
<Text style={styles.sectionTitle}>{section.title}</Text>
|
||||
</View>
|
||||
);
|
||||
|
||||
const renderItem = ({ item, index }: { item: ResponseWithQuestion; index: number }) => (
|
||||
<ResponseCard
|
||||
response={item}
|
||||
index={index}
|
||||
onEditPress={() => handleEditPress(item)}
|
||||
onChatPress={item.match_id ? () => router.push(`/chat/${item.match_id}`) : undefined}
|
||||
/>
|
||||
);
|
||||
|
||||
const GroupByButton = ({ option, label }: { option: GroupByOption; label: string }) => (
|
||||
<TouchableOpacity
|
||||
style={[styles.groupByButton, groupBy === option && styles.groupByButtonActive]}
|
||||
onPress={() => setGroupBy(option)}
|
||||
>
|
||||
<Text style={[styles.groupByText, groupBy === option && styles.groupByTextActive]}>
|
||||
{label}
|
||||
</Text>
|
||||
</TouchableOpacity>
|
||||
);
|
||||
|
||||
if (!user) {
|
||||
return (
|
||||
<GradientBackground>
|
||||
<View style={styles.loadingContainer}>
|
||||
<ActivityIndicator color={colors.primary} size="large" />
|
||||
</View>
|
||||
</GradientBackground>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<GradientBackground>
|
||||
{/* Ambient Orbs */}
|
||||
<Animated.View
|
||||
style={[styles.ambientOrb, styles.orbTopRight, orbStyle1]}
|
||||
pointerEvents="none"
|
||||
>
|
||||
<LinearGradient
|
||||
colors={[colors.premium.goldGlow, "transparent"]}
|
||||
style={styles.orbGradient}
|
||||
start={{ x: 0.5, y: 0.5 }}
|
||||
end={{ x: 1, y: 1 }}
|
||||
/>
|
||||
</Animated.View>
|
||||
<Animated.View
|
||||
style={[styles.ambientOrb, styles.orbBottomLeft, orbStyle2]}
|
||||
pointerEvents="none"
|
||||
>
|
||||
<LinearGradient
|
||||
colors={[`${ROSE_RGBA}0.2)`, "transparent"]}
|
||||
style={styles.orbGradient}
|
||||
start={{ x: 0.5, y: 0.5 }}
|
||||
end={{ x: 0, y: 0 }}
|
||||
/>
|
||||
</Animated.View>
|
||||
|
||||
<View style={styles.container}>
|
||||
{/* Header */}
|
||||
<Animated.View
|
||||
entering={FadeIn.duration(400)}
|
||||
style={[styles.header, isWideScreen && styles.headerWide]}
|
||||
>
|
||||
{/* Back button */}
|
||||
<TouchableOpacity style={styles.backButton} onPress={() => router.back()}>
|
||||
<Ionicons name="chevron-back" size={24} color={colors.text} />
|
||||
</TouchableOpacity>
|
||||
|
||||
<View style={styles.headerContent}>
|
||||
<Text style={styles.headerLabel}>REVIEW</Text>
|
||||
<Text style={styles.headerTitle}>My Answers</Text>
|
||||
<DecorativeSeparator variant="gold" />
|
||||
|
||||
{/* Count badge */}
|
||||
<View style={styles.countBadgePremium}>
|
||||
<Ionicons name="checkmark-circle" size={12} color={ACCENT} />
|
||||
<Text style={styles.countTextPremium}>
|
||||
{responses.length} {responses.length === 1 ? "ANSWER" : "ANSWERS"}
|
||||
</Text>
|
||||
</View>
|
||||
</View>
|
||||
|
||||
{/* Group by buttons */}
|
||||
<View style={styles.groupByContainer}>
|
||||
<GroupByButton option="pack" label="Pack" />
|
||||
<GroupByButton option="date" label="Date" />
|
||||
<GroupByButton option="answer" label="Answer" />
|
||||
</View>
|
||||
</Animated.View>
|
||||
|
||||
{isLoading && responses.length === 0 ? (
|
||||
<View style={styles.loadingContainer}>
|
||||
<ActivityIndicator color={colors.primary} size="large" />
|
||||
</View>
|
||||
) : (
|
||||
<SectionList
|
||||
sections={sections}
|
||||
renderItem={renderItem}
|
||||
renderSectionHeader={renderSectionHeader}
|
||||
keyExtractor={(item) => item.id}
|
||||
contentContainerStyle={[styles.list, isWideScreen && styles.listWide]}
|
||||
showsVerticalScrollIndicator={false}
|
||||
stickySectionHeadersEnabled={false}
|
||||
refreshControl={
|
||||
<RefreshControl
|
||||
refreshing={isLoading}
|
||||
onRefresh={fetchResponses}
|
||||
tintColor={colors.primary}
|
||||
colors={[colors.primary]}
|
||||
/>
|
||||
}
|
||||
ListEmptyComponent={
|
||||
<Animated.View
|
||||
entering={FadeInUp.duration(600).springify()}
|
||||
style={styles.emptyContent}
|
||||
>
|
||||
<View style={styles.emptyIcon}>
|
||||
<Ionicons name="help-circle-outline" size={48} color={ACCENT} />
|
||||
</View>
|
||||
<Text style={styles.emptyTitle}>No Answers Yet</Text>
|
||||
<Text style={styles.emptyDescription}>
|
||||
Start swiping on questions to build your answer history. You can
|
||||
come back here to review and change your answers anytime.
|
||||
</Text>
|
||||
<GlassButton
|
||||
onPress={() => router.push("/(app)/swipe")}
|
||||
style={{ marginTop: spacing.lg }}
|
||||
>
|
||||
Start Swiping
|
||||
</GlassButton>
|
||||
</Animated.View>
|
||||
}
|
||||
/>
|
||||
)}
|
||||
</View>
|
||||
|
||||
{/* Edit Response Sheet */}
|
||||
<EditResponseSheet
|
||||
visible={isEditSheetVisible}
|
||||
response={selectedResponse}
|
||||
onClose={handleEditClose}
|
||||
onSuccess={handleEditSuccess}
|
||||
/>
|
||||
</GradientBackground>
|
||||
);
|
||||
}
|
||||
|
||||
const styles = StyleSheet.create({
|
||||
container: {
|
||||
flex: 1,
|
||||
},
|
||||
loadingContainer: {
|
||||
flex: 1,
|
||||
justifyContent: "center",
|
||||
alignItems: "center",
|
||||
},
|
||||
// Ambient orbs
|
||||
ambientOrb: {
|
||||
position: "absolute",
|
||||
width: 300,
|
||||
height: 300,
|
||||
borderRadius: 150,
|
||||
},
|
||||
orbTopRight: {
|
||||
top: 60,
|
||||
right: -40,
|
||||
},
|
||||
orbBottomLeft: {
|
||||
bottom: 180,
|
||||
left: -40,
|
||||
},
|
||||
orbGradient: {
|
||||
width: "100%",
|
||||
height: "100%",
|
||||
borderRadius: 150,
|
||||
},
|
||||
// Header
|
||||
header: {
|
||||
paddingTop: 60,
|
||||
paddingHorizontal: spacing.lg,
|
||||
paddingBottom: spacing.md,
|
||||
},
|
||||
headerWide: {
|
||||
alignSelf: "center",
|
||||
width: "100%",
|
||||
maxWidth: MAX_CONTENT_WIDTH,
|
||||
},
|
||||
backButton: {
|
||||
position: "absolute",
|
||||
top: 60,
|
||||
left: spacing.md,
|
||||
zIndex: 10,
|
||||
width: 40,
|
||||
height: 40,
|
||||
borderRadius: 20,
|
||||
backgroundColor: "rgba(255, 255, 255, 0.1)",
|
||||
justifyContent: "center",
|
||||
alignItems: "center",
|
||||
},
|
||||
headerContent: {
|
||||
alignItems: "center",
|
||||
},
|
||||
headerLabel: {
|
||||
...typography.caption2,
|
||||
fontWeight: "600",
|
||||
letterSpacing: 3,
|
||||
color: ACCENT,
|
||||
marginBottom: spacing.xs,
|
||||
},
|
||||
headerTitle: {
|
||||
...typography.largeTitle,
|
||||
color: colors.text,
|
||||
textAlign: "center",
|
||||
},
|
||||
countBadgePremium: {
|
||||
flexDirection: "row",
|
||||
alignItems: "center",
|
||||
backgroundColor: `${ACCENT_RGBA}0.1)`,
|
||||
paddingHorizontal: spacing.md,
|
||||
paddingVertical: spacing.sm,
|
||||
borderRadius: radius.full,
|
||||
borderWidth: 1,
|
||||
borderColor: `${ACCENT_RGBA}0.2)`,
|
||||
gap: spacing.xs,
|
||||
},
|
||||
countTextPremium: {
|
||||
...typography.caption2,
|
||||
fontWeight: "600",
|
||||
letterSpacing: 2,
|
||||
color: ACCENT,
|
||||
},
|
||||
// Group by buttons
|
||||
groupByContainer: {
|
||||
flexDirection: "row",
|
||||
justifyContent: "center",
|
||||
gap: spacing.sm,
|
||||
marginTop: spacing.md,
|
||||
},
|
||||
groupByButton: {
|
||||
paddingHorizontal: spacing.md,
|
||||
paddingVertical: spacing.sm,
|
||||
borderRadius: radius.full,
|
||||
backgroundColor: "rgba(255, 255, 255, 0.05)",
|
||||
borderWidth: 1,
|
||||
borderColor: "rgba(255, 255, 255, 0.1)",
|
||||
},
|
||||
groupByButtonActive: {
|
||||
backgroundColor: `${ACCENT_RGBA}0.15)`,
|
||||
borderColor: `${ACCENT_RGBA}0.3)`,
|
||||
},
|
||||
groupByText: {
|
||||
...typography.caption1,
|
||||
color: colors.textSecondary,
|
||||
fontWeight: "500",
|
||||
},
|
||||
groupByTextActive: {
|
||||
color: ACCENT,
|
||||
fontWeight: "600",
|
||||
},
|
||||
// Section header
|
||||
sectionHeader: {
|
||||
paddingVertical: spacing.sm,
|
||||
paddingHorizontal: spacing.xs,
|
||||
marginTop: spacing.md,
|
||||
marginBottom: spacing.xs,
|
||||
},
|
||||
sectionTitle: {
|
||||
...typography.title3,
|
||||
color: colors.text,
|
||||
},
|
||||
// List
|
||||
list: {
|
||||
padding: spacing.lg,
|
||||
paddingTop: spacing.sm,
|
||||
paddingBottom: Platform.OS === "ios" ? 120 : 100,
|
||||
},
|
||||
listWide: {
|
||||
alignSelf: "center",
|
||||
width: "100%",
|
||||
maxWidth: MAX_CONTENT_WIDTH,
|
||||
},
|
||||
// Empty state
|
||||
emptyContent: {
|
||||
width: "100%",
|
||||
alignItems: "center",
|
||||
paddingHorizontal: spacing.md,
|
||||
paddingVertical: spacing.xxl,
|
||||
},
|
||||
emptyIcon: {
|
||||
width: 80,
|
||||
height: 80,
|
||||
borderRadius: 40,
|
||||
backgroundColor: `${ACCENT_RGBA}0.1)`,
|
||||
justifyContent: "center",
|
||||
alignItems: "center",
|
||||
marginBottom: spacing.lg,
|
||||
},
|
||||
emptyTitle: {
|
||||
...typography.title2,
|
||||
color: colors.text,
|
||||
marginBottom: spacing.sm,
|
||||
},
|
||||
emptyDescription: {
|
||||
...typography.body,
|
||||
color: colors.textSecondary,
|
||||
textAlign: "center",
|
||||
paddingHorizontal: spacing.md,
|
||||
},
|
||||
});
|
||||
+113
-50
@@ -1,6 +1,6 @@
|
||||
import { useState, useEffect, useRef } from "react";
|
||||
import { useState, useEffect, useRef, useCallback } from "react";
|
||||
import { View, StyleSheet, Text, ActivityIndicator, Platform } from "react-native";
|
||||
import { useLocalSearchParams, router } from "expo-router";
|
||||
import { useLocalSearchParams, router, useFocusEffect } from "expo-router";
|
||||
import { Ionicons } from "@expo/vector-icons";
|
||||
import Animated, {
|
||||
FadeIn,
|
||||
@@ -20,25 +20,28 @@ import { skipQuestion, getSkippedQuestionIds } from "../../src/lib/skippedQuesti
|
||||
import { hasSeenSwipeTutorial, markSwipeTutorialSeen } from "../../src/lib/swipeTutorialSeen";
|
||||
import { invokeWithAuthRetry } from "../../src/lib/authErrorHandler";
|
||||
// import SwipeCard from "../../src/components/SwipeCard";
|
||||
import { SwipeCardPremium as SwipeCard } from "../../src/components/swipe"; // PoC: Premium styling
|
||||
import { SwipeTutorial } from "../../src/components/tutorials";
|
||||
import { QuestionFeedbackModal } from "../../src/components/feedback";
|
||||
import { GradientBackground, GlassCard, GlassButton, DecorativeSeparator } from "../../src/components/ui";
|
||||
import { SwipeCardPremium as SwipeCard } from "../../src/components/swipe"; // PoC: Premium styling
|
||||
import { SwipeTutorial } from "../../src/components/tutorials";
|
||||
import { QuestionFeedbackModal } from "../../src/components/feedback";
|
||||
import { GradientBackground, GlassCard, GlassButton, DecorativeSeparator } from "../../src/components/ui";
|
||||
import { Events } from "../../src/lib/analytics";
|
||||
import { colors, gradients, spacing, typography, radius, shadows } from "../../src/theme";
|
||||
|
||||
export default function SwipeScreen() {
|
||||
const { packId } = useLocalSearchParams();
|
||||
const [questions, setQuestions] = useState<any[]>([]);
|
||||
const [isLoading, setIsLoading] = useState(true);
|
||||
const [currentIndex, setCurrentIndex] = useState(0);
|
||||
const [showTutorial, setShowTutorial] = useState(false);
|
||||
const [feedbackQuestion, setFeedbackQuestion] = useState<{id: string, text: string} | null>(null);
|
||||
const [isBlocked, setIsBlocked] = useState(false);
|
||||
const [gapInfo, setGapInfo] = useState<{ unanswered: number; threshold: number } | null>(null);
|
||||
const { enabledPackIds, fetchPacks } = usePacksStore();
|
||||
const params = useLocalSearchParams();
|
||||
// Normalize packId - useLocalSearchParams can return string | string[]
|
||||
const packId = Array.isArray(params.packId) ? params.packId[0] : params.packId;
|
||||
const [questions, setQuestions] = useState<any[]>([]);
|
||||
const [isLoading, setIsLoading] = useState(true);
|
||||
const [currentIndex, setCurrentIndex] = useState(0);
|
||||
const [showTutorial, setShowTutorial] = useState(false);
|
||||
const [feedbackQuestion, setFeedbackQuestion] = useState<{id: string, text: string} | null>(null);
|
||||
const [isBlocked, setIsBlocked] = useState(false);
|
||||
const [gapInfo, setGapInfo] = useState<{ unanswered: number; threshold: number } | null>(null);
|
||||
const { enabledPackIds, ensureEnabledPacksLoaded } = usePacksStore();
|
||||
const { partner, couple } = useAuthStore();
|
||||
const hasTrackedExhausted = useRef(false);
|
||||
const fetchRequestId = useRef(0);
|
||||
|
||||
// Ambient orb breathing animations
|
||||
const { orbStyle1, orbStyle2 } = useAmbientOrbAnimation();
|
||||
@@ -61,12 +64,34 @@ export default function SwipeScreen() {
|
||||
],
|
||||
}));
|
||||
|
||||
const isFirstMount = useRef(true);
|
||||
|
||||
useEffect(() => {
|
||||
fetchPacks().then(() => fetchQuestions());
|
||||
// Reset state when pack changes
|
||||
setCurrentIndex(0);
|
||||
setIsLoading(true);
|
||||
setIsBlocked(false);
|
||||
setGapInfo(null);
|
||||
hasTrackedExhausted.current = false;
|
||||
|
||||
// Use lightweight ensureEnabledPacksLoaded instead of full fetchPacks
|
||||
ensureEnabledPacksLoaded().then(() => fetchQuestions());
|
||||
checkTutorial();
|
||||
hasTrackedExhausted.current = false; // Reset when pack changes
|
||||
}, [packId]);
|
||||
|
||||
// Refresh questions when screen gains focus (handles partner answering while away)
|
||||
useFocusEffect(
|
||||
useCallback(() => {
|
||||
// Skip on first mount since useEffect already fetches
|
||||
if (isFirstMount.current) {
|
||||
isFirstMount.current = false;
|
||||
return;
|
||||
}
|
||||
// Silently refetch to get updated partner_answered status
|
||||
fetchQuestions();
|
||||
}, [packId])
|
||||
);
|
||||
|
||||
// Check if user is too far ahead of partner (called when questions empty or after swipe)
|
||||
const checkAnswerGap = async (): Promise<boolean> => {
|
||||
if (!couple || !partner) {
|
||||
@@ -80,6 +105,9 @@ export default function SwipeScreen() {
|
||||
|
||||
if (error) {
|
||||
console.error('Error checking answer gap:', error);
|
||||
// Clear blocked state on error so user isn't stuck
|
||||
setIsBlocked(false);
|
||||
setGapInfo(null);
|
||||
return false;
|
||||
}
|
||||
|
||||
@@ -97,10 +125,18 @@ export default function SwipeScreen() {
|
||||
}
|
||||
return result.is_blocked;
|
||||
}
|
||||
|
||||
// No data returned - clear blocked state
|
||||
setIsBlocked(false);
|
||||
setGapInfo(null);
|
||||
return false;
|
||||
} catch (error) {
|
||||
console.error('Failed to check answer gap:', error);
|
||||
// Clear blocked state on error so user isn't stuck
|
||||
setIsBlocked(false);
|
||||
setGapInfo(null);
|
||||
return false;
|
||||
}
|
||||
return false;
|
||||
};
|
||||
|
||||
// Calculate effective total questions (accounting for gap limit)
|
||||
@@ -150,7 +186,20 @@ export default function SwipeScreen() {
|
||||
}
|
||||
}, [enabledPackIds]);
|
||||
|
||||
// Fisher-Yates shuffle for stable randomization
|
||||
const shuffleArray = <T,>(array: T[]): T[] => {
|
||||
const shuffled = [...array];
|
||||
for (let i = shuffled.length - 1; i > 0; i--) {
|
||||
const j = Math.floor(Math.random() * (i + 1));
|
||||
[shuffled[i], shuffled[j]] = [shuffled[j], shuffled[i]];
|
||||
}
|
||||
return shuffled;
|
||||
};
|
||||
|
||||
const fetchQuestions = async () => {
|
||||
// Race protection: increment request ID and track current request
|
||||
const currentRequestId = ++fetchRequestId.current;
|
||||
|
||||
try {
|
||||
const [{ data, error }, skippedIds] = await Promise.all([
|
||||
supabase.rpc("get_recommended_questions", {
|
||||
@@ -159,6 +208,11 @@ export default function SwipeScreen() {
|
||||
getSkippedQuestionIds()
|
||||
]);
|
||||
|
||||
// Race guard: if a newer request started, discard this result
|
||||
if (currentRequestId !== fetchRequestId.current) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (error) {
|
||||
console.error("Error fetching recommended questions:", error);
|
||||
throw error;
|
||||
@@ -177,9 +231,11 @@ export default function SwipeScreen() {
|
||||
setGapInfo(null);
|
||||
}
|
||||
|
||||
const sorted = filtered.sort((a: any, b: any) => {
|
||||
let scoreA = Math.random();
|
||||
let scoreB = Math.random();
|
||||
// First shuffle to randomize, then stable sort by priority scores
|
||||
const shuffled = shuffleArray(filtered);
|
||||
const sorted = shuffled.sort((a: any, b: any) => {
|
||||
let scoreA = 0;
|
||||
let scoreB = 0;
|
||||
|
||||
if (a.partner_answered && a.is_two_part) scoreA += 1.5;
|
||||
else if (a.partner_answered) scoreA += 0.7;
|
||||
@@ -192,11 +248,18 @@ export default function SwipeScreen() {
|
||||
return scoreB - scoreA;
|
||||
});
|
||||
|
||||
setQuestions(sorted);
|
||||
// Only update state if still the current request
|
||||
if (currentRequestId === fetchRequestId.current) {
|
||||
setQuestions(sorted);
|
||||
}
|
||||
} catch (error) {
|
||||
console.error(error);
|
||||
if (currentRequestId === fetchRequestId.current) {
|
||||
console.error(error);
|
||||
}
|
||||
} finally {
|
||||
setIsLoading(false);
|
||||
if (currentRequestId === fetchRequestId.current) {
|
||||
setIsLoading(false);
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
@@ -574,17 +637,17 @@ export default function SwipeScreen() {
|
||||
<View style={[styles.stackCard, styles.stackCardSecond]} />
|
||||
)}
|
||||
|
||||
{/* Active card */}
|
||||
<SwipeCard
|
||||
key={questions[currentIndex].id}
|
||||
question={questions[currentIndex]}
|
||||
onSwipe={handleSwipe}
|
||||
onReport={() => setFeedbackQuestion({
|
||||
id: questions[currentIndex].id,
|
||||
text: questions[currentIndex].text
|
||||
})}
|
||||
/>
|
||||
</View>
|
||||
{/* Active card */}
|
||||
<SwipeCard
|
||||
key={questions[currentIndex].id}
|
||||
question={questions[currentIndex]}
|
||||
onSwipe={handleSwipe}
|
||||
onReport={() => setFeedbackQuestion({
|
||||
id: questions[currentIndex].id,
|
||||
text: questions[currentIndex].text
|
||||
})}
|
||||
/>
|
||||
</View>
|
||||
|
||||
{/* Hint - Premium styling */}
|
||||
<Animated.View
|
||||
@@ -598,21 +661,21 @@ export default function SwipeScreen() {
|
||||
<View style={styles.bottomSpacer} />
|
||||
</View>
|
||||
|
||||
{/* Tutorial Overlay */}
|
||||
{showTutorial && (
|
||||
<SwipeTutorial onComplete={handleTutorialComplete} />
|
||||
)}
|
||||
|
||||
{/* Question Feedback Modal */}
|
||||
<QuestionFeedbackModal
|
||||
visible={!!feedbackQuestion}
|
||||
onClose={() => setFeedbackQuestion(null)}
|
||||
questionId={feedbackQuestion?.id || ''}
|
||||
questionText={feedbackQuestion?.text || ''}
|
||||
/>
|
||||
</GradientBackground>
|
||||
);
|
||||
}
|
||||
{/* Tutorial Overlay */}
|
||||
{showTutorial && (
|
||||
<SwipeTutorial onComplete={handleTutorialComplete} />
|
||||
)}
|
||||
|
||||
{/* Question Feedback Modal */}
|
||||
<QuestionFeedbackModal
|
||||
visible={!!feedbackQuestion}
|
||||
onClose={() => setFeedbackQuestion(null)}
|
||||
questionId={feedbackQuestion?.id || ''}
|
||||
questionText={feedbackQuestion?.text || ''}
|
||||
/>
|
||||
</GradientBackground>
|
||||
);
|
||||
}
|
||||
|
||||
const styles = StyleSheet.create({
|
||||
container: {
|
||||
|
||||
Reference in new issue
Block a user