mirror of
https://github.com/jjbrunton/Sauci.git
synced 2026-10-31 18:19:43 +00:00
Refactor profile screen and organize project structure
- Extract ProfileScreen and components to features/profile - Move shared hooks and stores to top-level - Add RLS policies for admin permissions - Add RELEASING.md guide - Update .gitignore
This commit is contained in:
1 parent
df4c4d486b
commit
8f8c6e401d
79 files changed
+6689
-4944
No files matched your search
+5
-5
@@ -17,11 +17,11 @@ import { LinearGradient } from 'expo-linear-gradient';
|
||||
import * as ImagePicker from 'expo-image-picker';
|
||||
import * as FileSystem from 'expo-file-system';
|
||||
import { decode } from 'base64-arraybuffer';
|
||||
import { supabase } from '../lib/supabase';
|
||||
import { getDeviceInfo } from '../lib/deviceInfo';
|
||||
import { useAuthStore } from '../store';
|
||||
import { colors, gradients, spacing, radius, typography } from '../theme';
|
||||
import type { FeedbackType } from '../types';
|
||||
import { supabase } from '../../lib/supabase';
|
||||
import { getDeviceInfo } from '../../lib/deviceInfo';
|
||||
import { useAuthStore } from '../../store';
|
||||
import { colors, gradients, spacing, radius, typography } from '../../theme';
|
||||
import type { FeedbackType } from '../../types';
|
||||
|
||||
interface FeedbackModalProps {
|
||||
visible: boolean;
|
||||
+4
-4
@@ -11,10 +11,10 @@ import {
|
||||
} from 'react-native';
|
||||
import { Ionicons } from '@expo/vector-icons';
|
||||
import { LinearGradient } from 'expo-linear-gradient';
|
||||
import { supabase } from '../lib/supabase';
|
||||
import { getDeviceInfo } from '../lib/deviceInfo';
|
||||
import { useAuthStore } from '../store';
|
||||
import { colors, gradients, spacing, radius, typography } from '../theme';
|
||||
import { supabase } from '../../lib/supabase';
|
||||
import { getDeviceInfo } from '../../lib/deviceInfo';
|
||||
import { useAuthStore } from '../../store';
|
||||
import { colors, gradients, spacing, radius, typography } from '../../theme';
|
||||
|
||||
interface QuestionFeedbackModalProps {
|
||||
visible: boolean;
|
||||
@@ -0,0 +1,2 @@
|
||||
export { FeedbackModal } from './FeedbackModal';
|
||||
export { QuestionFeedbackModal } from './QuestionFeedbackModal';
|
||||
+5
-5
@@ -12,10 +12,10 @@ import {
|
||||
} from "react-native";
|
||||
import { Ionicons } from "@expo/vector-icons";
|
||||
import { LinearGradient } from "expo-linear-gradient";
|
||||
import { useSubscriptionStore } from "../store";
|
||||
import { colors, gradients, spacing, radius, typography } from "../theme";
|
||||
import type { PurchasesPackage } from "../lib/revenuecat";
|
||||
import { Events } from "../lib/analytics";
|
||||
import { useSubscriptionStore } from "../../store";
|
||||
import { colors, gradients, spacing, radius, typography } from "../../theme";
|
||||
import type { PurchasesPackage } from "../../lib/revenuecat";
|
||||
import { Events } from "../../lib/analytics";
|
||||
|
||||
interface PaywallProps {
|
||||
visible: boolean;
|
||||
@@ -330,7 +330,7 @@ export function Paywall({ visible, onClose, onSuccess }: PaywallProps) {
|
||||
style={[
|
||||
styles.purchaseButton,
|
||||
(!selectedPackage || isPurchasing) &&
|
||||
styles.purchaseButtonDisabled,
|
||||
styles.purchaseButtonDisabled,
|
||||
]}
|
||||
onPress={handlePurchase}
|
||||
disabled={!selectedPackage || isPurchasing}
|
||||
@@ -0,0 +1 @@
|
||||
export { Paywall } from './Paywall';
|
||||
+2
-2
@@ -17,8 +17,8 @@ import Animated, {
|
||||
Easing,
|
||||
} from "react-native-reanimated";
|
||||
import { Ionicons } from "@expo/vector-icons";
|
||||
import { colors, gradients, radius, shadows, blur, typography, spacing, animations } from "../theme";
|
||||
import { QuestionFeedbackModal } from "./QuestionFeedbackModal";
|
||||
import { colors, gradients, radius, shadows, blur, typography, spacing, animations } from "../../theme";
|
||||
import { QuestionFeedbackModal } from "../feedback";
|
||||
|
||||
// Haptics helper - not supported on web
|
||||
const triggerHaptic = async (style: 'light' | 'medium' | 'heavy' = 'medium') => {
|
||||
File renamed without changes.
+1
-1
@@ -1,7 +1,7 @@
|
||||
import { useState, useRef } from "react";
|
||||
import { View, Text, StyleSheet, Animated, PanResponder, TouchableOpacity, useWindowDimensions } from "react-native";
|
||||
import { Ionicons } from "@expo/vector-icons";
|
||||
import { QuestionFeedbackModal } from "./QuestionFeedbackModal";
|
||||
import { QuestionFeedbackModal } from "../feedback";
|
||||
|
||||
const MAX_CARD_WIDTH = 400;
|
||||
const SWIPE_THRESHOLD = 100;
|
||||
+3
-3
@@ -23,7 +23,7 @@ import Animated, {
|
||||
FadeIn,
|
||||
} from "react-native-reanimated";
|
||||
import { Ionicons } from "@expo/vector-icons";
|
||||
import { colors, gradients, featureColors, radius, shadows, blur, typography, spacing, animations } from "../theme";
|
||||
import { colors, gradients, featureColors, radius, shadows, blur, typography, spacing, animations } from "../../theme";
|
||||
|
||||
// Use feature colors for consistency
|
||||
const { accent: ACCENT } = featureColors.dares;
|
||||
@@ -33,8 +33,8 @@ const { accent: ROSE } = featureColors.quiz;
|
||||
// Gold: #D4AF37 = rgb(212, 175, 55)
|
||||
// Rose: #E8A4AE = rgb(232, 164, 174)
|
||||
const rgba = {
|
||||
gold: (opacity: number) => `rgba(212, 175, 55, ${opacity})`,
|
||||
rose: (opacity: number) => `rgba(232, 164, 174, ${opacity})`,
|
||||
gold: (opacity: number) => `rgba(212, 175, 55, ${opacity})`,
|
||||
rose: (opacity: number) => `rgba(232, 164, 174, ${opacity})`,
|
||||
};
|
||||
|
||||
// Haptics helper
|
||||
@@ -0,0 +1,2 @@
|
||||
export { default as SwipeCard } from './SwipeCard';
|
||||
export { default as SwipeCardPremium } from './SwipeCardPremium';
|
||||
+2
-2
@@ -15,8 +15,8 @@ import Animated, {
|
||||
FadeOut,
|
||||
interpolate,
|
||||
} from "react-native-reanimated";
|
||||
import { colors, gradients, radius, shadows, blur, typography, spacing } from "../theme";
|
||||
import { Events } from "../lib/analytics";
|
||||
import { colors, gradients, radius, shadows, blur, typography, spacing } from "../../theme";
|
||||
import { Events } from "../../lib/analytics";
|
||||
|
||||
interface Props {
|
||||
onComplete: () => void;
|
||||
+2
-2
@@ -18,8 +18,8 @@ import Animated, {
|
||||
Easing,
|
||||
runOnJS,
|
||||
} from "react-native-reanimated";
|
||||
import { colors, gradients, radius, shadows, blur, typography, spacing, animations } from "../theme";
|
||||
import { Events } from "../lib/analytics";
|
||||
import { colors, gradients, radius, shadows, blur, typography, spacing, animations } from "../../theme";
|
||||
import { Events } from "../../lib/analytics";
|
||||
|
||||
interface Props {
|
||||
onComplete: () => void;
|
||||
@@ -0,0 +1,2 @@
|
||||
export { default as SwipeTutorial } from './SwipeTutorial';
|
||||
export { default as MatchesTutorial } from './MatchesTutorial';
|
||||
@@ -0,0 +1,160 @@
|
||||
import React from 'react';
|
||||
import { StyleSheet, ViewStyle } from 'react-native';
|
||||
import { LinearGradient } from 'expo-linear-gradient';
|
||||
import Animated from 'react-native-reanimated';
|
||||
import { useAmbientOrbAnimation, AmbientOrbConfig } from '../../hooks';
|
||||
import { colors } from '../../theme';
|
||||
|
||||
export type OrbVariant = 'default' | 'chat' | 'premium';
|
||||
|
||||
export interface OrbPosition {
|
||||
top?: number;
|
||||
bottom?: number;
|
||||
left?: number;
|
||||
right?: number;
|
||||
}
|
||||
|
||||
export interface AmbientOrbsProps {
|
||||
/** Visual variant preset */
|
||||
variant?: OrbVariant;
|
||||
/** Custom animation config */
|
||||
animationConfig?: AmbientOrbConfig;
|
||||
/** Custom position for orb 1 */
|
||||
orb1Position?: OrbPosition;
|
||||
/** Custom position for orb 2 */
|
||||
orb2Position?: OrbPosition;
|
||||
/** Custom colors for orb 1 [primary, secondary]. Default: gold */
|
||||
orb1Colors?: [string, string];
|
||||
/** Custom colors for orb 2 [primary, secondary]. Default: rose */
|
||||
orb2Colors?: [string, string];
|
||||
/** Orb size */
|
||||
size?: number;
|
||||
/** Container style (for absolute positioning) */
|
||||
style?: ViewStyle;
|
||||
}
|
||||
|
||||
// Preset configurations for different screens
|
||||
const VARIANT_CONFIGS: Record<OrbVariant, {
|
||||
orb1Colors: [string, string];
|
||||
orb2Colors: [string, string];
|
||||
animationConfig?: AmbientOrbConfig;
|
||||
}> = {
|
||||
default: {
|
||||
orb1Colors: [colors.premium.goldGlow, 'transparent'],
|
||||
orb2Colors: ['rgba(232, 164, 174, 0.2)', 'transparent'],
|
||||
},
|
||||
chat: {
|
||||
orb1Colors: [colors.premium.goldGlow, 'transparent'],
|
||||
orb2Colors: ['rgba(232, 164, 174, 0.2)', 'transparent'],
|
||||
animationConfig: {
|
||||
opacityRange1: [0.2, 0.4],
|
||||
opacityRange2: [0.15, 0.35],
|
||||
},
|
||||
},
|
||||
premium: {
|
||||
orb1Colors: [colors.premium.goldGlow, 'transparent'],
|
||||
orb2Colors: ['rgba(232, 164, 174, 0.25)', 'transparent'],
|
||||
animationConfig: {
|
||||
opacityRange1: [0.25, 0.5],
|
||||
opacityRange2: [0.2, 0.4],
|
||||
},
|
||||
},
|
||||
};
|
||||
|
||||
// Default positions for orbs
|
||||
const DEFAULT_ORB1_POSITION: OrbPosition = {
|
||||
top: 60,
|
||||
right: -40,
|
||||
};
|
||||
|
||||
const DEFAULT_ORB2_POSITION: OrbPosition = {
|
||||
bottom: 180,
|
||||
left: -40,
|
||||
};
|
||||
|
||||
/**
|
||||
* Ambient orbs component for premium visual atmosphere.
|
||||
* Provides floating, breathing gradient orbs as background decoration.
|
||||
* Used across swipe, chat, and matches screens.
|
||||
*/
|
||||
export function AmbientOrbs({
|
||||
variant = 'default',
|
||||
animationConfig,
|
||||
orb1Position = DEFAULT_ORB1_POSITION,
|
||||
orb2Position = DEFAULT_ORB2_POSITION,
|
||||
orb1Colors,
|
||||
orb2Colors,
|
||||
size = 300,
|
||||
style,
|
||||
}: AmbientOrbsProps) {
|
||||
const variantConfig = VARIANT_CONFIGS[variant];
|
||||
|
||||
// Merge animation config with variant defaults
|
||||
const finalAnimationConfig = animationConfig || variantConfig.animationConfig;
|
||||
|
||||
// Get animation styles from hook
|
||||
const { orbStyle1, orbStyle2 } = useAmbientOrbAnimation(finalAnimationConfig);
|
||||
|
||||
// Final colors (props override variant defaults)
|
||||
const finalOrb1Colors = orb1Colors || variantConfig.orb1Colors;
|
||||
const finalOrb2Colors = orb2Colors || variantConfig.orb2Colors;
|
||||
|
||||
const orbSize = {
|
||||
width: size,
|
||||
height: size,
|
||||
borderRadius: size / 2,
|
||||
};
|
||||
|
||||
const gradientSize = {
|
||||
width: '100%' as const,
|
||||
height: '100%' as const,
|
||||
borderRadius: size / 2,
|
||||
};
|
||||
|
||||
return (
|
||||
<>
|
||||
<Animated.View
|
||||
style={[
|
||||
styles.orb,
|
||||
orbSize,
|
||||
orb1Position,
|
||||
orbStyle1,
|
||||
style,
|
||||
]}
|
||||
pointerEvents="none"
|
||||
>
|
||||
<LinearGradient
|
||||
colors={finalOrb1Colors}
|
||||
style={gradientSize}
|
||||
start={{ x: 0.5, y: 0.5 }}
|
||||
end={{ x: 1, y: 1 }}
|
||||
/>
|
||||
</Animated.View>
|
||||
<Animated.View
|
||||
style={[
|
||||
styles.orb,
|
||||
orbSize,
|
||||
orb2Position,
|
||||
orbStyle2,
|
||||
style,
|
||||
]}
|
||||
pointerEvents="none"
|
||||
>
|
||||
<LinearGradient
|
||||
colors={finalOrb2Colors}
|
||||
style={gradientSize}
|
||||
start={{ x: 0.5, y: 0.5 }}
|
||||
end={{ x: 0, y: 0 }}
|
||||
/>
|
||||
</Animated.View>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
const styles = StyleSheet.create({
|
||||
orb: {
|
||||
position: 'absolute',
|
||||
},
|
||||
});
|
||||
|
||||
export default AmbientOrbs;
|
||||
@@ -0,0 +1,71 @@
|
||||
import React from 'react';
|
||||
import { View, StyleSheet, ViewStyle, DimensionValue } from 'react-native';
|
||||
import { colors, spacing } from '../../theme';
|
||||
|
||||
export type SeparatorVariant = 'rose' | 'gold' | 'muted';
|
||||
|
||||
export interface DecorativeSeparatorProps {
|
||||
/** Color variant: 'rose' (default), 'gold' (premium/yes-yes), 'muted' */
|
||||
variant?: SeparatorVariant;
|
||||
/** Width of the separator. Default: 140 */
|
||||
width?: DimensionValue;
|
||||
/** Vertical margin. Default: spacing.lg */
|
||||
marginVertical?: number;
|
||||
/** Additional container style */
|
||||
style?: ViewStyle;
|
||||
}
|
||||
|
||||
const VARIANT_COLORS: Record<SeparatorVariant, { line: string; diamond: string }> = {
|
||||
rose: {
|
||||
line: 'rgba(232, 164, 174, 0.3)',
|
||||
diamond: colors.premium.rose,
|
||||
},
|
||||
gold: {
|
||||
line: 'rgba(212, 175, 55, 0.3)',
|
||||
diamond: colors.premium.gold,
|
||||
},
|
||||
muted: {
|
||||
line: 'rgba(255, 255, 255, 0.1)',
|
||||
diamond: 'rgba(255, 255, 255, 0.3)',
|
||||
},
|
||||
};
|
||||
|
||||
/**
|
||||
* Decorative separator with diamond accent.
|
||||
* Used for visual separation in waiting states, headers, and empty states.
|
||||
*/
|
||||
export const DecorativeSeparator: React.FC<DecorativeSeparatorProps> = ({
|
||||
variant = 'rose',
|
||||
width = 140,
|
||||
marginVertical = spacing.lg,
|
||||
style,
|
||||
}) => {
|
||||
const variantColors = VARIANT_COLORS[variant];
|
||||
|
||||
return (
|
||||
<View style={[styles.container, { width, marginVertical }, style]}>
|
||||
<View style={[styles.line, { backgroundColor: variantColors.line }]} />
|
||||
<View style={[styles.diamond, { backgroundColor: variantColors.diamond }]} />
|
||||
<View style={[styles.line, { backgroundColor: variantColors.line }]} />
|
||||
</View>
|
||||
);
|
||||
};
|
||||
|
||||
const styles = StyleSheet.create({
|
||||
container: {
|
||||
flexDirection: 'row',
|
||||
alignItems: 'center',
|
||||
justifyContent: 'center',
|
||||
},
|
||||
line: {
|
||||
flex: 1,
|
||||
height: 1,
|
||||
},
|
||||
diamond: {
|
||||
width: 6,
|
||||
height: 6,
|
||||
transform: [{ rotate: '45deg' }],
|
||||
marginHorizontal: spacing.sm,
|
||||
opacity: 0.7,
|
||||
},
|
||||
});
|
||||
@@ -0,0 +1,201 @@
|
||||
import React, { useEffect } from 'react';
|
||||
import { View, Text, StyleSheet, ActivityIndicator, ViewStyle, TextStyle } from 'react-native';
|
||||
import { LinearGradient } from 'expo-linear-gradient';
|
||||
import Animated, {
|
||||
FadeIn,
|
||||
FadeOut,
|
||||
useSharedValue,
|
||||
useAnimatedStyle,
|
||||
withRepeat,
|
||||
withTiming,
|
||||
interpolate,
|
||||
Easing,
|
||||
} from 'react-native-reanimated';
|
||||
import { colors, typography, spacing, radius } from '../../theme';
|
||||
|
||||
export type LoadingVariant = 'spinner' | 'shimmer' | 'progress';
|
||||
|
||||
export interface LoadingOverlayProps {
|
||||
/** Whether the loading overlay is visible */
|
||||
visible: boolean;
|
||||
/** Visual variant of the loading indicator */
|
||||
variant?: LoadingVariant;
|
||||
/** Status text to display */
|
||||
statusText?: string;
|
||||
/** Secondary/detail text */
|
||||
detailText?: string;
|
||||
/** Progress value (0-100) for 'progress' variant */
|
||||
progress?: number;
|
||||
/** Custom spinner color */
|
||||
spinnerColor?: string;
|
||||
/** Whether to use full-screen overlay mode */
|
||||
fullScreen?: boolean;
|
||||
/** Custom container style */
|
||||
style?: ViewStyle;
|
||||
/** Custom text style */
|
||||
textStyle?: TextStyle;
|
||||
}
|
||||
|
||||
/**
|
||||
* Flexible loading overlay component supporting multiple variants.
|
||||
* Used for upload status, loading states, and progress indicators.
|
||||
*/
|
||||
export function LoadingOverlay({
|
||||
visible,
|
||||
variant = 'spinner',
|
||||
statusText,
|
||||
detailText,
|
||||
progress = 0,
|
||||
spinnerColor = colors.premium.gold,
|
||||
fullScreen = false,
|
||||
style,
|
||||
textStyle,
|
||||
}: LoadingOverlayProps) {
|
||||
const shimmer = useSharedValue(0);
|
||||
|
||||
useEffect(() => {
|
||||
if (visible && variant === 'shimmer') {
|
||||
shimmer.value = withRepeat(
|
||||
withTiming(1, { duration: 1500, easing: Easing.inOut(Easing.ease) }),
|
||||
-1,
|
||||
false
|
||||
);
|
||||
} else {
|
||||
shimmer.value = 0;
|
||||
}
|
||||
}, [visible, variant]);
|
||||
|
||||
const shimmerStyle = useAnimatedStyle(() => ({
|
||||
opacity: interpolate(shimmer.value, [0, 0.5, 1], [0.3, 0.6, 0.3]),
|
||||
}));
|
||||
|
||||
if (!visible) return null;
|
||||
|
||||
const renderContent = () => {
|
||||
switch (variant) {
|
||||
case 'shimmer':
|
||||
return (
|
||||
<Animated.View style={[styles.shimmerContent, shimmerStyle]}>
|
||||
<ActivityIndicator color={spinnerColor} size="small" />
|
||||
{statusText && (
|
||||
<Text style={[styles.statusText, textStyle]}>{statusText}</Text>
|
||||
)}
|
||||
{detailText && (
|
||||
<Text style={styles.detailText}>{detailText}</Text>
|
||||
)}
|
||||
</Animated.View>
|
||||
);
|
||||
|
||||
case 'progress':
|
||||
return (
|
||||
<View style={styles.progressContent}>
|
||||
<View style={styles.progressBarContainer}>
|
||||
<LinearGradient
|
||||
colors={[colors.premium.gold, colors.premium.goldDark]}
|
||||
style={[styles.progressBar, { width: `${Math.min(100, Math.max(0, progress))}%` }]}
|
||||
start={{ x: 0, y: 0 }}
|
||||
end={{ x: 1, y: 0 }}
|
||||
/>
|
||||
</View>
|
||||
{statusText && (
|
||||
<Text style={[styles.statusText, textStyle]}>{statusText}</Text>
|
||||
)}
|
||||
{detailText && (
|
||||
<Text style={styles.detailText}>{detailText}</Text>
|
||||
)}
|
||||
</View>
|
||||
);
|
||||
|
||||
case 'spinner':
|
||||
default:
|
||||
return (
|
||||
<View style={styles.spinnerContent}>
|
||||
<ActivityIndicator color={spinnerColor} size="large" />
|
||||
{statusText && (
|
||||
<Text style={[styles.statusText, textStyle]}>{statusText}</Text>
|
||||
)}
|
||||
{detailText && (
|
||||
<Text style={styles.detailText}>{detailText}</Text>
|
||||
)}
|
||||
</View>
|
||||
);
|
||||
}
|
||||
};
|
||||
|
||||
if (fullScreen) {
|
||||
return (
|
||||
<Animated.View
|
||||
entering={FadeIn.duration(200)}
|
||||
exiting={FadeOut.duration(200)}
|
||||
style={[styles.fullScreenContainer, style]}
|
||||
>
|
||||
{renderContent()}
|
||||
</Animated.View>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<Animated.View
|
||||
entering={FadeIn.duration(200)}
|
||||
exiting={FadeOut.duration(200)}
|
||||
style={[styles.container, style]}
|
||||
>
|
||||
{renderContent()}
|
||||
</Animated.View>
|
||||
);
|
||||
}
|
||||
|
||||
const styles = StyleSheet.create({
|
||||
container: {
|
||||
alignItems: 'center',
|
||||
justifyContent: 'center',
|
||||
padding: spacing.md,
|
||||
},
|
||||
fullScreenContainer: {
|
||||
...StyleSheet.absoluteFillObject,
|
||||
backgroundColor: 'rgba(13, 13, 26, 0.85)',
|
||||
alignItems: 'center',
|
||||
justifyContent: 'center',
|
||||
zIndex: 1000,
|
||||
},
|
||||
spinnerContent: {
|
||||
alignItems: 'center',
|
||||
justifyContent: 'center',
|
||||
gap: spacing.sm,
|
||||
},
|
||||
shimmerContent: {
|
||||
alignItems: 'center',
|
||||
justifyContent: 'center',
|
||||
gap: spacing.xs,
|
||||
},
|
||||
progressContent: {
|
||||
alignItems: 'center',
|
||||
justifyContent: 'center',
|
||||
gap: spacing.sm,
|
||||
width: '100%',
|
||||
},
|
||||
statusText: {
|
||||
...typography.body,
|
||||
color: colors.textSecondary,
|
||||
textAlign: 'center',
|
||||
marginTop: spacing.xs,
|
||||
},
|
||||
detailText: {
|
||||
...typography.caption1,
|
||||
color: colors.textTertiary,
|
||||
textAlign: 'center',
|
||||
},
|
||||
progressBarContainer: {
|
||||
width: '80%',
|
||||
height: 4,
|
||||
backgroundColor: 'rgba(255, 255, 255, 0.1)',
|
||||
borderRadius: radius.full,
|
||||
overflow: 'hidden',
|
||||
},
|
||||
progressBar: {
|
||||
height: '100%',
|
||||
borderRadius: radius.full,
|
||||
},
|
||||
});
|
||||
|
||||
export default LoadingOverlay;
|
||||
@@ -0,0 +1,95 @@
|
||||
import React from 'react';
|
||||
import { View, StyleSheet, ViewStyle } from 'react-native';
|
||||
import { Ionicons } from '@expo/vector-icons';
|
||||
import { colors } from '../../theme';
|
||||
|
||||
export type ReceiptStatus = 'sent' | 'delivered' | 'read';
|
||||
|
||||
export interface ReadReceiptProps {
|
||||
/** Current status of the message */
|
||||
status: ReceiptStatus;
|
||||
/** Size of the icon */
|
||||
size?: number;
|
||||
/** Custom color for read state (default: gold accent) */
|
||||
readColor?: string;
|
||||
/** Custom color for unread states (default: muted white) */
|
||||
unreadColor?: string;
|
||||
/** Custom container style */
|
||||
style?: ViewStyle;
|
||||
}
|
||||
|
||||
/**
|
||||
* Read receipt indicator component for messages.
|
||||
* Displays 3-state delivery status:
|
||||
* - Single tick (grey): sent to server
|
||||
* - Double tick (grey): delivered to partner's device
|
||||
* - Double tick (colored): read by partner
|
||||
*/
|
||||
export function ReadReceipt({
|
||||
status,
|
||||
size = 14,
|
||||
readColor = colors.premium.gold,
|
||||
unreadColor = 'rgba(255, 255, 255, 0.4)',
|
||||
style,
|
||||
}: ReadReceiptProps) {
|
||||
const getIcon = () => {
|
||||
switch (status) {
|
||||
case 'read':
|
||||
// Double tick colored - read
|
||||
return (
|
||||
<Ionicons
|
||||
name="checkmark-done"
|
||||
size={size}
|
||||
color={readColor}
|
||||
/>
|
||||
);
|
||||
case 'delivered':
|
||||
// Double tick grey - delivered but not read
|
||||
return (
|
||||
<Ionicons
|
||||
name="checkmark-done"
|
||||
size={size}
|
||||
color={unreadColor}
|
||||
/>
|
||||
);
|
||||
case 'sent':
|
||||
default:
|
||||
// Single tick grey - sent but not delivered
|
||||
return (
|
||||
<Ionicons
|
||||
name="checkmark"
|
||||
size={size}
|
||||
color={unreadColor}
|
||||
/>
|
||||
);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<View style={[styles.container, style]}>
|
||||
{getIcon()}
|
||||
</View>
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Helper to derive receipt status from message timestamps.
|
||||
* Use this when you have raw delivered_at/read_at values.
|
||||
*/
|
||||
export function getReceiptStatus(
|
||||
deliveredAt: string | null | undefined,
|
||||
readAt: string | null | undefined
|
||||
): ReceiptStatus {
|
||||
if (readAt) return 'read';
|
||||
if (deliveredAt) return 'delivered';
|
||||
return 'sent';
|
||||
}
|
||||
|
||||
const styles = StyleSheet.create({
|
||||
container: {
|
||||
justifyContent: 'center',
|
||||
alignItems: 'center',
|
||||
},
|
||||
});
|
||||
|
||||
export default ReadReceipt;
|
||||
@@ -3,3 +3,11 @@ export { GlassCard } from './GlassCard';
|
||||
export { GlassButton } from './GlassButton';
|
||||
export { GlassInput } from './GlassInput';
|
||||
export { ShimmerEffect } from './ShimmerEffect';
|
||||
export { DecorativeSeparator } from './DecorativeSeparator';
|
||||
export type { DecorativeSeparatorProps, SeparatorVariant } from './DecorativeSeparator';
|
||||
export { LoadingOverlay } from './LoadingOverlay';
|
||||
export type { LoadingOverlayProps, LoadingVariant } from './LoadingOverlay';
|
||||
export { ReadReceipt, getReceiptStatus } from './ReadReceipt';
|
||||
export type { ReadReceiptProps, ReceiptStatus } from './ReadReceipt';
|
||||
export { AmbientOrbs } from './AmbientOrbs';
|
||||
export type { AmbientOrbsProps, OrbVariant, OrbPosition } from './AmbientOrbs';
|
||||
Reference in new issue
Block a user