mirror of
https://github.com/jjbrunton/Sauci.git
synced 2026-10-31 18:19:43 +00:00
Implement infinite scroll for Matches and Answers
Add pagination support to stores and update list screens to load data in batches for improved performance with large datasets.
This commit is contained in:
1 parent
ae91602921
commit
bb2ce5a6a6
8 files changed
+622
-164
No files matched your search
@@ -10,7 +10,13 @@ import Animated, {
|
|||||||
FadeInDown,
|
FadeInDown,
|
||||||
FadeInRight,
|
FadeInRight,
|
||||||
FadeInUp,
|
FadeInUp,
|
||||||
|
useSharedValue,
|
||||||
|
useAnimatedScrollHandler,
|
||||||
|
useAnimatedStyle,
|
||||||
|
interpolate,
|
||||||
|
Extrapolation,
|
||||||
} from "react-native-reanimated";
|
} from "react-native-reanimated";
|
||||||
|
import { BlurView } from "expo-blur";
|
||||||
import { GradientBackground, GlassCard, GlassButton, DecorativeSeparator } from "../../src/components/ui";
|
import { GradientBackground, GlassCard, GlassButton, DecorativeSeparator } from "../../src/components/ui";
|
||||||
import { useAmbientOrbAnimation } from "../../src/hooks";
|
import { useAmbientOrbAnimation } from "../../src/hooks";
|
||||||
import { colors, gradients, spacing, typography, radius, shadows } from "../../src/theme";
|
import { colors, gradients, spacing, typography, radius, shadows } from "../../src/theme";
|
||||||
@@ -24,15 +30,65 @@ const ROSE = colors.premium.rose;
|
|||||||
const ROSE_RGBA = 'rgba(232, 164, 174, ';
|
const ROSE_RGBA = 'rgba(232, 164, 174, ';
|
||||||
|
|
||||||
const MAX_CONTENT_WIDTH = 500;
|
const MAX_CONTENT_WIDTH = 500;
|
||||||
|
const NAV_BAR_HEIGHT = 44;
|
||||||
|
const STATUS_BAR_HEIGHT = 60;
|
||||||
|
const HEADER_SCROLL_DISTANCE = 100;
|
||||||
|
|
||||||
|
const AnimatedFlatList = Animated.createAnimatedComponent(FlatList);
|
||||||
|
|
||||||
export default function MatchesScreen() {
|
export default function MatchesScreen() {
|
||||||
const { matches, fetchMatches, markAllAsSeen, isLoading } = useMatchStore();
|
const { matches, fetchMatches, markAllAsSeen, isLoading, hasMore, isLoadingMore, totalCount } = useMatchStore();
|
||||||
const { user } = useAuthStore();
|
const { user } = useAuthStore();
|
||||||
const router = useRouter();
|
const router = useRouter();
|
||||||
const { width } = useWindowDimensions();
|
const { width } = useWindowDimensions();
|
||||||
const isWideScreen = width > MAX_CONTENT_WIDTH;
|
const isWideScreen = width > MAX_CONTENT_WIDTH;
|
||||||
const [showTutorial, setShowTutorial] = useState(false);
|
const [showTutorial, setShowTutorial] = useState(false);
|
||||||
|
|
||||||
|
const scrollY = useSharedValue(0);
|
||||||
|
|
||||||
|
const scrollHandler = useAnimatedScrollHandler({
|
||||||
|
onScroll: (event) => {
|
||||||
|
scrollY.value = event.contentOffset.y;
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
// Animated styles for collapsing header
|
||||||
|
const heroStyle = useAnimatedStyle(() => {
|
||||||
|
const opacity = interpolate(
|
||||||
|
scrollY.value,
|
||||||
|
[0, HEADER_SCROLL_DISTANCE * 0.7],
|
||||||
|
[1, 0],
|
||||||
|
Extrapolation.CLAMP
|
||||||
|
);
|
||||||
|
const scale = interpolate(
|
||||||
|
scrollY.value,
|
||||||
|
[0, HEADER_SCROLL_DISTANCE],
|
||||||
|
[1, 0.95],
|
||||||
|
Extrapolation.CLAMP
|
||||||
|
);
|
||||||
|
return { opacity, transform: [{ scale }] };
|
||||||
|
});
|
||||||
|
|
||||||
|
const compactHeaderStyle = useAnimatedStyle(() => {
|
||||||
|
const opacity = interpolate(
|
||||||
|
scrollY.value,
|
||||||
|
[HEADER_SCROLL_DISTANCE * 0.5, HEADER_SCROLL_DISTANCE],
|
||||||
|
[0, 1],
|
||||||
|
Extrapolation.CLAMP
|
||||||
|
);
|
||||||
|
return { opacity };
|
||||||
|
});
|
||||||
|
|
||||||
|
const navBarBackgroundStyle = useAnimatedStyle(() => {
|
||||||
|
const opacity = interpolate(
|
||||||
|
scrollY.value,
|
||||||
|
[0, HEADER_SCROLL_DISTANCE * 0.8],
|
||||||
|
[0, 1],
|
||||||
|
Extrapolation.CLAMP
|
||||||
|
);
|
||||||
|
return { opacity };
|
||||||
|
});
|
||||||
|
|
||||||
// Ambient orb breathing animations
|
// Ambient orb breathing animations
|
||||||
const { orbStyle1, orbStyle2 } = useAmbientOrbAnimation();
|
const { orbStyle1, orbStyle2 } = useAmbientOrbAnimation();
|
||||||
|
|
||||||
@@ -58,10 +114,17 @@ export default function MatchesScreen() {
|
|||||||
|
|
||||||
useFocusEffect(
|
useFocusEffect(
|
||||||
useCallback(() => {
|
useCallback(() => {
|
||||||
fetchMatches();
|
fetchMatches(true);
|
||||||
}, [])
|
}, [])
|
||||||
);
|
);
|
||||||
|
|
||||||
|
const handleLoadMore = useCallback(() => {
|
||||||
|
console.log('[Matches] onEndReached called', { isLoading, isLoadingMore, hasMore, matchesCount: matches.length });
|
||||||
|
if (!isLoading && !isLoadingMore && hasMore) {
|
||||||
|
fetchMatches(false);
|
||||||
|
}
|
||||||
|
}, [isLoading, isLoadingMore, hasMore, matches.length]);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (matches.length > 0) {
|
if (matches.length > 0) {
|
||||||
markAllAsSeen();
|
markAllAsSeen();
|
||||||
@@ -169,6 +232,15 @@ export default function MatchesScreen() {
|
|||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|
||||||
|
const renderFooter = () => {
|
||||||
|
if (!isLoadingMore) return null;
|
||||||
|
return (
|
||||||
|
<View style={styles.footerLoader}>
|
||||||
|
<ActivityIndicator color={colors.primary} size="small" />
|
||||||
|
</View>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
if (!user) {
|
if (!user) {
|
||||||
return (
|
return (
|
||||||
<GradientBackground>
|
<GradientBackground>
|
||||||
@@ -200,48 +272,69 @@ export default function MatchesScreen() {
|
|||||||
</Animated.View>
|
</Animated.View>
|
||||||
|
|
||||||
<View style={styles.container}>
|
<View style={styles.container}>
|
||||||
{/* Premium Header */}
|
{/* Fixed Nav Bar */}
|
||||||
<Animated.View
|
<View style={styles.navBar}>
|
||||||
entering={FadeIn.duration(400)}
|
<Animated.View style={[styles.navBarBackground, navBarBackgroundStyle]}>
|
||||||
style={[styles.header, isWideScreen && styles.headerWide]}
|
{Platform.OS === "ios" ? (
|
||||||
>
|
<BlurView intensity={80} tint="dark" style={StyleSheet.absoluteFill} />
|
||||||
{/* My Answers button */}
|
) : (
|
||||||
|
<View style={[StyleSheet.absoluteFill, { backgroundColor: "rgba(13, 13, 26, 0.95)" }]} />
|
||||||
|
)}
|
||||||
|
</Animated.View>
|
||||||
|
|
||||||
|
<Animated.Text style={[styles.navBarTitle, compactHeaderStyle]} numberOfLines={1}>
|
||||||
|
Matches
|
||||||
|
</Animated.Text>
|
||||||
|
|
||||||
|
{/* My Answers button moved to Nav Bar */}
|
||||||
<TouchableOpacity
|
<TouchableOpacity
|
||||||
style={styles.myAnswersButton}
|
style={styles.navBarButton}
|
||||||
onPress={() => router.push("/(app)/my-answers")}
|
onPress={() => router.push({ pathname: "/(app)/my-answers", params: { returnTo: "/(app)/matches" } })}
|
||||||
>
|
>
|
||||||
<Ionicons name="list-outline" size={20} color={ACCENT} />
|
<Ionicons name="list-outline" size={20} color={ACCENT} />
|
||||||
</TouchableOpacity>
|
</TouchableOpacity>
|
||||||
|
</View>
|
||||||
|
|
||||||
<View style={styles.headerContent}>
|
<AnimatedFlatList
|
||||||
{/* Premium label */}
|
|
||||||
<Text style={styles.headerLabel}>DISCOVER</Text>
|
|
||||||
<Text style={styles.headerTitle}>Matches</Text>
|
|
||||||
|
|
||||||
{/* Decorative separator */}
|
|
||||||
<DecorativeSeparator variant="gold" />
|
|
||||||
|
|
||||||
{/* Count badge */}
|
|
||||||
<View style={styles.countBadgePremium}>
|
|
||||||
<Ionicons name="heart" size={12} color={ACCENT} />
|
|
||||||
<Text style={styles.countTextPremium}>{matches.length} {matches.length === 1 ? 'MATCH' : 'MATCHES'}</Text>
|
|
||||||
</View>
|
|
||||||
</View>
|
|
||||||
</Animated.View>
|
|
||||||
|
|
||||||
<FlatList
|
|
||||||
data={matches}
|
data={matches}
|
||||||
renderItem={renderItem}
|
renderItem={renderItem}
|
||||||
keyExtractor={(item) => item.id}
|
keyExtractor={(item: any) => item.id}
|
||||||
contentContainerStyle={[styles.list, isWideScreen && styles.listWide]}
|
contentContainerStyle={[styles.list, isWideScreen && styles.listWide]}
|
||||||
showsVerticalScrollIndicator={false}
|
showsVerticalScrollIndicator={false}
|
||||||
refreshControl={
|
onScroll={scrollHandler}
|
||||||
<RefreshControl
|
scrollEventThrottle={16}
|
||||||
refreshing={isLoading}
|
refreshControl={
|
||||||
onRefresh={fetchMatches}
|
<RefreshControl
|
||||||
tintColor={colors.primary}
|
refreshing={isLoading}
|
||||||
colors={[colors.primary]}
|
onRefresh={() => fetchMatches(true)}
|
||||||
/>
|
tintColor={colors.primary}
|
||||||
|
colors={[colors.primary]}
|
||||||
|
progressViewOffset={STATUS_BAR_HEIGHT + NAV_BAR_HEIGHT}
|
||||||
|
/>
|
||||||
|
}
|
||||||
|
onEndReached={handleLoadMore}
|
||||||
|
onEndReachedThreshold={2}
|
||||||
|
ListFooterComponent={renderFooter}
|
||||||
|
ListHeaderComponent={
|
||||||
|
<Animated.View
|
||||||
|
entering={FadeIn.duration(400)}
|
||||||
|
style={[styles.header, isWideScreen && styles.headerWide, heroStyle]}
|
||||||
|
>
|
||||||
|
<View style={styles.headerContent}>
|
||||||
|
{/* Premium label */}
|
||||||
|
<Text style={styles.headerLabel}>DISCOVER</Text>
|
||||||
|
<Text style={styles.headerTitle}>Matches</Text>
|
||||||
|
|
||||||
|
{/* Decorative separator */}
|
||||||
|
<DecorativeSeparator variant="gold" />
|
||||||
|
|
||||||
|
{/* Count badge */}
|
||||||
|
<View style={styles.countBadgePremium}>
|
||||||
|
<Ionicons name="heart" size={12} color={ACCENT} />
|
||||||
|
<Text style={styles.countTextPremium}>{totalCount ?? matches.length} {(totalCount ?? matches.length) === 1 ? 'MATCH' : 'MATCHES'}</Text>
|
||||||
|
</View>
|
||||||
|
</View>
|
||||||
|
</Animated.View>
|
||||||
}
|
}
|
||||||
ListEmptyComponent={
|
ListEmptyComponent={
|
||||||
<Animated.View
|
<Animated.View
|
||||||
@@ -320,31 +413,56 @@ const styles = StyleSheet.create({
|
|||||||
height: '100%',
|
height: '100%',
|
||||||
borderRadius: 150,
|
borderRadius: 150,
|
||||||
},
|
},
|
||||||
// Premium Header
|
// Fixed Nav Bar
|
||||||
|
navBar: {
|
||||||
|
position: "absolute",
|
||||||
|
top: 0,
|
||||||
|
left: 0,
|
||||||
|
right: 0,
|
||||||
|
height: STATUS_BAR_HEIGHT + NAV_BAR_HEIGHT,
|
||||||
|
flexDirection: "row",
|
||||||
|
alignItems: "center",
|
||||||
|
justifyContent: "center",
|
||||||
|
paddingTop: STATUS_BAR_HEIGHT - 10,
|
||||||
|
paddingHorizontal: spacing.md,
|
||||||
|
zIndex: 100,
|
||||||
|
},
|
||||||
|
navBarBackground: {
|
||||||
|
...StyleSheet.absoluteFillObject,
|
||||||
|
borderBottomWidth: 1,
|
||||||
|
borderBottomColor: 'rgba(212, 175, 55, 0.15)', // Gold tint for matches
|
||||||
|
overflow: "hidden",
|
||||||
|
},
|
||||||
|
navBarTitle: {
|
||||||
|
...typography.headline,
|
||||||
|
color: colors.text,
|
||||||
|
textAlign: "center",
|
||||||
|
},
|
||||||
|
navBarButton: {
|
||||||
|
position: 'absolute',
|
||||||
|
right: spacing.md,
|
||||||
|
top: STATUS_BAR_HEIGHT - 5,
|
||||||
|
width: 36,
|
||||||
|
height: 36,
|
||||||
|
borderRadius: 18,
|
||||||
|
backgroundColor: 'rgba(255, 255, 255, 0.08)',
|
||||||
|
justifyContent: 'center',
|
||||||
|
alignItems: 'center',
|
||||||
|
borderWidth: 1,
|
||||||
|
borderColor: 'rgba(255, 255, 255, 0.1)',
|
||||||
|
},
|
||||||
|
// Header (Hero)
|
||||||
header: {
|
header: {
|
||||||
paddingTop: 60,
|
paddingTop: STATUS_BAR_HEIGHT + spacing.md,
|
||||||
paddingHorizontal: spacing.lg,
|
paddingHorizontal: spacing.lg,
|
||||||
paddingBottom: spacing.md,
|
paddingBottom: spacing.lg,
|
||||||
|
alignItems: 'center',
|
||||||
},
|
},
|
||||||
headerWide: {
|
headerWide: {
|
||||||
alignSelf: 'center',
|
alignSelf: 'center',
|
||||||
width: '100%',
|
width: '100%',
|
||||||
maxWidth: MAX_CONTENT_WIDTH,
|
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: {
|
headerContent: {
|
||||||
alignItems: 'center',
|
alignItems: 'center',
|
||||||
},
|
},
|
||||||
@@ -380,7 +498,8 @@ const styles = StyleSheet.create({
|
|||||||
// List
|
// List
|
||||||
list: {
|
list: {
|
||||||
padding: spacing.lg,
|
padding: spacing.lg,
|
||||||
paddingTop: spacing.sm,
|
// Remove top padding as header handles it
|
||||||
|
paddingTop: 0,
|
||||||
paddingBottom: Platform.OS === 'ios' ? 120 : 100,
|
paddingBottom: Platform.OS === 'ios' ? 120 : 100,
|
||||||
},
|
},
|
||||||
listWide: {
|
listWide: {
|
||||||
@@ -536,4 +655,9 @@ const styles = StyleSheet.create({
|
|||||||
color: colors.textTertiary,
|
color: colors.textTertiary,
|
||||||
textAlign: 'center',
|
textAlign: 'center',
|
||||||
},
|
},
|
||||||
|
footerLoader: {
|
||||||
|
paddingVertical: spacing.md,
|
||||||
|
alignItems: "center",
|
||||||
|
justifyContent: "center",
|
||||||
|
},
|
||||||
});
|
});
|
||||||
@@ -11,11 +11,20 @@ import {
|
|||||||
} from "react-native";
|
} from "react-native";
|
||||||
import { useResponsesStore, groupResponses, useAuthStore } from "../../src/store";
|
import { useResponsesStore, groupResponses, useAuthStore } from "../../src/store";
|
||||||
import type { ResponseWithQuestion } from "../../src/store";
|
import type { ResponseWithQuestion } from "../../src/store";
|
||||||
import { useCallback, useState } from "react";
|
import { useCallback, useState, useEffect } from "react";
|
||||||
import { useFocusEffect, useRouter } from "expo-router";
|
import { useFocusEffect, useRouter, useLocalSearchParams } from "expo-router";
|
||||||
import { Ionicons } from "@expo/vector-icons";
|
import { Ionicons } from "@expo/vector-icons";
|
||||||
import { LinearGradient } from "expo-linear-gradient";
|
import { LinearGradient } from "expo-linear-gradient";
|
||||||
import Animated, { FadeIn, FadeInUp } from "react-native-reanimated";
|
import Animated, {
|
||||||
|
FadeIn,
|
||||||
|
FadeInUp,
|
||||||
|
useSharedValue,
|
||||||
|
useAnimatedScrollHandler,
|
||||||
|
useAnimatedStyle,
|
||||||
|
interpolate,
|
||||||
|
Extrapolation,
|
||||||
|
} from "react-native-reanimated";
|
||||||
|
import { BlurView } from "expo-blur";
|
||||||
import { GradientBackground, GlassButton, DecorativeSeparator } from "../../src/components/ui";
|
import { GradientBackground, GlassButton, DecorativeSeparator } from "../../src/components/ui";
|
||||||
import { useAmbientOrbAnimation } from "../../src/hooks";
|
import { useAmbientOrbAnimation } from "../../src/hooks";
|
||||||
import { colors, spacing, typography, radius } from "../../src/theme";
|
import { colors, spacing, typography, radius } from "../../src/theme";
|
||||||
@@ -29,30 +38,91 @@ const ROSE = colors.premium.rose;
|
|||||||
const ROSE_RGBA = "rgba(232, 164, 174, ";
|
const ROSE_RGBA = "rgba(232, 164, 174, ";
|
||||||
|
|
||||||
const MAX_CONTENT_WIDTH = 500;
|
const MAX_CONTENT_WIDTH = 500;
|
||||||
|
const NAV_BAR_HEIGHT = 44;
|
||||||
|
const STATUS_BAR_HEIGHT = 60;
|
||||||
|
const HEADER_SCROLL_DISTANCE = 100;
|
||||||
|
|
||||||
|
const AnimatedSectionList = Animated.createAnimatedComponent(SectionList);
|
||||||
|
|
||||||
type GroupByOption = "pack" | "date" | "answer";
|
type GroupByOption = "pack" | "date" | "answer";
|
||||||
|
|
||||||
export default function MyAnswersScreen() {
|
export default function MyAnswersScreen() {
|
||||||
const { responses, isLoading, groupBy, fetchResponses, setGroupBy } = useResponsesStore();
|
const { responses, isLoading, isLoadingMore, hasMore, groupBy, fetchResponses, setGroupBy, totalCount } = useResponsesStore();
|
||||||
const { user } = useAuthStore();
|
const { user } = useAuthStore();
|
||||||
const router = useRouter();
|
const router = useRouter();
|
||||||
|
const params = useLocalSearchParams();
|
||||||
const { width } = useWindowDimensions();
|
const { width } = useWindowDimensions();
|
||||||
const isWideScreen = width > MAX_CONTENT_WIDTH;
|
const isWideScreen = width > MAX_CONTENT_WIDTH;
|
||||||
|
|
||||||
|
const handleBack = () => {
|
||||||
|
const returnTo = (params.returnTo as string) || "/(app)/swipe";
|
||||||
|
router.push(returnTo as any);
|
||||||
|
};
|
||||||
|
|
||||||
// State for edit sheet
|
// State for edit sheet
|
||||||
const [selectedResponse, setSelectedResponse] = useState<ResponseWithQuestion | null>(null);
|
const [selectedResponse, setSelectedResponse] = useState<ResponseWithQuestion | null>(null);
|
||||||
const [isEditSheetVisible, setIsEditSheetVisible] = useState(false);
|
const [isEditSheetVisible, setIsEditSheetVisible] = useState(false);
|
||||||
|
|
||||||
|
// Scroll animation
|
||||||
|
const scrollY = useSharedValue(0);
|
||||||
|
const scrollHandler = useAnimatedScrollHandler({
|
||||||
|
onScroll: (event) => {
|
||||||
|
scrollY.value = event.contentOffset.y;
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
const heroStyle = useAnimatedStyle(() => {
|
||||||
|
const opacity = interpolate(
|
||||||
|
scrollY.value,
|
||||||
|
[0, HEADER_SCROLL_DISTANCE * 0.7],
|
||||||
|
[1, 0],
|
||||||
|
Extrapolation.CLAMP
|
||||||
|
);
|
||||||
|
const scale = interpolate(
|
||||||
|
scrollY.value,
|
||||||
|
[0, HEADER_SCROLL_DISTANCE],
|
||||||
|
[1, 0.95],
|
||||||
|
Extrapolation.CLAMP
|
||||||
|
);
|
||||||
|
return { opacity, transform: [{ scale }] };
|
||||||
|
});
|
||||||
|
|
||||||
|
const compactHeaderStyle = useAnimatedStyle(() => {
|
||||||
|
const opacity = interpolate(
|
||||||
|
scrollY.value,
|
||||||
|
[HEADER_SCROLL_DISTANCE * 0.5, HEADER_SCROLL_DISTANCE],
|
||||||
|
[0, 1],
|
||||||
|
Extrapolation.CLAMP
|
||||||
|
);
|
||||||
|
return { opacity };
|
||||||
|
});
|
||||||
|
|
||||||
|
const navBarBackgroundStyle = useAnimatedStyle(() => {
|
||||||
|
const opacity = interpolate(
|
||||||
|
scrollY.value,
|
||||||
|
[0, HEADER_SCROLL_DISTANCE * 0.8],
|
||||||
|
[0, 1],
|
||||||
|
Extrapolation.CLAMP
|
||||||
|
);
|
||||||
|
return { opacity };
|
||||||
|
});
|
||||||
|
|
||||||
// Ambient orb breathing animations
|
// Ambient orb breathing animations
|
||||||
const { orbStyle1, orbStyle2 } = useAmbientOrbAnimation();
|
const { orbStyle1, orbStyle2 } = useAmbientOrbAnimation();
|
||||||
|
|
||||||
// Fetch responses on focus
|
// Fetch responses on focus
|
||||||
useFocusEffect(
|
useFocusEffect(
|
||||||
useCallback(() => {
|
useCallback(() => {
|
||||||
fetchResponses();
|
fetchResponses(true);
|
||||||
}, [])
|
}, [])
|
||||||
);
|
);
|
||||||
|
|
||||||
|
const handleLoadMore = useCallback(() => {
|
||||||
|
if (!isLoading && !isLoadingMore && hasMore) {
|
||||||
|
fetchResponses(false);
|
||||||
|
}
|
||||||
|
}, [isLoading, isLoadingMore, hasMore]);
|
||||||
|
|
||||||
const sections = groupResponses(responses, groupBy);
|
const sections = groupResponses(responses, groupBy);
|
||||||
|
|
||||||
const handleEditPress = (response: ResponseWithQuestion) => {
|
const handleEditPress = (response: ResponseWithQuestion) => {
|
||||||
@@ -67,16 +137,16 @@ export default function MyAnswersScreen() {
|
|||||||
|
|
||||||
const handleEditSuccess = () => {
|
const handleEditSuccess = () => {
|
||||||
// Refetch responses to get updated data
|
// Refetch responses to get updated data
|
||||||
fetchResponses();
|
fetchResponses(true);
|
||||||
};
|
};
|
||||||
|
|
||||||
const renderSectionHeader = ({ section }: { section: { title: string } }) => (
|
const renderSectionHeader = ({ section }: { section: any }) => (
|
||||||
<View style={styles.sectionHeader}>
|
<View style={styles.sectionHeader}>
|
||||||
<Text style={styles.sectionTitle}>{section.title}</Text>
|
<Text style={styles.sectionTitle}>{section.title}</Text>
|
||||||
</View>
|
</View>
|
||||||
);
|
);
|
||||||
|
|
||||||
const renderItem = ({ item, index }: { item: ResponseWithQuestion; index: number }) => (
|
const renderItem = ({ item, index }: { item: any; index: number }) => (
|
||||||
<ResponseCard
|
<ResponseCard
|
||||||
response={item}
|
response={item}
|
||||||
index={index}
|
index={index}
|
||||||
@@ -85,6 +155,15 @@ export default function MyAnswersScreen() {
|
|||||||
/>
|
/>
|
||||||
);
|
);
|
||||||
|
|
||||||
|
const renderFooter = () => {
|
||||||
|
if (!isLoadingMore) return null;
|
||||||
|
return (
|
||||||
|
<View style={styles.footerLoader}>
|
||||||
|
<ActivityIndicator color={colors.primary} size="small" />
|
||||||
|
</View>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
const GroupByButton = ({ option, label }: { option: GroupByOption; label: string }) => (
|
const GroupByButton = ({ option, label }: { option: GroupByOption; label: string }) => (
|
||||||
<TouchableOpacity
|
<TouchableOpacity
|
||||||
style={[styles.groupByButton, groupBy === option && styles.groupByButtonActive]}
|
style={[styles.groupByButton, groupBy === option && styles.groupByButtonActive]}
|
||||||
@@ -133,59 +212,79 @@ export default function MyAnswersScreen() {
|
|||||||
</Animated.View>
|
</Animated.View>
|
||||||
|
|
||||||
<View style={styles.container}>
|
<View style={styles.container}>
|
||||||
{/* Header */}
|
{/* Fixed Nav Bar */}
|
||||||
<Animated.View
|
<View style={styles.navBar}>
|
||||||
entering={FadeIn.duration(400)}
|
<Animated.View style={[styles.navBarBackground, navBarBackgroundStyle]}>
|
||||||
style={[styles.header, isWideScreen && styles.headerWide]}
|
{Platform.OS === "ios" ? (
|
||||||
>
|
<BlurView intensity={80} tint="dark" style={StyleSheet.absoluteFill} />
|
||||||
{/* Back button */}
|
) : (
|
||||||
<TouchableOpacity style={styles.backButton} onPress={() => router.back()}>
|
<View style={[StyleSheet.absoluteFill, { backgroundColor: "rgba(13, 13, 26, 0.95)" }]} />
|
||||||
|
)}
|
||||||
|
</Animated.View>
|
||||||
|
|
||||||
|
<TouchableOpacity style={styles.navBarBackButton} onPress={handleBack}>
|
||||||
<Ionicons name="chevron-back" size={24} color={colors.text} />
|
<Ionicons name="chevron-back" size={24} color={colors.text} />
|
||||||
</TouchableOpacity>
|
</TouchableOpacity>
|
||||||
|
|
||||||
<View style={styles.headerContent}>
|
<Animated.Text style={[styles.navBarTitle, compactHeaderStyle]} numberOfLines={1}>
|
||||||
<Text style={styles.headerLabel}>REVIEW</Text>
|
My Answers
|
||||||
<Text style={styles.headerTitle}>My Answers</Text>
|
</Animated.Text>
|
||||||
<DecorativeSeparator variant="gold" />
|
</View>
|
||||||
|
|
||||||
{/* 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 ? (
|
{isLoading && responses.length === 0 ? (
|
||||||
<View style={styles.loadingContainer}>
|
<View style={styles.loadingContainer}>
|
||||||
<ActivityIndicator color={colors.primary} size="large" />
|
<ActivityIndicator color={colors.primary} size="large" />
|
||||||
</View>
|
</View>
|
||||||
) : (
|
) : (
|
||||||
<SectionList
|
<AnimatedSectionList
|
||||||
sections={sections}
|
sections={sections}
|
||||||
renderItem={renderItem}
|
renderItem={renderItem}
|
||||||
renderSectionHeader={renderSectionHeader}
|
renderSectionHeader={renderSectionHeader}
|
||||||
keyExtractor={(item) => item.id}
|
keyExtractor={(item: any) => item.id}
|
||||||
contentContainerStyle={[styles.list, isWideScreen && styles.listWide]}
|
contentContainerStyle={[styles.list, isWideScreen && styles.listWide]}
|
||||||
showsVerticalScrollIndicator={false}
|
showsVerticalScrollIndicator={false}
|
||||||
stickySectionHeadersEnabled={false}
|
stickySectionHeadersEnabled={false}
|
||||||
|
onScroll={scrollHandler}
|
||||||
|
scrollEventThrottle={16}
|
||||||
refreshControl={
|
refreshControl={
|
||||||
<RefreshControl
|
<RefreshControl
|
||||||
refreshing={isLoading}
|
refreshing={isLoading}
|
||||||
onRefresh={fetchResponses}
|
onRefresh={() => fetchResponses(true)}
|
||||||
tintColor={colors.primary}
|
tintColor={colors.primary}
|
||||||
colors={[colors.primary]}
|
colors={[colors.primary]}
|
||||||
|
progressViewOffset={STATUS_BAR_HEIGHT + NAV_BAR_HEIGHT}
|
||||||
/>
|
/>
|
||||||
}
|
}
|
||||||
|
onEndReached={handleLoadMore}
|
||||||
|
onEndReachedThreshold={2}
|
||||||
|
ListFooterComponent={renderFooter}
|
||||||
|
ListHeaderComponent={
|
||||||
|
<Animated.View
|
||||||
|
entering={FadeIn.duration(400)}
|
||||||
|
style={[styles.header, isWideScreen && styles.headerWide, heroStyle]}
|
||||||
|
>
|
||||||
|
<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}>
|
||||||
|
{totalCount ?? responses.length} {(totalCount ?? 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>
|
||||||
|
}
|
||||||
ListEmptyComponent={
|
ListEmptyComponent={
|
||||||
<Animated.View
|
<Animated.View
|
||||||
entering={FadeInUp.duration(600).springify()}
|
entering={FadeInUp.duration(600).springify()}
|
||||||
@@ -251,29 +350,57 @@ const styles = StyleSheet.create({
|
|||||||
height: "100%",
|
height: "100%",
|
||||||
borderRadius: 150,
|
borderRadius: 150,
|
||||||
},
|
},
|
||||||
// Header
|
// Fixed Nav Bar
|
||||||
|
navBar: {
|
||||||
|
position: "absolute",
|
||||||
|
top: 0,
|
||||||
|
left: 0,
|
||||||
|
right: 0,
|
||||||
|
height: STATUS_BAR_HEIGHT + NAV_BAR_HEIGHT,
|
||||||
|
flexDirection: "row",
|
||||||
|
alignItems: "center",
|
||||||
|
justifyContent: "center",
|
||||||
|
paddingTop: STATUS_BAR_HEIGHT - 10,
|
||||||
|
paddingHorizontal: spacing.md,
|
||||||
|
zIndex: 100,
|
||||||
|
},
|
||||||
|
navBarBackground: {
|
||||||
|
...StyleSheet.absoluteFillObject,
|
||||||
|
borderBottomWidth: 1,
|
||||||
|
borderBottomColor: 'rgba(212, 175, 55, 0.15)', // Gold tint
|
||||||
|
overflow: "hidden",
|
||||||
|
},
|
||||||
|
navBarBackButton: {
|
||||||
|
position: "absolute",
|
||||||
|
top: STATUS_BAR_HEIGHT - 5,
|
||||||
|
left: spacing.md,
|
||||||
|
zIndex: 10,
|
||||||
|
width: 36,
|
||||||
|
height: 36,
|
||||||
|
borderRadius: 18,
|
||||||
|
backgroundColor: "rgba(255, 255, 255, 0.08)",
|
||||||
|
justifyContent: "center",
|
||||||
|
alignItems: "center",
|
||||||
|
borderWidth: 1,
|
||||||
|
borderColor: "rgba(255, 255, 255, 0.1)",
|
||||||
|
},
|
||||||
|
navBarTitle: {
|
||||||
|
...typography.headline,
|
||||||
|
color: colors.text,
|
||||||
|
textAlign: "center",
|
||||||
|
},
|
||||||
|
// Header (Hero)
|
||||||
header: {
|
header: {
|
||||||
paddingTop: 60,
|
paddingTop: STATUS_BAR_HEIGHT + spacing.md,
|
||||||
paddingHorizontal: spacing.lg,
|
paddingHorizontal: spacing.lg,
|
||||||
paddingBottom: spacing.md,
|
paddingBottom: spacing.md,
|
||||||
|
alignItems: 'center',
|
||||||
},
|
},
|
||||||
headerWide: {
|
headerWide: {
|
||||||
alignSelf: "center",
|
alignSelf: "center",
|
||||||
width: "100%",
|
width: "100%",
|
||||||
maxWidth: MAX_CONTENT_WIDTH,
|
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: {
|
headerContent: {
|
||||||
alignItems: "center",
|
alignItems: "center",
|
||||||
},
|
},
|
||||||
@@ -348,7 +475,7 @@ const styles = StyleSheet.create({
|
|||||||
// List
|
// List
|
||||||
list: {
|
list: {
|
||||||
padding: spacing.lg,
|
padding: spacing.lg,
|
||||||
paddingTop: spacing.sm,
|
paddingTop: 0, // Header handles top spacing
|
||||||
paddingBottom: Platform.OS === "ios" ? 120 : 100,
|
paddingBottom: Platform.OS === "ios" ? 120 : 100,
|
||||||
},
|
},
|
||||||
listWide: {
|
listWide: {
|
||||||
@@ -383,4 +510,9 @@ const styles = StyleSheet.create({
|
|||||||
textAlign: "center",
|
textAlign: "center",
|
||||||
paddingHorizontal: spacing.md,
|
paddingHorizontal: spacing.md,
|
||||||
},
|
},
|
||||||
|
footerLoader: {
|
||||||
|
paddingVertical: spacing.md,
|
||||||
|
alignItems: "center",
|
||||||
|
justifyContent: "center",
|
||||||
|
},
|
||||||
});
|
});
|
||||||
@@ -1,5 +1,5 @@
|
|||||||
import { useState, useEffect, useRef, useCallback } from "react";
|
import { useState, useEffect, useRef, useCallback } from "react";
|
||||||
import { View, StyleSheet, Text, ActivityIndicator, Platform } from "react-native";
|
import { View, StyleSheet, Text, ActivityIndicator, Platform, TouchableOpacity } from "react-native";
|
||||||
import { useLocalSearchParams, router, useFocusEffect } from "expo-router";
|
import { useLocalSearchParams, router, useFocusEffect } from "expo-router";
|
||||||
import { Ionicons } from "@expo/vector-icons";
|
import { Ionicons } from "@expo/vector-icons";
|
||||||
import Animated, {
|
import Animated, {
|
||||||
@@ -186,15 +186,6 @@ export default function SwipeScreen() {
|
|||||||
}
|
}
|
||||||
}, [enabledPackIds]);
|
}, [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 () => {
|
const fetchQuestions = async () => {
|
||||||
// Race protection: increment request ID and track current request
|
// Race protection: increment request ID and track current request
|
||||||
@@ -231,26 +222,10 @@ export default function SwipeScreen() {
|
|||||||
setGapInfo(null);
|
setGapInfo(null);
|
||||||
}
|
}
|
||||||
|
|
||||||
// First shuffle to randomize, then stable sort by priority scores
|
// Server provides ordering (daily seeded random with priority)
|
||||||
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;
|
|
||||||
else if (a.is_two_part) scoreA += 0.4;
|
|
||||||
|
|
||||||
if (b.partner_answered && b.is_two_part) scoreB += 1.5;
|
|
||||||
else if (b.partner_answered) scoreB += 0.7;
|
|
||||||
else if (b.is_two_part) scoreB += 0.4;
|
|
||||||
|
|
||||||
return scoreB - scoreA;
|
|
||||||
});
|
|
||||||
|
|
||||||
// Only update state if still the current request
|
// Only update state if still the current request
|
||||||
if (currentRequestId === fetchRequestId.current) {
|
if (currentRequestId === fetchRequestId.current) {
|
||||||
setQuestions(sorted);
|
setQuestions(filtered);
|
||||||
}
|
}
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
if (currentRequestId === fetchRequestId.current) {
|
if (currentRequestId === fetchRequestId.current) {
|
||||||
@@ -655,6 +630,14 @@ export default function SwipeScreen() {
|
|||||||
style={styles.hintContainer}
|
style={styles.hintContainer}
|
||||||
>
|
>
|
||||||
<Text style={styles.hintTextPremium}>Swipe or tap to answer</Text>
|
<Text style={styles.hintTextPremium}>Swipe or tap to answer</Text>
|
||||||
|
|
||||||
|
<TouchableOpacity
|
||||||
|
onPress={() => router.push({ pathname: "/my-answers", params: { returnTo: "/(app)/swipe" } })}
|
||||||
|
style={styles.editLinkButton}
|
||||||
|
hitSlop={{ top: 10, bottom: 10, left: 10, right: 10 }}
|
||||||
|
>
|
||||||
|
<Text style={styles.editLinkText}>Edit previous answers</Text>
|
||||||
|
</TouchableOpacity>
|
||||||
</Animated.View>
|
</Animated.View>
|
||||||
|
|
||||||
{/* Bottom spacing for tab bar */}
|
{/* Bottom spacing for tab bar */}
|
||||||
@@ -799,6 +782,18 @@ const styles = StyleSheet.create({
|
|||||||
color: colors.textTertiary,
|
color: colors.textTertiary,
|
||||||
letterSpacing: 0.5,
|
letterSpacing: 0.5,
|
||||||
},
|
},
|
||||||
|
editLinkButton: {
|
||||||
|
marginTop: spacing.md,
|
||||||
|
paddingVertical: spacing.xs,
|
||||||
|
paddingHorizontal: spacing.sm,
|
||||||
|
},
|
||||||
|
editLinkText: {
|
||||||
|
...typography.caption2,
|
||||||
|
color: colors.premium.gold,
|
||||||
|
textDecorationLine: 'underline',
|
||||||
|
opacity: 0.8,
|
||||||
|
letterSpacing: 0.5,
|
||||||
|
},
|
||||||
bottomSpacer: {
|
bottomSpacer: {
|
||||||
height: Platform.OS === 'ios' ? 100 : 80,
|
height: Platform.OS === 'ios' ? 100 : 80,
|
||||||
},
|
},
|
||||||
|
|||||||
@@ -10,6 +10,7 @@ function createThenableQuery(result: any) {
|
|||||||
is: jest.fn(() => query),
|
is: jest.fn(() => query),
|
||||||
in: jest.fn(() => query),
|
in: jest.fn(() => query),
|
||||||
order: jest.fn(() => query),
|
order: jest.fn(() => query),
|
||||||
|
range: jest.fn(() => query),
|
||||||
then: (resolve: any, reject: any) => Promise.resolve(result).then(resolve, reject),
|
then: (resolve: any, reject: any) => Promise.resolve(result).then(resolve, reject),
|
||||||
};
|
};
|
||||||
return query;
|
return query;
|
||||||
|
|||||||
@@ -1,8 +1,16 @@
|
|||||||
import React from 'react';
|
import React from 'react';
|
||||||
import { View, Text, StyleSheet, ScrollView, TouchableOpacity, useWindowDimensions, Platform, Alert } from 'react-native';
|
import { View, Text, StyleSheet, ScrollView, TouchableOpacity, useWindowDimensions, Platform, Alert } from 'react-native';
|
||||||
import { Ionicons } from '@expo/vector-icons';
|
import { Ionicons } from '@expo/vector-icons';
|
||||||
import Animated, { FadeInDown } from 'react-native-reanimated';
|
import Animated, {
|
||||||
|
FadeInDown,
|
||||||
|
useSharedValue,
|
||||||
|
useAnimatedScrollHandler,
|
||||||
|
useAnimatedStyle,
|
||||||
|
interpolate,
|
||||||
|
Extrapolation
|
||||||
|
} from 'react-native-reanimated';
|
||||||
import { LinearGradient } from 'expo-linear-gradient';
|
import { LinearGradient } from 'expo-linear-gradient';
|
||||||
|
import { BlurView } from 'expo-blur';
|
||||||
|
|
||||||
import { GradientBackground, GlassCard } from '../../components/ui';
|
import { GradientBackground, GlassCard } from '../../components/ui';
|
||||||
import { FeedbackModal } from '../../components/feedback';
|
import { FeedbackModal } from '../../components/feedback';
|
||||||
@@ -22,6 +30,11 @@ import { AppearanceSettings, CoupleStatus, NotificationSettings, PrivacySettings
|
|||||||
|
|
||||||
const MAX_CONTENT_WIDTH = 500;
|
const MAX_CONTENT_WIDTH = 500;
|
||||||
const ACCENT_GRADIENT = featureColors.profile.gradient as [string, string];
|
const ACCENT_GRADIENT = featureColors.profile.gradient as [string, string];
|
||||||
|
const NAV_BAR_HEIGHT = 44;
|
||||||
|
const STATUS_BAR_HEIGHT = 60;
|
||||||
|
const HEADER_SCROLL_DISTANCE = 100;
|
||||||
|
|
||||||
|
const AnimatedScrollView = Animated.createAnimatedComponent(ScrollView);
|
||||||
|
|
||||||
export function ProfileScreen() {
|
export function ProfileScreen() {
|
||||||
const { width } = useWindowDimensions();
|
const { width } = useWindowDimensions();
|
||||||
@@ -56,20 +69,81 @@ export function ProfileScreen() {
|
|||||||
});
|
});
|
||||||
};
|
};
|
||||||
|
|
||||||
|
// Scroll animation
|
||||||
|
const scrollY = useSharedValue(0);
|
||||||
|
const scrollHandler = useAnimatedScrollHandler({
|
||||||
|
onScroll: (event) => {
|
||||||
|
scrollY.value = event.contentOffset.y;
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
const heroStyle = useAnimatedStyle(() => {
|
||||||
|
const opacity = interpolate(
|
||||||
|
scrollY.value,
|
||||||
|
[0, HEADER_SCROLL_DISTANCE * 0.7],
|
||||||
|
[1, 0],
|
||||||
|
Extrapolation.CLAMP
|
||||||
|
);
|
||||||
|
const scale = interpolate(
|
||||||
|
scrollY.value,
|
||||||
|
[0, HEADER_SCROLL_DISTANCE],
|
||||||
|
[1, 0.95],
|
||||||
|
Extrapolation.CLAMP
|
||||||
|
);
|
||||||
|
return { opacity, transform: [{ scale }] };
|
||||||
|
});
|
||||||
|
|
||||||
|
const compactHeaderStyle = useAnimatedStyle(() => {
|
||||||
|
const opacity = interpolate(
|
||||||
|
scrollY.value,
|
||||||
|
[HEADER_SCROLL_DISTANCE * 0.5, HEADER_SCROLL_DISTANCE],
|
||||||
|
[0, 1],
|
||||||
|
Extrapolation.CLAMP
|
||||||
|
);
|
||||||
|
return { opacity };
|
||||||
|
});
|
||||||
|
|
||||||
|
const navBarBackgroundStyle = useAnimatedStyle(() => {
|
||||||
|
const opacity = interpolate(
|
||||||
|
scrollY.value,
|
||||||
|
[0, HEADER_SCROLL_DISTANCE * 0.8],
|
||||||
|
[0, 1],
|
||||||
|
Extrapolation.CLAMP
|
||||||
|
);
|
||||||
|
return { opacity };
|
||||||
|
});
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<GradientBackground>
|
<GradientBackground>
|
||||||
<ScrollView
|
{/* Fixed Nav Bar */}
|
||||||
|
<View style={styles.navBar}>
|
||||||
|
<Animated.View style={[styles.navBarBackground, navBarBackgroundStyle]}>
|
||||||
|
{Platform.OS === "ios" ? (
|
||||||
|
<BlurView intensity={80} tint="dark" style={StyleSheet.absoluteFill} />
|
||||||
|
) : (
|
||||||
|
<View style={[StyleSheet.absoluteFill, { backgroundColor: "rgba(13, 13, 26, 0.95)" }]} />
|
||||||
|
)}
|
||||||
|
</Animated.View>
|
||||||
|
|
||||||
|
<Animated.Text style={[styles.navBarTitle, compactHeaderStyle]} numberOfLines={1}>
|
||||||
|
Settings
|
||||||
|
</Animated.Text>
|
||||||
|
</View>
|
||||||
|
|
||||||
|
<AnimatedScrollView
|
||||||
style={styles.container}
|
style={styles.container}
|
||||||
contentContainerStyle={[
|
contentContainerStyle={[
|
||||||
styles.contentContainer,
|
styles.contentContainer,
|
||||||
isWideScreen && styles.contentContainerWide,
|
isWideScreen && styles.contentContainerWide,
|
||||||
]}
|
]}
|
||||||
showsVerticalScrollIndicator={false}
|
showsVerticalScrollIndicator={false}
|
||||||
|
onScroll={scrollHandler}
|
||||||
|
scrollEventThrottle={16}
|
||||||
>
|
>
|
||||||
{/* Header title */}
|
{/* Header title */}
|
||||||
<Animated.View
|
<Animated.View
|
||||||
entering={FadeInDown.delay(100).duration(500)}
|
entering={FadeInDown.delay(100).duration(500)}
|
||||||
style={styles.header}
|
style={[styles.header, heroStyle]}
|
||||||
>
|
>
|
||||||
<Text style={styles.headerLabel}>YOUR SAUCI</Text>
|
<Text style={styles.headerLabel}>YOUR SAUCI</Text>
|
||||||
<Text style={styles.title}>Settings</Text>
|
<Text style={styles.title}>Settings</Text>
|
||||||
@@ -305,7 +379,7 @@ export function ProfileScreen() {
|
|||||||
</Animated.View>
|
</Animated.View>
|
||||||
|
|
||||||
<View style={styles.bottomSpacer} />
|
<View style={styles.bottomSpacer} />
|
||||||
</ScrollView>
|
</AnimatedScrollView>
|
||||||
|
|
||||||
{/* Modals */}
|
{/* Modals */}
|
||||||
<FeedbackModal
|
<FeedbackModal
|
||||||
@@ -324,7 +398,33 @@ const styles = StyleSheet.create({
|
|||||||
container: {
|
container: {
|
||||||
flex: 1,
|
flex: 1,
|
||||||
},
|
},
|
||||||
|
// Fixed Nav Bar
|
||||||
|
navBar: {
|
||||||
|
position: "absolute",
|
||||||
|
top: 0,
|
||||||
|
left: 0,
|
||||||
|
right: 0,
|
||||||
|
height: STATUS_BAR_HEIGHT + NAV_BAR_HEIGHT,
|
||||||
|
flexDirection: "row",
|
||||||
|
alignItems: "center",
|
||||||
|
justifyContent: "center",
|
||||||
|
paddingTop: STATUS_BAR_HEIGHT - 10,
|
||||||
|
paddingHorizontal: spacing.md,
|
||||||
|
zIndex: 100,
|
||||||
|
},
|
||||||
|
navBarBackground: {
|
||||||
|
...StyleSheet.absoluteFillObject,
|
||||||
|
borderBottomWidth: 1,
|
||||||
|
borderBottomColor: 'rgba(155, 89, 182, 0.15)', // Purple tint for profile
|
||||||
|
overflow: "hidden",
|
||||||
|
},
|
||||||
|
navBarTitle: {
|
||||||
|
...typography.headline,
|
||||||
|
color: colors.text,
|
||||||
|
textAlign: "center",
|
||||||
|
},
|
||||||
contentContainer: {
|
contentContainer: {
|
||||||
|
// Remove top padding here as the header adds it
|
||||||
paddingBottom: Platform.OS === 'ios' ? 100 : 80,
|
paddingBottom: Platform.OS === 'ios' ? 100 : 80,
|
||||||
},
|
},
|
||||||
contentContainerWide: {
|
contentContainerWide: {
|
||||||
@@ -333,7 +433,7 @@ const styles = StyleSheet.create({
|
|||||||
maxWidth: MAX_CONTENT_WIDTH,
|
maxWidth: MAX_CONTENT_WIDTH,
|
||||||
},
|
},
|
||||||
header: {
|
header: {
|
||||||
paddingTop: 60,
|
paddingTop: STATUS_BAR_HEIGHT + spacing.md,
|
||||||
paddingHorizontal: spacing.lg,
|
paddingHorizontal: spacing.lg,
|
||||||
paddingBottom: spacing.md,
|
paddingBottom: spacing.md,
|
||||||
alignItems: 'center',
|
alignItems: 'center',
|
||||||
|
|||||||
@@ -3,12 +3,18 @@ import { supabase } from "../lib/supabase";
|
|||||||
import type { Match } from "@/types";
|
import type { Match } from "@/types";
|
||||||
import { useAuthStore } from "./authStore";
|
import { useAuthStore } from "./authStore";
|
||||||
|
|
||||||
|
const BATCH_SIZE = 20;
|
||||||
|
|
||||||
interface MatchState {
|
interface MatchState {
|
||||||
matches: Match[];
|
matches: Match[];
|
||||||
newMatchesCount: number;
|
newMatchesCount: number;
|
||||||
|
totalCount: number | null;
|
||||||
isLoading: boolean;
|
isLoading: boolean;
|
||||||
|
isLoadingMore: boolean;
|
||||||
|
page: number;
|
||||||
|
hasMore: boolean;
|
||||||
error: string | null;
|
error: string | null;
|
||||||
fetchMatches: () => Promise<void>;
|
fetchMatches: (refresh?: boolean) => Promise<void>;
|
||||||
markAsSeen: (matchId: string) => Promise<void>;
|
markAsSeen: (matchId: string) => Promise<void>;
|
||||||
markAllAsSeen: () => Promise<void>;
|
markAllAsSeen: () => Promise<void>;
|
||||||
addMatch: (match: Match) => void;
|
addMatch: (match: Match) => void;
|
||||||
@@ -19,22 +25,51 @@ interface MatchState {
|
|||||||
export const useMatchStore = create<MatchState>((set, get) => ({
|
export const useMatchStore = create<MatchState>((set, get) => ({
|
||||||
matches: [],
|
matches: [],
|
||||||
newMatchesCount: 0,
|
newMatchesCount: 0,
|
||||||
|
totalCount: null,
|
||||||
isLoading: false,
|
isLoading: false,
|
||||||
|
isLoadingMore: false,
|
||||||
|
page: 0,
|
||||||
|
hasMore: true,
|
||||||
error: null,
|
error: null,
|
||||||
|
|
||||||
fetchMatches: async () => {
|
fetchMatches: async (refresh = false) => {
|
||||||
const userId = useAuthStore.getState().user?.id;
|
const userId = useAuthStore.getState().user?.id;
|
||||||
const coupleId = useAuthStore.getState().user?.couple_id;
|
const coupleId = useAuthStore.getState().user?.couple_id;
|
||||||
|
|
||||||
// Early return if no couple - user isn't paired yet
|
// Early return if no couple - user isn't paired yet
|
||||||
if (!coupleId) {
|
if (!coupleId) {
|
||||||
set({ matches: [], newMatchesCount: 0, isLoading: false });
|
set({ matches: [], newMatchesCount: 0, totalCount: 0, isLoading: false });
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
set({ isLoading: true, error: null });
|
const state = get();
|
||||||
|
if (state.isLoading || (state.isLoadingMore && !refresh)) return;
|
||||||
|
|
||||||
|
if (refresh) {
|
||||||
|
set({ isLoading: true, error: null, page: 0, hasMore: true });
|
||||||
|
} else {
|
||||||
|
if (!state.hasMore) return;
|
||||||
|
set({ isLoadingMore: true });
|
||||||
|
}
|
||||||
|
|
||||||
try {
|
try {
|
||||||
|
const currentPage = refresh ? 0 : state.page;
|
||||||
|
const from = currentPage * BATCH_SIZE;
|
||||||
|
const to = from + BATCH_SIZE - 1;
|
||||||
|
|
||||||
|
// Fetch total count on refresh
|
||||||
|
let totalCount = state.totalCount;
|
||||||
|
if (refresh) {
|
||||||
|
const { count, error: countError } = await supabase
|
||||||
|
.from("matches")
|
||||||
|
.select("*", { count: "exact", head: true })
|
||||||
|
.eq("couple_id", coupleId);
|
||||||
|
|
||||||
|
if (!countError) {
|
||||||
|
totalCount = count ?? 0;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
const { data: matches, error: matchError } = await supabase
|
const { data: matches, error: matchError } = await supabase
|
||||||
.from("matches")
|
.from("matches")
|
||||||
.select(`
|
.select(`
|
||||||
@@ -42,11 +77,16 @@ export const useMatchStore = create<MatchState>((set, get) => ({
|
|||||||
question:questions(*)
|
question:questions(*)
|
||||||
`)
|
`)
|
||||||
.eq("couple_id", coupleId)
|
.eq("couple_id", coupleId)
|
||||||
.order("created_at", { ascending: false });
|
.order("created_at", { ascending: false })
|
||||||
|
.range(from, to);
|
||||||
|
|
||||||
if (matchError) throw matchError;
|
if (matchError) throw matchError;
|
||||||
if (!matches) {
|
if (!matches || matches.length === 0) {
|
||||||
set({ matches: [], newMatchesCount: 0, isLoading: false });
|
if (refresh) {
|
||||||
|
set({ matches: [], newMatchesCount: 0, totalCount: totalCount ?? 0, isLoading: false, hasMore: false });
|
||||||
|
} else {
|
||||||
|
set({ isLoadingMore: false, hasMore: false });
|
||||||
|
}
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -90,6 +130,10 @@ export const useMatchStore = create<MatchState>((set, get) => ({
|
|||||||
}));
|
}));
|
||||||
|
|
||||||
// Sort: unread messages first, then by most recent
|
// Sort: unread messages first, then by most recent
|
||||||
|
// Note: Since we fetch by created_at desc, and then sort by unread in memory,
|
||||||
|
// pagination might be slightly inconsistent if unread messages are scattered.
|
||||||
|
// However, implementing complex sort in Supabase with computed columns is harder.
|
||||||
|
// For now, we accept that the sort is applied within the fetched batch.
|
||||||
const sortedData = data.sort((a, b) => {
|
const sortedData = data.sort((a, b) => {
|
||||||
// First priority: matches with unread messages
|
// First priority: matches with unread messages
|
||||||
if (a.unreadCount > 0 && b.unreadCount === 0) return -1;
|
if (a.unreadCount > 0 && b.unreadCount === 0) return -1;
|
||||||
@@ -99,11 +143,22 @@ export const useMatchStore = create<MatchState>((set, get) => ({
|
|||||||
return new Date(b.created_at).getTime() - new Date(a.created_at).getTime();
|
return new Date(b.created_at).getTime() - new Date(a.created_at).getTime();
|
||||||
});
|
});
|
||||||
|
|
||||||
const newCount = sortedData.filter((m) => m.is_new).length;
|
// Calculate new matches count - this is tricky with pagination
|
||||||
set({ matches: sortedData, newMatchesCount: newCount, isLoading: false });
|
// Ideally we should have a separate query for count, but we'll just count in current batch
|
||||||
|
const newMatchesInBatch = sortedData.filter((m) => m.is_new).length;
|
||||||
|
|
||||||
|
set((state) => ({
|
||||||
|
matches: refresh ? sortedData : [...state.matches, ...sortedData],
|
||||||
|
newMatchesCount: refresh ? newMatchesInBatch : state.newMatchesCount + newMatchesInBatch,
|
||||||
|
totalCount: refresh ? totalCount : state.totalCount,
|
||||||
|
isLoading: false,
|
||||||
|
isLoadingMore: false,
|
||||||
|
page: currentPage + 1,
|
||||||
|
hasMore: matches.length === BATCH_SIZE
|
||||||
|
}));
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
console.error("Error fetching matches:", err);
|
console.error("Error fetching matches:", err);
|
||||||
set({ error: "Failed to load matches", isLoading: false });
|
set({ error: "Failed to load matches", isLoading: false, isLoadingMore: false });
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
|
||||||
@@ -140,6 +195,7 @@ export const useMatchStore = create<MatchState>((set, get) => ({
|
|||||||
set((state) => ({
|
set((state) => ({
|
||||||
matches: [match, ...state.matches],
|
matches: [match, ...state.matches],
|
||||||
newMatchesCount: state.newMatchesCount + 1,
|
newMatchesCount: state.newMatchesCount + 1,
|
||||||
|
totalCount: (state.totalCount ?? 0) + 1,
|
||||||
}));
|
}));
|
||||||
},
|
},
|
||||||
|
|
||||||
@@ -153,7 +209,7 @@ export const useMatchStore = create<MatchState>((set, get) => ({
|
|||||||
}));
|
}));
|
||||||
},
|
},
|
||||||
|
|
||||||
clearMatches: () => {
|
clearMatches: () => {
|
||||||
set({ matches: [], newMatchesCount: 0, isLoading: false, error: null });
|
set({ matches: [], newMatchesCount: 0, totalCount: null, isLoading: false, error: null, page: 0, hasMore: true, isLoadingMore: false });
|
||||||
},
|
},
|
||||||
}));
|
}));
|
||||||
@@ -33,11 +33,17 @@ export interface UpdateResponseResult {
|
|||||||
|
|
||||||
export type GroupByOption = "pack" | "date" | "answer";
|
export type GroupByOption = "pack" | "date" | "answer";
|
||||||
|
|
||||||
|
const BATCH_SIZE = 20;
|
||||||
|
|
||||||
interface ResponsesState {
|
interface ResponsesState {
|
||||||
responses: ResponseWithQuestion[];
|
responses: ResponseWithQuestion[];
|
||||||
isLoading: boolean;
|
isLoading: boolean;
|
||||||
|
isLoadingMore: boolean;
|
||||||
groupBy: GroupByOption;
|
groupBy: GroupByOption;
|
||||||
fetchResponses: () => Promise<void>;
|
hasMore: boolean;
|
||||||
|
page: number;
|
||||||
|
totalCount: number | null;
|
||||||
|
fetchResponses: (refresh?: boolean) => Promise<void>;
|
||||||
updateResponse: (
|
updateResponse: (
|
||||||
questionId: string,
|
questionId: string,
|
||||||
newAnswer: AnswerType,
|
newAnswer: AnswerType,
|
||||||
@@ -50,17 +56,46 @@ interface ResponsesState {
|
|||||||
export const useResponsesStore = create<ResponsesState>((set, get) => ({
|
export const useResponsesStore = create<ResponsesState>((set, get) => ({
|
||||||
responses: [],
|
responses: [],
|
||||||
isLoading: false,
|
isLoading: false,
|
||||||
|
isLoadingMore: false,
|
||||||
groupBy: "pack",
|
groupBy: "pack",
|
||||||
|
hasMore: true,
|
||||||
|
page: 0,
|
||||||
|
totalCount: null,
|
||||||
|
|
||||||
fetchResponses: async () => {
|
fetchResponses: async (refresh = false) => {
|
||||||
const userId = useAuthStore.getState().user?.id;
|
const userId = useAuthStore.getState().user?.id;
|
||||||
const coupleId = useAuthStore.getState().user?.couple_id;
|
const coupleId = useAuthStore.getState().user?.couple_id;
|
||||||
|
|
||||||
if (!userId || !coupleId) return;
|
if (!userId || !coupleId) return;
|
||||||
|
|
||||||
set({ isLoading: true });
|
const state = get();
|
||||||
|
if (state.isLoading || (state.isLoadingMore && !refresh)) return;
|
||||||
|
|
||||||
|
if (refresh) {
|
||||||
|
set({ isLoading: true, page: 0, hasMore: true });
|
||||||
|
} else {
|
||||||
|
if (!state.hasMore) return;
|
||||||
|
set({ isLoadingMore: true });
|
||||||
|
}
|
||||||
|
|
||||||
try {
|
try {
|
||||||
|
const currentPage = refresh ? 0 : state.page;
|
||||||
|
const from = currentPage * BATCH_SIZE;
|
||||||
|
const to = from + BATCH_SIZE - 1;
|
||||||
|
|
||||||
|
// Fetch total count on refresh
|
||||||
|
let totalCount = state.totalCount;
|
||||||
|
if (refresh) {
|
||||||
|
const { count, error: countError } = await supabase
|
||||||
|
.from("responses")
|
||||||
|
.select("*", { count: "exact", head: true })
|
||||||
|
.eq("user_id", userId);
|
||||||
|
|
||||||
|
if (!countError) {
|
||||||
|
totalCount = count ?? 0;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
// Fetch all user's responses with questions and packs
|
// Fetch all user's responses with questions and packs
|
||||||
const { data: responses, error: responsesError } = await supabase
|
const { data: responses, error: responsesError } = await supabase
|
||||||
.from("responses")
|
.from("responses")
|
||||||
@@ -79,16 +114,21 @@ export const useResponsesStore = create<ResponsesState>((set, get) => ({
|
|||||||
)
|
)
|
||||||
`)
|
`)
|
||||||
.eq("user_id", userId)
|
.eq("user_id", userId)
|
||||||
.order("created_at", { ascending: false });
|
.order("created_at", { ascending: false })
|
||||||
|
.range(from, to);
|
||||||
|
|
||||||
if (responsesError) {
|
if (responsesError) {
|
||||||
console.error("Error fetching responses:", responsesError);
|
console.error("Error fetching responses:", responsesError);
|
||||||
set({ isLoading: false });
|
set({ isLoading: false, isLoadingMore: false });
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
if (!responses) {
|
if (!responses || responses.length === 0) {
|
||||||
set({ responses: [], isLoading: false });
|
if (refresh) {
|
||||||
|
set({ responses: [], totalCount: totalCount ?? 0, isLoading: false, hasMore: false });
|
||||||
|
} else {
|
||||||
|
set({ isLoadingMore: false, hasMore: false });
|
||||||
|
}
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -155,10 +195,19 @@ export const useResponsesStore = create<ResponsesState>((set, get) => ({
|
|||||||
};
|
};
|
||||||
});
|
});
|
||||||
|
|
||||||
set({ responses: transformedResponses, isLoading: false });
|
set((state) => ({
|
||||||
|
responses: refresh
|
||||||
|
? transformedResponses
|
||||||
|
: [...state.responses, ...transformedResponses],
|
||||||
|
totalCount: refresh ? totalCount : state.totalCount,
|
||||||
|
isLoading: false,
|
||||||
|
isLoadingMore: false,
|
||||||
|
page: currentPage + 1,
|
||||||
|
hasMore: responses.length === BATCH_SIZE
|
||||||
|
}));
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error("Error in fetchResponses:", error);
|
console.error("Error in fetchResponses:", error);
|
||||||
set({ isLoading: false });
|
set({ isLoading: false, isLoadingMore: false });
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
|
||||||
@@ -221,8 +270,8 @@ export const useResponsesStore = create<ResponsesState>((set, get) => ({
|
|||||||
set({ groupBy });
|
set({ groupBy });
|
||||||
},
|
},
|
||||||
|
|
||||||
clearResponses: () => {
|
clearResponses: () => {
|
||||||
set({ responses: [], isLoading: false, groupBy: "pack" });
|
set({ responses: [], isLoading: false, groupBy: "pack", page: 0, hasMore: true, isLoadingMore: false, totalCount: null });
|
||||||
},
|
},
|
||||||
}));
|
}));
|
||||||
|
|
||||||
|
|||||||
@@ -39,6 +39,7 @@ jest.mock('@supabase/supabase-js', () => {
|
|||||||
is: jest.fn(),
|
is: jest.fn(),
|
||||||
in: jest.fn(),
|
in: jest.fn(),
|
||||||
order: jest.fn(),
|
order: jest.fn(),
|
||||||
|
range: jest.fn(),
|
||||||
update: jest.fn(),
|
update: jest.fn(),
|
||||||
upsert: jest.fn(),
|
upsert: jest.fn(),
|
||||||
insert: jest.fn(),
|
insert: jest.fn(),
|
||||||
|
|||||||
Reference in new issue
Block a user