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:
jjbrunton committed 2026-01-03 20:10:58 +00:00
1 parent 994dd2a295
commit ae91602921
21 files changed
+2453 -237

No files matched your search

+25 -16
View File
@@ -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 */}
+17 -14
View File
@@ -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}
+24 -2
View File
@@ -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',
},
+386
View File
@@ -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
View File
@@ -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: {