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:
Jamie Brunton committed 2026-01-01 23:03:43 +00:00
1 parent df4c4d486b
commit 8f8c6e401d
79 files changed
+6689 -4944

No files matched your search

@@ -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;
@@ -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';
@@ -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';
@@ -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') => {
@@ -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;
@@ -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';
@@ -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;
@@ -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;
+8
View File
@@ -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';