mirror of
https://github.com/jjbrunton/Sauci.git
synced 2026-10-31 18:19:43 +00:00
Enhance swipe UI and add chat date separators
This commit is contained in:
1 parent
7e686c147a
commit
a567fba349
12 files changed
+507
-286
No files matched your search
@@ -11,8 +11,8 @@ import Animated, {
|
||||
FadeInRight,
|
||||
FadeInUp,
|
||||
} from "react-native-reanimated";
|
||||
import { GradientBackground, GlassCard, GlassButton, DecorativeSeparator } from "../../src/components/ui";
|
||||
import { useAmbientOrbAnimation } from "../../src/hooks";
|
||||
import { GradientBackground, GlassCard, GlassButton, DecorativeSeparator } from "../../src/components/ui";
|
||||
import { useAmbientOrbAnimation } from "../../src/hooks";
|
||||
import { colors, gradients, spacing, typography, radius, shadows } from "../../src/theme";
|
||||
import { MatchesTutorial } from "../../src/components/tutorials";
|
||||
import { hasSeenMatchesTutorial, markMatchesTutorialSeen } from "../../src/lib/matchesTutorialSeen";
|
||||
@@ -29,11 +29,11 @@ export default function MatchesScreen() {
|
||||
const { matches, fetchMatches, markAllAsSeen } = useMatchStore();
|
||||
const { user } = useAuthStore();
|
||||
const router = useRouter();
|
||||
const { width } = useWindowDimensions();
|
||||
const isWideScreen = width > MAX_CONTENT_WIDTH;
|
||||
const [showTutorial, setShowTutorial] = useState(false);
|
||||
|
||||
// Ambient orb breathing animations
|
||||
const { width } = useWindowDimensions();
|
||||
const isWideScreen = width > MAX_CONTENT_WIDTH;
|
||||
const [showTutorial, setShowTutorial] = useState(false);
|
||||
|
||||
// Ambient orb breathing animations
|
||||
const { orbStyle1, orbStyle2 } = useAmbientOrbAnimation();
|
||||
|
||||
// Check if tutorial should be shown when screen is focused or matches change
|
||||
@@ -101,15 +101,15 @@ export default function MatchesScreen() {
|
||||
start={{ x: 0, y: 0 }}
|
||||
end={{ x: 1, y: 1 }}
|
||||
/>
|
||||
{/* Top silk highlight */}
|
||||
<LinearGradient
|
||||
colors={[`${ACCENT_RGBA}0.06)`, 'transparent']}
|
||||
style={styles.cardSilkHighlight}
|
||||
start={{ x: 0.5, y: 0 }}
|
||||
end={{ x: 0.5, y: 1 }}
|
||||
/>
|
||||
|
||||
<View style={styles.matchRow}>
|
||||
{/* Top silk highlight */}
|
||||
<LinearGradient
|
||||
colors={[`${ACCENT_RGBA}0.06)`, 'transparent']}
|
||||
style={styles.cardSilkHighlight}
|
||||
start={{ x: 0.5, y: 0 }}
|
||||
end={{ x: 0.5, y: 1 }}
|
||||
/>
|
||||
|
||||
<View style={styles.matchRow}>
|
||||
{/* Premium icon container */}
|
||||
<View style={[
|
||||
styles.iconContainerPremium,
|
||||
@@ -295,13 +295,13 @@ export default function MatchesScreen() {
|
||||
/>
|
||||
</View>
|
||||
|
||||
{/* Matches Tutorial Overlay */}
|
||||
{showTutorial && (
|
||||
<MatchesTutorial onComplete={handleTutorialComplete} />
|
||||
)}
|
||||
</GradientBackground>
|
||||
);
|
||||
}
|
||||
{/* Matches Tutorial Overlay */}
|
||||
{showTutorial && (
|
||||
<MatchesTutorial onComplete={handleTutorialComplete} />
|
||||
)}
|
||||
</GradientBackground>
|
||||
);
|
||||
}
|
||||
|
||||
const styles = StyleSheet.create({
|
||||
container: {
|
||||
@@ -387,13 +387,13 @@ const styles = StyleSheet.create({
|
||||
maxWidth: MAX_CONTENT_WIDTH,
|
||||
},
|
||||
// Premium Match Card
|
||||
matchCardPremium: {
|
||||
marginBottom: spacing.sm,
|
||||
borderRadius: radius.lg,
|
||||
overflow: 'hidden',
|
||||
padding: spacing.md,
|
||||
},
|
||||
cardSilkHighlight: {
|
||||
matchCardPremium: {
|
||||
marginBottom: spacing.sm,
|
||||
borderRadius: radius.lg,
|
||||
overflow: 'hidden',
|
||||
padding: spacing.md,
|
||||
},
|
||||
cardSilkHighlight: {
|
||||
position: 'absolute',
|
||||
top: 0,
|
||||
left: 0,
|
||||
|
||||
@@ -20,20 +20,22 @@ 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 { 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 [isBlocked, setIsBlocked] = useState(false);
|
||||
const [gapInfo, setGapInfo] = useState<{ unanswered: number; threshold: number } | null>(null);
|
||||
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 { partner, couple } = useAuthStore();
|
||||
const hasTrackedExhausted = useRef(false);
|
||||
@@ -572,13 +574,17 @@ export default function SwipeScreen() {
|
||||
<View style={[styles.stackCard, styles.stackCardSecond]} />
|
||||
)}
|
||||
|
||||
{/* Active card */}
|
||||
<SwipeCard
|
||||
key={questions[currentIndex].id}
|
||||
question={questions[currentIndex]}
|
||||
onSwipe={handleSwipe}
|
||||
/>
|
||||
</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
|
||||
@@ -592,13 +598,21 @@ export default function SwipeScreen() {
|
||||
<View style={styles.bottomSpacer} />
|
||||
</View>
|
||||
|
||||
{/* Tutorial Overlay */}
|
||||
{showTutorial && (
|
||||
<SwipeTutorial onComplete={handleTutorialComplete} />
|
||||
)}
|
||||
</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: {
|
||||
|
||||
@@ -781,7 +781,7 @@ const styles = StyleSheet.create({
|
||||
color: colors.primary,
|
||||
},
|
||||
forgotPassword: {
|
||||
alignSelf: "flex-end",
|
||||
alignSelf: "center",
|
||||
marginTop: -spacing.xs,
|
||||
marginBottom: spacing.xs,
|
||||
},
|
||||
|
||||
@@ -505,7 +505,10 @@
|
||||
LIBRARY_SEARCH_PATHS = "$(SDKROOT)/usr/lib/swift\"$(inherited)\"";
|
||||
MTL_ENABLE_DEBUG_INFO = YES;
|
||||
ONLY_ACTIVE_ARCH = YES;
|
||||
OTHER_LDFLAGS = "$(inherited) ";
|
||||
OTHER_LDFLAGS = (
|
||||
"$(inherited)",
|
||||
" ",
|
||||
);
|
||||
REACT_NATIVE_PATH = "${PODS_ROOT}/../../../../node_modules/react-native";
|
||||
SDKROOT = iphoneos;
|
||||
SWIFT_ACTIVE_COMPILATION_CONDITIONS = "$(inherited) DEBUG";
|
||||
@@ -560,7 +563,10 @@
|
||||
);
|
||||
LIBRARY_SEARCH_PATHS = "$(SDKROOT)/usr/lib/swift\"$(inherited)\"";
|
||||
MTL_ENABLE_DEBUG_INFO = NO;
|
||||
OTHER_LDFLAGS = "$(inherited) ";
|
||||
OTHER_LDFLAGS = (
|
||||
"$(inherited)",
|
||||
" ",
|
||||
);
|
||||
REACT_NATIVE_PATH = "${PODS_ROOT}/../../../../node_modules/react-native";
|
||||
SDKROOT = iphoneos;
|
||||
USE_HERMES = true;
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import { useState, useEffect } from "react";
|
||||
import { View, Text, StyleSheet, Platform, useWindowDimensions } from "react-native";
|
||||
import { View, Text, StyleSheet, Platform, useWindowDimensions, TouchableOpacity as RNTouchableOpacity } from "react-native";
|
||||
import { Gesture, GestureDetector, TouchableOpacity } from "react-native-gesture-handler";
|
||||
import { BlurView } from "expo-blur";
|
||||
import { LinearGradient } from "expo-linear-gradient";
|
||||
@@ -36,7 +36,7 @@ const MAX_CARD_WIDTH = 400;
|
||||
const SWIPE_THRESHOLD = 100;
|
||||
|
||||
interface Props {
|
||||
question: { id: string; text: string; intensity: number; partner_text?: string | null; is_two_part?: boolean };
|
||||
question: { id: string; text: string; intensity: number; partner_text?: string | null; is_two_part?: boolean; partner_answered?: boolean };
|
||||
onSwipe: (direction: "left" | "right" | "up" | "down") => void;
|
||||
}
|
||||
|
||||
@@ -305,14 +305,18 @@ function CardContent({
|
||||
/>
|
||||
|
||||
{/* Feedback Button */}
|
||||
<View style={styles.feedbackButton}>
|
||||
<RNTouchableOpacity
|
||||
style={styles.feedbackButton}
|
||||
onPress={onFeedbackPress}
|
||||
hitSlop={{ top: 20, bottom: 20, left: 20, right: 20 }}
|
||||
activeOpacity={0.7}
|
||||
>
|
||||
<Ionicons
|
||||
name="flag-outline"
|
||||
size={18}
|
||||
size={22}
|
||||
color={colors.textTertiary}
|
||||
onPress={onFeedbackPress}
|
||||
/>
|
||||
</View>
|
||||
</RNTouchableOpacity>
|
||||
|
||||
{/* Overlays with gradient badges */}
|
||||
<Animated.View style={[styles.overlay, overlayStyle("right")]} pointerEvents="none">
|
||||
@@ -368,6 +372,21 @@ function CardContent({
|
||||
<Text style={[styles.text, question.is_two_part && styles.twoPartText]}>
|
||||
{question.text}
|
||||
</Text>
|
||||
|
||||
{/* Partner's version of the question - only show when user is answering first */}
|
||||
{question.partner_text && !question.partner_answered && (
|
||||
<View style={styles.partnerTextContainer}>
|
||||
<View style={styles.partnerDivider}>
|
||||
<View style={styles.partnerDividerLine} />
|
||||
<View style={styles.partnerLabelContainer}>
|
||||
<Ionicons name="swap-horizontal" size={12} color={colors.secondary} />
|
||||
<Text style={styles.partnerLabel}>Partner sees</Text>
|
||||
</View>
|
||||
<View style={styles.partnerDividerLine} />
|
||||
</View>
|
||||
<Text style={styles.partnerText}>{question.partner_text}</Text>
|
||||
</View>
|
||||
)}
|
||||
</View>
|
||||
|
||||
{/* Action Buttons */}
|
||||
@@ -524,14 +543,14 @@ const styles = StyleSheet.create({
|
||||
position: 'absolute',
|
||||
top: spacing.md,
|
||||
right: spacing.md,
|
||||
width: 36,
|
||||
height: 36,
|
||||
borderRadius: 18,
|
||||
width: 44,
|
||||
height: 44,
|
||||
borderRadius: 22,
|
||||
backgroundColor: colors.glass.background,
|
||||
justifyContent: 'center',
|
||||
alignItems: 'center',
|
||||
zIndex: 100,
|
||||
elevation: 10,
|
||||
zIndex: 999,
|
||||
elevation: 999,
|
||||
borderWidth: 1,
|
||||
borderColor: colors.glass.border,
|
||||
},
|
||||
@@ -565,6 +584,42 @@ const styles = StyleSheet.create({
|
||||
...typography.title3,
|
||||
lineHeight: 28,
|
||||
},
|
||||
partnerTextContainer: {
|
||||
marginTop: spacing.lg,
|
||||
width: '100%',
|
||||
paddingHorizontal: spacing.sm,
|
||||
},
|
||||
partnerDivider: {
|
||||
flexDirection: 'row',
|
||||
alignItems: 'center',
|
||||
marginBottom: spacing.sm,
|
||||
},
|
||||
partnerDividerLine: {
|
||||
flex: 1,
|
||||
height: 1,
|
||||
backgroundColor: colors.glass.border,
|
||||
},
|
||||
partnerLabelContainer: {
|
||||
flexDirection: 'row',
|
||||
alignItems: 'center',
|
||||
paddingHorizontal: spacing.md,
|
||||
paddingVertical: spacing.xs,
|
||||
backgroundColor: colors.secondaryLight,
|
||||
borderRadius: radius.full,
|
||||
gap: spacing.xs,
|
||||
},
|
||||
partnerLabel: {
|
||||
...typography.caption1,
|
||||
color: colors.secondary,
|
||||
fontWeight: '500',
|
||||
letterSpacing: 0.3,
|
||||
},
|
||||
partnerText: {
|
||||
...typography.subhead,
|
||||
color: colors.textSecondary,
|
||||
textAlign: 'center',
|
||||
fontStyle: 'italic',
|
||||
},
|
||||
footer: {
|
||||
paddingBottom: spacing.lg,
|
||||
paddingHorizontal: spacing.lg,
|
||||
|
||||
@@ -7,7 +7,7 @@ const MAX_CARD_WIDTH = 400;
|
||||
const SWIPE_THRESHOLD = 100;
|
||||
|
||||
interface Props {
|
||||
question: { id: string; text: string; intensity: number; partner_text?: string | null };
|
||||
question: { id: string; text: string; intensity: number; partner_text?: string | null; partner_answered?: boolean };
|
||||
onSwipe: (direction: "left" | "right" | "up" | "down") => void;
|
||||
}
|
||||
|
||||
@@ -208,6 +208,21 @@ export default function SwipeCard({ question, onSwipe }: Props) {
|
||||
))}
|
||||
</View>
|
||||
<Text style={[styles.text, question.partner_text ? styles.twoPartText : null]}>{question.text}</Text>
|
||||
|
||||
{/* Partner's version of the question - only show when user is answering first */}
|
||||
{question.partner_text && !question.partner_answered && (
|
||||
<View style={styles.partnerTextContainer}>
|
||||
<View style={styles.partnerDivider}>
|
||||
<View style={styles.partnerDividerLine} />
|
||||
<View style={styles.partnerLabelContainer}>
|
||||
<Ionicons name="swap-horizontal" size={12} color="#9b59b6" />
|
||||
<Text style={styles.partnerLabel}>Partner sees</Text>
|
||||
</View>
|
||||
<View style={styles.partnerDividerLine} />
|
||||
</View>
|
||||
<Text style={styles.partnerText}>{question.partner_text}</Text>
|
||||
</View>
|
||||
)}
|
||||
</View>
|
||||
|
||||
<View style={styles.footer}>
|
||||
@@ -369,26 +384,41 @@ const styles = StyleSheet.create({
|
||||
paddingVertical: 8,
|
||||
borderRadius: 12,
|
||||
},
|
||||
partnerContainer: {
|
||||
partnerTextContainer: {
|
||||
marginTop: 24,
|
||||
width: "100%",
|
||||
alignItems: "center",
|
||||
paddingHorizontal: 8,
|
||||
},
|
||||
divider: {
|
||||
partnerDivider: {
|
||||
flexDirection: "row",
|
||||
alignItems: "center",
|
||||
marginBottom: 8,
|
||||
},
|
||||
partnerDividerLine: {
|
||||
flex: 1,
|
||||
height: 1,
|
||||
width: "80%",
|
||||
backgroundColor: "rgba(255,255,255,0.1)",
|
||||
marginVertical: 24,
|
||||
position: "relative",
|
||||
justifyContent: "center",
|
||||
alignItems: "center",
|
||||
backgroundColor: "rgba(255, 255, 255, 0.08)",
|
||||
},
|
||||
dividerText: {
|
||||
color: "#666",
|
||||
backgroundColor: "#16213e",
|
||||
paddingHorizontal: 10,
|
||||
partnerLabelContainer: {
|
||||
flexDirection: "row",
|
||||
alignItems: "center",
|
||||
paddingHorizontal: 16,
|
||||
paddingVertical: 4,
|
||||
backgroundColor: "rgba(155, 89, 182, 0.15)",
|
||||
borderRadius: 9999,
|
||||
gap: 4,
|
||||
},
|
||||
partnerLabel: {
|
||||
fontSize: 12,
|
||||
fontWeight: "bold",
|
||||
position: "absolute",
|
||||
color: "#9b59b6",
|
||||
fontWeight: "500",
|
||||
letterSpacing: 0.3,
|
||||
},
|
||||
partnerText: {
|
||||
fontSize: 14,
|
||||
color: "rgba(255, 255, 255, 0.7)",
|
||||
textAlign: "center",
|
||||
fontStyle: "italic",
|
||||
},
|
||||
buttonContainer: {
|
||||
flexDirection: "row",
|
||||
|
||||
@@ -53,11 +53,12 @@ const MAX_CARD_WIDTH = 400;
|
||||
const SWIPE_THRESHOLD = 100;
|
||||
|
||||
interface Props {
|
||||
question: { id: string; text: string; intensity: number; partner_text?: string | null; is_two_part?: boolean };
|
||||
question: { id: string; text: string; intensity: number; partner_text?: string | null; is_two_part?: boolean; partner_answered?: boolean };
|
||||
onSwipe: (direction: "left" | "right" | "up" | "down") => void;
|
||||
onReport?: () => void;
|
||||
}
|
||||
|
||||
export default function SwipeCardPremium({ question, onSwipe }: Props) {
|
||||
export default function SwipeCardPremium({ question, onSwipe, onReport }: Props) {
|
||||
const { width: screenWidth } = useWindowDimensions();
|
||||
const cardWidth = Math.min(screenWidth - 48, MAX_CARD_WIDTH);
|
||||
|
||||
@@ -173,6 +174,17 @@ export default function SwipeCardPremium({ question, onSwipe }: Props) {
|
||||
|
||||
return (
|
||||
<View style={[styles.cardWrapper, { width: cardWidth }]}>
|
||||
{/* Report Button - outside GestureDetector to receive touches */}
|
||||
{onReport && (
|
||||
<Pressable
|
||||
style={styles.reportButton}
|
||||
onPress={onReport}
|
||||
hitSlop={{ top: 10, bottom: 10, left: 10, right: 10 }}
|
||||
>
|
||||
<Ionicons name="flag-outline" size={16} color={colors.textTertiary} />
|
||||
</Pressable>
|
||||
)}
|
||||
|
||||
<GestureDetector gesture={gesture}>
|
||||
<Animated.View style={[styles.cardOuter, animatedStyle, cardShadowStyle, { shadowColor: ACCENT }]}>
|
||||
{/* Premium dark background */}
|
||||
@@ -244,6 +256,21 @@ export default function SwipeCardPremium({ question, onSwipe }: Props) {
|
||||
{question.text}
|
||||
</Text>
|
||||
|
||||
{/* Partner's version of the question - only show when user is answering first */}
|
||||
{question.partner_text && !question.partner_answered && (
|
||||
<View style={styles.partnerTextContainer}>
|
||||
<View style={styles.partnerDivider}>
|
||||
<View style={styles.partnerDividerLine} />
|
||||
<View style={styles.partnerLabelBadge}>
|
||||
<Ionicons name="swap-horizontal" size={10} color={ROSE} />
|
||||
<Text style={styles.partnerLabelText}>PARTNER SEES</Text>
|
||||
</View>
|
||||
<View style={styles.partnerDividerLine} />
|
||||
</View>
|
||||
<Text style={styles.partnerQuestionText}>{question.partner_text}</Text>
|
||||
</View>
|
||||
)}
|
||||
|
||||
{/* Bottom separator */}
|
||||
<View style={styles.separator}>
|
||||
<View style={styles.separatorLine} />
|
||||
@@ -366,6 +393,14 @@ const styles = StyleSheet.create({
|
||||
right: 0,
|
||||
height: 120,
|
||||
},
|
||||
reportButton: {
|
||||
position: 'absolute',
|
||||
top: spacing.md,
|
||||
right: spacing.md,
|
||||
zIndex: 100,
|
||||
padding: spacing.sm,
|
||||
opacity: 0.6,
|
||||
},
|
||||
premiumBorder: {
|
||||
position: 'absolute',
|
||||
top: 0,
|
||||
@@ -430,6 +465,45 @@ const styles = StyleSheet.create({
|
||||
lineHeight: 32,
|
||||
paddingHorizontal: spacing.sm,
|
||||
},
|
||||
partnerTextContainer: {
|
||||
marginTop: spacing.lg,
|
||||
width: '100%',
|
||||
paddingHorizontal: spacing.sm,
|
||||
},
|
||||
partnerDivider: {
|
||||
flexDirection: 'row',
|
||||
alignItems: 'center',
|
||||
marginBottom: spacing.sm,
|
||||
},
|
||||
partnerDividerLine: {
|
||||
flex: 1,
|
||||
height: 1,
|
||||
backgroundColor: rgba.rose(0.2),
|
||||
},
|
||||
partnerLabelBadge: {
|
||||
flexDirection: 'row',
|
||||
alignItems: 'center',
|
||||
paddingHorizontal: spacing.md,
|
||||
paddingVertical: spacing.xs,
|
||||
backgroundColor: rgba.rose(0.1),
|
||||
borderRadius: radius.full,
|
||||
borderWidth: 1,
|
||||
borderColor: rgba.rose(0.2),
|
||||
gap: spacing.xs,
|
||||
},
|
||||
partnerLabelText: {
|
||||
...typography.caption2,
|
||||
fontWeight: '600',
|
||||
letterSpacing: 1.5,
|
||||
color: rgba.rose(0.8),
|
||||
},
|
||||
partnerQuestionText: {
|
||||
...typography.subhead,
|
||||
color: colors.textSecondary,
|
||||
textAlign: 'center',
|
||||
fontStyle: 'italic',
|
||||
opacity: 0.85,
|
||||
},
|
||||
footer: {
|
||||
paddingBottom: spacing.xl,
|
||||
paddingHorizontal: spacing.lg,
|
||||
|
||||
@@ -78,24 +78,24 @@ const STEPS: TutorialStep[] = [
|
||||
icon: "arrow-down",
|
||||
color: colors.textTertiary,
|
||||
gradient: ["#6c757d", "#495057"],
|
||||
translateX: 0,
|
||||
translateY: 80,
|
||||
isSwipe: true,
|
||||
},
|
||||
{
|
||||
key: "feedback",
|
||||
label: "FEEDBACK",
|
||||
description: "Tap the flag to report issues with a question",
|
||||
icon: "flag-outline",
|
||||
color: colors.primary,
|
||||
gradient: gradients.primary as [string, string],
|
||||
translateX: 0,
|
||||
translateY: 0,
|
||||
isSwipe: false,
|
||||
},
|
||||
];
|
||||
|
||||
export default function SwipeTutorial({ onComplete }: Props) {
|
||||
translateX: 0,
|
||||
translateY: 80,
|
||||
isSwipe: true,
|
||||
},
|
||||
{
|
||||
key: "feedback",
|
||||
label: "FEEDBACK",
|
||||
description: "Tap the flag to report issues with a question",
|
||||
icon: "flag-outline",
|
||||
color: colors.primary,
|
||||
gradient: gradients.primary as [string, string],
|
||||
translateX: 0,
|
||||
translateY: 0,
|
||||
isSwipe: false,
|
||||
},
|
||||
];
|
||||
|
||||
export default function SwipeTutorial({ onComplete }: Props) {
|
||||
const { width: screenWidth, height: screenHeight } = useWindowDimensions();
|
||||
const insets = useSafeAreaInsets();
|
||||
const [currentStep, setCurrentStep] = useState(0);
|
||||
@@ -330,27 +330,27 @@ export default function SwipeTutorial({ onComplete }: Props) {
|
||||
</View>
|
||||
)}
|
||||
|
||||
{/* Demo Card */}
|
||||
<Animated.View style={[styles.demoCard, cardAnimatedStyle, shadows.xl]}>
|
||||
<LinearGradient
|
||||
colors={[colors.glass.backgroundLight, colors.glass.background]}
|
||||
style={styles.demoCardInner}
|
||||
>
|
||||
{/* Feedback button indicator */}
|
||||
<View style={[
|
||||
styles.feedbackButtonDemo,
|
||||
!isSwipeStep && styles.feedbackButtonHighlight
|
||||
]}>
|
||||
{!isSwipeStep && (
|
||||
<Animated.View style={[styles.feedbackPulse, pulseAnimatedStyle]}>
|
||||
<View style={[styles.pulseInner, { backgroundColor: currentStep_.color }]} />
|
||||
</Animated.View>
|
||||
)}
|
||||
<Ionicons name="flag-outline" size={18} color={!isSwipeStep ? colors.text : colors.textTertiary} />
|
||||
</View>
|
||||
|
||||
{/* Overlay - only for swipe steps */}
|
||||
{isSwipeStep && (
|
||||
{/* Demo Card */}
|
||||
<Animated.View style={[styles.demoCard, cardAnimatedStyle, shadows.xl]}>
|
||||
<LinearGradient
|
||||
colors={[colors.glass.backgroundLight, colors.glass.background]}
|
||||
style={styles.demoCardInner}
|
||||
>
|
||||
{/* Feedback button indicator */}
|
||||
<View style={[
|
||||
styles.feedbackButtonDemo,
|
||||
!isSwipeStep && styles.feedbackButtonHighlight
|
||||
]}>
|
||||
{!isSwipeStep && (
|
||||
<Animated.View style={[styles.feedbackPulse, pulseAnimatedStyle]}>
|
||||
<View style={[styles.pulseInner, { backgroundColor: currentStep_.color }]} />
|
||||
</Animated.View>
|
||||
)}
|
||||
<Ionicons name="flag-outline" size={18} color={!isSwipeStep ? colors.text : colors.textTertiary} />
|
||||
</View>
|
||||
|
||||
{/* Overlay - only for swipe steps */}
|
||||
{isSwipeStep && (
|
||||
<Animated.View
|
||||
style={[
|
||||
styles.demoOverlay,
|
||||
@@ -552,40 +552,40 @@ const styles = StyleSheet.create({
|
||||
borderRadius: radius.xl,
|
||||
overflow: "visible",
|
||||
},
|
||||
demoCardInner: {
|
||||
flex: 1,
|
||||
borderRadius: radius.xl,
|
||||
borderWidth: 1,
|
||||
borderColor: colors.glass.border,
|
||||
overflow: "hidden",
|
||||
},
|
||||
feedbackButtonDemo: {
|
||||
position: 'absolute',
|
||||
top: spacing.md,
|
||||
right: spacing.md,
|
||||
width: 36,
|
||||
height: 36,
|
||||
borderRadius: 18,
|
||||
backgroundColor: colors.glass.background,
|
||||
justifyContent: 'center',
|
||||
alignItems: 'center',
|
||||
zIndex: 100,
|
||||
borderWidth: 1,
|
||||
borderColor: colors.glass.border,
|
||||
},
|
||||
feedbackButtonHighlight: {
|
||||
backgroundColor: colors.primary,
|
||||
borderColor: colors.primary,
|
||||
},
|
||||
feedbackPulse: {
|
||||
position: "absolute",
|
||||
width: 56,
|
||||
height: 56,
|
||||
borderRadius: 28,
|
||||
justifyContent: "center",
|
||||
alignItems: "center",
|
||||
},
|
||||
demoOverlay: {
|
||||
demoCardInner: {
|
||||
flex: 1,
|
||||
borderRadius: radius.xl,
|
||||
borderWidth: 1,
|
||||
borderColor: colors.glass.border,
|
||||
overflow: "hidden",
|
||||
},
|
||||
feedbackButtonDemo: {
|
||||
position: 'absolute',
|
||||
top: spacing.md,
|
||||
right: spacing.md,
|
||||
width: 36,
|
||||
height: 36,
|
||||
borderRadius: 18,
|
||||
backgroundColor: colors.glass.background,
|
||||
justifyContent: 'center',
|
||||
alignItems: 'center',
|
||||
zIndex: 100,
|
||||
borderWidth: 1,
|
||||
borderColor: colors.glass.border,
|
||||
},
|
||||
feedbackButtonHighlight: {
|
||||
backgroundColor: colors.primary,
|
||||
borderColor: colors.primary,
|
||||
},
|
||||
feedbackPulse: {
|
||||
position: "absolute",
|
||||
width: 56,
|
||||
height: 56,
|
||||
borderRadius: 28,
|
||||
justifyContent: "center",
|
||||
alignItems: "center",
|
||||
},
|
||||
demoOverlay: {
|
||||
...StyleSheet.absoluteFillObject,
|
||||
justifyContent: "center",
|
||||
alignItems: "center",
|
||||
|
||||
@@ -282,7 +282,7 @@ export const ChatScreen: React.FC = () => {
|
||||
user={user}
|
||||
match={match}
|
||||
insets={insets}
|
||||
onBack={() => router.back()}
|
||||
onBack={() => router.push('/(app)/matches')}
|
||||
/>
|
||||
|
||||
<ChatMessages
|
||||
|
||||
@@ -5,6 +5,7 @@ import Animated, { FadeIn } from 'react-native-reanimated';
|
||||
|
||||
import { MessageBubble } from './MessageBubble';
|
||||
import { MessageContent } from './MessageContent';
|
||||
import { DateSeparator } from './DateSeparator';
|
||||
import { UploadProgress } from './UploadProgress';
|
||||
import { TypingIndicator } from './TypingIndicator';
|
||||
import { DecorativeSeparator } from '../../../components/ui';
|
||||
@@ -29,6 +30,17 @@ interface ChatMessagesProps {
|
||||
const ACCENT = colors.premium.gold;
|
||||
const ACCENT_RGBA = 'rgba(212, 175, 55, ';
|
||||
|
||||
/**
|
||||
* Check if two dates are on the same calendar day
|
||||
*/
|
||||
function isSameDay(date1: Date, date2: Date): boolean {
|
||||
return (
|
||||
date1.getFullYear() === date2.getFullYear() &&
|
||||
date1.getMonth() === date2.getMonth() &&
|
||||
date1.getDate() === date2.getDate()
|
||||
);
|
||||
}
|
||||
|
||||
export const ChatMessages: React.FC<ChatMessagesProps> = ({
|
||||
messages,
|
||||
userId,
|
||||
@@ -43,22 +55,35 @@ export const ChatMessages: React.FC<ChatMessagesProps> = ({
|
||||
|
||||
const renderMessage = ({ item, index }: { item: Message; index: number }) => {
|
||||
const isMe = item.user_id === userId;
|
||||
const messageDate = new Date(item.created_at!);
|
||||
|
||||
// Determine if we should show a date separator above this message
|
||||
// In an inverted list, index 0 is newest (bottom), higher indices are older (top)
|
||||
// Show separator when this message's date differs from the next (older) message
|
||||
const nextMessage = messages[index + 1];
|
||||
const showDateSeparator = !nextMessage || (
|
||||
nextMessage.created_at &&
|
||||
!isSameDay(messageDate, new Date(nextMessage.created_at))
|
||||
);
|
||||
|
||||
return (
|
||||
<MessageBubble
|
||||
isMe={isMe}
|
||||
index={index}
|
||||
onLongPress={() => onMessageLongPress(item, isMe)}
|
||||
>
|
||||
<MessageContent
|
||||
item={item}
|
||||
<>
|
||||
<MessageBubble
|
||||
isMe={isMe}
|
||||
currentUserId={userId || ''}
|
||||
revealMessage={revealMessage}
|
||||
onImagePress={onImagePress}
|
||||
onVideoFullScreen={onVideoFullScreen}
|
||||
index={index}
|
||||
onLongPress={() => onMessageLongPress(item, isMe)}
|
||||
/>
|
||||
</MessageBubble>
|
||||
>
|
||||
<MessageContent
|
||||
item={item}
|
||||
isMe={isMe}
|
||||
currentUserId={userId || ''}
|
||||
revealMessage={revealMessage}
|
||||
onImagePress={onImagePress}
|
||||
onVideoFullScreen={onVideoFullScreen}
|
||||
/>
|
||||
</MessageBubble>
|
||||
{showDateSeparator && <DateSeparator date={messageDate} />}
|
||||
</>
|
||||
);
|
||||
};
|
||||
|
||||
|
||||
@@ -0,0 +1,84 @@
|
||||
import React from 'react';
|
||||
import { View, Text, StyleSheet } from 'react-native';
|
||||
import { colors, spacing, typography, radius } from '../../../theme';
|
||||
|
||||
interface DateSeparatorProps {
|
||||
date: Date;
|
||||
}
|
||||
|
||||
/**
|
||||
* Formats a date for display in chat, similar to WhatsApp/Signal:
|
||||
* - "Today" for today's date
|
||||
* - "Yesterday" for yesterday
|
||||
* - "Monday" through "Saturday" for the past week
|
||||
* - "Jan 3, 2024" for older dates
|
||||
*/
|
||||
function formatDateLabel(date: Date): string {
|
||||
const now = new Date();
|
||||
const today = new Date(now.getFullYear(), now.getMonth(), now.getDate());
|
||||
const messageDate = new Date(date.getFullYear(), date.getMonth(), date.getDate());
|
||||
|
||||
const diffTime = today.getTime() - messageDate.getTime();
|
||||
const diffDays = Math.floor(diffTime / (1000 * 60 * 60 * 24));
|
||||
|
||||
if (diffDays === 0) {
|
||||
return 'Today';
|
||||
}
|
||||
|
||||
if (diffDays === 1) {
|
||||
return 'Yesterday';
|
||||
}
|
||||
|
||||
// Within the last week, show day name
|
||||
if (diffDays < 7) {
|
||||
return date.toLocaleDateString(undefined, { weekday: 'long' });
|
||||
}
|
||||
|
||||
// Same year - show "Jan 3"
|
||||
if (date.getFullYear() === now.getFullYear()) {
|
||||
return date.toLocaleDateString(undefined, {
|
||||
month: 'short',
|
||||
day: 'numeric'
|
||||
});
|
||||
}
|
||||
|
||||
// Different year - show "Jan 3, 2024"
|
||||
return date.toLocaleDateString(undefined, {
|
||||
month: 'short',
|
||||
day: 'numeric',
|
||||
year: 'numeric'
|
||||
});
|
||||
}
|
||||
|
||||
export const DateSeparator: React.FC<DateSeparatorProps> = ({ date }) => {
|
||||
const label = formatDateLabel(date);
|
||||
|
||||
return (
|
||||
<View style={styles.container}>
|
||||
<View style={styles.pill}>
|
||||
<Text style={styles.text}>{label}</Text>
|
||||
</View>
|
||||
</View>
|
||||
);
|
||||
};
|
||||
|
||||
const styles = StyleSheet.create({
|
||||
container: {
|
||||
alignItems: 'center',
|
||||
justifyContent: 'center',
|
||||
paddingVertical: spacing.md,
|
||||
},
|
||||
pill: {
|
||||
backgroundColor: colors.glass.background,
|
||||
borderRadius: radius.full,
|
||||
paddingHorizontal: spacing.md,
|
||||
paddingVertical: spacing.xs + 2,
|
||||
borderWidth: 1,
|
||||
borderColor: colors.glass.border,
|
||||
},
|
||||
text: {
|
||||
...typography.caption1,
|
||||
color: colors.textSecondary,
|
||||
textAlign: 'center',
|
||||
},
|
||||
});
|
||||
Reference in new issue
Block a user