Add question types, nudge feature, and share modal

- Add new question card components (photo, audio, text, who-likely)
- Add share preview modal for matches
- Add nudge notification system with rate limiting
- Add session-based notification improvements
- Add match confetti celebration animation
- Update onboarding flow with paywall tracking
- Remove deprecated swipe card and tutorial components
- Update GlassCard/GlassInput to use solid backgrounds
- Fix routing issues after packs screen removal
This commit is contained in:
jjbrunton committed 2026-01-25 19:17:39 +00:00
1 parent 8612b16b93
commit 1daf386765
114 files changed
+14877 -7911

No files matched your search

+2
View File
@@ -134,6 +134,7 @@ Edge functions in `apps/supabase/functions/` handle complex operations:
- `delete-relationship` - Deletes couple data (matches, messages, media) while keeping accounts
- `delete-account` - Permanently deletes user account and all associated data (Apple App Store requirement)
- `send-notification` - Push notifications for matches
- `send-nudge-notification` - Partner nudge notifications (rate limited to once per 12 hours)
- `revenuecat-webhook` - Subscription webhook handler
- `sync-subscription` - Syncs RevenueCat subscription status
- `redeem-code` - Redeems promotional codes for premium access (website only)
@@ -165,6 +166,7 @@ This approach:
| `revenuecat-webhook` | false | Webhook signature validation |
| `send-notification` | false | Internal trigger (no auth) |
| `send-message-notification` | false | Internal trigger (no auth) |
| `send-nudge-notification` | false | Manual via getUser() |
### Database Best Practices
- **Always use `.maybeSingle()` instead of `.single()`** when the row might not exist. `.single()` throws an error if 0 or >1 rows are returned.
@@ -114,7 +114,7 @@ export function AiGeneratorDialog({
context={context}
config={generation.config}
onCountChange={generation.setCount}
onToneChange={generation.setTone}
onExplicitChange={generation.setExplicit}
onCrudeLangChange={generation.setCrudeLang}
onInspirationChange={generation.setInspiration}
/>
+11 -42
View File
@@ -1,9 +1,7 @@
import { Button } from '@/components/ui/button';
import { Input } from '@/components/ui/input';
import { Label } from '@/components/ui/label';
import { Textarea } from '@/components/ui/textarea';
import { Switch } from '@/components/ui/switch';
import { TONE_LEVELS, type ToneLevel } from '@/lib/openai';
import type { GeneratorType, GeneratorContext, GenerationConfig } from './hooks/useAiGeneration';
// =============================================================================
@@ -15,7 +13,7 @@ interface GenerationFormProps {
context?: GeneratorContext;
config: GenerationConfig;
onCountChange: (count: number) => void;
onToneChange: (tone: ToneLevel) => void;
onExplicitChange: (isExplicit: boolean) => void;
onCrudeLangChange: (crudeLang: boolean) => void;
onInspirationChange: (inspiration: string) => void;
}
@@ -29,12 +27,10 @@ export function GenerationForm({
context,
config,
onCountChange,
onToneChange,
onExplicitChange,
onCrudeLangChange,
onInspirationChange,
}: GenerationFormProps) {
const isExplicit = config.tone >= 4;
return (
<div className="space-y-4">
{/* Question Count (questions only) */}
@@ -53,42 +49,15 @@ export function GenerationForm({
</div>
)}
{/* Intimacy Level Selector (questions only) */}
{type === 'questions' && (
<div className="space-y-2">
<Label>Intimacy Level</Label>
<div className="flex flex-wrap gap-2">
{TONE_LEVELS.map((t) => (
<Button
key={t.level}
type="button"
variant={config.tone === t.level ? 'default' : 'outline'}
size="sm"
onClick={() => onToneChange(t.level as ToneLevel)}
className={config.tone === t.level ? 'ring-2 ring-offset-1' : ''}
title={t.description}
>
{t.level}. {t.label}
</Button>
))}
</div>
<p className="text-xs text-muted-foreground">
{TONE_LEVELS.find(t => t.level === config.tone)?.description}
</p>
</div>
)}
{/* Wild/Explicit Toggle (for other generators) */}
{(type === 'category-ideas' || type === 'category-pack-ideas' || type === 'pack') && (
<div className="flex items-center space-x-2">
<Switch
id="explicit-mode"
checked={isExplicit}
onCheckedChange={(checked: boolean) => onToneChange(checked ? 5 : 3)}
/>
<Label htmlFor="explicit-mode">Include Wild/Adult Ideas</Label>
</div>
)}
{/* Explicit Toggle */}
<div className="flex items-center space-x-2">
<Switch
id="explicit-mode"
checked={config.isExplicit}
onCheckedChange={onExplicitChange}
/>
<Label htmlFor="explicit-mode">Explicit Content</Label>
</div>
{/* Crude Language Override Toggle */}
<div className="flex items-center space-x-2">
+1 -12
View File
@@ -10,7 +10,6 @@ import {
Bot,
} from 'lucide-react';
import {
INTENSITY_LEVELS,
type GeneratedQuestion,
type QuestionReview,
type CouncilGenerationResult,
@@ -199,16 +198,6 @@ function QuestionItem({
)}
</div>
<div className="flex items-center gap-2 flex-wrap">
{/* Intensity Badge */}
<span
className={`inline-block text-xs px-2 py-0.5 rounded text-white ${
INTENSITY_LEVELS[question.intensity - 1]?.color || 'bg-muted'
}`}
title={INTENSITY_LEVELS[question.intensity - 1]?.description}
>
{question.intensity} - {INTENSITY_LEVELS[question.intensity - 1]?.label || 'Unknown'}
</span>
{/* Source Generator Badge (cherry-pick mode) */}
{selectionMode === 'cherry_pick' && question.sourceGeneratorModel && (
<span className="inline-flex items-center gap-1 text-xs px-2 py-0.5 rounded bg-blue-100 text-blue-700 dark:bg-blue-900/30 dark:text-blue-400">
@@ -228,7 +217,7 @@ function QuestionItem({
: 'bg-red-100 text-red-700 dark:bg-red-900/30 dark:text-red-400'
}`}
title={review.scores
? `Scores: ${review.scores.guidelineCompliance}/${review.scores.creativity}/${review.scores.clarity}/${review.scores.intensityAccuracy}`
? `Scores: ${review.scores.guidelineCompliance}/${review.scores.creativity}/${review.scores.clarity}`
: ''
}
>
@@ -91,15 +91,6 @@ export function ReviewSummary({
</p>
)}
{/* Intensity distribution for cherry-pick */}
{selectionMode === 'cherry_pick' && cherryPickResult && Object.keys(cherryPickResult.intensityDistribution).length > 0 && (
<p>
Intensity distribution: {Object.entries(cherryPickResult.intensityDistribution)
.sort(([a], [b]) => Number(a) - Number(b))
.map(([level, count]) => `L${level}:${count}`)
.join(', ')}
</p>
)}
</div>
)}
</div>
@@ -10,7 +10,6 @@ import {
type GeneratedQuestion,
type GeneratedCategoryIdea,
type GeneratedPackIdea,
type ToneLevel,
type QuestionReview,
type CouncilGenerationResult,
type GenerationCandidate,
@@ -38,7 +37,7 @@ export interface GeneratorContext {
export interface GenerationConfig {
count: number;
tone: ToneLevel;
isExplicit: boolean;
crudeLang: boolean;
inspiration: string;
}
@@ -77,7 +76,7 @@ export function useAiGeneration(
// Config state
const [config, setConfig] = useState<GenerationConfig>({
count: 10,
tone: context?.isExplicit ? 5 : 2,
isExplicit: Boolean(context?.isExplicit),
crudeLang: false,
inspiration: '',
});
@@ -118,14 +117,12 @@ export function useAiGeneration(
// Council config
const councilConfig = getCouncilConfig();
// Update tone when context changes
// Update explicit flag when context changes
useEffect(() => {
if (type === 'questions') {
setConfig(prev => ({
...prev,
tone: context?.isExplicit ? 5 : 2,
}));
}
setConfig(prev => ({
...prev,
isExplicit: Boolean(context?.isExplicit),
}));
}, [context?.isExplicit, type]);
// Config setters
@@ -133,8 +130,8 @@ export function useAiGeneration(
setConfig(prev => ({ ...prev, count }));
}, []);
const setTone = useCallback((tone: ToneLevel) => {
setConfig(prev => ({ ...prev, tone }));
const setExplicit = useCallback((isExplicit: boolean) => {
setConfig(prev => ({ ...prev, isExplicit }));
}, []);
const setCrudeLang = useCallback((crudeLang: boolean) => {
@@ -219,7 +216,7 @@ export function useAiGeneration(
setFilter('all');
setSourceModel(null);
const isExplicit = config.tone >= 4;
const isExplicit = config.isExplicit;
try {
if (type === 'pack') {
@@ -253,7 +250,7 @@ export function useAiGeneration(
context.packName,
config.count,
undefined,
config.tone,
isExplicit ? 5 : 2,
context.packDescription || undefined,
context.existingQuestions,
config.crudeLang,
@@ -369,7 +366,7 @@ export function useAiGeneration(
// Config
config,
setCount,
setTone,
setExplicit,
setCrudeLang,
setInspiration,
@@ -203,11 +203,12 @@ export function PackFormDialog({
<Switch
checked={formData.is_public}
onCheckedChange={(checked) => {
setField('is_public', checked);
// Clear scheduled release if making public immediately
if (checked) {
setField('scheduled_release_at', null);
}
const next = {
...formData,
is_public: checked,
...(checked ? { scheduled_release_at: null } : {}),
};
onFormChange(next);
}}
/>
</div>
+3 -33
View File
@@ -37,7 +37,6 @@ import { Plus, Pencil, Trash2, Zap, Loader2, Clock } from 'lucide-react';
import { Checkbox } from '@/components/ui/checkbox';
import { toast } from 'sonner';
import { IconPreview } from '@/components/ui/icon-picker';
import { INTENSITY_LEVELS } from '@/lib/openai';
// =============================================================================
// Types
@@ -71,8 +70,7 @@ const DURATION_OPTIONS = [
{ value: '168', label: '1 week' },
];
// Helper to get intensity info by level (1-indexed)
const getIntensity = (level: number) => INTENSITY_LEVELS[level - 1] || INTENSITY_LEVELS[0];
const getDefaultIntensity = (isExplicit?: boolean) => (isExplicit ? 4 : 2);
// Format duration for display
const formatDuration = (hours: number | null): string => {
@@ -107,7 +105,7 @@ export function DaresPage() {
suggested_duration_hours: string;
}>({
text: '',
intensity: 1,
intensity: getDefaultIntensity(),
suggested_duration_hours: '',
});
@@ -169,7 +167,7 @@ export function DaresPage() {
const openCreateDialog = () => {
setEditingDare(null);
setFormData({ text: '', intensity: 1, suggested_duration_hours: '' });
setFormData({ text: '', intensity: getDefaultIntensity(pack?.is_explicit), suggested_duration_hours: '' });
setDialogOpen(true);
};
@@ -343,25 +341,6 @@ export function DaresPage() {
/>
</div>
<div className="space-y-2">
<Label>Intensity Level</Label>
<div className="flex gap-2 flex-wrap">
{INTENSITY_LEVELS.map((intensity) => (
<Button
key={intensity.level}
type="button"
variant={formData.intensity === intensity.level ? 'default' : 'outline'}
size="sm"
onClick={() => setFormData(d => ({ ...d, intensity: intensity.level }))}
className={formData.intensity === intensity.level ? intensity.color : ''}
title={intensity.description}
>
{intensity.level} - {intensity.label}
</Button>
))}
</div>
</div>
<div className="space-y-2">
<Label>Suggested Duration</Label>
<Select
@@ -467,7 +446,6 @@ export function DaresPage() {
</TableHead>
<TableHead className="w-12">#</TableHead>
<TableHead>Dare</TableHead>
<TableHead className="w-24">Intensity</TableHead>
<TableHead className="w-24">Duration</TableHead>
<TableHead className="w-24">Actions</TableHead>
</TableRow>
@@ -491,14 +469,6 @@ export function DaresPage() {
<TableCell>
<span className="line-clamp-2">{dare.text}</span>
</TableCell>
<TableCell>
<Badge
className={`${getIntensity(dare.intensity).color} text-white`}
title={getIntensity(dare.intensity).description}
>
{getIntensity(dare.intensity).label}
</Badge>
</TableCell>
<TableCell>
{dare.suggested_duration_hours ? (
<Badge variant="outline" className="text-xs">
+9 -40
View File
@@ -73,12 +73,8 @@ interface Question {
config?: QuestionConfig | null;
}
import { INTENSITY_LEVELS } from '@/lib/openai';
// Helper to get intensity info by level (1-indexed)
const getIntensity = (level: number) => INTENSITY_LEVELS[level - 1] || INTENSITY_LEVELS[0];
const DEFAULT_AUDIO_DURATION = 60;
const getDefaultIntensity = (isExplicit?: boolean) => (isExplicit ? 4 : 2);
const QUESTION_TYPE_OPTIONS: Array<{ value: QuestionType; label: string }> = [
{ value: 'swipe', label: 'Swipe' },
@@ -138,7 +134,7 @@ export function QuestionsPage() {
}>({
text: '',
partner_text: '',
intensity: 1,
intensity: getDefaultIntensity(),
allowed_couple_genders: [],
target_user_genders: [],
required_props: '',
@@ -276,7 +272,7 @@ export function QuestionsPage() {
setFormData({
text: '',
partner_text: '',
intensity: 1,
intensity: getDefaultIntensity(pack?.is_explicit),
allowed_couple_genders: [],
target_user_genders: [],
required_props: '',
@@ -388,6 +384,7 @@ export function QuestionsPage() {
const handleAiGenerated = (generatedQuestions: Array<{ text: string; partner_text?: string; intensity: number; inverse_pair_id?: string | null }>) => {
const generatedQuestionType: QuestionType = 'swipe';
const defaultIntensity = getDefaultIntensity(pack?.is_explicit);
// Bulk insert AI-generated questions with inverse_of linking
const insertQuestions = async () => {
@@ -414,7 +411,7 @@ export function QuestionsPage() {
pack_id: packId,
text: q.text,
partner_text: q.partner_text || null,
intensity: q.intensity,
intensity: defaultIntensity,
inverse_of: null,
question_type: generatedQuestionType,
config: null,
@@ -434,7 +431,7 @@ export function QuestionsPage() {
pack_id: packId,
text: primary.text,
partner_text: primary.partner_text || null,
intensity: primary.intensity,
intensity: defaultIntensity,
inverse_of: null,
question_type: generatedQuestionType,
config: null,
@@ -452,7 +449,7 @@ export function QuestionsPage() {
pack_id: packId,
text: inverse.text,
partner_text: inverse.partner_text || null,
intensity: inverse.intensity,
intensity: defaultIntensity,
inverse_of: primaryId,
question_type: generatedQuestionType,
config: null,
@@ -470,7 +467,7 @@ export function QuestionsPage() {
pack_id: packId,
text: extra.text,
partner_text: extra.partner_text || null,
intensity: extra.intensity,
intensity: defaultIntensity,
inverse_of: null,
question_type: generatedQuestionType,
config: null,
@@ -486,7 +483,7 @@ export function QuestionsPage() {
pack_id: packId,
text: pair[0].text,
partner_text: pair[0].partner_text || null,
intensity: pair[0].intensity,
intensity: defaultIntensity,
inverse_of: null,
question_type: generatedQuestionType,
config: null,
@@ -732,25 +729,6 @@ export function QuestionsPage() {
</p>
</div>
<div className="space-y-2">
<Label>Intensity Level</Label>
<div className="flex gap-2">
{INTENSITY_LEVELS.map((intensity) => (
<Button
key={intensity.level}
type="button"
variant={formData.intensity === intensity.level ? 'default' : 'outline'}
size="sm"
onClick={() => setFormData(d => ({ ...d, intensity: intensity.level }))}
className={formData.intensity === intensity.level ? intensity.color : ''}
title={intensity.description}
>
{intensity.level} - {intensity.label}
</Button>
))}
</div>
</div>
<div className="space-y-2">
<Label htmlFor="required_props">Required Props (optional)</Label>
<Input
@@ -1026,7 +1004,6 @@ export function QuestionsPage() {
<TableHead className="w-32">Partner Text</TableHead>
<TableHead className="w-24">Couples</TableHead>
<TableHead className="w-24">Initiator</TableHead>
<TableHead className="w-24">Intensity</TableHead>
<TableHead className="w-24">Actions</TableHead>
</TableRow>
</TableHeader>
@@ -1124,14 +1101,6 @@ export function QuestionsPage() {
<span className="text-muted-foreground text-xs">Any</span>
)}
</TableCell>
<TableCell>
<Badge
className={`${getIntensity(question.intensity).color} text-white`}
title={getIntensity(question.intensity).description}
>
{getIntensity(question.intensity).label}
</Badge>
</TableCell>
<TableCell>
<div className="flex gap-1">
<Button
+5
View File
@@ -0,0 +1,5 @@
// https://docs.expo.dev/guides/using-eslint/
module.exports = {
extends: 'expo',
ignorePatterns: ['/dist/*'],
};
+119 -110
View File
@@ -1,24 +1,24 @@
# Sauci Design System
A boutique, premium design framework for the Sauci couples intimacy app.
# Sauci Design System
A boutique, premium design framework for the Sauci couples intimacy app.
## Brand Identity
**Aesthetic:** Boutique lingerie store meets modern dating app. Think Ann Summers sophistication - sensual but tasteful, premium but accessible, intimate but playful.
**Aesthetic:** Boutique lingerie store meets modern dating app. Think Ann Summers sophistication - sensual but tasteful, premium but accessible, intimate but playful.
**Mood:** Romantic, luxurious, warm, inviting, confident
**Mood:** Romantic, luxurious, warm, inviting, confident
**Key Principles:**
- Glass-morphism for depth and elegance
- Dark backgrounds with warm accents
- Primary gradient (rose → purple) as the core brand signature
- Gold reserved for premium/dares features only
- Gradients for visual interest and brand recognition
**Key Principles:**
- Dark, velvety backgrounds with subtle gradient atmosphere
- Solid, high-contrast surfaces (no glass or blur effects)
- Primary gradient (rose → purple) as the core brand signature
- Gold reserved for premium/dares features only
- Soft glows and shadows for depth, not transparency
---
## Color Hierarchy
## Color Hierarchy
### 1. Brand Colors (Primary Palette)
@@ -26,14 +26,14 @@ The core brand identity. Used for primary actions, key UI elements, and brand re
| Name | Hex | RGB | Usage |
|------|-----|-----|-------|
| **Primary** | `#e94560` | 233, 69, 96 | Primary buttons, active states, key accents |
| **Primary Dark** | `#c73a52` | 199, 58, 82 | Pressed states, darker variants |
| **Secondary** | `#9b59b6` | 155, 89, 182 | Secondary accents, gradient endpoints |
| **Secondary Dark** | `#8e44ad` | 142, 68, 173 | Pressed states, darker variants |
**Primary Gradient:** `#e94560 → #9b59b6` (Rose to Purple)
- Direction: Left to right (horizontal) or top to bottom (vertical)
- Use for: Primary buttons, match celebrations, brand moments
| **Primary** | `#e1306c` | 225, 48, 108 | Primary buttons, active states, key accents |
| **Primary Dark** | `#c12055` | 193, 32, 85 | Pressed states, darker variants |
| **Secondary** | `#9b59b6` | 155, 89, 182 | Secondary accents, gradient endpoints |
| **Secondary Dark** | `#8e44ad` | 142, 68, 173 | Pressed states, darker variants |
**Primary Gradient:** `#e1306c → #9b59b6` (Rose to Purple)
- Direction: Left to right (horizontal) or top to bottom (vertical)
- Use for: Primary buttons, match celebrations, brand moments
### 2. Premium/Luxury Colors
@@ -53,11 +53,11 @@ Each core feature has a designated accent color for consistency and recognition.
| Feature | Primary Color | Hex | Gradient |
|---------|---------------|-----|----------|
| **Swipe/Match** | Primary | `#e94560` | Primary gradient (rose → purple) |
| **Quiz** | Rose | `#E8A4AE` | `#E8A4AE → #D4919B` |
| **Dares** | Gold | `#D4AF37` | `#D4AF37 → #B8860B` |
| **Chat** | Primary | `#e94560` | Primary gradient |
| **Profile** | Secondary | `#9b59b6` | Secondary gradient |
| **Swipe/Match** | Primary | `#e1306c` | Primary gradient (rose → purple) |
| **Quiz** | Rose | `#E8A4AE` | `#E8A4AE → #D4919B` |
| **Dares** | Gold | `#D4AF37` | `#D4AF37 → #B8860B` |
| **Chat** | Primary | `#e1306c` | Primary gradient |
| **Profile** | Secondary | `#9b59b6` | Secondary gradient |
**Usage Rules:**
- Feature icons use their designated color
@@ -71,22 +71,31 @@ Dark, rich backgrounds that make content pop.
| Name | Hex | RGBA | Usage |
|------|-----|------|-------|
| **Background** | `#0d0d1a` | 13, 13, 26 | Primary app background |
| **Background Light** | `#1a1a2e` | 26, 26, 46 | Cards, elevated surfaces |
| **Surface** | — | `rgba(22, 33, 62, 0.6)` | Glass overlays |
| **Surface Solid** | `#16213e` | 22, 33, 62 | Non-transparent surfaces |
### 5. Glass/Surface Colors
For the glass-morphism effect that defines the UI.
| Name | RGBA | Usage |
|------|------|-------|
| **Glass Background** | `rgba(22, 33, 62, 0.4)` | Standard glass cards |
| **Glass Background Heavy** | `rgba(22, 33, 62, 0.6)` | Emphasized glass cards |
| **Glass Border** | `rgba(255, 255, 255, 0.08)` | Subtle borders |
| **Glass Border Light** | `rgba(255, 255, 255, 0.12)` | Prominent borders |
| **Glass Highlight** | `rgba(255, 255, 255, 0.05)` | Top edge shine |
| **Background** | `#0e0e11` | 14, 14, 17 | Primary app background |
| **Background Light** | `#17171c` | 23, 23, 28 | Cards, elevated surfaces |
| **Surface** | `#17171c` | 23, 23, 28 | Primary surface color |
| **Surface Solid** | `#17171c` | 23, 23, 28 | Opaque surfaces |
| **Border** | `#303036` | 48, 48, 54 | Dividers, outlines, input borders |
### 5. Tile/Category Colors (Home Screen)
Discovery/Home tiles use vibrant, solid colors for quick recognition.
| Token | Hex |
|-------|-----|
| **teal** | `#14B8A6` |
| **purple** | `#8B5CF6` |
| **orange** | `#F97316` |
| **coral** | `#F87171` |
| **emerald** | `#10B981` |
| **indigo** | `#6366F1` |
| **rose** | `#EC4899` |
| **amber** | `#F59E0B` |
**Usage Rules:**
- Tiles are solid fills with a subtle dark overlay for depth
- Category colors are consistent across the app
- Avoid gradients on tiles unless it is a premium moment
### 6. Text Colors
@@ -122,14 +131,14 @@ Consistent across all swipe interactions:
---
## Gradients
## Gradients
### Brand Gradients
```
Primary: #e94560 → #9b59b6 (Rose to Purple)
Primary Subtle: rgba(233, 69, 96, 0.8) → rgba(155, 89, 182, 0.8)
Primary Reverse: #9b59b6 → #e94560 (Purple to Rose)
Primary: #e1306c → #9b59b6 (Rose to Purple)
Primary Subtle: rgba(225, 48, 108, 0.8) → rgba(155, 89, 182, 0.8)
Primary Reverse: #9b59b6 → #e1306c (Purple to Rose)
```
### Premium Gradients
@@ -142,8 +151,8 @@ Premium Rose: #E8A4AE → #D4919B
### Background Gradients
```
Background: #1a1a2e → #0d0d1a (Light to Dark)
Background Reverse: #0d0d1a → #1a1a2e (Dark to Light)
Background: #0e0e11 → #1f1417 → #0e0e11
Background Reverse: #0e0e11 → #1f1417 → #0e0e11
```
### Boutique Card Gradients
@@ -163,44 +172,44 @@ Boutique Amethyst: rgba(142, 68, 173, 0.4) → rgba(44, 62, 80, 0.4)
## Component Patterns
### Buttons
### Buttons
| Variant | Background | Text | Border |
|---------|------------|------|--------|
| **Primary** | Primary gradient | White | None |
| **Secondary** | Glass background | White | Glass border |
| **Ghost** | Transparent | Primary | None |
| **Danger** | Error gradient | White | None |
| **Feature** | Feature color gradient | White | None |
| **Primary** | Primary gradient | White | None |
| **Secondary** | Surface | White | Border |
| **Ghost** | Transparent | Primary | None |
| **Danger** | Error gradient | White | None |
| **Feature** | Feature color gradient | White | None |
### Cards
### Cards
| Variant | Background | Border | Use Case |
|---------|------------|--------|----------|
| **Glass** | Glass background | Glass border | Standard content |
| **Glass Elevated** | Glass background heavy | Glass border light | Emphasized content |
| **Solid** | Background light | Glass border | Non-blur contexts |
| **Solid** | Surface | Border | Standard content |
| **Raised** | Surface | Border | Emphasized content + shadow |
| **Tinted** | Category color | None | Home tiles, feature promos |
### Inputs
### Inputs
| State | Border Color | Background |
|-------|--------------|------------|
| **Default** | Glass border | Glass background |
| **Focused** | Primary | Glass background |
| **Error** | Error | Glass background |
| **Disabled** | Glass border | Glass background (opacity 0.5) |
| **Default** | Border | Surface |
| **Focused** | Primary | Surface |
| **Error** | Error | Surface |
| **Disabled** | Border | Surface (opacity 0.6) |
---
## Tab Bar
## Tab Bar
| Element | Color |
|---------|-------|
| **Background** | `rgba(13, 13, 26, 0.92)` with blur |
| **Top Border** | `rgba(233, 69, 96, 0.2)` (primary tint) |
| **Active Icon** | Primary `#e94560` |
| **Inactive Icon** | Text secondary |
| **Badge** | Rose `#E8A4AE` |
| **Background** | `#0e0e11` |
| **Top Border** | `rgba(225, 48, 108, 0.2)` (primary tint) |
| **Active Icon** | Primary `#e1306c` |
| **Inactive Icon** | Text secondary |
| **Badge** | Rose `#E8A4AE` |
## Play Button (Center Tab)
@@ -208,10 +217,10 @@ The main "play" button uses the primary brand gradient for maximum brand presenc
| Element | Color |
|---------|-------|
| **Glow** | Primary glow `rgba(233, 69, 96, 0.3)` |
| **Border** | `rgba(233, 69, 96, 0.4)` |
| **Highlight** | Primary → Secondary gradient |
| **Icon** | Primary `#e94560` |
| **Glow** | Primary glow `rgba(225, 48, 108, 0.3)` |
| **Border** | `rgba(225, 48, 108, 0.4)` |
| **Highlight** | Primary → Secondary gradient |
| **Icon** | Primary `#e1306c` |
## Radial Menu
@@ -219,9 +228,9 @@ Feature-specific colors for quick action items:
| Item | Variant | Color |
|------|---------|-------|
| **Match** | Primary | `#e94560` (core feature) |
| **Dares** | Gold | `#D4AF37` (premium/adventure) |
| **Quiz** | Rose | `#E8A4AE` (intimacy) |
| **Match** | Primary | `#e1306c` (core feature) |
| **Dares** | Gold | `#D4AF37` (premium/adventure) |
| **Quiz** | Rose | `#E8A4AE` (intimacy) |
---
@@ -278,17 +287,17 @@ Feature-specific colors for quick action items:
| Style | Size | Weight | Line Height | Usage |
|-------|------|--------|-------------|-------|
| `largeTitle` | 34px | Bold | 41px | Screen titles |
| `title1` | 28px | Bold | 34px | Section headers |
| `title2` | 22px | Bold | 28px | Card titles |
| `title3` | 20px | SemiBold | 25px | Subsection headers |
| `headline` | 17px | SemiBold | 22px | Emphasized body |
| `body` | 17px | Regular | 22px | Body text |
| `callout` | 16px | Regular | 21px | Secondary body |
| `subhead` | 15px | Regular | 20px | Supporting text |
| `footnote` | 13px | Regular | 18px | Captions |
| `caption1` | 12px | Regular | 16px | Small labels |
| `caption2` | 11px | Regular | 13px | Tiny text |
| `largeTitle` | 32px | Bold | 40px | Screen titles |
| `title1` | 28px | Bold | 34px | Section headers |
| `title2` | 24px | Bold | 30px | Card titles |
| `title3` | 20px | SemiBold | 26px | Subsection headers |
| `headline` | 17px | SemiBold | 22px | Emphasized body |
| `body` | 16px | Regular | 22px | Body text |
| `callout` | 15px | Regular | 20px | Secondary body |
| `subhead` | 14px | Regular | 20px | Supporting text |
| `footnote` | 13px | Regular | 18px | Captions |
| `caption1` | 12px | Regular | 16px | Small labels |
| `caption2` | 11px | Regular | 14px | Tiny text |
---
@@ -305,35 +314,35 @@ Feature-specific colors for quick action items:
---
## Platform Considerations
### iOS
- Use BlurView for glass effects
- Haptic feedback on interactions
- Native-feeling animations
### Android
- Fallback to solid semi-transparent backgrounds
- Reduced blur intensity for performance
- Adjusted shadow rendering
## Platform Considerations
### iOS
- Favor solid surfaces and shadows for depth
- Haptic feedback on interactions
- Native-feeling animations
### Android
- Match iOS surface colors and shadows
- Avoid blur and heavy transparency for performance
- Adjust shadow rendering as needed
---
## Do's and Don'ts
### Do
- Use the primary gradient for main CTAs
- Apply feature colors consistently within a feature
- Use glass effects for depth and hierarchy
- Keep text high-contrast (white on dark)
- Use gold/rose for premium moments
### Do
- Use the primary gradient for main CTAs
- Apply feature colors consistently within a feature
- Use solid surfaces with soft shadows for depth
- Keep text high-contrast (white on dark)
- Use gold/rose for premium moments
### Don't
- Mix feature colors within a single screen
- Use more than 2-3 accent colors per screen
- Apply the primary gradient to everything
- Use pure black (#000000) for backgrounds
- Forget to include the glow effect on premium elements
### Don't
- Mix feature colors within a single screen
- Use more than 2-3 accent colors per screen
- Apply the primary gradient to everything
- Use glass, blur, or frosted effects
- Use pure black (#000000) for backgrounds
---
+31 -60
View File
@@ -4,44 +4,35 @@
## Architecture
This is an Expo React Native app using:
- **Routing**: expo-router with file-based routing in `app/` directory
- **State**: Zustand stores in `src/store/index.ts`
- **Backend**: Supabase client in `src/lib/supabase.ts`
- **UI**: Glass-morphism themed components in `src/components/ui/`
- **Theme**: Centralized theme in `src/theme/index.ts`
This is an Expo React Native app using:
- **Routing**: expo-router with file-based routing in `app/` directory
- **State**: Zustand stores in `src/store/index.ts`
- **Backend**: Supabase client in `src/lib/supabase.ts`
Design guidance lives in `apps/mobile/DESIGN.md`.
## Directory Structure
```
app/ # Expo Router routes
(auth)/ # Authentication screens
(app)/ # Protected app screens (tabs)
src/
components/ # Reusable components
ui/ # Base UI components (GlassCard, GlassButton, etc.)
lib/ # Utilities (supabase, analytics, revenuecat)
store/ # Zustand stores (index.ts)
theme/ # Theme constants (colors, gradients, spacing, etc.)
types/ # TypeScript types
```
## Critical Rules
### Tab Bar - NEVER CHANGE
**NEVER set `backgroundColor: 'transparent'` on the tab bar.**
Always use:
```typescript
backgroundColor: 'rgba(18, 18, 18, 0.85)',
```
The BlurView in `tabBarBackground` is unreliable. The solid rgba background acts as a fallback.
Location: `app/(app)/_layout.tsx` - `tabBarStyle`
### State Management
app/ # Expo Router routes
(auth)/ # Authentication screens
(app)/ # Protected app screens (tabs)
src/
components/ # Reusable components
ui/
lib/ # Utilities (supabase, analytics, revenuecat)
store/ # Zustand stores (index.ts)
theme/
types/ # TypeScript types
```
## Critical Rules
### File Size
- Keep files small: aim for 200-300 lines max per file.
### State Management
- Use existing Zustand stores: `useAuthStore`, `useMatchStore`, `usePacksStore`, `useMessageStore`, `useSubscriptionStore`
- Access cross-store state via `getState()` (e.g., `useAuthStore.getState().user`)
@@ -53,24 +44,6 @@ Location: `app/(app)/_layout.tsx` - `tabBarStyle`
- `.single()` throws an error if 0 or >1 rows are returned
- Check for null before performing operations
### UI Components
Use existing UI primitives from `src/components/ui/`:
- `GlassCard` - Glass-morphism card container
- `GlassButton` - Glass-styled button
- `GlassInput` - Glass-styled input field
- `GradientBackground` - Screen background gradient
- `ShimmerEffect` - Loading shimmer animation
### Theme Usage
Import from `src/theme/index.ts`:
```typescript
import { colors, gradients, spacing, radius, typography, shadows } from '@/theme';
```
**DO NOT hardcode colors**. Always use theme values.
### Response Handling
- Responses use UPSERT with `onConflict: "user_id,question_id"`
@@ -92,11 +65,9 @@ import { colors, gradients, spacing, radius, typography, shadows } from '@/theme
- Use `Events` from `src/lib/analytics` for tracking
- Track: `packEnabled`, `packDisabled`, `signOut`, etc.
## Forbidden Actions
1. **DO NOT** change tab bar background to transparent
2. **DO NOT** use `.single()` for queries that might return 0 rows
3. **DO NOT** hardcode colors - use theme values
4. **DO NOT** create new stores without integrating with auth sign-out cleanup
5. **DO NOT** bypass RevenueCat availability checks
6. **DO NOT** add navigation logic outside of expo-router
## Forbidden Actions
1. **DO NOT** use `.single()` for queries that might return 0 rows
2. **DO NOT** create new stores without integrating with auth sign-out cleanup
3. **DO NOT** bypass RevenueCat availability checks
4. **DO NOT** add navigation logic outside of expo-router
+24 -422
View File
@@ -1,19 +1,18 @@
import { Tabs, useRouter, useSegments } from "expo-router";
import { Ionicons } from "@expo/vector-icons";
import { View, ActivityIndicator, StyleSheet, Platform, Text, Pressable, Animated, AppState, AppStateStatus } from "react-native";
import { View, ActivityIndicator, StyleSheet, Platform, Text, Pressable, Animated, AppState } from "react-native";
import { BlurView } from "expo-blur";
import { LinearGradient } from "expo-linear-gradient";
import { useEffect, useState, useCallback, useRef } from "react";
import { useAuthStore, useMatchStore, useMessageStore, useSubscriptionStore, usePacksStore, useStreakStore } from "../../src/store";
import { colors, gradients, blur, radius, spacing, typography, shadows } from "../../src/theme";
import { colors, radius, spacing, typography, shadows } from "../../src/theme";
import { supabase } from "../../src/lib/supabase";
import { isBiometricEnabled } from "../../src/lib/biometricAuth";
import { checkAndRegisterPushToken } from "../../src/lib/notifications";
import { syncBadgeCount } from "../../src/lib/badge";
import { BiometricLockScreen } from "../../src/components/BiometricLockScreen";
import { MatchNotificationModal, MatchNotificationData } from "../../src/components/MatchNotificationModal";
import { Events } from "../../src/lib/analytics";
import type { MatchWithQuestion, AnswerType } from "../../src/types";
import { needsOnboarding } from "../../src/constants/onboarding";
import type { MatchWithQuestion } from "../../src/types";
import type { Database } from "../../src/types/supabase";
type Message = Database["public"]["Tables"]["messages"]["Row"];
@@ -54,157 +53,21 @@ function TabBarBackground() {
return null;
}
interface PlayTabButtonProps {
children?: React.ReactNode;
onPress?: (e: any) => void;
accessibilityState?: { selected?: boolean };
isMenuOpen?: boolean;
onToggleMenu?: () => void;
}
interface RadialMenuItem {
id: string;
icon: keyof typeof Ionicons.glyphMap;
label: string;
angle: number; // degrees from top (0 = top, -45 = left, 45 = right)
variant: 'primary' | 'gold' | 'rose' | 'muted';
}
// Brand color palette for navigation
const NAV_COLORS = {
// Primary brand colors (from logo)
primary: colors.primary,
primaryRgba: 'rgba(233, 69, 96, ',
secondary: colors.secondary,
secondaryRgba: 'rgba(155, 89, 182, ',
// Feature-specific colors
gold: colors.premium.gold,
goldRgba: 'rgba(212, 175, 55, ',
rose: colors.premium.rose,
roseRgba: 'rgba(232, 164, 174, ',
dark: '#0d0d1a',
};
const RADIAL_MENU_ITEMS: RadialMenuItem[] = [
{ id: 'dares', icon: 'flash', label: 'Dares', angle: -50, variant: 'gold' },
{ id: 'match', icon: 'flame', label: 'Match', angle: 0, variant: 'primary' },
{ id: 'quiz', icon: 'help-circle', label: 'Quiz', angle: 50, variant: 'rose' },
];
const RADIAL_DISTANCE = 100; // Distance from center button
// Store for sharing state between PlayTabButton and AppLayout
let globalMenuToggle: (() => void) | null = null;
let globalMenuOpen = false;
function PlayTabButton({ accessibilityState }: PlayTabButtonProps) {
const isSelected = accessibilityState?.selected;
const [, forceUpdate] = useState({});
// Subscribe to global state changes
useEffect(() => {
const interval = setInterval(() => {
forceUpdate({});
}, 50);
return () => clearInterval(interval);
}, []);
return (
<View style={styles.playButtonContainer}>
{/* Outer glow effect - primary brand */}
<View style={[styles.playButtonGlow, isSelected && styles.playButtonGlowActive]} />
{/* Main circle button - premium dark with primary gradient border */}
<Pressable onPress={() => globalMenuToggle?.()}>
<Animated.View style={styles.playButtonCircle}>
<View style={styles.playButtonInner}>
{/* Silk highlight at top - primary gradient */}
<LinearGradient
colors={[`${NAV_COLORS.primaryRgba}0.2)`, `${NAV_COLORS.secondaryRgba}0.1)`, 'transparent']}
style={styles.playButtonHighlight}
start={{ x: 0, y: 0 }}
end={{ x: 1, y: 1 }}
/>
<Ionicons
name={globalMenuOpen ? "close" : "flame"}
size={28}
color={globalMenuOpen ? colors.textSecondary : NAV_COLORS.primary}
/>
</View>
</Animated.View>
</Pressable>
</View>
);
}
export default function AppLayout() {
const router = useRouter();
const segments = useSegments();
const { isAuthenticated, isLoading, user, signOut, updateLastActive } = useAuthStore();
const { matches, newMatchesCount, addMatch, updateMatchUnreadCount } = useMatchStore();
const { matches, newMatchesCount, addMatch, updateMatchUnreadCount, pendingQuestions, fetchPendingQuestions } = useMatchStore();
const { unreadCount, lastMessage, fetchUnreadCount, addMessage, clearLastMessage } = useMessageStore();
const { fetchEnabledPacks } = usePacksStore();
const { initializeRevenueCat } = useSubscriptionStore();
const { fetchStreak } = useStreakStore();
const [matchNotification, setMatchNotification] = useState<MatchNotificationData | null>(null);
const messageToastAnim = useRef(new Animated.Value(-100)).current;
const messageTimeoutRef = useRef<NodeJS.Timeout | null>(null);
const [isLocked, setIsLocked] = useState(false);
const hasCheckedInitialBiometric = useRef(false);
const wentToBackgroundAt = useRef<number | null>(null);
// Radial menu state
const [isRadialMenuOpen, setIsRadialMenuOpen] = useState(false);
const radialMenuAnim = useRef(new Animated.Value(0)).current;
const toggleRadialMenu = useCallback(() => {
const toValue = isRadialMenuOpen ? 0 : 1;
setIsRadialMenuOpen(!isRadialMenuOpen);
globalMenuOpen = !isRadialMenuOpen;
Animated.spring(radialMenuAnim, {
toValue,
useNativeDriver: true,
tension: 100,
friction: 8,
}).start();
}, [isRadialMenuOpen, radialMenuAnim]);
const closeRadialMenu = useCallback(() => {
setIsRadialMenuOpen(false);
globalMenuOpen = false;
Animated.timing(radialMenuAnim, {
toValue: 0,
duration: 150,
useNativeDriver: true,
}).start();
}, [radialMenuAnim]);
const handleRadialMenuItemPress = useCallback((itemId: string) => {
closeRadialMenu();
// Navigate based on item
switch (itemId) {
case 'match':
router.push('/(app)/swipe');
break;
case 'dares':
router.push('/(app)/dares');
break;
case 'quiz':
router.push('/(app)/quiz');
break;
}
}, [router, closeRadialMenu]);
// Register global menu toggle for PlayTabButton
useEffect(() => {
globalMenuToggle = toggleRadialMenu;
return () => {
globalMenuToggle = null;
};
}, [toggleRadialMenu]);
// Check if we're on screens that should hide the tab bar
const segmentStrings = segments as string[];
const isOnOnboarding = segmentStrings.includes("onboarding");
@@ -213,11 +76,11 @@ export default function AppLayout() {
const isOnSettingsSubscreen = segmentStrings.includes("settings") && segmentStrings.length > 2;
const shouldHideTabBar = isOnOnboarding || isOnPairing || isOnChat || isOnSettingsSubscreen;
// Redirect to login when not authenticated, or to onboarding if not completed
// Redirect to login when not authenticated, or to onboarding if not completed/outdated
useEffect(() => {
if (!isLoading && !isAuthenticated) {
router.replace("/(auth)/login");
} else if (!isLoading && isAuthenticated && user && !user.onboarding_completed && !isOnOnboarding) {
} else if (!isLoading && isAuthenticated && user && needsOnboarding(user.onboarding_completed, user.onboarding_version) && !isOnOnboarding) {
router.replace("/(app)/onboarding");
}
}, [isLoading, isAuthenticated, user, router, isOnOnboarding]);
@@ -316,10 +179,17 @@ export default function AppLayout() {
}
}, [user?.couple_id, fetchStreak]);
// Sync app icon badge count whenever matches or messages change
// Fetch pending questions (Your Turn) when user is in a couple
useEffect(() => {
syncBadgeCount(newMatchesCount, unreadCount);
}, [newMatchesCount, unreadCount]);
if (user?.couple_id) {
fetchPendingQuestions();
}
}, [user?.couple_id, fetchPendingQuestions]);
// Sync app icon badge count whenever matches, messages, or pending questions change
useEffect(() => {
syncBadgeCount(newMatchesCount + pendingQuestions.length, unreadCount);
}, [newMatchesCount, unreadCount, pendingQuestions.length]);
// Initialize RevenueCat for subscription management (iOS and Android only)
useEffect(() => {
@@ -367,7 +237,7 @@ export default function AppLayout() {
};
}, [user?.id, user?.couple_id]);
// Subscribe to realtime match notifications
// Subscribe to realtime match notifications (for badge updates and analytics)
useEffect(() => {
if (!user?.couple_id) return;
@@ -401,42 +271,12 @@ export default function AppLayout() {
Events.milestoneMatch(currentMatchCount + 1);
}
// Add to store
// Add to store (updates badge count via newMatchesCount)
addMatch(matchWithQuestion);
Events.matchCreated(matchWithQuestion.match_type || "unknown");
// Fetch individual responses to show what each person said
const { data: responses } = await supabase
.from("responses")
.select("user_id, answer")
.eq("question_id", matchWithQuestion.question_id)
.eq("couple_id", user.couple_id);
let userResponse: AnswerType | undefined;
let partnerResponse: AnswerType | undefined;
if (responses && responses.length >= 2) {
for (const r of responses) {
if (r.user_id === user.id) {
userResponse = r.answer as AnswerType;
} else {
partnerResponse = r.answer as AnswerType;
}
}
}
// Get partner name from auth store
const { partner } = useAuthStore.getState();
// Show notification with response data
setMatchNotification({
id: matchWithQuestion.id,
match_type: matchWithQuestion.match_type,
question: matchWithQuestion.question,
userResponse,
partnerResponse,
partnerName: partner?.name,
});
// Note: No modal popup - user will see badge on matches tab
// Confetti animation is shown inline on swipe screen when user creates match
}
}
)
@@ -561,18 +401,6 @@ export default function AppLayout() {
};
}, [lastMessage]);
const dismissNotification = useCallback(() => {
setMatchNotification(null);
}, []);
const handleMatchChat = useCallback(() => {
if (matchNotification) {
const matchId = matchNotification.id;
setMatchNotification(null);
router.push(`/(app)/chat/${matchId}`);
}
}, [matchNotification, router]);
const dismissMessageToast = useCallback(() => {
Animated.timing(messageToastAnim, {
toValue: -100,
@@ -591,7 +419,7 @@ export default function AppLayout() {
}, [lastMessage, dismissMessageToast, router]);
// Show loading state while checking authentication or redirecting to onboarding
if (isLoading || !isAuthenticated || (user && !user.onboarding_completed && !isOnOnboarding)) {
if (isLoading || !isAuthenticated || (user && needsOnboarding(user.onboarding_completed, user.onboarding_version) && !isOnOnboarding)) {
return (
<View style={styles.loadingContainer}>
<ActivityIndicator size="large" color={colors.primary} />
@@ -633,20 +461,10 @@ export default function AppLayout() {
),
}}
/>
<Tabs.Screen
name="packs"
options={{
title: "Packs",
tabBarIcon: ({ color, size }) => (
<Ionicons name="layers" size={size} color={color} />
),
}}
/>
<Tabs.Screen
name="swipe"
options={{
title: "",
tabBarButton: (props) => <PlayTabButton {...props} />,
href: null, // Hide from tab bar - accessed from home screen
}}
/>
<Tabs.Screen
@@ -656,7 +474,7 @@ export default function AppLayout() {
tabBarIcon: ({ color, size }) => (
<Ionicons name="heart" size={size} color={color} />
),
tabBarBadge: (newMatchesCount + unreadCount) > 0 ? (newMatchesCount + unreadCount) : undefined,
tabBarBadge: (newMatchesCount + unreadCount + pendingQuestions.length) > 0 ? (newMatchesCount + unreadCount + pendingQuestions.length) : undefined,
tabBarBadgeStyle: {
backgroundColor: colors.premium.rose,
fontSize: 9,
@@ -720,128 +538,6 @@ export default function AppLayout() {
/>
</Tabs>
{/* Radial Menu Overlay */}
{isRadialMenuOpen && (
<Pressable
style={radialStyles.overlay}
onPress={closeRadialMenu}
/>
)}
{/* Radial Menu Items - positioned above the center button */}
<Animated.View
style={[
radialStyles.menuContainer,
{ opacity: radialMenuAnim },
]}
pointerEvents={isRadialMenuOpen ? 'auto' : 'none'}
>
{RADIAL_MENU_ITEMS.map((item) => {
// Convert angle to radians and calculate position
const angleRad = (item.angle - 90) * (Math.PI / 180); // -90 to make 0 = top
const translateX = Math.cos(angleRad) * RADIAL_DISTANCE;
const translateY = Math.sin(angleRad) * RADIAL_DISTANCE;
const itemScale = radialMenuAnim.interpolate({
inputRange: [0, 1],
outputRange: [0.3, 1],
});
const itemTranslateX = radialMenuAnim.interpolate({
inputRange: [0, 1],
outputRange: [0, translateX],
});
const itemTranslateY = radialMenuAnim.interpolate({
inputRange: [0, 1],
outputRange: [0, translateY],
});
// Brand and feature variant colors
const getVariantStyles = () => {
switch (item.variant) {
case 'primary':
return {
bg: `${NAV_COLORS.primaryRgba}0.15)`,
border: `${NAV_COLORS.primaryRgba}0.35)`,
icon: NAV_COLORS.primary,
label: NAV_COLORS.primary,
};
case 'gold':
return {
bg: `${NAV_COLORS.goldRgba}0.15)`,
border: `${NAV_COLORS.goldRgba}0.35)`,
icon: NAV_COLORS.gold,
label: NAV_COLORS.gold,
};
case 'rose':
return {
bg: `${NAV_COLORS.roseRgba}0.15)`,
border: `${NAV_COLORS.roseRgba}0.35)`,
icon: NAV_COLORS.rose,
label: NAV_COLORS.rose,
};
case 'muted':
default:
return {
bg: 'rgba(255, 255, 255, 0.08)',
border: 'rgba(255, 255, 255, 0.15)',
icon: colors.textSecondary,
label: colors.textSecondary,
};
}
};
const variantStyles = getVariantStyles();
return (
<Animated.View
key={item.id}
style={[
radialStyles.menuItem,
{
transform: [
{ translateX: itemTranslateX },
{ translateY: itemTranslateY },
{ scale: itemScale },
],
},
]}
>
<Pressable
onPress={() => handleRadialMenuItemPress(item.id)}
style={radialStyles.menuItemButton}
>
<View style={[
radialStyles.menuItemCircle,
{
backgroundColor: variantStyles.bg,
borderColor: variantStyles.border,
},
]}>
<Ionicons
name={item.icon}
size={22}
color={variantStyles.icon}
/>
</View>
<Text style={[radialStyles.menuItemLabel, { color: variantStyles.label }]}>
{item.label}
</Text>
</Pressable>
</Animated.View>
);
})}
</Animated.View>
{/* Match Notification Modal - Premium styling with response details */}
<MatchNotificationModal
visible={!!matchNotification}
match={matchNotification}
onDismiss={dismissNotification}
onChat={handleMatchChat}
/>
{/* Message Toast Notification */}
{lastMessage && (
<Animated.View
@@ -923,98 +619,4 @@ const styles = StyleSheet.create({
color: colors.textSecondary,
marginTop: 2,
},
// Play button styles - Premium boutique
playButtonContainer: {
flex: 1,
alignItems: 'center',
justifyContent: 'center',
marginTop: -28,
},
playButtonGlow: {
position: 'absolute',
width: 76,
height: 76,
borderRadius: 38,
backgroundColor: colors.primaryGlow,
opacity: 0.4,
},
playButtonGlowActive: {
opacity: 0.7,
...shadows.glow(colors.primary),
},
playButtonCircle: {
width: 64,
height: 64,
borderRadius: 32,
overflow: 'hidden',
borderWidth: 1.5,
borderColor: 'rgba(233, 69, 96, 0.4)', // primary brand
...shadows.lg,
},
playButtonInner: {
flex: 1,
backgroundColor: '#0d0d1a',
alignItems: 'center',
justifyContent: 'center',
},
playButtonHighlight: {
position: 'absolute',
top: 0,
left: 0,
right: 0,
height: '50%',
},
playButtonGradient: {
flex: 1,
alignItems: 'center',
justifyContent: 'center',
},
});
// Radial menu styles - Premium boutique
const TAB_BAR_HEIGHT = Platform.OS === 'ios' ? 88 : 64;
const radialStyles = StyleSheet.create({
overlay: {
...StyleSheet.absoluteFillObject,
backgroundColor: 'rgba(13, 13, 26, 0.85)',
zIndex: 998,
},
menuContainer: {
position: 'absolute',
bottom: TAB_BAR_HEIGHT + 30, // Position above tab bar, centered on button
left: 0,
right: 0,
alignItems: 'center',
zIndex: 999,
},
menuItem: {
position: 'absolute',
alignItems: 'center',
},
menuItemButton: {
alignItems: 'center',
},
menuItemCircle: {
width: 56,
height: 56,
borderRadius: 28,
backgroundColor: 'rgba(22, 33, 62, 0.9)',
justifyContent: 'center',
alignItems: 'center',
...shadows.md,
borderWidth: 1.5,
borderColor: 'rgba(255, 255, 255, 0.15)',
},
menuItemLabel: {
...typography.caption2,
color: colors.text,
marginTop: 8,
fontWeight: '500',
letterSpacing: 1,
textTransform: 'uppercase',
textShadowColor: 'rgba(0, 0, 0, 0.5)',
textShadowOffset: { width: 0, height: 1 },
textShadowRadius: 2,
},
});
+41 -41
View File
@@ -162,15 +162,36 @@ const styles = StyleSheet.create({
marginHorizontal: spacing.md,
opacity: 0.6,
},
badge: {
backgroundColor: 'rgba(212, 175, 55, 0.1)',
paddingHorizontal: spacing.lg,
paddingVertical: spacing.sm,
borderRadius: radius.full,
borderWidth: 1,
borderColor: 'rgba(212, 175, 55, 0.2)',
marginBottom: spacing.xl,
},
badge: {
backgroundColor: colors.backgroundLight,
paddingHorizontal: spacing.lg,
paddingVertical: spacing.sm,
borderRadius: radius.full,
borderWidth: 1,
borderColor: colors.border,
marginBottom: spacing.xl,
},
interestButton: {
flexDirection: 'row',
alignItems: 'center',
justifyContent: 'center',
backgroundColor: colors.backgroundLight,
paddingHorizontal: spacing.lg,
paddingVertical: spacing.sm + 2,
borderRadius: radius.full,
borderWidth: 1,
borderColor: colors.border,
gap: spacing.sm,
},
interestButtonActive: {
backgroundColor: colors.background,
borderColor: colors.primary,
},
interestButtonText: {
...typography.subhead,
fontWeight: '600',
color: ACCENT_COLOR,
},
badgeText: {
...typography.caption2,
fontWeight: '600',
@@ -198,35 +219,14 @@ const styles = StyleSheet.create({
color: colors.text,
marginLeft: spacing.sm,
},
teaser: {
...typography.footnote,
fontStyle: 'italic',
color: colors.textTertiary,
textAlign: 'center',
},
interestButtonContainer: {
marginTop: spacing.xl,
minHeight: 44,
},
interestButton: {
flexDirection: 'row',
alignItems: 'center',
justifyContent: 'center',
backgroundColor: 'rgba(212, 175, 55, 0.1)',
paddingHorizontal: spacing.lg,
paddingVertical: spacing.sm + 2,
borderRadius: radius.full,
borderWidth: 1,
borderColor: 'rgba(212, 175, 55, 0.2)',
gap: spacing.sm,
},
interestButtonActive: {
backgroundColor: 'rgba(212, 175, 55, 0.15)',
borderColor: 'rgba(212, 175, 55, 0.3)',
},
interestButtonText: {
...typography.subhead,
fontWeight: '600',
color: ACCENT_COLOR,
},
});
teaser: {
...typography.footnote,
fontStyle: 'italic',
color: colors.textTertiary,
textAlign: 'center',
},
interestButtonContainer: {
marginTop: spacing.xl,
minHeight: 44,
},
});
+130 -105
View File
@@ -1,22 +1,23 @@
import { useCallback, useMemo, useState } from 'react';
import { ScrollView, StyleSheet, Platform, View, Text, useWindowDimensions } from 'react-native';
import { useFocusEffect } from 'expo-router';
import Animated, { FadeInDown } from 'react-native-reanimated';
import { Ionicons } from '@expo/vector-icons';
import { useAuthStore, usePacksStore } from '../../src/store';
import { GradientBackground } from '../../src/components/ui';
import { Paywall } from '../../src/components/paywall';
import { CompactHeader, ContentRow } from '../../src/components/discovery';
import { colors, spacing, typography, radius } from '../../src/theme';
import type { QuestionPack, Category } from '../../src/types';
const MAX_CONTENT_WIDTH = 600;
// Group packs by category
function groupPacksByCategory(
packs: QuestionPack[],
categories: Category[]
): { category: Category; packs: QuestionPack[] }[] {
import { useCallback, useMemo, useState } from 'react';
import { ScrollView, StyleSheet, Platform, View, Text, useWindowDimensions } from 'react-native';
import { useFocusEffect } from 'expo-router';
import Animated, { FadeInDown } from 'react-native-reanimated';
import { Ionicons } from '@expo/vector-icons';
import { BlurView } from 'expo-blur';
import { useAuthStore, usePacksStore } from '../../src/store';
import { GradientBackground } from '../../src/components/ui';
import { Paywall } from '../../src/components/paywall';
import { CompactHeader, ContentRow } from '../../src/components/discovery';
import { colors, spacing, typography, radius } from '../../src/theme';
import type { QuestionPack, Category } from '../../src/types';
const MAX_CONTENT_WIDTH = 600;
// Group packs by category
function groupPacksByCategory(
packs: QuestionPack[],
categories: Category[]
): { category: Category; packs: QuestionPack[] }[] {
const categoryMap = new Map<string, QuestionPack[]>();
const uncategorized: QuestionPack[] = [];
@@ -67,88 +68,97 @@ function groupPacksByCategory(
export default function DiscoveryScreen() {
const { user, partner, couple } = useAuthStore();
const { packs, categories, fetchPacks, getPackProgress } = usePacksStore();
const { width } = useWindowDimensions();
const [showPaywall, setShowPaywall] = useState(false);
const isPremiumUser = user?.is_premium ?? false;
const isWideScreen = width > MAX_CONTENT_WIDTH;
// Refresh data when screen gains focus
useFocusEffect(
useCallback(() => {
fetchPacks();
}, [])
);
// Group packs by category
const packsByCategory = useMemo(
() => groupPacksByCategory(packs, categories),
[packs, categories]
);
// Check if user has no packs enabled (empty state)
const hasNoPacks = packs.length === 0;
return (
<GradientBackground>
<ScrollView
style={styles.container}
contentContainerStyle={[
styles.contentContainer,
isWideScreen && styles.contentContainerWide,
]}
showsVerticalScrollIndicator={false}
>
<View style={[styles.innerContainer, isWideScreen && styles.innerContainerWide]}>
{/* Compact Header */}
<CompactHeader user={user} partner={partner} couple={couple} />
{/* Content Rows by Category */}
{packsByCategory.map((group, index) => (
<ContentRow
key={group.category.id}
title={group.category.name}
packs={group.packs}
isPremiumUser={isPremiumUser}
categoryId={group.category.id}
delay={200 + index * 100}
getPackProgress={getPackProgress}
onShowPaywall={() => setShowPaywall(true)}
/>
))}
{/* Empty state when no packs */}
{hasNoPacks && (
<Animated.View
entering={FadeInDown.delay(300).duration(500).springify()}
style={styles.emptyState}
>
<View style={styles.emptyIcon}>
<Ionicons name="layers-outline" size={48} color={colors.primary} />
</View>
<Text style={styles.emptyTitle}>No packs available</Text>
<Text style={styles.emptySubtitle}>
Check back soon for new content
</Text>
</Animated.View>
)}
<View style={styles.bottomSpacer} />
</View>
</ScrollView>
{/* Paywall Modal */}
<Paywall
visible={showPaywall}
onClose={() => setShowPaywall(false)}
onSuccess={() => {
setShowPaywall(false);
fetchPacks(); // Refresh packs to update lock status
}}
/>
</GradientBackground>
);
}
const { width } = useWindowDimensions();
const [showPaywall, setShowPaywall] = useState(false);
const [headerHeight, setHeaderHeight] = useState(130);
const isPremiumUser = user?.is_premium ?? false;
const isWideScreen = width > MAX_CONTENT_WIDTH;
// Refresh data when screen gains focus
useFocusEffect(
useCallback(() => {
fetchPacks();
}, [])
);
// Group packs by category
const packsByCategory = useMemo(
() => groupPacksByCategory(packs, categories),
[packs, categories]
);
// Check if user has no packs enabled (empty state)
const hasNoPacks = packs.length === 0;
return (
<GradientBackground>
<View
style={[styles.stickyHeader, isWideScreen && styles.stickyHeaderWide]}
onLayout={(e) => setHeaderHeight(e.nativeEvent.layout.height)}
>
<BlurView intensity={80} tint="dark" style={StyleSheet.absoluteFill} />
<View style={[StyleSheet.absoluteFill, { backgroundColor: 'rgba(14, 14, 17, 0.85)' }]} />
<CompactHeader user={user} partner={partner} couple={couple} />
</View>
<ScrollView
style={styles.container}
contentContainerStyle={[
styles.contentContainer,
isWideScreen && styles.contentContainerWide,
{ paddingTop: headerHeight },
]}
showsVerticalScrollIndicator={false}
>
<View style={[styles.innerContainer, isWideScreen && styles.innerContainerWide]}>
{/* Content Rows by Category */}
{packsByCategory.map((group, index) => (
<ContentRow
key={group.category.id}
title={group.category.name}
packs={group.packs}
isPremiumUser={isPremiumUser}
categoryId={group.category.id}
delay={200 + index * 100}
getPackProgress={getPackProgress}
onShowPaywall={() => setShowPaywall(true)}
/>
))}
{/* Empty state when no packs */}
{hasNoPacks && (
<Animated.View
entering={FadeInDown.delay(300).duration(500).springify()}
style={styles.emptyState}
>
<View style={styles.emptyIcon}>
<Ionicons name="layers-outline" size={48} color={colors.primary} />
</View>
<Text style={styles.emptyTitle}>No packs available</Text>
<Text style={styles.emptySubtitle}>
Check back soon for new content
</Text>
</Animated.View>
)}
<View style={styles.bottomSpacer} />
</View>
</ScrollView>
{/* Paywall Modal */}
<Paywall
visible={showPaywall}
onClose={() => setShowPaywall(false)}
onSuccess={() => {
setShowPaywall(false);
fetchPacks(); // Refresh packs to update lock status
}}
/>
</GradientBackground>
);
}
const styles = StyleSheet.create({
container: {
@@ -194,7 +204,22 @@ const styles = StyleSheet.create({
color: colors.textSecondary,
textAlign: 'center',
},
bottomSpacer: {
height: spacing.lg,
},
});
bottomSpacer: {
height: spacing.lg,
},
stickyHeader: {
position: 'absolute',
top: 0,
left: 0,
right: 0,
zIndex: 10,
overflow: 'hidden', // Ensures blur doesn't bleed if we rounded corners (optional)
},
stickyHeaderWide: {
left: '50%',
right: 'auto',
width: MAX_CONTENT_WIDTH,
transform: [{ translateX: -MAX_CONTENT_WIDTH / 2 }],
},
});
File diff suppressed because it is too large. Load diff
+56 -45
View File
@@ -48,7 +48,7 @@ type GroupByOption = "pack" | "date" | "answer";
export default function MyAnswersScreen() {
const { responses, isLoading, isLoadingMore, hasMore, groupBy, dateSortOrder, fetchResponses, setGroupBy, toggleDateSortOrder, totalCount } = useResponsesStore();
const { user } = useAuthStore();
const { user, partner } = useAuthStore();
const router = useRouter();
const params = useLocalSearchParams();
const { width } = useWindowDimensions();
@@ -152,6 +152,9 @@ export default function MyAnswersScreen() {
index={index}
onEditPress={() => handleEditPress(item)}
onChatPress={item.match_id ? () => router.push(`/chat/${item.match_id}`) : undefined}
viewerId={user?.id}
viewerName={user?.name}
partnerName={partner?.name}
/>
);
@@ -210,7 +213,7 @@ export default function MyAnswersScreen() {
return (
<GradientBackground>
{/* Ambient Orbs */}
{/* Ambient Orbs - Commented out for flat look
<Animated.View
style={[styles.ambientOrb, styles.orbTopRight, orbStyle1]}
pointerEvents="none"
@@ -233,16 +236,13 @@ export default function MyAnswersScreen() {
end={{ x: 0, y: 0 }}
/>
</Animated.View>
*/}
<View style={styles.container}>
{/* 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)" }]} />
)}
<View style={[StyleSheet.absoluteFill, { backgroundColor: colors.background }]} />
</Animated.View>
<TouchableOpacity style={styles.navBarBackButton} onPress={handleBack}>
@@ -322,7 +322,7 @@ export default function MyAnswersScreen() {
come back here to review and change your answers anytime.
</Text>
<GlassButton
onPress={() => router.push("/(app)/swipe")}
onPress={() => router.push("/")}
style={{ marginTop: spacing.lg }}
>
Start Swiping
@@ -390,7 +390,8 @@ const styles = StyleSheet.create({
navBarBackground: {
...StyleSheet.absoluteFillObject,
borderBottomWidth: 1,
borderBottomColor: 'rgba(212, 175, 55, 0.15)', // Gold tint
borderBottomColor: colors.border,
backgroundColor: colors.background,
overflow: "hidden",
},
navBarBackButton: {
@@ -401,12 +402,52 @@ const styles = StyleSheet.create({
width: 36,
height: 36,
borderRadius: 18,
backgroundColor: "rgba(255, 255, 255, 0.08)",
backgroundColor: colors.backgroundLight,
justifyContent: "center",
alignItems: "center",
borderWidth: 1,
borderColor: "rgba(255, 255, 255, 0.1)",
borderColor: colors.border,
},
// ...
countBadgePremium: {
flexDirection: "row",
alignItems: "center",
backgroundColor: colors.backgroundLight,
paddingHorizontal: spacing.md,
paddingVertical: spacing.sm,
borderRadius: radius.full,
borderWidth: 1,
borderColor: colors.border,
gap: spacing.xs,
},
// ...
groupByButton: {
flexDirection: "row",
alignItems: "center",
paddingHorizontal: spacing.md,
paddingVertical: spacing.sm,
borderRadius: radius.full,
backgroundColor: colors.backgroundLight,
borderWidth: 1,
borderColor: colors.border,
},
groupByButtonActive: {
backgroundColor: colors.background,
borderColor: colors.primary,
},
// ...
emptyIcon: {
width: 80,
height: 80,
borderRadius: 40,
backgroundColor: colors.backgroundLight,
justifyContent: "center",
alignItems: "center",
marginBottom: spacing.lg,
borderWidth: 1,
borderColor: colors.border,
},
navBarTitle: {
...typography.headline,
color: colors.text,
@@ -439,17 +480,7 @@ const styles = StyleSheet.create({
color: colors.text,
textAlign: "center",
},
countBadgePremium: {
flexDirection: "row",
alignItems: "center",
backgroundColor: `${ACCENT_RGBA}0.1)`,
paddingHorizontal: spacing.md,
paddingVertical: spacing.sm,
borderRadius: radius.full,
borderWidth: 1,
borderColor: `${ACCENT_RGBA}0.2)`,
gap: spacing.xs,
},
countTextPremium: {
...typography.caption2,
fontWeight: "600",
@@ -463,20 +494,8 @@ const styles = StyleSheet.create({
gap: spacing.sm,
marginTop: spacing.md,
},
groupByButton: {
flexDirection: "row",
alignItems: "center",
paddingHorizontal: spacing.md,
paddingVertical: spacing.sm,
borderRadius: radius.full,
backgroundColor: "rgba(255, 255, 255, 0.05)",
borderWidth: 1,
borderColor: "rgba(255, 255, 255, 0.1)",
},
groupByButtonActive: {
backgroundColor: `${ACCENT_RGBA}0.15)`,
borderColor: `${ACCENT_RGBA}0.3)`,
},
groupByText: {
...typography.caption1,
color: colors.textSecondary,
@@ -518,15 +537,7 @@ const styles = StyleSheet.create({
paddingHorizontal: spacing.md,
paddingVertical: spacing.xxl,
},
emptyIcon: {
width: 80,
height: 80,
borderRadius: 40,
backgroundColor: `${ACCENT_RGBA}0.1)`,
justifyContent: "center",
alignItems: "center",
marginBottom: spacing.lg,
},
emptyTitle: {
...typography.title2,
color: colors.text,
+127 -74
View File
@@ -25,13 +25,16 @@ import { GradientBackground } from '../../src/components/ui/GradientBackground';
import { GlassCard } from '../../src/components/ui/GlassCard';
import { GlassButton } from '../../src/components/ui/GlassButton';
import { GlassToggle } from '../../src/components/ui/GlassToggle';
import { Paywall } from '../../src/components/paywall';
import { colors, gradients, spacing, typography, radius, shadows } from '../../src/theme';
import { useAuthStore } from '../../src/store';
import { supabase } from '../../src/lib/supabase';
import { getProfileError } from '../../src/lib/errors';
import { registerForPushNotificationsAsync, savePushToken } from '../../src/lib/notifications';
import { Events } from '../../src/lib/analytics';
import { hasSeenOnboardingPaywall, markOnboardingPaywallSeen } from '../../src/lib/onboardingPaywallSeen';
import { useAvatarPicker } from '../../src/hooks/useAvatarPicker';
import { REQUIRED_ONBOARDING_VERSION, ONBOARDING_OFFERING_ID } from '../../src/constants/onboarding';
import type { Gender } from '../../src/types';
const GENDER_OPTIONS: { value: Gender; label: string; icon: string }[] = [
@@ -69,7 +72,9 @@ export default function OnboardingScreen() {
const [hideNsfw, setHideNsfw] = useState(user?.hide_nsfw ?? false);
const [isLoading, setIsLoading] = useState(false);
const [error, setError] = useState<string | null>(null);
const [showPaywall, setShowPaywall] = useState(false);
const inputRef = useRef<TextInput>(null);
const paywallNavigationRef = useRef(false);
const {
avatarUri,
@@ -187,6 +192,7 @@ export default function OnboardingScreen() {
show_explicit_content: showExplicitContent,
hide_nsfw: hideNsfw,
onboarding_completed: true,
onboarding_version: REQUIRED_ONBOARDING_VERSION,
updated_at: new Date().toISOString(),
};
@@ -210,6 +216,15 @@ export default function OnboardingScreen() {
console.log('[Onboarding] User fetched, navigating to home...');
Events.profileUpdated(updatedFields);
Events.onboardingComplete();
const currentUser = useAuthStore.getState().user;
if (currentUser?.id && !currentUser.is_premium) {
const hasSeenPaywall = await hasSeenOnboardingPaywall(currentUser.id);
if (!hasSeenPaywall) {
paywallNavigationRef.current = true;
setShowPaywall(true);
return;
}
}
router.replace('/');
} catch (err: any) {
console.error('Onboarding error:', err);
@@ -219,6 +234,18 @@ export default function OnboardingScreen() {
}
};
const handlePaywallClose = () => {
setShowPaywall(false);
const currentUser = useAuthStore.getState().user;
if (currentUser?.id) {
void markOnboardingPaywallSeen(currentUser.id);
}
if (paywallNavigationRef.current) {
paywallNavigationRef.current = false;
router.replace('/');
}
};
const renderStage = () => {
switch (stage) {
case 'avatar':
@@ -248,12 +275,11 @@ export default function OnboardingScreen() {
{avatarUri ? (
<Image source={{ uri: avatarUri }} style={styles.avatarImage} />
) : (
<LinearGradient
colors={[colors.glass.background, colors.glass.backgroundLight]}
<View
style={styles.avatarPlaceholder}
>
<Ionicons name="camera" size={40} color={colors.textSecondary} />
</LinearGradient>
</View>
)}
<View style={styles.avatarBadge}>
<Ionicons name={avatarUri ? "pencil" : "add"} size={16} color={colors.text} />
@@ -662,6 +688,12 @@ export default function OnboardingScreen() {
{renderStage()}
</ScrollView>
</KeyboardAvoidingView>
<Paywall
visible={showPaywall}
onClose={handlePaywallClose}
offeringId={ONBOARDING_OFFERING_ID}
/>
</GradientBackground>
);
}
@@ -685,9 +717,92 @@ const styles = StyleSheet.create({
width: 40,
height: 40,
borderRadius: 20,
backgroundColor: colors.glass.background,
backgroundColor: colors.backgroundLight,
alignItems: 'center',
justifyContent: 'center',
borderWidth: 1,
borderColor: colors.border,
},
// ...
progressDot: {
width: 8,
height: 8,
borderRadius: 4,
backgroundColor: colors.border,
},
// ...
avatarPlaceholder: {
width: 120,
height: 120,
borderRadius: 60,
alignItems: 'center',
justifyContent: 'center',
borderWidth: 2,
borderColor: colors.border,
borderStyle: 'dashed',
backgroundColor: colors.backgroundLight,
},
// ...
inputContainer: {
flexDirection: 'row',
alignItems: 'center',
backgroundColor: colors.background, // Flat background
borderRadius: radius.md,
borderWidth: 1,
borderColor: colors.border,
paddingHorizontal: spacing.md,
},
// ...
genderOption: {
width: '48%',
alignItems: 'center',
backgroundColor: colors.backgroundLight,
borderRadius: radius.lg,
borderWidth: 2,
borderColor: colors.border,
paddingVertical: spacing.lg,
paddingHorizontal: spacing.md,
gap: spacing.sm,
},
// ...
purposeOption: {
flexDirection: 'row',
alignItems: 'center',
backgroundColor: colors.backgroundLight,
borderRadius: radius.lg,
borderWidth: 2,
borderColor: colors.border,
padding: spacing.md,
},
// ...
purposeIcon: {
width: 44,
height: 44,
borderRadius: 22,
backgroundColor: colors.background,
alignItems: 'center',
justifyContent: 'center',
marginRight: spacing.md,
borderWidth: 1,
borderColor: colors.border,
},
// ...
radioOuter: {
width: 24,
height: 24,
borderRadius: 12,
borderWidth: 2,
borderColor: colors.border,
alignItems: 'center',
justifyContent: 'center',
},
// ...
notificationFeature: {
flexDirection: 'row',
alignItems: 'center',
paddingVertical: spacing.md,
borderBottomWidth: 1,
borderBottomColor: colors.border,
},
backButtonPlaceholder: {
width: 40,
@@ -697,12 +812,7 @@ const styles = StyleSheet.create({
justifyContent: 'center',
gap: spacing.sm,
},
progressDot: {
width: 8,
height: 8,
borderRadius: 4,
backgroundColor: colors.glass.border,
},
progressDotActive: {
backgroundColor: colors.primary,
width: 24,
@@ -747,16 +857,7 @@ const styles = StyleSheet.create({
borderWidth: 3,
borderColor: colors.primary,
},
avatarPlaceholder: {
width: 120,
height: 120,
borderRadius: 60,
alignItems: 'center',
justifyContent: 'center',
borderWidth: 2,
borderColor: colors.glass.border,
borderStyle: 'dashed',
},
avatarBadge: {
position: 'absolute',
bottom: 4,
@@ -800,15 +901,7 @@ const styles = StyleSheet.create({
color: colors.text,
marginBottom: spacing.md,
},
inputContainer: {
flexDirection: 'row',
alignItems: 'center',
backgroundColor: colors.glass.background,
borderRadius: radius.md,
borderWidth: 1,
borderColor: colors.glass.border,
paddingHorizontal: spacing.md,
},
inputIcon: {
marginRight: spacing.sm,
},
@@ -823,17 +916,7 @@ const styles = StyleSheet.create({
flexWrap: 'wrap',
gap: spacing.sm,
},
genderOption: {
width: '48%',
alignItems: 'center',
backgroundColor: colors.glass.background,
borderRadius: radius.lg,
borderWidth: 2,
borderColor: colors.glass.border,
paddingVertical: spacing.lg,
paddingHorizontal: spacing.md,
gap: spacing.sm,
},
genderOptionSelected: {
borderColor: colors.primary,
backgroundColor: colors.primaryLight,
@@ -850,28 +933,12 @@ const styles = StyleSheet.create({
purposeList: {
gap: spacing.sm,
},
purposeOption: {
flexDirection: 'row',
alignItems: 'center',
backgroundColor: colors.glass.background,
borderRadius: radius.lg,
borderWidth: 2,
borderColor: colors.glass.border,
padding: spacing.md,
},
purposeOptionSelected: {
borderColor: colors.primary,
backgroundColor: colors.primaryLight,
},
purposeIcon: {
width: 44,
height: 44,
borderRadius: 22,
backgroundColor: colors.glass.background,
alignItems: 'center',
justifyContent: 'center',
marginRight: spacing.md,
},
purposeIconSelected: {
backgroundColor: colors.primaryLight,
},
@@ -921,15 +988,7 @@ const styles = StyleSheet.create({
textAlign: 'center',
marginTop: spacing.lg,
},
radioOuter: {
width: 24,
height: 24,
borderRadius: 12,
borderWidth: 2,
borderColor: colors.glass.border,
alignItems: 'center',
justifyContent: 'center',
},
radioOuterSelected: {
borderColor: colors.primary,
},
@@ -956,13 +1015,7 @@ const styles = StyleSheet.create({
footer: {
marginTop: 'auto',
},
notificationFeature: {
flexDirection: 'row',
alignItems: 'center',
paddingVertical: spacing.md,
borderBottomWidth: 1,
borderBottomColor: colors.glass.border,
},
notificationFeatureLast: {
borderBottomWidth: 0,
},
-15
View File
@@ -1,15 +0,0 @@
import { Stack } from "expo-router";
import { colors } from "../../../src/theme";
export default function PacksLayout() {
return (
<Stack
screenOptions={{
headerShown: false,
contentStyle: { backgroundColor: colors.background },
animation: "fade",
animationDuration: 150,
}}
/>
);
}
-298
View File
@@ -1,298 +0,0 @@
import { View, Text, StyleSheet, Pressable, Platform } from "react-native";
import { Ionicons } from "@expo/vector-icons";
import { router } from "expo-router";
import { useEffect } from "react";
import Animated, { FadeInDown } from "react-native-reanimated";
import { LinearGradient } from "expo-linear-gradient";
import { GradientBackground } from "../../../src/components/ui";
import { usePacksStore } from "../../../src/store";
import { colors, spacing, radius, typography, shadows } from "../../../src/theme";
interface PackTypeCard {
id: string;
title: string;
description: string;
icon: keyof typeof Ionicons.glyphMap;
color: string;
colorRgba: string;
gradient: [string, string];
comingSoon: boolean;
route?: string;
}
const PACK_TYPES: PackTypeCard[] = [
{
id: "questions",
title: "Question Packs",
description: "Explore intimate questions together and discover what you both want",
icon: "layers",
color: colors.primary,
colorRgba: "rgba(233, 69, 96, ",
gradient: ["rgba(233, 69, 96, 0.25)", "rgba(155, 89, 182, 0.25)"],
comingSoon: false,
route: "/(app)/packs/questions",
},
{
id: "dares",
title: "Dare Packs",
description: "Spice things up with exciting dares for adventurous couples",
icon: "flash",
color: colors.premium.gold,
colorRgba: "rgba(212, 175, 55, ",
gradient: ["rgba(212, 175, 55, 0.25)", "rgba(184, 134, 11, 0.25)"],
comingSoon: true,
route: "/(app)/dares",
},
{
id: "quiz",
title: "Quiz Packs",
description: "Test how well you know each other with fun couple quizzes",
icon: "help-circle",
color: colors.premium.rose,
colorRgba: "rgba(232, 164, 174, ",
gradient: ["rgba(232, 164, 174, 0.25)", "rgba(212, 145, 155, 0.25)"],
comingSoon: true,
route: "/(app)/quiz",
},
];
export default function PackTypeSelection() {
const { fetchPacks } = usePacksStore();
// Prefetch packs data so it's ready when navigating to questions
useEffect(() => {
fetchPacks();
}, []);
const handlePackTypePress = (packType: PackTypeCard) => {
if (packType.route) {
router.push(packType.route as any);
}
};
return (
<GradientBackground>
<View style={styles.container}>
{/* Header */}
<Animated.View
entering={FadeInDown.duration(600).springify()}
style={styles.header}
>
<Text style={styles.label}>EXPLORE</Text>
<Text style={styles.title}>Content Packs</Text>
{/* Decorative Separator */}
<View style={styles.separator}>
<View style={styles.separatorLine} />
<View style={styles.separatorDiamond} />
<View style={styles.separatorLine} />
</View>
<Text style={styles.subtitle}>
Choose a pack type to explore
</Text>
</Animated.View>
{/* Pack Type Cards */}
<View style={styles.cardsContainer}>
{PACK_TYPES.map((packType, index) => (
<Animated.View
key={packType.id}
entering={FadeInDown.delay(100 + index * 100).duration(500).springify()}
>
<Pressable
onPress={() => handlePackTypePress(packType)}
style={({ pressed }) => [
styles.card,
pressed && !packType.comingSoon && styles.cardPressed,
packType.comingSoon && styles.cardDisabled,
]}
>
<LinearGradient
colors={packType.gradient as [string, string]}
start={{ x: 0, y: 0 }}
end={{ x: 1, y: 1 }}
style={styles.cardGradient}
>
{/* Icon Circle */}
<View
style={[
styles.iconCircle,
{
backgroundColor: `${packType.colorRgba}0.15)`,
borderColor: `${packType.colorRgba}0.3)`,
},
]}
>
<Ionicons
name={packType.icon}
size={32}
color={packType.comingSoon ? colors.textTertiary : packType.color}
/>
</View>
{/* Content */}
<View style={styles.cardContent}>
<View style={styles.cardTitleRow}>
<Text
style={[
styles.cardTitle,
packType.comingSoon && styles.cardTitleDisabled,
]}
>
{packType.title}
</Text>
{packType.comingSoon && (
<View style={styles.comingSoonBadge}>
<Text style={styles.comingSoonText}>Coming Soon</Text>
</View>
)}
</View>
<Text
style={[
styles.cardDescription,
packType.comingSoon && styles.cardDescriptionDisabled,
]}
>
{packType.description}
</Text>
</View>
{/* Arrow */}
{!packType.comingSoon && (
<Ionicons
name="chevron-forward"
size={24}
color={colors.textSecondary}
/>
)}
</LinearGradient>
</Pressable>
</Animated.View>
))}
</View>
</View>
</GradientBackground>
);
}
const styles = StyleSheet.create({
container: {
flex: 1,
},
header: {
paddingTop: 60,
paddingHorizontal: spacing.lg,
paddingBottom: spacing.lg,
alignItems: "center",
},
label: {
...typography.caption1,
fontWeight: "600",
letterSpacing: 3,
color: colors.primary,
textAlign: "center",
marginBottom: spacing.xs,
},
title: {
...typography.title1,
color: colors.text,
textAlign: "center",
},
separator: {
flexDirection: "row",
alignItems: "center",
justifyContent: "center",
marginVertical: spacing.md,
width: 140,
},
separatorLine: {
flex: 1,
height: 1,
backgroundColor: "rgba(233, 69, 96, 0.3)",
},
separatorDiamond: {
width: 6,
height: 6,
backgroundColor: colors.primary,
transform: [{ rotate: "45deg" }],
marginHorizontal: spacing.md,
opacity: 0.6,
},
subtitle: {
...typography.body,
color: colors.textSecondary,
textAlign: "center",
},
cardsContainer: {
paddingHorizontal: spacing.lg,
gap: spacing.md,
},
card: {
borderRadius: radius.lg,
overflow: "hidden",
borderWidth: 1,
borderColor: colors.glass.border,
...shadows.md,
},
cardPressed: {
opacity: 0.9,
transform: [{ scale: 0.98 }],
},
cardDisabled: {
opacity: 0.6,
},
cardGradient: {
flexDirection: "row",
alignItems: "center",
padding: spacing.lg,
gap: spacing.md,
},
iconCircle: {
width: 64,
height: 64,
borderRadius: 32,
justifyContent: "center",
alignItems: "center",
borderWidth: 1,
},
cardContent: {
flex: 1,
},
cardTitleRow: {
flexDirection: "row",
alignItems: "center",
gap: spacing.sm,
marginBottom: spacing.xs,
},
cardTitle: {
...typography.title3,
color: colors.text,
},
cardTitleDisabled: {
color: colors.textSecondary,
},
cardDescription: {
...typography.subhead,
color: colors.textSecondary,
lineHeight: 20,
},
cardDescriptionDisabled: {
color: colors.textTertiary,
},
comingSoonBadge: {
backgroundColor: "rgba(255, 255, 255, 0.1)",
paddingHorizontal: spacing.sm,
paddingVertical: 2,
borderRadius: radius.full,
borderWidth: 1,
borderColor: "rgba(255, 255, 255, 0.15)",
},
comingSoonText: {
...typography.caption2,
color: colors.textTertiary,
fontWeight: "500",
textTransform: "uppercase",
letterSpacing: 0.5,
},
});
-864
View File
@@ -1,864 +0,0 @@
import { View, Text, StyleSheet, RefreshControl, Platform, Alert, Pressable } from "react-native";
import { usePacksStore, useAuthStore, useSubscriptionStore } from "../../../src/store";
import { Ionicons } from "@expo/vector-icons";
import { router } from "expo-router";
import { useMemo, useState, useCallback } from "react";
import Animated, {
FadeIn,
FadeOut,
useAnimatedStyle,
useSharedValue,
withTiming,
useAnimatedScrollHandler,
interpolate,
Extrapolation,
} from "react-native-reanimated";
import { BlurView } from "expo-blur";
import { GradientBackground, GlassToggle } from "../../../src/components/ui";
import { Paywall } from "../../../src/components/paywall";
import { PackTeaser } from "../../../src/components/PackTeaser";
import { colors, spacing, radius, typography } from "../../../src/theme";
import { getPackIconName } from "../../../src/lib/packIcons";
import type { QuestionPack, Category } from "../../../src/types";
const HEADER_MAX_HEIGHT = 180;
const HEADER_MIN_HEIGHT = 100;
const HEADER_SCROLL_DISTANCE = HEADER_MAX_HEIGHT - HEADER_MIN_HEIGHT;
const INTENSITY_LABELS: Record<number, string> = {
1: "Light",
2: "Mild",
3: "Moderate",
4: "Spicy",
5: "Intense",
};
const clampIntensity = (value: number) => Math.min(5, Math.max(1, Math.round(value)));
const isValidIntensity = (value?: number | null): value is number =>
typeof value === "number" && value >= 1 && value <= 5;
const getIntensityDisplay = (pack: QuestionPack) => {
const min = isValidIntensity(pack.min_intensity) ? pack.min_intensity : null;
const max = isValidIntensity(pack.max_intensity) ? pack.max_intensity : null;
const avg = typeof pack.avg_intensity === "number" ? clampIntensity(pack.avg_intensity) : null;
const fallback = avg ?? (pack.is_explicit ? 5 : 2);
const minCount = min ?? max ?? fallback;
const maxCount = max ?? min ?? fallback;
const label = minCount === maxCount
? INTENSITY_LABELS[minCount]
: `${INTENSITY_LABELS[minCount]} - ${INTENSITY_LABELS[maxCount]}`;
return { minCount, maxCount, label };
};
interface PackRowProps {
pack: QuestionPack;
isEnabled: boolean;
isPremiumLocked: boolean;
isExpanded: boolean;
onToggle: () => void;
onPress: () => void;
onInfoPress: () => void;
}
function PackRow({ pack, isEnabled, isPremiumLocked, isExpanded, onToggle, onPress, onInfoPress }: PackRowProps) {
const questionCount = pack.questions?.[0]?.count || 0;
const intensityDisplay = getIntensityDisplay(pack);
return (
<Pressable onPress={onPress} style={styles.packRow}>
<View style={styles.packRowMain}>
<View style={[styles.packIcon, isEnabled && styles.packIconEnabled]}>
<Ionicons
name={getPackIconName(pack.icon)}
size={20}
color={isEnabled ? colors.primary : colors.textSecondary}
/>
</View>
<View style={styles.packContent}>
<View style={styles.packTitleRow}>
<Text style={styles.packName} numberOfLines={1}>{pack.name}</Text>
{pack.is_premium && (
<View style={[styles.premiumBadge, isPremiumLocked && styles.premiumBadgeLocked]}>
<Ionicons
name={isPremiumLocked ? "lock-closed" : "sparkles"}
size={12}
color={colors.premium.gold}
/>
<Text style={styles.premiumBadgeText}>PRO</Text>
</View>
)}
</View>
<Text style={styles.packMeta}>
{questionCount} {questionCount === 1 ? "question" : "questions"}
{isEnabled && <Text style={styles.packMetaEnabled}> · Enabled</Text>}
</Text>
<View style={styles.packIntensityRow}>
<View style={styles.packIntensityFlames}>
{Array.from({ length: 5 }).map((_, index) => {
const level = index + 1;
const isInRange = level >= intensityDisplay.minCount && level <= intensityDisplay.maxCount;
return (
<Ionicons
key={`intensity-${pack.id}-${index}`}
name={isInRange ? "flame" : "flame-outline"}
size={12}
color={isInRange ? colors.primary : colors.textTertiary}
/>
);
})}
</View>
<Text style={styles.packIntensityText}>{intensityDisplay.label}</Text>
</View>
</View>
<Ionicons
name={isExpanded ? "chevron-up" : "chevron-down"}
size={20}
color={colors.textTertiary}
/>
</View>
{isExpanded && (
<Animated.View
entering={FadeIn.duration(200)}
exiting={FadeOut.duration(150)}
style={styles.packExpanded}
>
{pack.description && (
<Text style={styles.packDescription}>{pack.description}</Text>
)}
<View style={styles.packActions}>
<Pressable style={styles.packInfoButton} onPress={onInfoPress}>
<Ionicons name="eye-outline" size={18} color={colors.textSecondary} />
<Text style={styles.packInfoButtonText}>Preview</Text>
</Pressable>
<Pressable
style={[
styles.packToggleButton,
isEnabled && styles.packToggleButtonEnabled,
isPremiumLocked && styles.packToggleButtonLocked,
]}
onPress={onToggle}
>
{isPremiumLocked ? (
<>
<Ionicons name="lock-closed" size={16} color={colors.premium.gold} />
<Text style={[styles.packToggleButtonText, styles.packToggleButtonTextLocked]}>
Unlock
</Text>
</>
) : isEnabled ? (
<>
<Ionicons name="checkmark" size={16} color={colors.primary} />
<Text style={[styles.packToggleButtonText, styles.packToggleButtonTextEnabled]}>
Enabled
</Text>
</>
) : (
<>
<Ionicons name="add" size={16} color={colors.text} />
<Text style={styles.packToggleButtonText}>Enable</Text>
</>
)}
</Pressable>
</View>
</Animated.View>
)}
</Pressable>
);
}
interface CategorySectionProps {
category: Category;
packs: QuestionPack[];
enabledPackIds: string[];
hasPremiumAccess: boolean;
expandedPackId: string | null;
onPackPress: (packId: string) => void;
onPackToggle: (pack: QuestionPack) => void;
onPackInfo: (pack: QuestionPack) => void;
}
function CategorySection({
category,
packs,
enabledPackIds,
hasPremiumAccess,
expandedPackId,
onPackPress,
onPackToggle,
onPackInfo,
}: CategorySectionProps) {
const [isCollapsed, setIsCollapsed] = useState(false);
const enabledCount = packs.filter(p => enabledPackIds.includes(p.id)).length;
const rotateAnim = useSharedValue(0);
const chevronStyle = useAnimatedStyle(() => ({
transform: [{ rotate: `${rotateAnim.value}deg` }],
}));
const handleToggleCollapse = () => {
rotateAnim.value = withTiming(isCollapsed ? 0 : -90, { duration: 200 });
setIsCollapsed(!isCollapsed);
};
return (
<View style={styles.categorySection}>
<Pressable style={styles.categoryHeader} onPress={handleToggleCollapse}>
<View style={styles.categoryHeaderLeft}>
<Ionicons
name={getPackIconName(category.icon)}
size={16}
color={colors.textSecondary}
/>
<Text style={styles.categoryName}>{category.name}</Text>
{enabledCount > 0 && (
<View style={styles.categoryBadge}>
<Text style={styles.categoryBadgeText}>{enabledCount}</Text>
</View>
)}
</View>
<Animated.View style={chevronStyle}>
<Ionicons name="chevron-down" size={20} color={colors.textSecondary} />
</Animated.View>
</Pressable>
{!isCollapsed && (
<Animated.View
entering={FadeIn.duration(200)}
exiting={FadeOut.duration(150)}
style={styles.packsList}
>
{packs.map((pack) => (
<PackRow
key={pack.id}
pack={pack}
isEnabled={enabledPackIds.includes(pack.id)}
isPremiumLocked={pack.is_premium && !hasPremiumAccess}
isExpanded={expandedPackId === pack.id}
onPress={() => onPackPress(pack.id)}
onToggle={() => onPackToggle(pack)}
onInfoPress={() => onPackInfo(pack)}
/>
))}
</Animated.View>
)}
</View>
);
}
export default function QuestionPacksScreen() {
const { packs, categories, enabledPackIds, togglePack, isLoading, fetchPacks, showAllIntensities, setShowAllIntensities } = usePacksStore();
const { user, partner } = useAuthStore();
const { subscription } = useSubscriptionStore();
const [expandedPackId, setExpandedPackId] = useState<string | null>(null);
const [showPaywall, setShowPaywall] = useState(false);
const [showTeaser, setShowTeaser] = useState(false);
const [teaserPack, setTeaserPack] = useState<QuestionPack | null>(null);
const scrollY = useSharedValue(0);
const scrollHandler = useAnimatedScrollHandler({
onScroll: (event) => {
scrollY.value = event.contentOffset.y;
},
});
const hasPremiumAccess = useMemo(() => {
return user?.is_premium || partner?.is_premium || subscription.isProUser;
}, [user?.is_premium, partner?.is_premium, subscription.isProUser]);
const packsByCategory = useMemo(() => {
const grouped = new Map<string, QuestionPack[]>();
categories.forEach(cat => grouped.set(cat.id, []));
grouped.set("uncategorized", []);
packs.forEach(pack => {
const catId = pack.category_id || "uncategorized";
const existing = grouped.get(catId) || [];
existing.push(pack);
grouped.set(catId, existing);
});
return grouped;
}, [packs, categories]);
const handlePackPress = useCallback((packId: string) => {
setExpandedPackId(current => current === packId ? null : packId);
}, []);
const handlePackToggle = useCallback(async (pack: QuestionPack) => {
if (pack.is_premium && !hasPremiumAccess) {
setTeaserPack(pack);
setShowTeaser(true);
return;
}
const result = await togglePack(pack.id);
if (!result.success && result.reason === "no_couple") {
Alert.alert(
"Pair with Partner",
"You need to pair with your partner before you can customise question packs.",
[
{ text: "Later", style: "cancel" },
{ text: "Pair Now", onPress: () => router.push("/pairing") }
]
);
}
}, [hasPremiumAccess, togglePack]);
const handlePackInfo = useCallback((pack: QuestionPack) => {
const isPremiumLocked = pack.is_premium && !hasPremiumAccess;
if (isPremiumLocked) {
setTeaserPack(pack);
setShowTeaser(true);
} else {
router.push(`/pack/${pack.id}`);
}
}, [hasPremiumAccess]);
const handleTeaserUnlock = useCallback(() => {
setShowTeaser(false);
setShowPaywall(true);
}, []);
const handleTeaserClose = useCallback(() => {
setShowTeaser(false);
setTeaserPack(null);
}, []);
const handleToggleComfortZone = useCallback((value: boolean) => {
setShowAllIntensities(value);
}, [setShowAllIntensities]);
const enabledPackCount = enabledPackIds.length;
const totalQuestions = packs
.filter(p => enabledPackIds.includes(p.id))
.reduce((sum, p) => sum + (p.questions?.[0]?.count || 0), 0);
// Animated styles for collapsing header
const largeHeaderStyle = useAnimatedStyle(() => {
const opacity = interpolate(
scrollY.value,
[0, HEADER_SCROLL_DISTANCE * 0.5],
[1, 0],
Extrapolation.CLAMP
);
const translateY = interpolate(
scrollY.value,
[0, HEADER_SCROLL_DISTANCE],
[0, -20],
Extrapolation.CLAMP
);
return { opacity, transform: [{ translateY }] };
});
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 (
<GradientBackground>
<View style={styles.container}>
{/* Fixed Nav Bar */}
<View style={styles.navBar}>
{/* Nav bar background with blur */}
<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>
{/* Back button */}
<Pressable
style={styles.backButton}
onPress={() => router.back()}
hitSlop={8}
>
<Ionicons name="chevron-back" size={24} color={colors.text} />
</Pressable>
{/* Compact title (fades in on scroll) */}
<Animated.Text style={[styles.navBarTitle, compactHeaderStyle]}>
Question Packs
</Animated.Text>
{/* Spacer for alignment */}
<View style={styles.navBarSpacer} />
</View>
{/* Scrollable Content */}
<Animated.ScrollView
showsVerticalScrollIndicator={false}
onScroll={scrollHandler}
scrollEventThrottle={16}
refreshControl={
<RefreshControl
refreshing={isLoading}
onRefresh={fetchPacks}
tintColor={colors.primary}
colors={[colors.primary]}
progressViewOffset={HEADER_MAX_HEIGHT}
/>
}
contentContainerStyle={styles.scrollContent}
>
{/* Large Header (fades out on scroll) */}
<Animated.View style={[styles.largeHeader, largeHeaderStyle]}>
<Text style={styles.label}>COLLECTION</Text>
<Text style={styles.title}>Question Packs</Text>
<View style={styles.separator}>
<View style={styles.separatorLine} />
<View style={styles.separatorDiamond} />
<View style={styles.separatorLine} />
</View>
<View style={styles.statsBadge}>
<Ionicons name="checkmark-circle" size={14} color={colors.primary} />
<Text style={styles.statsBadgeText}>
{totalQuestions} {totalQuestions === 1 ? "question" : "questions"} from {enabledPackCount} {enabledPackCount === 1 ? "pack" : "packs"}
</Text>
</View>
<Pressable
style={styles.comfortZoneToggle}
onPress={() => handleToggleComfortZone(!showAllIntensities)}
>
<View style={styles.comfortZoneToggleLeft}>
<Ionicons
name={showAllIntensities ? "flame" : "flame-outline"}
size={16}
color={showAllIntensities ? colors.primary : colors.textSecondary}
/>
<Text style={styles.comfortZoneToggleText}>
Show all intensities
</Text>
</View>
<GlassToggle
value={showAllIntensities}
onValueChange={handleToggleComfortZone}
/>
</Pressable>
</Animated.View>
{/* Categories */}
<View style={styles.categoriesContainer}>
{categories.map(category => {
const categoryPacks = packsByCategory.get(category.id) || [];
if (categoryPacks.length === 0) return null;
return (
<CategorySection
key={category.id}
category={category}
packs={categoryPacks}
enabledPackIds={enabledPackIds}
hasPremiumAccess={hasPremiumAccess}
expandedPackId={expandedPackId}
onPackPress={handlePackPress}
onPackToggle={handlePackToggle}
onPackInfo={handlePackInfo}
/>
);
})}
{(packsByCategory.get("uncategorized")?.length || 0) > 0 && (
<CategorySection
category={{
id: "uncategorized",
name: "Other",
description: null,
icon: "📁",
color: null,
sort_order: 999,
created_at: "",
is_public: true
}}
packs={packsByCategory.get("uncategorized") || []}
enabledPackIds={enabledPackIds}
hasPremiumAccess={hasPremiumAccess}
expandedPackId={expandedPackId}
onPackPress={handlePackPress}
onPackToggle={handlePackToggle}
onPackInfo={handlePackInfo}
/>
)}
</View>
</Animated.ScrollView>
{/* Modals */}
{teaserPack && (
<PackTeaser
visible={showTeaser}
packId={teaserPack.id}
packName={teaserPack.name}
packIcon={teaserPack.icon}
onClose={handleTeaserClose}
onUnlock={handleTeaserUnlock}
/>
)}
<Paywall
visible={showPaywall}
onClose={() => setShowPaywall(false)}
onSuccess={() => fetchPacks()}
/>
</View>
</GradientBackground>
);
}
const NAV_BAR_HEIGHT = 44;
const STATUS_BAR_HEIGHT = 60; // Match other pages
const styles = StyleSheet.create({
container: {
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",
paddingTop: STATUS_BAR_HEIGHT - 10,
paddingHorizontal: spacing.md,
zIndex: 100,
},
navBarBackground: {
...StyleSheet.absoluteFillObject,
borderBottomWidth: 1,
borderBottomColor: "rgba(255, 255, 255, 0.1)",
overflow: "hidden",
},
backButton: {
width: 40,
height: 40,
borderRadius: 20,
backgroundColor: "rgba(255, 255, 255, 0.1)",
justifyContent: "center",
alignItems: "center",
},
navBarTitle: {
flex: 1,
...typography.headline,
color: colors.text,
textAlign: "center",
marginRight: 40, // Balance with back button
},
navBarSpacer: {
width: 0,
},
// Large Header
largeHeader: {
alignItems: "center",
paddingBottom: spacing.lg,
},
label: {
...typography.caption1,
fontWeight: "600",
letterSpacing: 3,
color: colors.primary,
textAlign: "center",
marginBottom: spacing.xs,
},
title: {
...typography.title1,
color: colors.text,
textAlign: "center",
},
separator: {
flexDirection: "row",
alignItems: "center",
justifyContent: "center",
marginVertical: spacing.md,
width: 140,
},
separatorLine: {
flex: 1,
height: 1,
backgroundColor: "rgba(233, 69, 96, 0.3)",
},
separatorDiamond: {
width: 6,
height: 6,
backgroundColor: colors.primary,
transform: [{ rotate: "45deg" }],
marginHorizontal: spacing.md,
opacity: 0.6,
},
statsBadge: {
flexDirection: "row",
alignItems: "center",
backgroundColor: "rgba(233, 69, 96, 0.1)",
paddingHorizontal: spacing.md,
paddingVertical: spacing.sm,
borderRadius: radius.full,
borderWidth: 1,
borderColor: "rgba(233, 69, 96, 0.2)",
gap: spacing.sm,
},
statsBadgeText: {
...typography.caption1,
color: colors.textSecondary,
fontWeight: "500",
},
comfortZoneToggle: {
flexDirection: "row",
alignItems: "center",
justifyContent: "space-between",
backgroundColor: "rgba(255, 255, 255, 0.05)",
paddingHorizontal: spacing.md,
paddingVertical: spacing.sm,
borderRadius: radius.md,
borderWidth: 1,
borderColor: colors.glass.border,
marginTop: spacing.md,
width: "100%",
maxWidth: 280,
},
comfortZoneToggleLeft: {
flexDirection: "row",
alignItems: "center",
gap: spacing.sm,
},
comfortZoneToggleText: {
...typography.subhead,
color: colors.textSecondary,
fontWeight: "500",
},
// Scroll Content
scrollContent: {
paddingTop: 60, // Match other pages
paddingBottom: Platform.OS === "ios" ? 120 : 100,
},
categoriesContainer: {
paddingHorizontal: spacing.lg,
},
// Category Section
categorySection: {
marginBottom: spacing.lg,
},
categoryHeader: {
flexDirection: "row",
alignItems: "center",
justifyContent: "space-between",
paddingVertical: spacing.sm,
marginBottom: spacing.xs,
},
categoryHeaderLeft: {
flexDirection: "row",
alignItems: "center",
gap: spacing.sm,
},
categoryName: {
...typography.headline,
color: colors.text,
textTransform: "uppercase",
letterSpacing: 1,
fontSize: 13,
},
categoryBadge: {
backgroundColor: colors.primary,
paddingHorizontal: 8,
paddingVertical: 2,
borderRadius: 10,
minWidth: 20,
alignItems: "center",
},
categoryBadgeText: {
...typography.caption2,
color: colors.text,
fontWeight: "600",
},
packsList: {
backgroundColor: "rgba(255, 255, 255, 0.03)",
borderRadius: radius.lg,
borderWidth: 1,
borderColor: colors.glass.border,
overflow: "hidden",
},
// Pack Row
packRow: {
borderBottomWidth: 1,
borderBottomColor: colors.glass.border,
},
packRowMain: {
flexDirection: "row",
alignItems: "center",
padding: spacing.md,
gap: spacing.md,
},
packIcon: {
width: 44,
height: 44,
borderRadius: 12,
backgroundColor: "rgba(255, 255, 255, 0.05)",
justifyContent: "center",
alignItems: "center",
borderWidth: 1,
borderColor: "rgba(255, 255, 255, 0.08)",
},
packIconEnabled: {
backgroundColor: "rgba(233, 69, 96, 0.1)",
borderColor: "rgba(233, 69, 96, 0.2)",
},
packContent: {
flex: 1,
},
packTitleRow: {
flexDirection: "row",
alignItems: "center",
gap: spacing.xs,
},
packName: {
...typography.body,
color: colors.text,
fontWeight: "500",
flexShrink: 1,
},
premiumBadge: {
flexDirection: "row",
alignItems: "center",
gap: 4,
paddingHorizontal: 8,
paddingVertical: 3,
borderRadius: radius.full,
backgroundColor: "rgba(212, 175, 55, 0.12)",
borderWidth: 1,
borderColor: "rgba(212, 175, 55, 0.25)",
},
premiumBadgeLocked: {
backgroundColor: "rgba(212, 175, 55, 0.18)",
borderColor: "rgba(212, 175, 55, 0.35)",
},
premiumBadgeText: {
...typography.caption2,
color: colors.premium.gold,
fontWeight: "700",
letterSpacing: 0.6,
},
packMeta: {
...typography.caption1,
color: colors.textTertiary,
marginTop: 2,
},
packMetaEnabled: {
color: colors.primary,
},
packIntensityRow: {
flexDirection: "row",
alignItems: "center",
gap: 6,
marginTop: spacing.xs,
},
packIntensityFlames: {
flexDirection: "row",
alignItems: "center",
gap: 2,
},
packIntensityText: {
...typography.caption2,
color: colors.textSecondary,
fontWeight: "600",
},
// Expanded Pack
packExpanded: {
paddingHorizontal: spacing.md,
paddingBottom: spacing.md,
paddingTop: 0,
},
packDescription: {
...typography.subhead,
color: colors.textSecondary,
lineHeight: 20,
marginBottom: spacing.md,
},
packActions: {
flexDirection: "row",
gap: spacing.sm,
},
packInfoButton: {
flexDirection: "row",
alignItems: "center",
gap: spacing.xs,
paddingVertical: spacing.sm,
paddingHorizontal: spacing.md,
borderRadius: radius.sm,
backgroundColor: "rgba(255, 255, 255, 0.05)",
borderWidth: 1,
borderColor: colors.glass.border,
},
packInfoButtonText: {
...typography.subhead,
color: colors.textSecondary,
},
packToggleButton: {
flex: 1,
flexDirection: "row",
alignItems: "center",
justifyContent: "center",
gap: spacing.xs,
paddingVertical: spacing.sm,
paddingHorizontal: spacing.md,
borderRadius: radius.sm,
backgroundColor: "rgba(255, 255, 255, 0.08)",
borderWidth: 1,
borderColor: "rgba(255, 255, 255, 0.12)",
},
packToggleButtonEnabled: {
backgroundColor: "rgba(233, 69, 96, 0.1)",
borderColor: "rgba(233, 69, 96, 0.3)",
},
packToggleButtonLocked: {
backgroundColor: "rgba(212, 175, 55, 0.1)",
borderColor: "rgba(212, 175, 55, 0.3)",
},
packToggleButtonText: {
...typography.subhead,
color: colors.text,
fontWeight: "500",
},
packToggleButtonTextEnabled: {
color: colors.primary,
},
packToggleButtonTextLocked: {
color: colors.premium.gold,
},
});
+59 -45
View File
@@ -414,15 +414,61 @@ const styles = StyleSheet.create({
flexDirection: "row",
alignItems: "center",
},
backButton: {
width: 44,
height: 44,
borderRadius: 22,
backgroundColor: colors.glass.background,
justifyContent: "center",
alignItems: "center",
marginRight: spacing.md,
},
backButton: {
width: 44,
height: 44,
borderRadius: 22,
backgroundColor: colors.backgroundLight,
justifyContent: "center",
alignItems: "center",
marginRight: spacing.md,
borderWidth: 1,
borderColor: colors.border,
},
// ...
input: {
backgroundColor: colors.background, // Flat background
borderRadius: radius.md,
borderWidth: 1,
borderColor: colors.border,
padding: spacing.md,
...typography.headline,
color: colors.text,
textAlign: "center",
letterSpacing: 4,
fontFamily: Platform.OS === "ios" ? "Courier" : "monospace",
marginBottom: spacing.md,
},
// ...
dividerLine: {
flex: 1,
height: 1,
backgroundColor: colors.border,
},
// ...
copyIcon: {
width: 36,
height: 36,
borderRadius: 18,
backgroundColor: colors.background,
justifyContent: "center",
alignItems: "center",
marginLeft: spacing.md,
borderWidth: 1,
borderColor: colors.border,
},
// ...
waitingBadge: {
flexDirection: "row",
alignItems: "center",
backgroundColor: colors.backgroundLight,
paddingHorizontal: spacing.md,
paddingVertical: spacing.sm,
borderRadius: radius.full,
gap: spacing.sm,
borderWidth: 1,
borderColor: colors.border,
},
title: {
...typography.title1,
color: colors.text,
@@ -462,29 +508,13 @@ const styles = StyleSheet.create({
marginBottom: spacing.sm,
marginLeft: spacing.xs,
},
input: {
backgroundColor: colors.glass.background,
borderRadius: radius.md,
borderWidth: 1,
borderColor: colors.glass.border,
padding: spacing.md,
...typography.headline,
color: colors.text,
textAlign: "center",
letterSpacing: 4,
fontFamily: Platform.OS === "ios" ? "Courier" : "monospace",
marginBottom: spacing.md,
},
divider: {
flexDirection: "row",
alignItems: "center",
marginVertical: spacing.lg,
},
dividerLine: {
flex: 1,
height: 1,
backgroundColor: colors.glass.border,
},
dividerText: {
...typography.subhead,
color: colors.textTertiary,
@@ -501,15 +531,7 @@ const styles = StyleSheet.create({
letterSpacing: 6,
fontFamily: Platform.OS === "ios" ? "Courier" : "monospace",
},
copyIcon: {
width: 36,
height: 36,
borderRadius: 18,
backgroundColor: colors.glass.background,
justifyContent: "center",
alignItems: "center",
marginLeft: spacing.md,
},
tapToCopy: {
...typography.caption1,
color: colors.textTertiary,
@@ -520,15 +542,7 @@ const styles = StyleSheet.create({
alignItems: "center",
marginTop: spacing.xl,
},
waitingBadge: {
flexDirection: "row",
alignItems: "center",
backgroundColor: colors.glass.background,
paddingHorizontal: spacing.md,
paddingVertical: spacing.sm,
borderRadius: radius.full,
gap: spacing.sm,
},
waitingText: {
...typography.subhead,
color: colors.textTertiary,
+41 -41
View File
@@ -162,15 +162,36 @@ const styles = StyleSheet.create({
marginHorizontal: spacing.md,
opacity: 0.6,
},
badge: {
backgroundColor: 'rgba(232, 164, 174, 0.1)',
paddingHorizontal: spacing.lg,
paddingVertical: spacing.sm,
borderRadius: radius.full,
borderWidth: 1,
borderColor: 'rgba(232, 164, 174, 0.2)',
marginBottom: spacing.xl,
},
badge: {
backgroundColor: colors.backgroundLight,
paddingHorizontal: spacing.lg,
paddingVertical: spacing.sm,
borderRadius: radius.full,
borderWidth: 1,
borderColor: colors.border,
marginBottom: spacing.xl,
},
interestButton: {
flexDirection: 'row',
alignItems: 'center',
justifyContent: 'center',
backgroundColor: colors.backgroundLight,
paddingHorizontal: spacing.lg,
paddingVertical: spacing.sm + 2,
borderRadius: radius.full,
borderWidth: 1,
borderColor: colors.border,
gap: spacing.sm,
},
interestButtonActive: {
backgroundColor: colors.background,
borderColor: colors.primary,
},
interestButtonText: {
...typography.subhead,
fontWeight: '600',
color: ACCENT_COLOR,
},
badgeText: {
...typography.caption2,
fontWeight: '600',
@@ -198,35 +219,14 @@ const styles = StyleSheet.create({
color: colors.text,
marginLeft: spacing.sm,
},
teaser: {
...typography.footnote,
fontStyle: 'italic',
color: colors.textTertiary,
textAlign: 'center',
},
interestButtonContainer: {
marginTop: spacing.xl,
minHeight: 44,
},
interestButton: {
flexDirection: 'row',
alignItems: 'center',
justifyContent: 'center',
backgroundColor: 'rgba(232, 164, 174, 0.1)',
paddingHorizontal: spacing.lg,
paddingVertical: spacing.sm + 2,
borderRadius: radius.full,
borderWidth: 1,
borderColor: 'rgba(232, 164, 174, 0.2)',
gap: spacing.sm,
},
interestButtonActive: {
backgroundColor: 'rgba(232, 164, 174, 0.15)',
borderColor: 'rgba(232, 164, 174, 0.3)',
},
interestButtonText: {
...typography.subhead,
fontWeight: '600',
color: ACCENT_COLOR,
},
});
teaser: {
...typography.footnote,
fontStyle: 'italic',
color: colors.textTertiary,
textAlign: 'center',
},
interestButtonContainer: {
marginTop: spacing.xl,
minHeight: 44,
},
});
+343 -110
View File
@@ -2,6 +2,8 @@ import { useState, useEffect, useRef, useCallback } from "react";
import { View, StyleSheet, Text, ActivityIndicator, Platform, TouchableOpacity } from "react-native";
import { useLocalSearchParams, router, useFocusEffect } from "expo-router";
import { Ionicons } from "@expo/vector-icons";
import * as FileSystem from "expo-file-system";
import { decode } from "base64-arraybuffer";
import Animated, {
FadeIn,
FadeInUp,
@@ -17,16 +19,45 @@ import { supabase } from "../../src/lib/supabase";
import { usePacksStore, useAuthStore } from "../../src/store";
import { useAmbientOrbAnimation } from "../../src/hooks";
import { skipQuestion, getSkippedQuestionIds } from "../../src/lib/skippedQuestions";
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 {
QuestionCard,
QuestionCardText,
QuestionCardPhoto,
QuestionCardAudio,
QuestionCardWhoLikely
} from "../../src/components/questions";
import { QuestionFeedbackModal } from "../../src/components/feedback";
import { MatchConfetti } from "../../src/components/MatchConfetti";
import { GradientBackground, GlassCard, GlassButton, DecorativeSeparator } from "../../src/components/ui";
import { Paywall } from "../../src/components/paywall";
import { Events } from "../../src/lib/analytics";
import { colors, gradients, spacing, typography, radius, shadows } from "../../src/theme";
import { colors, gradients, spacing, typography, radius, shadows, getCategoryColor } from "../../src/theme";
import type { AnswerType } from "../../src/types";
// Response data types for different question types
interface TextResponseData {
type: 'text_answer';
text: string;
}
interface PhotoResponseData {
type: 'photo';
media_path: string;
}
interface AudioResponseData {
type: 'audio';
media_path: string;
duration_seconds: number;
}
interface WhoLikelyResponseData {
type: 'who_likely';
chosen_user_id: string;
}
type ResponseData = TextResponseData | PhotoResponseData | AudioResponseData | WhoLikelyResponseData;
interface DailyLimitInfo {
responses_today: number;
@@ -47,15 +78,25 @@ export default function SwipeScreen() {
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 [isUploading, setIsUploading] = useState(false);
const [isBlocked, setIsBlocked] = useState(false);
const [gapInfo, setGapInfo] = useState<{ unanswered: number; threshold: number } | null>(null);
const [dailyLimitInfo, setDailyLimitInfo] = useState<DailyLimitInfo | null>(null);
const [showPaywall, setShowPaywall] = useState(false);
const [packContext, setPackContext] = useState<{ name: string; icon: string } | null>(null);
const { enabledPackIds, ensureEnabledPacksLoaded } = usePacksStore();
const { partner, couple } = useAuthStore();
const [showConfetti, setShowConfetti] = useState(false);
const { enabledPackIds, ensureEnabledPacksLoaded, packs } = usePacksStore();
const { user, partner, couple } = useAuthStore();
// Supported question types - filter out types the UI doesn't support yet
const supportedTypes = ['swipe', 'text_answer', 'photo', 'audio', 'who_likely'];
// Filter questions to only supported types (safety during rollout)
// Must be calculated early since it's used by effectiveTotal and throughout rendering
const filteredQuestions = questions.filter(q =>
!q.question_type || supportedTypes.includes(q.question_type)
);
const hasTrackedExhausted = useRef(false);
const fetchRequestId = useRef(0);
const lastFetchedMode = useRef<string | undefined>(undefined);
@@ -134,8 +175,7 @@ export default function SwipeScreen() {
fetchQuestions();
}
});
checkTutorial();
}, [packId, mode]);
}, [packId, mode, startQuestionId]);
// Fetch pack context when packId is provided
useEffect(() => {
@@ -252,7 +292,7 @@ export default function SwipeScreen() {
// Calculate effective total questions (accounting for gap limit AND daily limit)
const effectiveTotal = (() => {
let total = questions.length;
let total = filteredQuestions.length;
// Apply gap limit if active
if (gapInfo && gapInfo.threshold > 0) {
@@ -270,23 +310,11 @@ export default function SwipeScreen() {
// Track when all questions are exhausted
useEffect(() => {
if (questions.length > 0 && currentIndex >= questions.length && !hasTrackedExhausted.current) {
if (filteredQuestions.length > 0 && currentIndex >= filteredQuestions.length && !hasTrackedExhausted.current) {
hasTrackedExhausted.current = true;
Events.allQuestionsExhausted();
}
}, [currentIndex, questions.length]);
const checkTutorial = async () => {
const seen = await hasSeenSwipeTutorial();
if (!seen) {
setShowTutorial(true);
}
};
const handleTutorialComplete = async () => {
await markSwipeTutorialSeen();
setShowTutorial(false);
};
}, [currentIndex, filteredQuestions.length]);
// Filter out questions from disabled packs immediately when enabledPackIds changes
// Skip this filter in pending mode - user should answer ALL questions partner swiped on
@@ -328,7 +356,12 @@ export default function SwipeScreen() {
}
// Filter out recently skipped questions
const filtered = (data || []).filter((q: any) => !skippedIds.has(q.id));
let filtered = (data || []).filter((q: any) => !skippedIds.has(q.id));
// If no unskipped questions remain, show skipped ones again
if (filtered.length === 0 && data && data.length > 0) {
filtered = data;
}
// Always check answer gap and daily limit when user has partner and not viewing specific pack
if (partner && !packId) {
@@ -469,33 +502,123 @@ export default function SwipeScreen() {
}
};
const handleSwipe = async (direction: "left" | "right" | "up" | "down") => {
const question = questions[currentIndex];
// Upload media (photo/audio) to Supabase storage
const uploadResponseMedia = async (
localUri: string,
questionId: string,
mediaType: 'photo' | 'audio'
): Promise<string | null> => {
if (!user?.id) return null;
setCurrentIndex(prev => prev + 1);
try {
const timestamp = Date.now();
const extMatch = localUri.match(/\.(\w+)$/);
const ext = extMatch ? extMatch[1] : (mediaType === 'photo' ? 'jpg' : 'm4a');
const fileName = `${user.id}/${questionId}_${timestamp}.${ext}`;
const contentType = mediaType === 'photo'
? `image/${ext === 'jpg' ? 'jpeg' : ext}`
: `audio/${ext}`;
let fileBody: ArrayBuffer | Blob;
if (Platform.OS === 'web') {
const response = await fetch(localUri);
fileBody = await response.blob();
} else {
const base64 = await FileSystem.readAsStringAsync(localUri, {
encoding: FileSystem.EncodingType.Base64,
});
fileBody = decode(base64);
}
const { error } = await supabase.storage
.from('response-media')
.upload(fileName, fileBody, { contentType });
if (error) {
console.error('Media upload error:', error);
return null;
}
return fileName;
} catch (error) {
console.error('Media upload failed:', error);
return null;
}
};
// Handle answer submission from question cards (supports response_data for new question types)
const handleAnswer = async (questionId: string, answer: AnswerType | 'skip', responseData?: ResponseData) => {
const question = filteredQuestions[currentIndex];
// Handle skip - don't submit a response, just store it for later
if (direction === "down") {
await skipQuestion(question.id);
if (answer === 'skip') {
setCurrentIndex(prev => prev + 1);
await skipQuestion(questionId);
Events.questionSkipped();
return;
}
const answer = direction === "right" ? "yes" : direction === "left" ? "no" : "maybe";
try {
const { error } = await invokeWithAuthRetry("submit-response", {
// Upload media if response contains photo or audio with local URI
let finalResponseData = responseData;
if (responseData) {
if (responseData.type === 'photo' && responseData.media_path) {
setIsUploading(true);
try {
const uploadedPath = await uploadResponseMedia(responseData.media_path, questionId, 'photo');
if (uploadedPath) {
finalResponseData = { ...responseData, media_path: uploadedPath };
} else {
console.error('Photo upload failed, skipping response_data');
finalResponseData = undefined;
}
} finally {
setIsUploading(false);
}
} else if (responseData.type === 'audio' && responseData.media_path) {
setIsUploading(true);
try {
const uploadedPath = await uploadResponseMedia(responseData.media_path, questionId, 'audio');
if (uploadedPath) {
finalResponseData = { ...responseData, media_path: uploadedPath };
} else {
console.error('Audio upload failed, skipping response_data');
finalResponseData = undefined;
}
} finally {
setIsUploading(false);
}
}
}
const { data, error } = await invokeWithAuthRetry("submit-response", {
body: {
question_id: question.id,
question_id: questionId,
answer,
response_data: finalResponseData,
},
});
if (error) {
console.error("Submit response error:", error);
// Advance to next question even on error
setCurrentIndex(prev => prev + 1);
} else {
Events.questionAnswered(answer, question.pack_id);
// Check if a match was created
const hasMatch = data?.match != null;
if (hasMatch) {
// Show confetti celebration - delay advancing to next question
setShowConfetti(true);
// Confetti component will call onAnimationComplete which advances
} else {
// No match - advance immediately
setCurrentIndex(prev => prev + 1);
}
// Skip limit checks in pending mode - user is just catching up
if (mode !== 'pending') {
// Increment local daily count if we have limit info
@@ -514,6 +637,131 @@ export default function SwipeScreen() {
}
} catch (error) {
console.error("Failed to submit response", error);
// Advance to next question even on error
setCurrentIndex(prev => prev + 1);
}
};
// Handle confetti animation completion
const handleConfettiComplete = useCallback(() => {
setShowConfetti(false);
setCurrentIndex(prev => prev + 1);
}, []);
// Get pack info for a question - use packContext if viewing a specific pack, otherwise question's pack
const getQuestionPackInfo = (question: any) => {
// If viewing specific pack, use that context but try to enrich with color from store
if (packId) {
const pack = packs.find(p => p.id === packId);
const categoryColor = pack ? getCategoryColor(pack.category) : undefined;
if (packContext) {
return { ...packContext, color: categoryColor };
}
if (pack) {
return { name: pack.name, icon: pack.icon || 'layers', color: categoryColor };
}
}
// For pending mode or mixed mode, look up pack details
if (question.pack_id) {
const pack = packs.find(p => p.id === question.pack_id);
const categoryColor = pack ? getCategoryColor(pack.category) : undefined;
// Pending questions might have name/icon embedded if pack not found in store
if (question.pack_name) {
return {
name: question.pack_name,
icon: question.pack_icon || 'layers',
color: categoryColor
};
}
if (pack) {
return { name: pack.name, icon: pack.icon || 'layers', color: categoryColor };
}
}
return null;
};
// Render the appropriate question card based on question_type
const renderQuestionCard = (question: any) => {
// For backwards compatibility: default to 'swipe' if no question_type
const questionType = question.question_type || 'swipe';
const packInfo = getQuestionPackInfo(question);
switch (questionType) {
case 'text_answer':
return (
<QuestionCardText
key={question.id}
question={question}
packInfo={packInfo}
onAnswer={(answer, responseData) => handleAnswer(question.id, answer, responseData)}
onReport={() => setFeedbackQuestion({
id: question.id,
text: question.text
})}
/>
);
case 'photo':
return (
<QuestionCardPhoto
key={question.id}
question={question}
packInfo={packInfo}
onAnswer={(answer, responseData) => handleAnswer(question.id, answer, responseData)}
onReport={() => setFeedbackQuestion({
id: question.id,
text: question.text
})}
/>
);
case 'audio':
return (
<QuestionCardAudio
key={question.id}
question={question}
packInfo={packInfo}
onAnswer={(answer, responseData) => handleAnswer(question.id, answer, responseData)}
onReport={() => setFeedbackQuestion({
id: question.id,
text: question.text
})}
/>
);
case 'who_likely':
return (
<QuestionCardWhoLikely
key={question.id}
question={question}
packInfo={packInfo}
user={{ id: user?.id || '', name: user?.name || undefined, avatar_url: user?.avatar_url }}
partner={{ id: partner?.id || '', name: partner?.name || undefined, avatar_url: partner?.avatar_url }}
onAnswer={(responseData) => handleAnswer(question.id, 'yes', responseData)}
onSkip={() => handleAnswer(question.id, 'skip')}
onReport={() => setFeedbackQuestion({
id: question.id,
text: question.text
})}
/>
);
case 'swipe':
default:
// Standard swipe card for 'swipe' type and any unknown types (backwards compatibility)
return (
<QuestionCard
key={question.id}
question={question}
packInfo={packInfo}
onAnswer={(answer) => handleAnswer(question.id, answer)}
onReport={() => setFeedbackQuestion({
id: question.id,
text: question.text
})}
/>
);
}
};
@@ -600,7 +848,7 @@ export default function SwipeScreen() {
const LIMIT_ACCENT = colors.premium.gold;
return (
<GradientBackground>
{/* Ambient Orbs - Gold/warm glow */}
{/* Ambient Orbs - Gold/warm glow - Commented out for flat design
<Animated.View style={[styles.ambientOrb, styles.dailyLimitOrbTop, orbStyle1]} pointerEvents="none">
<LinearGradient
colors={[colors.premium.goldGlow, 'transparent']}
@@ -617,6 +865,7 @@ export default function SwipeScreen() {
end={{ x: 0, y: 0 }}
/>
</Animated.View>
*/}
<View style={styles.centerContainer}>
<Animated.View
@@ -826,10 +1075,10 @@ export default function SwipeScreen() {
<Text style={styles.waitingTeaser}>Your journey of discovery awaits</Text>
<GlassButton
onPress={() => router.push("/packs")}
onPress={() => router.push("/")}
style={{ marginTop: spacing.lg }}
>
Choose Packs
Browse Packs
</GlassButton>
</Animated.View>
</View>
@@ -837,7 +1086,7 @@ export default function SwipeScreen() {
);
}
if (currentIndex >= questions.length) {
if (currentIndex >= filteredQuestions.length) {
const CAUGHT_UP_ACCENT = colors.premium.rose;
const isPackMode = !!packId;
const isPendingMode = mode === 'pending';
@@ -878,8 +1127,8 @@ export default function SwipeScreen() {
{isPendingMode
? "You've answered all the questions your partner swiped on. Nice work! Check back later for more."
: isPackMode
? "You've answered all questions in this pack. Explore other packs to discover more!"
: "You've answered all available questions. New questions are added regularly, or explore different packs."
? "You've answered all questions in this pack. Head home to discover more!"
: "You've answered all available questions. New questions are added regularly, or explore different packs from home."
}
</Text>
@@ -907,8 +1156,8 @@ export default function SwipeScreen() {
<Text style={styles.waitingFeatureText}>New questions weekly</Text>
</View>
<View style={styles.waitingFeatureItem}>
<Ionicons name="layers-outline" size={16} color={CAUGHT_UP_ACCENT} />
<Text style={styles.waitingFeatureText}>Try different packs</Text>
<Ionicons name="home-outline" size={16} color={CAUGHT_UP_ACCENT} />
<Text style={styles.waitingFeatureText}>Explore from home</Text>
</View>
<View style={styles.waitingFeatureItem}>
<Ionicons name="chatbubbles-outline" size={16} color={CAUGHT_UP_ACCENT} />
@@ -936,10 +1185,10 @@ export default function SwipeScreen() {
</GlassButton>
) : isPackMode ? (
<GlassButton
onPress={() => router.back()}
onPress={() => router.push("/")}
style={{ marginTop: spacing.lg }}
>
Explore More Packs
Back to Home
</GlassButton>
) : (
<GlassButton
@@ -958,7 +1207,7 @@ export default function SwipeScreen() {
return (
<GradientBackground>
{/* Ambient Orbs - Premium gold/rose */}
{/* Ambient Orbs - Premium gold/rose - Commented out for flat design
<Animated.View style={[styles.ambientOrb, styles.orbTopRight, orbStyle1]} pointerEvents="none">
<LinearGradient
colors={[colors.premium.goldGlow, 'transparent']}
@@ -975,6 +1224,7 @@ export default function SwipeScreen() {
end={{ x: 0, y: 0 }}
/>
</Animated.View>
*/}
<View style={styles.container}>
{/* Header */}
@@ -995,14 +1245,14 @@ export default function SwipeScreen() {
{mode === 'pending' ? 'YOUR TURN' : packContext ? packContext.name.toUpperCase() : 'EXPLORE'}
</Text>
<Text style={styles.progressText}>
{currentIndex + 1} of {effectiveTotal || questions.length}
{currentIndex + 1} of {effectiveTotal || filteredQuestions.length}
</Text>
<View style={[styles.progressBar, styles.progressBarPremium]}>
<Animated.View
style={[
styles.progressFill,
styles.progressFillPremium,
{ width: `${((currentIndex + 1) / (effectiveTotal || questions.length || 1)) * 100}%` }
{ width: `${((currentIndex + 1) / (effectiveTotal || filteredQuestions.length || 1)) * 100}%` }
]}
>
{/* Shimmer sweep */}
@@ -1025,52 +1275,23 @@ export default function SwipeScreen() {
{/* Card Stack */}
<View style={styles.cardContainer}>
{/* Third card (deepest) */}
{questions[currentIndex + 2] && (
{filteredQuestions[currentIndex + 2] && (
<View style={[styles.stackCard, styles.stackCardThird]} />
)}
{/* Second card */}
{questions[currentIndex + 1] && (
{filteredQuestions[currentIndex + 1] && (
<View style={[styles.stackCard, styles.stackCardSecond]} />
)}
{/* Active card */}
<SwipeCard
key={questions[currentIndex].id}
question={questions[currentIndex]}
onSwipe={handleSwipe}
onReport={() => setFeedbackQuestion({
id: questions[currentIndex].id,
text: questions[currentIndex].text
})}
/>
{/* Active card - render appropriate component based on question_type */}
{renderQuestionCard(filteredQuestions[currentIndex])}
</View>
{/* Hint - Premium styling */}
<Animated.View
entering={FadeIn.delay(500).duration(400)}
style={styles.hintContainer}
>
<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>
{/* Bottom spacing for tab bar */}
<View style={styles.bottomSpacer} />
</View>
{/* Tutorial Overlay */}
{showTutorial && (
<SwipeTutorial onComplete={handleTutorialComplete} />
)}
{/* Question Feedback Modal */}
<QuestionFeedbackModal
visible={!!feedbackQuestion}
@@ -1079,6 +1300,12 @@ export default function SwipeScreen() {
questionText={feedbackQuestion?.text || ''}
/>
{/* Match Confetti Animation */}
<MatchConfetti
visible={showConfetti}
onAnimationComplete={handleConfettiComplete}
/>
{/* Paywall Modal */}
<Paywall
visible={showPaywall}
@@ -1089,6 +1316,16 @@ export default function SwipeScreen() {
fetchQuestions();
}}
/>
{/* Upload spinner overlay */}
{isUploading && (
<View style={styles.uploadOverlay}>
<View style={styles.uploadSpinnerContainer}>
<ActivityIndicator size="large" color={colors.premium.gold} />
<Text style={styles.uploadText}>Uploading...</Text>
</View>
</View>
)}
</GradientBackground>
);
}
@@ -1193,11 +1430,11 @@ const styles = StyleSheet.create({
justifyContent: "center",
alignItems: "center",
},
// Card stack - Premium styling
// Card stack - Premium styling (background cards)
stackCard: {
position: "absolute",
width: "85%",
height: 500,
height: "85%",
backgroundColor: '#0d0d1a',
borderRadius: radius.xl,
borderWidth: 1,
@@ -1213,32 +1450,6 @@ const styles = StyleSheet.create({
opacity: 0.2,
...shadows.sm,
},
hintContainer: {
alignItems: "center",
paddingBottom: spacing.md,
},
hintText: {
...typography.caption1,
color: colors.textTertiary,
},
hintTextPremium: {
...typography.caption1,
fontStyle: 'italic',
color: colors.textTertiary,
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: {
height: Platform.OS === 'ios' ? 100 : 80,
},
@@ -1443,4 +1654,26 @@ const styles = StyleSheet.create({
fontWeight: '600',
color: '#000',
},
// Upload overlay styles
uploadOverlay: {
...StyleSheet.absoluteFillObject,
backgroundColor: 'rgba(0, 0, 0, 0.6)',
justifyContent: 'center',
alignItems: 'center',
zIndex: 100,
},
uploadSpinnerContainer: {
backgroundColor: colors.backgroundLight,
paddingHorizontal: spacing.xl,
paddingVertical: spacing.lg,
borderRadius: radius.lg,
alignItems: 'center',
gap: spacing.md,
borderWidth: 1,
borderColor: 'rgba(212, 175, 55, 0.2)',
},
uploadText: {
...typography.subhead,
color: colors.text,
},
});
+10 -6
View File
@@ -768,10 +768,18 @@ const styles = StyleSheet.create({
},
modeToggleContainer: {
flexDirection: "row",
backgroundColor: colors.glass.background,
backgroundColor: colors.background, // Flat background
borderRadius: radius.md,
padding: 4,
marginBottom: spacing.lg,
borderWidth: 1,
borderColor: colors.border,
},
// ...
dividerLine: {
flex: 1,
height: 1,
backgroundColor: colors.border,
},
modeToggle: {
flex: 1,
@@ -832,11 +840,7 @@ const styles = StyleSheet.create({
alignItems: "center",
marginBottom: spacing.lg,
},
dividerLine: {
flex: 1,
height: 1,
backgroundColor: colors.glass.border,
},
dividerText: {
...typography.caption1,
color: colors.textTertiary,
+16 -15
View File
@@ -1,7 +1,8 @@
import { Redirect } from "expo-router";
import { useAuthStore } from "../src/store";
import { View, ActivityIndicator, StyleSheet } from "react-native";
import { Redirect } from "expo-router";
import { useAuthStore } from "../src/store";
import { View, ActivityIndicator, StyleSheet } from "react-native";
import { needsOnboarding } from "../src/constants/onboarding";
export default function Index() {
const { isAuthenticated, isLoading, user } = useAuthStore();
@@ -19,8 +20,8 @@ export default function Index() {
return <Redirect href="/(auth)/login" />;
}
// If user hasn't completed onboarding, go to onboarding first
if (!user?.onboarding_completed) {
// If user hasn't completed onboarding or needs to re-onboard, go to onboarding
if (needsOnboarding(user?.onboarding_completed, user?.onboarding_version)) {
return <Redirect href="/(app)/onboarding" />;
}
@@ -28,12 +29,12 @@ export default function Index() {
// No forced redirect to pairing for users without a couple
return <Redirect href="/(app)" />;
}
const styles = StyleSheet.create({
container: {
flex: 1,
justifyContent: "center",
alignItems: "center",
backgroundColor: "#1a1a2e",
},
});
const styles = StyleSheet.create({
container: {
flex: 1,
justifyContent: "center",
alignItems: "center",
backgroundColor: "#1a1a2e",
},
});
+115 -89
View File
@@ -242,12 +242,19 @@ export default function PackDetailsScreen() {
{category ? category.name.toUpperCase() : 'PREVIEW'}
</Text>
{/* Explicit badge */}
{pack?.is_explicit && (
<View style={styles.explicitBadge}>
<Ionicons name="flame" size={12} color={colors.text} />
<Text style={styles.explicitBadgeText}>18+ EXPLICIT</Text>
</View>
)}
{/* Icon with enhanced glow for premium */}
<View style={styles.iconWrapper}>
{pack?.is_premium && (
<>
<View style={styles.iconGlowOuter} />
<View style={styles.iconGlow} />
{/* Glow effects removed for flat design */}
</>
)}
<View style={[
@@ -329,11 +336,7 @@ export default function PackDetailsScreen() {
{/* 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)" }]} />
)}
<View style={[StyleSheet.absoluteFill, { backgroundColor: colors.background }]} />
</Animated.View>
<TouchableOpacity onPress={() => router.back()} style={styles.backButton}>
@@ -440,8 +443,87 @@ const styles = StyleSheet.create({
navBarBackground: {
...StyleSheet.absoluteFillObject,
borderBottomWidth: 1,
borderBottomColor: 'rgba(233, 69, 96, 0.15)',
borderBottomColor: colors.border,
overflow: "hidden",
backgroundColor: colors.background, // Flat
},
// ...
// ...
premiumBanner: {
marginHorizontal: spacing.lg,
marginBottom: spacing.lg,
borderRadius: radius.md,
overflow: 'hidden',
// Removed heavy shadow
borderWidth: 1,
borderColor: colors.premium.gold,
},
// ...
iconWrapper: {
position: 'relative',
marginBottom: spacing.md,
// Removed glowing background elements
},
// ...
iconContainer: {
width: 72,
height: 72,
borderRadius: 36,
backgroundColor: colors.backgroundLight,
justifyContent: 'center',
alignItems: 'center',
borderWidth: 1,
borderColor: colors.border,
overflow: 'hidden',
},
iconContainerPremium: {
borderColor: colors.premium.gold,
borderWidth: 2,
backgroundColor: colors.backgroundLight,
// Removed heavy shadows
},
// ...
statsBadge: {
flexDirection: 'row',
alignItems: 'center',
gap: spacing.xs,
backgroundColor: colors.backgroundLight,
paddingHorizontal: spacing.lg,
paddingVertical: spacing.sm,
borderRadius: radius.full,
borderWidth: 1,
borderColor: colors.border,
},
// ...
questionCard: {
backgroundColor: colors.backgroundLight, // Flat
borderRadius: radius.md,
borderWidth: 1,
borderColor: colors.border,
marginBottom: spacing.sm,
},
// ...
partnerTextContainer: {
flexDirection: 'row',
alignItems: 'center',
gap: spacing.xs,
marginBottom: spacing.sm,
paddingVertical: spacing.xs,
paddingHorizontal: spacing.sm,
backgroundColor: colors.background, // Nested flat container
borderRadius: radius.sm,
alignSelf: 'flex-start',
borderWidth: 1,
borderColor: colors.border,
},
// ...
tooltipContainer: {
maxWidth: 320,
overflow: 'hidden',
backgroundColor: colors.backgroundLight, // Flat
borderWidth: 1,
borderColor: colors.border,
},
backButton: {
zIndex: 10,
@@ -482,17 +564,7 @@ const styles = StyleSheet.create({
},
// Premium Banner
premiumBanner: {
marginHorizontal: spacing.lg,
marginBottom: spacing.lg,
borderRadius: radius.md,
overflow: 'hidden',
shadowColor: colors.premium.gold,
shadowOffset: { width: 0, height: 4 },
shadowOpacity: 0.3,
shadowRadius: 8,
elevation: 8,
},
premiumBannerContent: {
flexDirection: 'row',
alignItems: 'center',
@@ -519,49 +591,32 @@ const styles = StyleSheet.create({
labelPremium: {
color: colors.premium.gold,
},
iconWrapper: {
position: 'relative',
explicitBadge: {
flexDirection: 'row',
alignItems: 'center',
backgroundColor: colors.error,
paddingHorizontal: spacing.md,
paddingVertical: spacing.xs,
borderRadius: radius.full,
gap: spacing.xs,
marginBottom: spacing.md,
},
explicitBadgeText: {
...typography.caption2,
fontWeight: '700',
color: colors.text,
letterSpacing: 1,
},
// Removed glow styles
iconGlowOuter: {
position: 'absolute',
top: -20,
left: -20,
right: -20,
bottom: -20,
borderRadius: 60,
backgroundColor: 'rgba(212, 175, 55, 0.08)',
display: 'none',
},
iconGlow: {
position: 'absolute',
top: -10,
left: -10,
right: -10,
bottom: -10,
borderRadius: 50,
backgroundColor: 'rgba(212, 175, 55, 0.2)',
},
iconContainer: {
width: 72,
height: 72,
borderRadius: 36,
backgroundColor: 'rgba(255, 255, 255, 0.06)',
justifyContent: 'center',
alignItems: 'center',
borderWidth: 1,
borderColor: 'rgba(255, 255, 255, 0.1)',
overflow: 'hidden',
},
iconContainerPremium: {
borderColor: colors.premium.gold,
borderWidth: 2,
backgroundColor: 'rgba(212, 175, 55, 0.15)',
shadowColor: colors.premium.gold,
shadowOffset: { width: 0, height: 0 },
shadowOpacity: 0.5,
shadowRadius: 12,
elevation: 8,
display: 'none',
},
crownBadge: {
position: 'absolute',
top: -4,
@@ -647,17 +702,7 @@ const styles = StyleSheet.create({
paddingHorizontal: spacing.md,
marginBottom: spacing.md,
},
statsBadge: {
flexDirection: 'row',
alignItems: 'center',
gap: spacing.xs,
backgroundColor: 'rgba(233, 69, 96, 0.1)',
paddingHorizontal: spacing.lg,
paddingVertical: spacing.sm,
borderRadius: radius.full,
borderWidth: 1,
borderColor: 'rgba(233, 69, 96, 0.2)',
},
statsBadgePremium: {
backgroundColor: 'rgba(212, 175, 55, 0.1)',
borderColor: 'rgba(212, 175, 55, 0.3)',
@@ -676,13 +721,7 @@ const styles = StyleSheet.create({
paddingHorizontal: spacing.md,
paddingBottom: Platform.OS === 'ios' ? 100 : 80,
},
questionCard: {
backgroundColor: 'rgba(22, 33, 62, 0.4)',
borderRadius: radius.md,
borderWidth: 1,
borderColor: 'rgba(255, 255, 255, 0.06)',
marginBottom: spacing.sm,
},
questionContent: {
padding: spacing.md,
},
@@ -697,17 +736,7 @@ const styles = StyleSheet.create({
marginBottom: spacing.sm,
lineHeight: 22,
},
partnerTextContainer: {
flexDirection: 'row',
alignItems: 'center',
gap: spacing.xs,
marginBottom: spacing.sm,
paddingVertical: spacing.xs,
paddingHorizontal: spacing.sm,
backgroundColor: 'rgba(232, 164, 174, 0.1)',
borderRadius: radius.sm,
alignSelf: 'flex-start',
},
partnerText: {
...typography.caption1,
color: colors.premium.rose,
@@ -755,10 +784,7 @@ const styles = StyleSheet.create({
alignItems: 'center',
padding: spacing.lg,
},
tooltipContainer: {
maxWidth: 320,
overflow: 'hidden',
},
tooltipTitle: {
...typography.title3,
color: colors.text,
+31
View File
@@ -300,6 +300,8 @@ PODS:
- Yoga
- ExpoSecureStore (14.0.1):
- ExpoModulesCore
- ExpoSharing (14.0.8):
- ExpoModulesCore
- EXUpdatesInterface (1.0.0):
- ExpoModulesCore
- fast_float (6.1.4)
@@ -1703,6 +1705,27 @@ PODS:
- Yoga
- react-native-safe-area-context (4.12.0):
- React-Core
- react-native-view-shot (4.0.3):
- DoubleConversion
- glog
- hermes-engine
- RCT-Folly (= 2024.10.14.00)
- RCTRequired
- RCTTypeSafety
- React-Core
- React-debug
- React-Fabric
- React-featureflags
- React-graphics
- React-ImageManager
- React-NativeModulesApple
- React-RCTFabric
- React-rendererdebug
- React-utils
- ReactCodegen
- ReactCommon/turbomodule/bridging
- ReactCommon/turbomodule/core
- Yoga
- React-nativeconfig (0.76.9)
- React-NativeModulesApple (0.76.9):
- glog
@@ -2230,6 +2253,7 @@ DEPENDENCIES:
- ExpoMediaLibrary (from `../../../node_modules/expo-media-library/ios`)
- ExpoModulesCore (from `../../../node_modules/expo-modules-core`)
- ExpoSecureStore (from `../../../node_modules/expo-secure-store/ios`)
- ExpoSharing (from `../../../node_modules/expo-sharing/ios`)
- EXUpdatesInterface (from `../../../node_modules/expo-updates-interface/ios`)
- fast_float (from `../../../node_modules/react-native/third-party-podspecs/fast_float.podspec`)
- FBLazyVector (from `../../../node_modules/react-native/Libraries/FBLazyVector`)
@@ -2269,6 +2293,7 @@ DEPENDENCIES:
- React-microtasksnativemodule (from `../../../node_modules/react-native/ReactCommon/react/nativemodule/microtasks`)
- react-native-compressor (from `../../../node_modules/react-native-compressor`)
- react-native-safe-area-context (from `../../../node_modules/react-native-safe-area-context`)
- react-native-view-shot (from `../../../node_modules/react-native-view-shot`)
- React-nativeconfig (from `../../../node_modules/react-native/ReactCommon`)
- React-NativeModulesApple (from `../../../node_modules/react-native/ReactCommon/react/nativemodule/core/platform/ios`)
- React-perflogger (from `../../../node_modules/react-native/ReactCommon/reactperflogger`)
@@ -2399,6 +2424,8 @@ EXTERNAL SOURCES:
:path: "../../../node_modules/expo-modules-core"
ExpoSecureStore:
:path: "../../../node_modules/expo-secure-store/ios"
ExpoSharing:
:path: "../../../node_modules/expo-sharing/ios"
EXUpdatesInterface:
:path: "../../../node_modules/expo-updates-interface/ios"
fast_float:
@@ -2474,6 +2501,8 @@ EXTERNAL SOURCES:
:path: "../../../node_modules/react-native-compressor"
react-native-safe-area-context:
:path: "../../../node_modules/react-native-safe-area-context"
react-native-view-shot:
:path: "../../../node_modules/react-native-view-shot"
React-nativeconfig:
:path: "../../../node_modules/react-native/ReactCommon"
React-NativeModulesApple:
@@ -2586,6 +2615,7 @@ SPEC CHECKSUMS:
ExpoMediaLibrary: 2709fc2e4fcf87d28767714abcc603c4bec228c6
ExpoModulesCore: 2d8d1c8e238bd5e1448b06c284ac896b162fb856
ExpoSecureStore: 9a3665d7161dd1031be386e278bffd623658f316
ExpoSharing: 0d983394ed4a80334bab5a0d5384f75710feb7e8
EXUpdatesInterface: 7c977640bdd8b85833c19e3959ba46145c5719db
fast_float: 06eeec4fe712a76acc9376682e4808b05ce978b6
FBLazyVector: 7605ea4810e0e10ae4815292433c09bf4324ba45
@@ -2638,6 +2668,7 @@ SPEC CHECKSUMS:
React-microtasksnativemodule: 338bfa0a7d8cd1ed212b055f2143a6789252137a
react-native-compressor: 40b3e2b942aebac3daaae9549b23a13510c67a6f
react-native-safe-area-context: 8b8404e70b0cbf2a56428a17017c14c1dcc16448
react-native-view-shot: 4147b714c5316d7e17ed652d0b5a182c028f842e
React-nativeconfig: 415626a63057638759bcc75e0a96e2e07771a479
React-NativeModulesApple: d33b55553c6957ff94835574636838d78121a1c6
React-perflogger: 72e653eb3aba9122f9e57cf012d22d2486f33358
+39 -39
View File
@@ -10,13 +10,13 @@
13B07FBC1A68108700A75B9A /* AppDelegate.mm in Sources */ = {isa = PBXBuildFile; fileRef = 13B07FB01A68108700A75B9A /* AppDelegate.mm */; };
13B07FBF1A68108700A75B9A /* Images.xcassets in Resources */ = {isa = PBXBuildFile; fileRef = 13B07FB51A68108700A75B9A /* Images.xcassets */; };
13B07FC11A68108700A75B9A /* main.m in Sources */ = {isa = PBXBuildFile; fileRef = 13B07FB71A68108700A75B9A /* main.m */; };
205B32B3C49E4FAE89D6C5F0 /* noop-file.swift in Sources */ = {isa = PBXBuildFile; fileRef = CCCF3A9B29424BA98FBA8B48 /* noop-file.swift */; };
3E461D99554A48A4959DE609 /* SplashScreen.storyboard in Resources */ = {isa = PBXBuildFile; fileRef = AA286B85B6C04FC6940260E9 /* SplashScreen.storyboard */; };
42547B8429A622FDB6A08D1A /* PrivacyInfo.xcprivacy in Resources */ = {isa = PBXBuildFile; fileRef = 6F16AA977A27D49DC4EABEBD /* PrivacyInfo.xcprivacy */; };
6576698ADC99475D84AFD6D9 /* GoogleService-Info.plist in Resources */ = {isa = PBXBuildFile; fileRef = E4DB794D33A243BDB0648CC7 /* GoogleService-Info.plist */; };
944B75281101CC74377D82DB /* PrivacyInfo.xcprivacy in Resources */ = {isa = PBXBuildFile; fileRef = 8D7E553B42EBF30261ADEC30 /* PrivacyInfo.xcprivacy */; };
B18059E884C0ABDD17F3DC3D /* ExpoModulesProvider.swift in Sources */ = {isa = PBXBuildFile; fileRef = FAC715A2D49A985799AEE119 /* ExpoModulesProvider.swift */; };
B7C05C8A003645F0BE64907E /* GoogleService-Info.plist in Resources */ = {isa = PBXBuildFile; fileRef = 635D1AC9C28D4D029F2F8C7A /* GoogleService-Info.plist */; };
BB2F792D24A3F905000567C9 /* Expo.plist in Resources */ = {isa = PBXBuildFile; fileRef = BB2F792C24A3F905000567C9 /* Expo.plist */; };
C3F98487868740D8A0D61E28 /* noop-file.swift in Sources */ = {isa = PBXBuildFile; fileRef = DC04D93C99ED45B8A9943233 /* noop-file.swift */; };
FE422ED79FD52F74C1E29BFB /* Pods_Sauci.framework in Frameworks */ = {isa = PBXBuildFile; fileRef = 8641A69B4B5E9431AF468A7C /* Pods_Sauci.framework */; };
BCF95FBAB579F4031AACC822 /* Pods_Sauci.framework in Frameworks */ = {isa = PBXBuildFile; fileRef = 95792FE2B2AD39D770B9A7F1 /* Pods_Sauci.framework */; };
/* End PBXBuildFile section */
/* Begin PBXFileReference section */
@@ -26,15 +26,15 @@
13B07FB51A68108700A75B9A /* Images.xcassets */ = {isa = PBXFileReference; lastKnownFileType = folder.assetcatalog; name = Images.xcassets; path = Sauci/Images.xcassets; sourceTree = "<group>"; };
13B07FB61A68108700A75B9A /* Info.plist */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = text.plist.xml; name = Info.plist; path = Sauci/Info.plist; sourceTree = "<group>"; };
13B07FB71A68108700A75B9A /* main.m */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.c.objc; name = main.m; path = Sauci/main.m; sourceTree = "<group>"; };
635D1AC9C28D4D029F2F8C7A /* GoogleService-Info.plist */ = {isa = PBXFileReference; explicitFileType = undefined; fileEncoding = 4; includeInIndex = 0; lastKnownFileType = text.plist.xml; name = "GoogleService-Info.plist"; path = "Sauci/GoogleService-Info.plist"; sourceTree = "<group>"; };
6C2E3173556A471DD304B334 /* Pods-Sauci.debug.xcconfig */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = text.xcconfig; name = "Pods-Sauci.debug.xcconfig"; path = "Target Support Files/Pods-Sauci/Pods-Sauci.debug.xcconfig"; sourceTree = "<group>"; };
6F16AA977A27D49DC4EABEBD /* PrivacyInfo.xcprivacy */ = {isa = PBXFileReference; includeInIndex = 1; name = PrivacyInfo.xcprivacy; path = Sauci/PrivacyInfo.xcprivacy; sourceTree = "<group>"; };
78D1B9CD5B66459A822DF0D3 /* Sauci-Bridging-Header.h */ = {isa = PBXFileReference; explicitFileType = undefined; fileEncoding = 4; includeInIndex = 0; lastKnownFileType = sourcecode.c.h; name = "Sauci-Bridging-Header.h"; path = "Sauci/Sauci-Bridging-Header.h"; sourceTree = "<group>"; };
7A4D352CD337FB3A3BF06240 /* Pods-Sauci.release.xcconfig */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = text.xcconfig; name = "Pods-Sauci.release.xcconfig"; path = "Target Support Files/Pods-Sauci/Pods-Sauci.release.xcconfig"; sourceTree = "<group>"; };
8641A69B4B5E9431AF468A7C /* Pods_Sauci.framework */ = {isa = PBXFileReference; explicitFileType = wrapper.framework; includeInIndex = 0; path = Pods_Sauci.framework; sourceTree = BUILT_PRODUCTS_DIR; };
8D7E553B42EBF30261ADEC30 /* PrivacyInfo.xcprivacy */ = {isa = PBXFileReference; includeInIndex = 1; name = PrivacyInfo.xcprivacy; path = Sauci/PrivacyInfo.xcprivacy; sourceTree = "<group>"; };
95792FE2B2AD39D770B9A7F1 /* Pods_Sauci.framework */ = {isa = PBXFileReference; explicitFileType = wrapper.framework; includeInIndex = 0; path = Pods_Sauci.framework; sourceTree = BUILT_PRODUCTS_DIR; };
AA286B85B6C04FC6940260E9 /* SplashScreen.storyboard */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = file.storyboard; name = SplashScreen.storyboard; path = Sauci/SplashScreen.storyboard; sourceTree = "<group>"; };
BB2F792C24A3F905000567C9 /* Expo.plist */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = text.plist.xml; path = Expo.plist; sourceTree = "<group>"; };
CA157390B5A149C0AD0B6B08 /* Sauci-Bridging-Header.h */ = {isa = PBXFileReference; explicitFileType = undefined; fileEncoding = 4; includeInIndex = 0; lastKnownFileType = sourcecode.c.h; name = "Sauci-Bridging-Header.h"; path = "Sauci/Sauci-Bridging-Header.h"; sourceTree = "<group>"; };
DC04D93C99ED45B8A9943233 /* noop-file.swift */ = {isa = PBXFileReference; explicitFileType = undefined; fileEncoding = 4; includeInIndex = 0; lastKnownFileType = sourcecode.swift; name = "noop-file.swift"; path = "Sauci/noop-file.swift"; sourceTree = "<group>"; };
CCCF3A9B29424BA98FBA8B48 /* noop-file.swift */ = {isa = PBXFileReference; explicitFileType = undefined; fileEncoding = 4; includeInIndex = 0; lastKnownFileType = sourcecode.swift; name = "noop-file.swift"; path = "Sauci/noop-file.swift"; sourceTree = "<group>"; };
E4DB794D33A243BDB0648CC7 /* GoogleService-Info.plist */ = {isa = PBXFileReference; explicitFileType = undefined; fileEncoding = 4; includeInIndex = 0; lastKnownFileType = text.plist.xml; name = "GoogleService-Info.plist"; path = "Sauci/GoogleService-Info.plist"; sourceTree = "<group>"; };
ED297162215061F000B7C4FE /* JavaScriptCore.framework */ = {isa = PBXFileReference; lastKnownFileType = wrapper.framework; name = JavaScriptCore.framework; path = System/Library/Frameworks/JavaScriptCore.framework; sourceTree = SDKROOT; };
FAC715A2D49A985799AEE119 /* ExpoModulesProvider.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; name = ExpoModulesProvider.swift; path = "Pods/Target Support Files/Pods-Sauci/ExpoModulesProvider.swift"; sourceTree = "<group>"; };
/* End PBXFileReference section */
@@ -44,7 +44,7 @@
isa = PBXFrameworksBuildPhase;
buildActionMask = 2147483647;
files = (
FE422ED79FD52F74C1E29BFB /* Pods_Sauci.framework in Frameworks */,
BCF95FBAB579F4031AACC822 /* Pods_Sauci.framework in Frameworks */,
);
runOnlyForDeploymentPostprocessing = 0;
};
@@ -61,10 +61,10 @@
13B07FB61A68108700A75B9A /* Info.plist */,
13B07FB71A68108700A75B9A /* main.m */,
AA286B85B6C04FC6940260E9 /* SplashScreen.storyboard */,
635D1AC9C28D4D029F2F8C7A /* GoogleService-Info.plist */,
DC04D93C99ED45B8A9943233 /* noop-file.swift */,
CA157390B5A149C0AD0B6B08 /* Sauci-Bridging-Header.h */,
6F16AA977A27D49DC4EABEBD /* PrivacyInfo.xcprivacy */,
E4DB794D33A243BDB0648CC7 /* GoogleService-Info.plist */,
CCCF3A9B29424BA98FBA8B48 /* noop-file.swift */,
78D1B9CD5B66459A822DF0D3 /* Sauci-Bridging-Header.h */,
8D7E553B42EBF30261ADEC30 /* PrivacyInfo.xcprivacy */,
);
name = Sauci;
sourceTree = "<group>";
@@ -73,7 +73,7 @@
isa = PBXGroup;
children = (
ED297162215061F000B7C4FE /* JavaScriptCore.framework */,
8641A69B4B5E9431AF468A7C /* Pods_Sauci.framework */,
95792FE2B2AD39D770B9A7F1 /* Pods_Sauci.framework */,
);
name = Frameworks;
sourceTree = "<group>";
@@ -150,15 +150,15 @@
buildConfigurationList = 13B07F931A680F5B00A75B9A /* Build configuration list for PBXNativeTarget "Sauci" */;
buildPhases = (
08A4A3CD28434E44B6B9DE2E /* [CP] Check Pods Manifest.lock */,
380EBB147031A50785146268 /* [Expo] Configure project */,
343E25507BEB6EFEF50B08AF /* [Expo] Configure project */,
13B07F871A680F5B00A75B9A /* Sources */,
13B07F8C1A680F5B00A75B9A /* Frameworks */,
13B07F8E1A680F5B00A75B9A /* Resources */,
00DD1BFF1BD5951E006B06BC /* Bundle React Native code and images */,
800E24972A6A228C8D4807E9 /* [CP] Copy Pods Resources */,
5F6BF0C2EF824875B30C4F47 /* Upload Debug Symbols to Sentry */,
83F7C8F647047F466249E15C /* [CP] Embed Pods Frameworks */,
3AB0FB698F7BE8E7FEAC6EB4 /* [CP-User] [RNFB] Core Configuration */,
8C453F47518C4580A321EB65 /* Upload Debug Symbols to Sentry */,
9F907B34B4D68CFE07BD67A0 /* [CP] Embed Pods Frameworks */,
5020657B8213723F010419B9 /* [CP-User] [RNFB] Core Configuration */,
);
buildRules = (
);
@@ -210,8 +210,8 @@
BB2F792D24A3F905000567C9 /* Expo.plist in Resources */,
13B07FBF1A68108700A75B9A /* Images.xcassets in Resources */,
3E461D99554A48A4959DE609 /* SplashScreen.storyboard in Resources */,
B7C05C8A003645F0BE64907E /* GoogleService-Info.plist in Resources */,
42547B8429A622FDB6A08D1A /* PrivacyInfo.xcprivacy in Resources */,
6576698ADC99475D84AFD6D9 /* GoogleService-Info.plist in Resources */,
944B75281101CC74377D82DB /* PrivacyInfo.xcprivacy in Resources */,
);
runOnlyForDeploymentPostprocessing = 0;
};
@@ -255,7 +255,7 @@
shellScript = "diff \"${PODS_PODFILE_DIR_PATH}/Podfile.lock\" \"${PODS_ROOT}/Manifest.lock\" > /dev/null\nif [ $? != 0 ] ; then\n # print error to STDERR\n echo \"error: The sandbox is not in sync with the Podfile.lock. Run 'pod install' or update your CocoaPods installation.\" >&2\n exit 1\nfi\n# This output is used by Xcode 'outputs' to avoid re-running this script phase.\necho \"SUCCESS\" > \"${SCRIPT_OUTPUT_FILE_0}\"\n";
showEnvVarsInLog = 0;
};
380EBB147031A50785146268 /* [Expo] Configure project */ = {
343E25507BEB6EFEF50B08AF /* [Expo] Configure project */ = {
isa = PBXShellScriptBuildPhase;
alwaysOutOfDate = 1;
buildActionMask = 2147483647;
@@ -274,7 +274,7 @@
shellPath = /bin/sh;
shellScript = "# This script configures Expo modules and generates the modules provider file.\nbash -l -c \"./Pods/Target\\ Support\\ Files/Pods-Sauci/expo-configure-project.sh\"\n";
};
3AB0FB698F7BE8E7FEAC6EB4 /* [CP-User] [RNFB] Core Configuration */ = {
5020657B8213723F010419B9 /* [CP-User] [RNFB] Core Configuration */ = {
isa = PBXShellScriptBuildPhase;
buildActionMask = 2147483647;
files = (
@@ -287,20 +287,6 @@
shellPath = /bin/sh;
shellScript = "#!/usr/bin/env bash\n#\n# Copyright (c) 2016-present Invertase Limited & Contributors\n#\n# Licensed under the Apache License, Version 2.0 (the \"License\");\n# you may not use this library except in compliance with the License.\n# You may obtain a copy of the License at\n#\n# http://www.apache.org/licenses/LICENSE-2.0\n#\n# Unless required by applicable law or agreed to in writing, software\n# distributed under the License is distributed on an \"AS IS\" BASIS,\n# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.\n# See the License for the specific language governing permissions and\n# limitations under the License.\n#\n\n##########################################################################\n##########################################################################\n#\n# NOTE THAT IF YOU CHANGE THIS FILE YOU MUST RUN pod install AFTERWARDS\n#\n# This file is installed as an Xcode build script in the project file\n# by cocoapods, and you will not see your changes until you pod install\n#\n##########################################################################\n##########################################################################\n\nset -e\n\n_MAX_LOOKUPS=2;\n_SEARCH_RESULT=''\n_RN_ROOT_EXISTS=''\n_CURRENT_LOOKUPS=1\n_JSON_ROOT=\"'react-native'\"\n_JSON_FILE_NAME='firebase.json'\n_JSON_OUTPUT_BASE64='e30=' # { }\n_CURRENT_SEARCH_DIR=${PROJECT_DIR}\n_PLIST_BUDDY=/usr/libexec/PlistBuddy\n_TARGET_PLIST=\"${BUILT_PRODUCTS_DIR}/${INFOPLIST_PATH}\"\n_DSYM_PLIST=\"${DWARF_DSYM_FOLDER_PATH}/${DWARF_DSYM_FILE_NAME}/Contents/Info.plist\"\n\n# plist arrays\n_PLIST_ENTRY_KEYS=()\n_PLIST_ENTRY_TYPES=()\n_PLIST_ENTRY_VALUES=()\n\nfunction setPlistValue {\n echo \"note: setting plist entry '$1' of type '$2' in file '$4'\"\n ${_PLIST_BUDDY} -c \"Add :$1 $2 '$3'\" $4 || echo \"note: '$1' already exists\"\n}\n\nfunction getFirebaseJsonKeyValue () {\n if [[ ${_RN_ROOT_EXISTS} ]]; then\n ruby -Ku -e \"require 'rubygems';require 'json'; output=JSON.parse('$1'); puts output[$_JSON_ROOT]['$2']\"\n else\n echo \"\"\n fi;\n}\n\nfunction jsonBoolToYesNo () {\n if [[ $1 == \"false\" ]]; then\n echo \"NO\"\n elif [[ $1 == \"true\" ]]; then\n echo \"YES\"\n else echo \"NO\"\n fi\n}\n\necho \"note: -> RNFB build script started\"\necho \"note: 1) Locating ${_JSON_FILE_NAME} file:\"\n\nif [[ -z ${_CURRENT_SEARCH_DIR} ]]; then\n _CURRENT_SEARCH_DIR=$(pwd)\nfi;\n\nwhile true; do\n _CURRENT_SEARCH_DIR=$(dirname \"$_CURRENT_SEARCH_DIR\")\n if [[ \"$_CURRENT_SEARCH_DIR\" == \"/\" ]] || [[ ${_CURRENT_LOOKUPS} -gt ${_MAX_LOOKUPS} ]]; then break; fi;\n echo \"note: ($_CURRENT_LOOKUPS of $_MAX_LOOKUPS) Searching in '$_CURRENT_SEARCH_DIR' for a ${_JSON_FILE_NAME} file.\"\n _SEARCH_RESULT=$(find \"$_CURRENT_SEARCH_DIR\" -maxdepth 2 -name ${_JSON_FILE_NAME} -print | /usr/bin/head -n 1)\n if [[ ${_SEARCH_RESULT} ]]; then\n echo \"note: ${_JSON_FILE_NAME} found at $_SEARCH_RESULT\"\n break;\n fi;\n _CURRENT_LOOKUPS=$((_CURRENT_LOOKUPS+1))\ndone\n\nif [[ ${_SEARCH_RESULT} ]]; then\n _JSON_OUTPUT_RAW=$(cat \"${_SEARCH_RESULT}\")\n if ! _RN_ROOT_EXISTS=$(ruby -Ku -e \"require 'json'; output=JSON.parse('$_JSON_OUTPUT_RAW'); puts output[$_JSON_ROOT]\"); then\n echo \"error: Failed to parse firebase.json, check for syntax errors.\"\n exit 1\n fi\n\n if [[ ${_RN_ROOT_EXISTS} ]]; then\n if ! python3 --version >/dev/null 2>&1; then echo \"error: python3 not found, firebase.json file processing error.\" && exit 1; fi\n _JSON_OUTPUT_BASE64=$(python3 -c 'import json,sys,base64;print(base64.b64encode(bytes(json.dumps(json.loads(open('\"'${_SEARCH_RESULT}'\"', '\"'rb'\"').read())['${_JSON_ROOT}']), '\"'utf-8'\"')).decode())' || echo \"e30=\")\n fi\n\n _PLIST_ENTRY_KEYS+=(\"firebase_json_raw\")\n _PLIST_ENTRY_TYPES+=(\"string\")\n _PLIST_ENTRY_VALUES+=(\"$_JSON_OUTPUT_BASE64\")\n\n # config.app_data_collection_default_enabled\n _APP_DATA_COLLECTION_ENABLED=$(getFirebaseJsonKeyValue \"$_JSON_OUTPUT_RAW\" \"app_data_collection_default_enabled\")\n if [[ $_APP_DATA_COLLECTION_ENABLED ]]; then\n _PLIST_ENTRY_KEYS+=(\"FirebaseDataCollectionDefaultEnabled\")\n _PLIST_ENTRY_TYPES+=(\"bool\")\n _PLIST_ENTRY_VALUES+=(\"$(jsonBoolToYesNo \"$_APP_DATA_COLLECTION_ENABLED\")\")\n fi\n\n # config.analytics_auto_collection_enabled\n _ANALYTICS_AUTO_COLLECTION=$(getFirebaseJsonKeyValue \"$_JSON_OUTPUT_RAW\" \"analytics_auto_collection_enabled\")\n if [[ $_ANALYTICS_AUTO_COLLECTION ]]; then\n _PLIST_ENTRY_KEYS+=(\"FIREBASE_ANALYTICS_COLLECTION_ENABLED\")\n _PLIST_ENTRY_TYPES+=(\"bool\")\n _PLIST_ENTRY_VALUES+=(\"$(jsonBoolToYesNo \"$_ANALYTICS_AUTO_COLLECTION\")\")\n fi\n\n # config.analytics_collection_deactivated\n _ANALYTICS_DEACTIVATED=$(getFirebaseJsonKeyValue \"$_JSON_OUTPUT_RAW\" \"analytics_collection_deactivated\")\n if [[ $_ANALYTICS_DEACTIVATED ]]; then\n _PLIST_ENTRY_KEYS+=(\"FIREBASE_ANALYTICS_COLLECTION_DEACLine truncated
};
5F6BF0C2EF824875B30C4F47 /* Upload Debug Symbols to Sentry */ = {
isa = PBXShellScriptBuildPhase;
buildActionMask = 2147483647;
files = (
);
inputPaths = (
);
name = "Upload Debug Symbols to Sentry";
outputPaths = (
);
runOnlyForDeploymentPostprocessing = 0;
shellPath = /bin/sh;
shellScript = "/bin/sh `${NODE_BINARY:-node} --print \"require('path').dirname(require.resolve('@sentry/react-native/package.json')) + '/scripts/sentry-xcode-debug-files.sh'\"`";
};
800E24972A6A228C8D4807E9 /* [CP] Copy Pods Resources */ = {
isa = PBXShellScriptBuildPhase;
buildActionMask = 2147483647;
@@ -373,7 +359,21 @@
shellScript = "\"${PODS_ROOT}/Target Support Files/Pods-Sauci/Pods-Sauci-resources.sh\"\n";
showEnvVarsInLog = 0;
};
83F7C8F647047F466249E15C /* [CP] Embed Pods Frameworks */ = {
8C453F47518C4580A321EB65 /* Upload Debug Symbols to Sentry */ = {
isa = PBXShellScriptBuildPhase;
buildActionMask = 2147483647;
files = (
);
inputPaths = (
);
name = "Upload Debug Symbols to Sentry";
outputPaths = (
);
runOnlyForDeploymentPostprocessing = 0;
shellPath = /bin/sh;
shellScript = "/bin/sh `${NODE_BINARY:-node} --print \"require('path').dirname(require.resolve('@sentry/react-native/package.json')) + '/scripts/sentry-xcode-debug-files.sh'\"`";
};
9F907B34B4D68CFE07BD67A0 /* [CP] Embed Pods Frameworks */ = {
isa = PBXShellScriptBuildPhase;
buildActionMask = 2147483647;
files = (
@@ -401,7 +401,7 @@
13B07FBC1A68108700A75B9A /* AppDelegate.mm in Sources */,
13B07FC11A68108700A75B9A /* main.m in Sources */,
B18059E884C0ABDD17F3DC3D /* ExpoModulesProvider.swift in Sources */,
C3F98487868740D8A0D61E28 /* noop-file.swift in Sources */,
205B32B3C49E4FAE89D6C5F0 /* noop-file.swift in Sources */,
);
runOnlyForDeploymentPostprocessing = 0;
};
+4
View File
@@ -54,6 +54,7 @@
"expo-notifications": "~0.29.0",
"expo-router": "~4.0.0",
"expo-secure-store": "~14.0.0",
"expo-sharing": "^14.0.8",
"expo-status-bar": "~2.0.0",
"react": "18.3.1",
"react-dom": "18.3.1",
@@ -66,6 +67,7 @@
"react-native-screens": "~4.4.0",
"react-native-svg": "15.8.0",
"react-native-url-polyfill": "^2.0.0",
"react-native-view-shot": "^4.0.3",
"react-native-web": "~0.19.13",
"zustand": "^4.4.7"
},
@@ -77,6 +79,8 @@
"@types/react": "~18.3.12",
"@types/react-test-renderer": "^19.1.0",
"dotenv-cli": "^11.0.0",
"eslint": "^8.57.0",
"eslint-config-expo": "~8.0.1",
"jest": "^29.7.0",
"jest-expo": "~52.0.0",
"react-test-renderer": "^18.3.1",
@@ -72,22 +72,21 @@ describe('GlassCard', () => {
});
describe('platform behavior', () => {
it('uses BlurView on iOS', () => {
// GlassCard now uses solid backgrounds instead of BlurView for reliability
it('renders correctly on iOS', () => {
Object.defineProperty(Platform, 'OS', {
get: () => 'ios',
});
const { getByTestId } = render(
const { getByText } = render(
<GlassCard>
<Text>iOS Card</Text>
</GlassCard>
);
expect(getByTestId('blur-view')).toBeTruthy();
expect(getByText('iOS Card')).toBeTruthy();
});
// Note: Testing Android platform requires component re-evaluation
// which is complex with module-level Platform checks
it('renders correctly', () => {
const { getByText } = render(
<GlassCard>
@@ -100,43 +99,42 @@ describe('GlassCard', () => {
});
describe('intensity prop', () => {
// Note: intensity prop is kept for API compatibility but no longer affects visual rendering
// since BlurView was replaced with solid backgrounds for reliability
beforeEach(() => {
Object.defineProperty(Platform, 'OS', {
get: () => 'ios',
});
});
it('applies light intensity', () => {
const { getByTestId } = render(
it('accepts light intensity prop', () => {
const { getByText } = render(
<GlassCard intensity="light">
<Text>Light Card</Text>
</GlassCard>
);
const blurView = getByTestId('blur-view');
expect(blurView).toBeTruthy();
expect(getByText('Light Card')).toBeTruthy();
});
it('applies medium intensity by default', () => {
const { getByTestId } = render(
it('accepts medium intensity by default', () => {
const { getByText } = render(
<GlassCard>
<Text>Medium Card</Text>
</GlassCard>
);
const blurView = getByTestId('blur-view');
expect(blurView).toBeTruthy();
expect(getByText('Medium Card')).toBeTruthy();
});
it('applies heavy intensity', () => {
const { getByTestId } = render(
it('accepts heavy intensity prop', () => {
const { getByText } = render(
<GlassCard intensity="heavy">
<Text>Heavy Card</Text>
</GlassCard>
);
const blurView = getByTestId('blur-view');
expect(blurView).toBeTruthy();
expect(getByText('Heavy Card')).toBeTruthy();
});
});
@@ -92,16 +92,17 @@ describe('GlassInput', () => {
});
describe('platform behavior', () => {
it('uses BlurView on iOS', () => {
// GlassInput now uses solid backgrounds instead of BlurView for reliability
it('renders correctly on iOS', () => {
Object.defineProperty(Platform, 'OS', {
get: () => 'ios',
});
const { getByTestId } = render(
const { getByPlaceholderText } = render(
<GlassInput placeholder="iOS Input" />
);
expect(getByTestId('blur-view')).toBeTruthy();
expect(getByPlaceholderText('iOS Input')).toBeTruthy();
});
// Note: Testing Android platform requires component re-evaluation
+66 -47
View File
@@ -1,18 +1,18 @@
import React from 'react';
import { StyleSheet, View, Text, TouchableOpacity, Platform } from 'react-native';
import { LinearGradient } from 'expo-linear-gradient';
import { BlurView } from 'expo-blur';
import { Ionicons } from '@expo/vector-icons';
import Animated, {
useAnimatedStyle,
withSpring,
useSharedValue,
FadeIn,
} from 'react-native-reanimated';
import { ShimmerEffect } from './ui/ShimmerEffect';
import { GlassToggle } from './ui/GlassToggle';
import { colors, gradients, spacing, radius, typography, blur, shadows, animations } from '../theme';
import type { QuestionPack } from '../types';
import React from 'react';
import { StyleSheet, View, Text, TouchableOpacity, Platform } from 'react-native';
import { LinearGradient } from 'expo-linear-gradient';
import { BlurView } from 'expo-blur';
import { Ionicons } from '@expo/vector-icons';
import Animated, {
useAnimatedStyle,
withSpring,
useSharedValue,
FadeIn,
} from 'react-native-reanimated';
import { ShimmerEffect } from './ui/ShimmerEffect';
import { GlassToggle } from './ui/GlassToggle';
import { colors, gradients, spacing, radius, typography, blur, shadows, animations } from '../theme';
import type { QuestionPack } from '../types';
// Boutique gradient palette for pack cards
const BOUTIQUE_GRADIENTS: Array<readonly [string, string]> = [
@@ -71,33 +71,39 @@ export function BoutiquePackCard({
style={StyleSheet.absoluteFill}
/>
{/* Glass overlay */}
{useBlur ? (
<BlurView
intensity={blur.light}
tint="dark"
style={[StyleSheet.absoluteFill, styles.glassOverlay]}
/>
) : (
<View style={[StyleSheet.absoluteFill, styles.glassOverlayFallback]} />
)}
{/* Flat overlay - removed glass */}
<View style={[StyleSheet.absoluteFill, { backgroundColor: 'rgba(0,0,0,0.4)' }]} />
{/* Silk texture effect */}
{/* Silk texture effect - removed for flatter look
<LinearGradient
colors={gradients.silkLight as [string, string]}
start={{ x: 0, y: 0 }}
end={{ x: 1, y: 1 }}
style={[StyleSheet.absoluteFill, { opacity: 0.5 }]}
/>
*/}
{/* Top highlight */}
{/* Top highlight - removed
<LinearGradient
colors={[colors.glass.highlight, 'transparent']}
style={styles.topHighlight}
/>
*/}
{/* Content */}
<View style={styles.content}>
{/* Explicit Badge (top-left) */}
{pack.is_explicit && (
<View style={styles.explicitBadge}>
<Ionicons
name="flame"
size={10}
color={colors.text}
/>
<Text style={styles.explicitBadgeText}>18+</Text>
</View>
)}
{/* PRO Badge (top-right) */}
{pack.is_premium && (
<View style={[
@@ -148,17 +154,17 @@ export function BoutiquePackCard({
<View style={styles.footer}>
<View style={styles.questionBadge}>
<Text style={styles.questionCount}>{questionCount}</Text>
<Text style={styles.questionLabel}>questions</Text>
</View>
<GlassToggle
value={isEnabled}
onValueChange={onToggle}
disabled={isPremiumLocked}
style={isPremiumLocked ? { opacity: 0.4 } : undefined}
/>
</View>
</View>
<Text style={styles.questionLabel}>questions</Text>
</View>
<GlassToggle
value={isEnabled}
onValueChange={onToggle}
disabled={isPremiumLocked}
style={isPremiumLocked ? { opacity: 0.4 } : undefined}
/>
</View>
</View>
{/* Lock overlay for premium locked packs */}
{isPremiumLocked && (
@@ -209,7 +215,6 @@ const styles = StyleSheet.create({
cardPremium: {
borderWidth: 1,
borderColor: colors.premium.gold,
...shadows.glow(colors.premium.goldGlow),
},
cardEnabled: {
borderWidth: 1,
@@ -224,23 +229,37 @@ const styles = StyleSheet.create({
overflow: 'hidden',
},
glassOverlay: {
backgroundColor: 'rgba(22, 33, 62, 0.3)',
display: 'none',
},
glassOverlayFallback: {
backgroundColor: 'rgba(22, 33, 62, 0.5)',
display: 'none',
},
topHighlight: {
position: 'absolute',
top: 0,
left: 0,
right: 0,
height: 40,
display: 'none',
},
content: {
flex: 1,
padding: spacing.md,
justifyContent: 'space-between',
},
explicitBadge: {
position: 'absolute',
top: spacing.sm,
left: spacing.sm,
flexDirection: 'row',
alignItems: 'center',
backgroundColor: colors.error,
paddingHorizontal: spacing.sm,
paddingVertical: 3,
borderRadius: radius.xs,
gap: 3,
zIndex: 10,
},
explicitBadgeText: {
...typography.caption2,
color: colors.text,
fontWeight: '700',
},
proBadge: {
position: 'absolute',
top: spacing.sm,
@@ -255,7 +274,7 @@ const styles = StyleSheet.create({
zIndex: 10,
},
proBadgeLocked: {
backgroundColor: colors.glass.backgroundLight,
backgroundColor: colors.background, // Flat bg
},
proBadgeText: {
...typography.caption2,
@@ -0,0 +1,360 @@
/**
* MatchConfetti - Premium celebration animation for match moments
*
* Uses Reanimated for smooth animations. Displays an elegant burst of
* hearts and sparkles that explode outward, plus a prominent "It's a Match!" popup.
*/
import React, { useEffect, useMemo } from 'react';
import { View, StyleSheet, Dimensions, Text } from 'react-native';
import Animated, {
useSharedValue,
useAnimatedStyle,
withTiming,
withDelay,
withSpring,
withSequence,
Easing,
interpolate,
} from 'react-native-reanimated';
import { LinearGradient } from 'expo-linear-gradient';
import { Ionicons } from '@expo/vector-icons';
import * as Haptics from 'expo-haptics';
import { Platform } from 'react-native';
import { colors, typography, spacing, radius } from '../theme';
const { width: SCREEN_WIDTH, height: SCREEN_HEIGHT } = Dimensions.get('window');
interface Props {
visible: boolean;
onAnimationComplete?: () => void;
}
type ParticleShape = 'heart' | 'sparkle' | 'circle' | 'star';
interface Particle {
id: number;
startX: number;
startY: number;
endX: number;
endY: number;
rotation: number;
color: string;
size: number;
delay: number;
shape: ParticleShape;
}
const CONFETTI_COLORS = [
colors.primary, // Brand pink #e1306c
'#FF6B9D', // Hot pink
'#FFB3C6', // Light pink
colors.premium.rose, // Soft rose
'#9b59b6', // Secondary purple
colors.premium.gold, // Gold accent
'#FF8A5B', // Coral
'#FFFFFF', // White sparkle
];
const PARTICLE_COUNT = 40;
// Particle shapes distribution
const SHAPES: ParticleShape[] = ['heart', 'heart', 'sparkle', 'sparkle', 'circle', 'star'];
// Generate particle with physics-based trajectory
const generateParticle = (index: number): Particle => {
// Start from center of screen
const startX = SCREEN_WIDTH / 2;
const startY = SCREEN_HEIGHT / 2 - 80;
// Random angle for explosion (360 degrees) with more upward bias
const baseAngle = (index / PARTICLE_COUNT) * Math.PI * 2;
const angle = baseAngle + (Math.random() - 0.5) * 0.8;
const velocity = 180 + Math.random() * 280;
// Calculate end position - more spread, less gravity drop for hearts
const endX = startX + Math.cos(angle) * velocity;
const endY = startY + Math.sin(angle) * velocity + 250;
const shape = SHAPES[Math.floor(Math.random() * SHAPES.length)];
return {
id: index,
startX,
startY,
endX,
endY,
rotation: Math.random() * 540 - 270,
color: CONFETTI_COLORS[Math.floor(Math.random() * CONFETTI_COLORS.length)],
size: shape === 'heart' ? 14 + Math.random() * 10 : 8 + Math.random() * 8,
delay: Math.random() * 150,
shape,
};
};
// Heart shape component
const HeartShape: React.FC<{ size: number; color: string }> = ({ size, color }) => (
<Ionicons name="heart" size={size} color={color} />
);
// Sparkle/star shape component
const SparkleShape: React.FC<{ size: number; color: string }> = ({ size, color }) => (
<Ionicons name="sparkles" size={size} color={color} />
);
// Star shape component
const StarShape: React.FC<{ size: number; color: string }> = ({ size, color }) => (
<Ionicons name="star" size={size} color={color} />
);
const ConfettiParticle: React.FC<{ particle: Particle; visible: boolean }> = ({
particle,
visible,
}) => {
const progress = useSharedValue(0);
const opacity = useSharedValue(0);
useEffect(() => {
if (visible) {
progress.value = 0;
opacity.value = 1;
progress.value = withDelay(
particle.delay,
withTiming(1, {
duration: 1000,
easing: Easing.out(Easing.cubic),
})
);
opacity.value = withDelay(
particle.delay + 600,
withTiming(0, {
duration: 400,
easing: Easing.out(Easing.ease),
})
);
} else {
progress.value = 0;
opacity.value = 0;
}
}, [visible]);
const animatedStyle = useAnimatedStyle(() => {
const x = particle.startX + (particle.endX - particle.startX) * progress.value;
const y = particle.startY + (particle.endY - particle.startY) * progress.value;
const rotation = particle.rotation * progress.value;
// Scale pulses slightly then shrinks
const scale = interpolate(progress.value, [0, 0.2, 1], [0.5, 1.2, 0.6]);
return {
transform: [
{ translateX: x },
{ translateY: y },
{ rotate: `${rotation}deg` },
{ scale },
],
opacity: opacity.value,
};
});
const renderShape = () => {
switch (particle.shape) {
case 'heart':
return <HeartShape size={particle.size} color={particle.color} />;
case 'sparkle':
return <SparkleShape size={particle.size} color={particle.color} />;
case 'star':
return <StarShape size={particle.size} color={particle.color} />;
case 'circle':
default:
return (
<View
style={{
width: particle.size,
height: particle.size,
backgroundColor: particle.color,
borderRadius: particle.size / 2,
}}
/>
);
}
};
return (
<Animated.View style={[styles.particle, animatedStyle]}>
{renderShape()}
</Animated.View>
);
};
// Animated popup message component - prominent and celebratory
const MatchPopup: React.FC<{ visible: boolean }> = ({ visible }) => {
const scale = useSharedValue(0);
const opacity = useSharedValue(0);
const iconScale = useSharedValue(0);
useEffect(() => {
if (visible) {
// Pop in with bouncy spring, then fade out after delay
scale.value = withSequence(
withSpring(1, { damping: 10, stiffness: 180 }),
withDelay(3200, withTiming(0.9, { duration: 400 }))
);
// Fade in quickly, stay visible, then fade out
opacity.value = withSequence(
withTiming(1, { duration: 150 }),
withDelay(3200, withTiming(0, { duration: 400 }))
);
// Heart icon bounces after text appears
iconScale.value = withDelay(
150,
withSequence(
withSpring(1.3, { damping: 8, stiffness: 200 }),
withSpring(1, { damping: 12, stiffness: 200 })
)
);
} else {
scale.value = 0;
opacity.value = 0;
iconScale.value = 0;
}
}, [visible]);
const animatedStyle = useAnimatedStyle(() => ({
transform: [{ scale: scale.value }],
opacity: opacity.value,
}));
const iconAnimatedStyle = useAnimatedStyle(() => ({
transform: [{ scale: iconScale.value }],
}));
return (
<Animated.View style={[styles.popupContainer, animatedStyle]}>
<View style={styles.popup}>
<LinearGradient
colors={[colors.primary, '#9b59b6']}
start={{ x: 0, y: 0 }}
end={{ x: 1, y: 1 }}
style={styles.popupGradient}
>
<Text style={styles.popupTitle}>You both answered!</Text>
<Animated.View style={iconAnimatedStyle}>
<Ionicons name="heart" size={32} color="#fff" style={styles.popupIcon} />
</Animated.View>
</LinearGradient>
</View>
</Animated.View>
);
};
export const MatchConfetti: React.FC<Props> = ({ visible, onAnimationComplete }) => {
const particles = useMemo(
() => Array.from({ length: PARTICLE_COUNT }, (_, i) => generateParticle(i)),
[]
);
// Trigger haptic and callback when animation starts
useEffect(() => {
if (visible) {
// Celebration haptic pattern - satisfying burst
if (Platform.OS !== 'web') {
// Initial success notification
Haptics.notificationAsync(Haptics.NotificationFeedbackType.Success);
// Follow up with impacts for celebration feel
setTimeout(() => {
Haptics.impactAsync(Haptics.ImpactFeedbackStyle.Heavy);
}, 80);
setTimeout(() => {
Haptics.impactAsync(Haptics.ImpactFeedbackStyle.Medium);
}, 180);
setTimeout(() => {
Haptics.impactAsync(Haptics.ImpactFeedbackStyle.Heavy);
}, 300);
}
// Notify parent when animation completes (after popup fades)
if (onAnimationComplete) {
const timer = setTimeout(() => {
onAnimationComplete();
}, 4000);
return () => clearTimeout(timer);
}
}
}, [visible, onAnimationComplete]);
if (!visible) return null;
return (
<View style={styles.container} pointerEvents="none">
{/* Confetti particles */}
{particles.map((particle) => (
<ConfettiParticle
key={particle.id}
particle={particle}
visible={visible}
/>
))}
{/* Match popup message */}
<MatchPopup visible={visible} />
</View>
);
};
const styles = StyleSheet.create({
container: {
...StyleSheet.absoluteFillObject,
zIndex: 1000,
},
particle: {
position: 'absolute',
left: 0,
top: 0,
},
popupContainer: {
position: 'absolute',
top: 0,
left: 0,
right: 0,
bottom: 0,
justifyContent: 'center',
alignItems: 'center',
zIndex: 10,
},
popup: {
borderRadius: radius.xl,
shadowColor: colors.primary,
shadowOffset: { width: 0, height: 4 },
shadowOpacity: 0.6,
shadowRadius: 24,
elevation: 15,
overflow: 'hidden',
},
popupGradient: {
paddingHorizontal: spacing.xl + spacing.md,
paddingVertical: spacing.lg + spacing.sm,
alignItems: 'center',
},
popupTitle: {
...typography.title1,
color: '#fff',
fontWeight: '800',
textAlign: 'center',
textShadowColor: 'rgba(0, 0, 0, 0.3)',
textShadowOffset: { width: 0, height: 2 },
textShadowRadius: 4,
},
popupSubtext: {
...typography.callout,
color: 'rgba(255, 255, 255, 0.9)',
textAlign: 'center',
marginTop: spacing.xs,
},
popupIcon: {
marginTop: spacing.sm,
},
});
@@ -252,18 +252,12 @@ export const MatchNotificationModal: React.FC<Props> = ({
<View style={styles.glowOuter} />
<View style={styles.modalContainer}>
{/* Glass background */}
<BlurView intensity={40} tint="dark" style={styles.blurContainer}>
<LinearGradient
colors={['rgba(22, 33, 62, 0.95)', 'rgba(13, 13, 26, 0.98)']}
style={styles.gradientOverlay}
/>
</BlurView>
{/* Flat background */}
<View style={styles.flatBackground} />
{/* Content */}
<View style={styles.content}>
{/* Title section */}
<Text style={styles.title}>It's a Match!</Text>
{/* Title section */}
<Text style={styles.title}>It's a Match!</Text>
{/* Match type badge */}
<View style={[styles.matchTypeBadge, { borderColor: `${matchTypeInfo.color}30` }]}>
@@ -342,9 +336,6 @@ export const MatchNotificationModal: React.FC<Props> = ({
</Pressable>
</View>
</View>
{/* Border */}
<View style={styles.border} pointerEvents="none" />
</View>
</Animated.View>
</Animated.View>
@@ -374,24 +365,22 @@ const styles = StyleSheet.create({
borderRadius: radius.xl + 3,
backgroundColor: 'transparent',
borderWidth: 1,
borderColor: 'rgba(233, 69, 96, 0.15)',
shadowColor: colors.primary,
shadowOffset: { width: 0, height: 0 },
shadowOpacity: 0.2,
shadowRadius: 20,
elevation: 8,
borderColor: colors.primary, // Simpler border
opacity: 0.2, // Subtle opacity
},
modalContainer: {
width: '100%',
borderRadius: radius.xl,
overflow: 'hidden',
backgroundColor: colors.backgroundLight, // Solid background
borderWidth: 1,
borderColor: colors.border,
},
blurContainer: {
...StyleSheet.absoluteFillObject,
},
gradientOverlay: {
flatBackground: {
...StyleSheet.absoluteFillObject,
backgroundColor: colors.backgroundLight,
},
// Removed blur/gradient styles
content: {
padding: spacing.xl,
paddingTop: spacing.lg,
@@ -404,7 +393,7 @@ const styles = StyleSheet.create({
marginBottom: spacing.sm,
},
matchTypeBadge: {
backgroundColor: 'rgba(255, 255, 255, 0.05)',
backgroundColor: colors.background, // Darker flat background
paddingHorizontal: spacing.lg,
paddingVertical: spacing.sm,
borderRadius: radius.full,
@@ -523,21 +512,17 @@ const styles = StyleSheet.create({
dismissButton: {
flex: 1,
borderRadius: radius.lg,
backgroundColor: colors.glass.background,
backgroundColor: colors.background, // Flat dark background
borderWidth: 1,
borderColor: colors.glass.border,
borderColor: colors.border,
paddingVertical: spacing.md,
alignItems: 'center',
justifyContent: 'center',
},
// Removed border style since it's now on modalContainer
// border: { ... }
dismissButtonText: {
...typography.headline,
color: colors.textSecondary,
},
border: {
...StyleSheet.absoluteFillObject,
borderRadius: radius.xl,
borderWidth: 1,
borderColor: 'rgba(233, 69, 96, 0.2)',
},
});
@@ -1,72 +1,96 @@
import React from 'react';
import { StyleSheet, View, Text, TouchableOpacity } from 'react-native';
import { Ionicons } from '@expo/vector-icons';
import { Image } from 'expo-image';
import { router } from 'expo-router';
import Animated, { FadeInDown } from 'react-native-reanimated';
import { colors, spacing, typography, radius } from '../../theme';
import type { Profile, Couple } from '@/types';
interface CompactHeaderProps {
user: Profile | null;
partner: Profile | null;
couple: Couple | null;
}
export function CompactHeader({ user, partner, couple }: CompactHeaderProps) {
return (
<Animated.View
entering={FadeInDown.delay(100).duration(500).springify()}
style={styles.container}
>
{/* Greeting */}
<View style={styles.greetingRow}>
<View>
<Text style={styles.welcomeLabel}>Discover</Text>
<Text style={styles.greeting}>
Hey, {user?.name || 'Beautiful'}
</Text>
</View>
{/* Partner badge */}
{partner ? (
<View style={styles.partnerBadge}>
{partner.avatar_url ? (
<Image
source={{ uri: partner.avatar_url }}
style={styles.partnerAvatar}
cachePolicy="disk"
contentFit="cover"
transition={200}
/>
) : (
<Ionicons name="heart" size={12} color={colors.primary} />
)}
<Text style={styles.partnerText} numberOfLines={1}>
{partner.name || partner.email?.split('@')[0] || 'Partner'}
</Text>
</View>
) : couple ? (
<TouchableOpacity
style={styles.waitingBadge}
onPress={() => router.push('/(app)/pairing')}
>
<Ionicons name="hourglass-outline" size={12} color={colors.primary} />
<Text style={styles.waitingText}>Waiting</Text>
</TouchableOpacity>
) : (
<TouchableOpacity
style={styles.pairBadge}
onPress={() => router.push('/(app)/pairing')}
>
<Ionicons name="add-circle-outline" size={14} color={colors.textTertiary} />
<Text style={styles.pairText}>Connect</Text>
</TouchableOpacity>
)}
</View>
</Animated.View>
);
}
import React from 'react';
import { StyleSheet, View, Text, TouchableOpacity } from 'react-native';
import { Ionicons } from '@expo/vector-icons';
import { Image } from 'expo-image';
import { router } from 'expo-router';
import Animated, { FadeInDown } from 'react-native-reanimated';
import { colors, spacing, typography, radius } from '../../theme';
import type { Profile, Couple } from '@/types';
interface CompactHeaderProps {
user: Profile | null;
partner: Profile | null;
couple: Couple | null;
label?: string;
accessory?: React.ReactNode;
showGreeting?: boolean;
showPartnerBadge?: boolean;
}
export function CompactHeader({
user,
partner,
couple,
label = 'Discover',
accessory,
showGreeting = true,
showPartnerBadge = true,
}: CompactHeaderProps) {
return (
<Animated.View
entering={FadeInDown.delay(100).duration(500).springify()}
style={styles.container}
>
{showGreeting ? (
<View style={styles.greetingRow}>
<View>
<Text style={styles.welcomeLabel}>{label}</Text>
<Text style={styles.greeting}>
Hey, {user?.name || 'Beautiful'}
</Text>
</View>
<View style={styles.rightSection}>
{/* Partner badge */}
{showPartnerBadge ? (
partner ? (
<View style={styles.partnerBadge}>
{partner.avatar_url ? (
<Image
source={{ uri: partner.avatar_url }}
style={styles.partnerAvatar}
cachePolicy="disk"
contentFit="cover"
transition={200}
/>
) : (
<Ionicons name="heart" size={12} color={colors.primary} />
)}
<Text style={styles.partnerText} numberOfLines={1}>
{partner.name || partner.email?.split('@')[0] || 'Partner'}
</Text>
</View>
) : couple ? (
<TouchableOpacity
style={styles.waitingBadge}
onPress={() => router.push('/(app)/pairing')}
>
<Ionicons name="hourglass-outline" size={12} color={colors.primary} />
<Text style={styles.waitingText}>Waiting</Text>
</TouchableOpacity>
) : (
<TouchableOpacity
style={styles.pairBadge}
onPress={() => router.push('/(app)/pairing')}
>
<Ionicons name="add-circle-outline" size={14} color={colors.textTertiary} />
<Text style={styles.pairText}>Connect</Text>
</TouchableOpacity>
)
) : null}
{accessory ? <View style={styles.accessory}>{accessory}</View> : null}
</View>
</View>
) : (
<View style={styles.titleRow}>
{accessory ? <View style={styles.titleSpacer} /> : null}
<Text style={styles.titleOnly}>{label}</Text>
{accessory ? <View style={styles.accessory}>{accessory}</View> : null}
</View>
)}
</Animated.View>
);
}
const styles = StyleSheet.create({
container: {
@@ -74,11 +98,34 @@ const styles = StyleSheet.create({
paddingHorizontal: spacing.lg,
paddingBottom: spacing.lg,
},
greetingRow: {
flexDirection: 'row',
justifyContent: 'space-between',
alignItems: 'center',
},
greetingRow: {
flexDirection: 'row',
justifyContent: 'space-between',
alignItems: 'center',
},
rightSection: {
flexDirection: 'row',
alignItems: 'center',
gap: spacing.sm,
},
accessory: {
justifyContent: 'center',
alignItems: 'center',
},
titleRow: {
flexDirection: 'row',
alignItems: 'center',
justifyContent: 'center',
},
titleOnly: {
...typography.title2,
color: colors.text,
textAlign: 'center',
flex: 1,
},
titleSpacer: {
width: 32,
},
welcomeLabel: {
...typography.caption2,
fontWeight: '600',
@@ -32,7 +32,7 @@ export function ContentRow({
const handleSeeAll = () => {
// Navigate to packs screen with category filter
router.push({
pathname: '/(app)/packs',
pathname: '/',
params: categoryId ? { category: categoryId } : undefined,
});
};
@@ -29,14 +29,13 @@ export function ContentTile({ pack, isLocked, isNew = false, progress, onPress }
};
const backgroundColor = getCategoryColor(pack.category);
const icon = pack.icon || pack.category?.icon || 'layers';
const questionCount = pack.questions?.[0]?.count ?? 0;
// Calculate progress percentage
const progressPercent = progress && progress.totalQuestions > 0
? progress.answeredQuestions / progress.totalQuestions
: 0;
// Show progress ring for packs with questions (even at 0%)
const showProgress = progress && progress.totalQuestions > 0;
// Always show progress ring when progress data exists (even at 0% or 0 questions)
const showProgress = !!progress;
return (
<TouchableOpacity
@@ -55,6 +54,12 @@ export function ContentTile({ pack, isLocked, isNew = false, progress, onPress }
/>
</View>
<View style={styles.badges}>
{pack.is_explicit && (
<View style={styles.explicitBadge}>
<Ionicons name="flame" size={10} color={colors.text} />
<Text style={styles.explicitBadgeText}>18+</Text>
</View>
)}
{isNew && (
<View style={styles.newBadge}>
<Text style={styles.newBadgeText}>NEW</Text>
@@ -87,13 +92,6 @@ export function ContentTile({ pack, isLocked, isNew = false, progress, onPress }
</Text>
</View>
{/* Bottom info */}
<View style={styles.bottomRow}>
<Text style={styles.countText}>
{questionCount} {questionCount === 1 ? 'question' : 'questions'}
</Text>
</View>
{/* Decorative overlay for depth */}
<View style={styles.overlay} />
</View>
@@ -136,6 +134,20 @@ const styles = StyleSheet.create({
flexDirection: 'row',
gap: spacing.xs,
},
explicitBadge: {
flexDirection: 'row',
alignItems: 'center',
backgroundColor: colors.error,
paddingHorizontal: spacing.sm,
paddingVertical: 2,
borderRadius: radius.xs,
gap: 2,
},
explicitBadgeText: {
...typography.caption2,
fontWeight: '700',
color: colors.text,
},
newBadge: {
backgroundColor: 'rgba(255, 255, 255, 0.95)',
paddingHorizontal: spacing.sm,
@@ -173,13 +185,6 @@ const styles = StyleSheet.create({
color: colors.text,
fontWeight: '700',
},
bottomRow: {
marginTop: 'auto',
},
countText: {
...typography.caption1,
color: 'rgba(255, 255, 255, 0.7)',
},
});
export default ContentTile;
@@ -1,338 +1,338 @@
import React, { useState } from 'react';
import {
View,
Text,
StyleSheet,
Modal,
TouchableOpacity,
TextInput,
Alert,
ActivityIndicator,
} 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';
interface QuestionFeedbackModalProps {
visible: boolean;
onClose: () => void;
questionId: string;
questionText: string;
}
const FEEDBACK_REASONS = [
{ id: 'inappropriate', label: 'Inappropriate', icon: 'warning' as const },
{ id: 'confusing', label: 'Confusing', icon: 'help-circle' as const },
{ id: 'duplicate', label: 'Duplicate', icon: 'copy' as const },
{ id: 'other', label: 'Other', icon: 'ellipsis-horizontal' as const },
];
export function QuestionFeedbackModal({
visible,
onClose,
questionId,
questionText,
}: QuestionFeedbackModalProps) {
const { user } = useAuthStore();
const [selectedReason, setSelectedReason] = useState<string | null>(null);
const [additionalInfo, setAdditionalInfo] = useState('');
const [isSubmitting, setIsSubmitting] = useState(false);
const resetForm = () => {
setSelectedReason(null);
setAdditionalInfo('');
};
const handleClose = () => {
if (!isSubmitting) {
resetForm();
onClose();
}
};
const handleSubmit = async () => {
if (!selectedReason) {
Alert.alert('Please select a reason', 'Choose why you want to report this question.');
return;
}
if (!user) {
Alert.alert('Error', 'You must be logged in to submit feedback.');
return;
}
setIsSubmitting(true);
try {
const deviceInfo = getDeviceInfo();
const reasonLabel = FEEDBACK_REASONS.find(r => r.id === selectedReason)?.label || selectedReason;
const { error } = await supabase.from('feedback').insert({
user_id: user.id,
type: 'question',
title: `Question Feedback: ${reasonLabel}`,
description: additionalInfo.trim() || `Reported as: ${reasonLabel}`,
question_id: questionId,
device_info: deviceInfo,
});
if (error) throw error;
Alert.alert('Thank you!', 'Your feedback has been submitted.', [
{ text: 'OK', onPress: handleClose },
]);
resetForm();
} catch (error) {
Alert.alert('Error', 'Failed to submit feedback. Please try again.');
console.error(error);
} finally {
setIsSubmitting(false);
}
};
return (
<Modal visible={visible} transparent animationType="fade" onRequestClose={handleClose}>
<View style={styles.overlay}>
<View style={styles.content}>
{/* Header */}
<View style={styles.header}>
<LinearGradient
colors={gradients.primary as [string, string]}
style={styles.headerIcon}
start={{ x: 0, y: 0 }}
end={{ x: 1, y: 1 }}
>
<Ionicons name="flag" size={24} color={colors.text} />
</LinearGradient>
<Text style={styles.headerTitle}>Report Question</Text>
</View>
{/* Question Preview */}
<View style={styles.questionPreview}>
<Text style={styles.questionText} numberOfLines={2}>
"{questionText}"
</Text>
</View>
{/* Reason Selection */}
<Text style={styles.label}>What's the issue?</Text>
<View style={styles.reasonsGrid}>
{FEEDBACK_REASONS.map((reason) => (
<TouchableOpacity
key={reason.id}
style={[
styles.reasonButton,
selectedReason === reason.id && styles.reasonButtonActive,
]}
onPress={() => setSelectedReason(reason.id)}
activeOpacity={0.7}
disabled={isSubmitting}
>
<Ionicons
name={reason.icon}
size={20}
color={
selectedReason === reason.id
? colors.text
: colors.textSecondary
}
/>
<Text
style={[
styles.reasonText,
selectedReason === reason.id && styles.reasonTextActive,
]}
>
{reason.label}
</Text>
</TouchableOpacity>
))}
</View>
{/* Additional Info */}
<Text style={styles.label}>Additional details (optional)</Text>
<TextInput
style={styles.input}
value={additionalInfo}
onChangeText={setAdditionalInfo}
placeholder="Tell us more..."
placeholderTextColor={colors.textTertiary}
multiline
numberOfLines={3}
textAlignVertical="top"
editable={!isSubmitting}
/>
{/* Buttons */}
<View style={styles.buttons}>
<TouchableOpacity
style={styles.cancelButton}
onPress={handleClose}
disabled={isSubmitting}
activeOpacity={0.7}
>
<Text style={styles.cancelButtonText}>Cancel</Text>
</TouchableOpacity>
<TouchableOpacity
style={[
styles.submitButton,
!selectedReason && styles.submitButtonDisabled,
]}
onPress={handleSubmit}
disabled={isSubmitting || !selectedReason}
activeOpacity={0.7}
>
{isSubmitting ? (
<ActivityIndicator size="small" color={colors.text} />
) : (
<>
<Ionicons name="send" size={16} color={colors.text} />
<Text style={styles.submitButtonText}>Submit</Text>
</>
)}
</TouchableOpacity>
</View>
</View>
</View>
</Modal>
);
}
const styles = StyleSheet.create({
overlay: {
flex: 1,
backgroundColor: 'rgba(0, 0, 0, 0.8)',
justifyContent: 'center',
alignItems: 'center',
padding: spacing.lg,
},
content: {
backgroundColor: colors.backgroundLight,
borderRadius: radius.xl,
padding: spacing.lg,
width: '100%',
maxWidth: 360,
borderWidth: 1,
borderColor: colors.glass.border,
},
header: {
alignItems: 'center',
marginBottom: spacing.md,
},
headerIcon: {
width: 48,
height: 48,
borderRadius: 24,
justifyContent: 'center',
alignItems: 'center',
marginBottom: spacing.sm,
},
headerTitle: {
...typography.title3,
color: colors.text,
textAlign: 'center',
},
questionPreview: {
backgroundColor: colors.glass.background,
borderRadius: radius.md,
padding: spacing.md,
marginBottom: spacing.md,
borderWidth: 1,
borderColor: colors.glass.border,
},
questionText: {
...typography.subhead,
color: colors.textSecondary,
fontStyle: 'italic',
textAlign: 'center',
},
label: {
...typography.subhead,
color: colors.textSecondary,
marginBottom: spacing.sm,
marginLeft: spacing.xs,
},
reasonsGrid: {
flexDirection: 'row',
flexWrap: 'wrap',
gap: spacing.sm,
marginBottom: spacing.md,
},
reasonButton: {
flexDirection: 'row',
alignItems: 'center',
gap: spacing.xs,
backgroundColor: colors.glass.background,
paddingVertical: spacing.sm,
paddingHorizontal: spacing.md,
borderRadius: radius.md,
borderWidth: 1,
borderColor: colors.glass.border,
},
reasonButtonActive: {
backgroundColor: colors.primary,
borderColor: colors.primary,
},
reasonText: {
...typography.caption1,
color: colors.textSecondary,
fontWeight: '600',
},
reasonTextActive: {
color: colors.text,
},
input: {
backgroundColor: colors.glass.background,
borderRadius: radius.md,
borderWidth: 1,
borderColor: colors.glass.border,
padding: spacing.md,
...typography.body,
color: colors.text,
minHeight: 80,
marginBottom: spacing.md,
},
buttons: {
flexDirection: 'row',
gap: spacing.md,
},
cancelButton: {
flex: 1,
backgroundColor: colors.glass.backgroundLight,
paddingVertical: spacing.md,
borderRadius: radius.md,
alignItems: 'center',
borderWidth: 1,
borderColor: colors.glass.borderLight,
},
cancelButtonText: {
...typography.body,
color: colors.textSecondary,
fontWeight: '600',
},
submitButton: {
flex: 1,
backgroundColor: colors.primary,
paddingVertical: spacing.md,
borderRadius: radius.md,
alignItems: 'center',
flexDirection: 'row',
justifyContent: 'center',
gap: spacing.xs,
},
submitButtonDisabled: {
opacity: 0.5,
},
submitButtonText: {
...typography.body,
color: colors.text,
fontWeight: '600',
},
});
export default QuestionFeedbackModal;
import React, { useState } from 'react';
import {
View,
Text,
StyleSheet,
Modal,
TouchableOpacity,
TextInput,
Alert,
ActivityIndicator,
} 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';
interface QuestionFeedbackModalProps {
visible: boolean;
onClose: () => void;
questionId: string;
questionText: string;
}
const FEEDBACK_REASONS = [
{ id: 'inappropriate', label: 'Inappropriate', icon: 'warning' as const },
{ id: 'confusing', label: 'Confusing', icon: 'help-circle' as const },
{ id: 'duplicate', label: 'Duplicate', icon: 'copy' as const },
{ id: 'other', label: 'Other', icon: 'ellipsis-horizontal' as const },
];
export function QuestionFeedbackModal({
visible,
onClose,
questionId,
questionText,
}: QuestionFeedbackModalProps) {
const { user } = useAuthStore();
const [selectedReason, setSelectedReason] = useState<string | null>(null);
const [additionalInfo, setAdditionalInfo] = useState('');
const [isSubmitting, setIsSubmitting] = useState(false);
const resetForm = () => {
setSelectedReason(null);
setAdditionalInfo('');
};
const handleClose = () => {
if (!isSubmitting) {
resetForm();
onClose();
}
};
const handleSubmit = async () => {
if (!selectedReason) {
Alert.alert('Please select a reason', 'Choose why you want to report this question.');
return;
}
if (!user) {
Alert.alert('Error', 'You must be logged in to submit feedback.');
return;
}
setIsSubmitting(true);
try {
const deviceInfo = getDeviceInfo();
const reasonLabel = FEEDBACK_REASONS.find(r => r.id === selectedReason)?.label || selectedReason;
const { error } = await supabase.from('feedback').insert({
user_id: user.id,
type: 'question',
title: `Question Feedback: ${reasonLabel}`,
description: additionalInfo.trim() || `Reported as: ${reasonLabel}`,
question_id: questionId,
device_info: deviceInfo,
});
if (error) throw error;
Alert.alert('Thank you!', 'Your feedback has been submitted.', [
{ text: 'OK', onPress: handleClose },
]);
resetForm();
} catch (error) {
Alert.alert('Error', 'Failed to submit feedback. Please try again.');
console.error(error);
} finally {
setIsSubmitting(false);
}
};
return (
<Modal visible={visible} transparent animationType="fade" onRequestClose={handleClose}>
<View style={styles.overlay}>
<View style={styles.content}>
{/* Header */}
<View style={styles.header}>
<LinearGradient
colors={gradients.primary as [string, string]}
style={styles.headerIcon}
start={{ x: 0, y: 0 }}
end={{ x: 1, y: 1 }}
>
<Ionicons name="flag" size={24} color={colors.text} />
</LinearGradient>
<Text style={styles.headerTitle}>Report Question</Text>
</View>
{/* Question Preview */}
<View style={styles.questionPreview}>
<Text style={styles.questionText} numberOfLines={2}>
"{questionText}"
</Text>
</View>
{/* Reason Selection */}
<Text style={styles.label}>What's the issue?</Text>
<View style={styles.reasonsGrid}>
{FEEDBACK_REASONS.map((reason) => (
<TouchableOpacity
key={reason.id}
style={[
styles.reasonButton,
selectedReason === reason.id && styles.reasonButtonActive,
]}
onPress={() => setSelectedReason(reason.id)}
activeOpacity={0.7}
disabled={isSubmitting}
>
<Ionicons
name={reason.icon}
size={20}
color={
selectedReason === reason.id
? colors.text
: colors.textSecondary
}
/>
<Text
style={[
styles.reasonText,
selectedReason === reason.id && styles.reasonTextActive,
]}
>
{reason.label}
</Text>
</TouchableOpacity>
))}
</View>
{/* Additional Info */}
<Text style={styles.label}>Additional details (optional)</Text>
<TextInput
style={styles.input}
value={additionalInfo}
onChangeText={setAdditionalInfo}
placeholder="Tell us more..."
placeholderTextColor={colors.textTertiary}
multiline
numberOfLines={3}
textAlignVertical="top"
editable={!isSubmitting}
/>
{/* Buttons */}
<View style={styles.buttons}>
<TouchableOpacity
style={styles.cancelButton}
onPress={handleClose}
disabled={isSubmitting}
activeOpacity={0.7}
>
<Text style={styles.cancelButtonText}>Cancel</Text>
</TouchableOpacity>
<TouchableOpacity
style={[
styles.submitButton,
!selectedReason && styles.submitButtonDisabled,
]}
onPress={handleSubmit}
disabled={isSubmitting || !selectedReason}
activeOpacity={0.7}
>
{isSubmitting ? (
<ActivityIndicator size="small" color={colors.text} />
) : (
<>
<Ionicons name="send" size={16} color={colors.text} />
<Text style={styles.submitButtonText}>Submit</Text>
</>
)}
</TouchableOpacity>
</View>
</View>
</View>
</Modal>
);
}
const styles = StyleSheet.create({
overlay: {
flex: 1,
backgroundColor: 'rgba(0, 0, 0, 0.8)',
justifyContent: 'center',
alignItems: 'center',
padding: spacing.lg,
},
content: {
backgroundColor: colors.backgroundLight,
borderRadius: radius.xl,
padding: spacing.lg,
width: '100%',
maxWidth: 360,
borderWidth: 1,
borderColor: colors.border,
},
header: {
alignItems: 'center',
marginBottom: spacing.md,
},
headerIcon: {
width: 48,
height: 48,
borderRadius: 24,
justifyContent: 'center',
alignItems: 'center',
marginBottom: spacing.sm,
},
headerTitle: {
...typography.title3,
color: colors.text,
textAlign: 'center',
},
questionPreview: {
backgroundColor: colors.background, // Flat inner container
borderRadius: radius.md,
padding: spacing.md,
marginBottom: spacing.md,
borderWidth: 1,
borderColor: colors.border,
},
questionText: {
...typography.subhead,
color: colors.textSecondary,
fontStyle: 'italic',
textAlign: 'center',
},
label: {
...typography.subhead,
color: colors.textSecondary,
marginBottom: spacing.sm,
marginLeft: spacing.xs,
},
reasonsGrid: {
flexDirection: 'row',
flexWrap: 'wrap',
gap: spacing.sm,
marginBottom: spacing.md,
},
reasonButton: {
flexDirection: 'row',
alignItems: 'center',
gap: spacing.xs,
backgroundColor: colors.background, // Flat button
paddingVertical: spacing.sm,
paddingHorizontal: spacing.md,
borderRadius: radius.md,
borderWidth: 1,
borderColor: colors.border,
},
reasonButtonActive: {
backgroundColor: colors.primary,
borderColor: colors.primary,
},
reasonText: {
...typography.caption1,
color: colors.textSecondary,
fontWeight: '600',
},
reasonTextActive: {
color: colors.text,
},
input: {
backgroundColor: colors.background,
borderRadius: radius.md,
borderWidth: 1,
borderColor: colors.border,
padding: spacing.md,
...typography.body,
color: colors.text,
minHeight: 80,
marginBottom: spacing.md,
},
buttons: {
flexDirection: 'row',
gap: spacing.md,
},
cancelButton: {
flex: 1,
backgroundColor: colors.background,
paddingVertical: spacing.md,
borderRadius: radius.md,
alignItems: 'center',
borderWidth: 1,
borderColor: colors.border,
},
cancelButtonText: {
...typography.body,
color: colors.textSecondary,
fontWeight: '600',
},
submitButton: {
flex: 1,
backgroundColor: colors.primary,
paddingVertical: spacing.md,
borderRadius: radius.md,
alignItems: 'center',
flexDirection: 'row',
justifyContent: 'center',
gap: spacing.xs,
},
submitButtonDisabled: {
opacity: 0.5,
},
submitButtonText: {
...typography.body,
color: colors.text,
fontWeight: '600',
},
});
export default QuestionFeedbackModal;
+226 -256
View File
@@ -10,40 +10,45 @@ import {
Platform,
Pressable,
Linking,
Image,
} from "react-native";
import { Ionicons } from "@expo/vector-icons";
import { LinearGradient } from "expo-linear-gradient";
import { router } from "expo-router";
import { useAuthStore, useSubscriptionStore } from "../../store";
import { colors, gradients, spacing, radius, typography } from "../../theme";
import { colors, gradients, spacing, radius, typography, shadows } from "../../theme";
import type { PurchasesPackage } from "../../lib/revenuecat";
import { Events } from "../../lib/analytics";
const logoImage = require("../../../assets/logo.png");
interface PaywallProps {
visible: boolean;
onClose: () => void;
onSuccess?: () => void;
/** Optional RevenueCat offering identifier. If not provided, uses the default offering. */
offeringId?: string;
}
const FEATURES = [
{
icon: "diamond" as const,
title: "Unlock All Packs",
description: "Access every premium collection",
icon: "layers" as const,
title: "All Premium Packs",
description: "Unlock every intimate collection",
},
{
icon: "sparkles" as const,
title: "Exclusive Content",
description: "New packs added regularly",
title: "New Content Weekly",
description: "Fresh questions added regularly",
},
{
icon: "heart" as const,
title: "Share with Partner",
description: "Both of you get Pro access",
title: "Shared Access",
description: "Your partner gets Pro too",
},
];
export function Paywall({ visible, onClose, onSuccess }: PaywallProps) {
export function Paywall({ visible, onClose, onSuccess, offeringId }: PaywallProps) {
const { isAnonymous } = useAuthStore();
const handleClose = (reason: "dismiss" | "success" | "restore" | "system") => {
@@ -67,15 +72,13 @@ export function Paywall({ visible, onClose, onSuccess }: PaywallProps) {
const [guestConfirmed, setGuestConfirmed] = useState(false);
useEffect(() => {
if (visible && !offerings) {
fetchOfferings();
}
if (visible) {
fetchOfferings(offeringId);
Events.paywallShown("paywall");
setGuestConfirmed(false);
setShowGuestConfirm(false);
}
}, [visible, offerings, fetchOfferings]);
}, [visible, offeringId, fetchOfferings]);
useEffect(() => {
// Auto-select annual package as default (better value)
@@ -169,18 +172,13 @@ export function Paywall({ visible, onClose, onSuccess }: PaywallProps) {
>
<View style={styles.overlay}>
<View style={styles.content}>
{/* Premium gradient background */}
{/* Gold accent line at top */}
<LinearGradient
colors={['rgba(22, 33, 62, 0.98)', 'rgba(13, 13, 26, 1)']}
style={StyleSheet.absoluteFill}
colors={[colors.premium.gold, colors.premium.goldDark]}
style={styles.topAccent}
start={{ x: 0, y: 0 }}
end={{ x: 1, y: 0 }}
/>
{/* Top silk highlight */}
<LinearGradient
colors={['rgba(212, 175, 55, 0.08)', 'transparent']}
style={styles.silkHighlight}
/>
{/* Premium border accent */}
<View style={styles.premiumBorderAccent} />
<ScrollView
showsVerticalScrollIndicator={false}
@@ -192,47 +190,34 @@ export function Paywall({ visible, onClose, onSuccess }: PaywallProps) {
onPress={() => handleClose("dismiss")}
hitSlop={{ top: 10, bottom: 10, left: 10, right: 10 }}
>
<Ionicons name="close" size={24} color={colors.textSecondary} />
<Ionicons name="close" size={22} color={colors.textSecondary} />
</TouchableOpacity>
{/* Header */}
<View style={styles.header}>
{/* Premium label */}
<Text style={styles.label}>EXCLUSIVE ACCESS</Text>
{/* Icon with glow */}
<View style={styles.iconContainer}>
<LinearGradient
colors={[colors.premium.gold, colors.premium.goldDark]}
style={StyleSheet.absoluteFill}
start={{ x: 0, y: 0 }}
end={{ x: 1, y: 1 }}
{/* Sauci logo */}
<View style={styles.logoContainer}>
<Image
source={logoImage}
style={styles.logo}
resizeMode="contain"
/>
<Ionicons name="diamond" size={32} color={colors.background} />
</View>
<Text style={styles.title}>Sauci Pro</Text>
{/* Decorative separator */}
<View style={styles.separator}>
<View style={styles.separatorLine} />
<View style={styles.separatorDiamond} />
<View style={styles.separatorLine} />
</View>
<Text style={styles.subtitle}>
Unlock all premium content for you and your partner
Unlock the full intimate experience
</Text>
</View>
{/* Features */}
<View style={styles.features}>
{FEATURES.map((feature, index) => (
{FEATURES.map((feature) => (
<View key={feature.title} style={styles.featureRow}>
<View style={styles.featureIcon}>
<Ionicons
name={feature.icon}
size={18}
size={20}
color={colors.premium.gold}
/>
</View>
@@ -242,11 +227,6 @@ export function Paywall({ visible, onClose, onSuccess }: PaywallProps) {
{feature.description}
</Text>
</View>
<Ionicons
name="checkmark-circle"
size={20}
color={colors.premium.gold}
/>
</View>
))}
</View>
@@ -261,75 +241,96 @@ export function Paywall({ visible, onClose, onSuccess }: PaywallProps) {
</Text>
</View>
) : offerings?.availablePackages ? (
offerings.availablePackages.map((pkg) => {
const isSelected =
selectedPackage?.identifier === pkg.identifier;
const isAnnual = pkg.packageType === "ANNUAL";
return (
<Pressable
key={pkg.identifier}
onPress={() => setSelectedPackage(pkg)}
style={[
styles.packageCard,
isSelected && styles.packageCardSelected,
isAnnual && styles.packageCardAnnual,
]}
>
{isAnnual && (
<LinearGradient
colors={['rgba(212, 175, 55, 0.1)', 'rgba(184, 134, 11, 0.05)']}
style={StyleSheet.absoluteFill}
/>
)}
{isAnnual && savings && savings > 0 && (
<View style={styles.savingsBadge}>
<LinearGradient
colors={[colors.premium.gold, colors.premium.goldDark]}
style={StyleSheet.absoluteFill}
start={{ x: 0, y: 0 }}
end={{ x: 1, y: 0 }}
/>
<Ionicons name="star" size={10} color={colors.background} />
<Text style={styles.savingsText}>
SAVE {savings}%
</Text>
</View>
)}
<View style={styles.packageContent}>
<View
<>
{/* Annual package - hero card */}
{offerings.availablePackages
.filter((p) => p.packageType === "ANNUAL")
.map((pkg) => {
const isSelected = selectedPackage?.identifier === pkg.identifier;
return (
<Pressable
key={pkg.identifier}
onPress={() => setSelectedPackage(pkg)}
style={[
styles.radioOuter,
isSelected && styles.radioOuterSelected,
styles.packageCard,
styles.packageCardAnnual,
isSelected && styles.packageCardSelected,
]}
>
{isSelected && <View style={styles.radioInner} />}
</View>
<View style={styles.packageInfo}>
<View style={styles.packageTitleRow}>
<Text style={[
styles.packageTitle,
isAnnual && styles.packageTitleAnnual
{/* Subtle gold tint background */}
<View style={styles.annualBackground} />
{/* Best value badge */}
{savings && savings > 0 && (
<View style={styles.savingsBadge}>
<LinearGradient
colors={[colors.premium.gold, colors.premium.goldDark]}
style={StyleSheet.absoluteFill}
start={{ x: 0, y: 0 }}
end={{ x: 1, y: 0 }}
/>
<Text style={styles.savingsText}>
SAVE {savings}%
</Text>
</View>
)}
<View style={styles.packageContent}>
<View style={[
styles.radioOuter,
isSelected && styles.radioOuterSelected,
]}>
{isAnnual ? "Annual" : "Monthly"}
</Text>
{isAnnual && (
<View style={styles.bestValueBadge}>
<Text style={styles.bestValueText}>BEST VALUE</Text>
{isSelected && <View style={styles.radioInner} />}
</View>
<View style={styles.packageInfo}>
<View style={styles.packageHeader}>
<Text style={styles.packageTitleAnnual}>Annual</Text>
<View style={styles.recommendedBadge}>
<Ionicons name="star" size={10} color={colors.premium.gold} />
<Text style={styles.recommendedText}>BEST VALUE</Text>
</View>
</View>
)}
<Text style={styles.packagePriceAnnual}>
{formatPrice(pkg)}
</Text>
</View>
</View>
<Text style={[
styles.packagePrice,
isAnnual && styles.packagePriceAnnual
]}>
{formatPrice(pkg)}
</Text>
</View>
</View>
</Pressable>
);
})
</Pressable>
);
})}
{/* Monthly package */}
{offerings.availablePackages
.filter((p) => p.packageType === "MONTHLY")
.map((pkg) => {
const isSelected = selectedPackage?.identifier === pkg.identifier;
return (
<Pressable
key={pkg.identifier}
onPress={() => setSelectedPackage(pkg)}
style={[
styles.packageCard,
isSelected && styles.packageCardSelected,
]}
>
<View style={styles.packageContent}>
<View style={[
styles.radioOuter,
isSelected && styles.radioOuterSelected,
]}>
{isSelected && <View style={styles.radioInner} />}
</View>
<View style={styles.packageInfo}>
<Text style={styles.packageTitle}>Monthly</Text>
<Text style={styles.packagePrice}>
{formatPrice(pkg)}
</Text>
</View>
</View>
</Pressable>
);
})}
</>
) : (
<View style={styles.errorContainer}>
<Text style={styles.errorText}>
@@ -342,7 +343,7 @@ export function Paywall({ visible, onClose, onSuccess }: PaywallProps) {
)}
<TouchableOpacity
style={styles.retryButton}
onPress={fetchOfferings}
onPress={() => fetchOfferings(offeringId)}
>
<Text style={styles.retryButtonText}>Retry</Text>
</TouchableOpacity>
@@ -379,7 +380,6 @@ export function Paywall({ visible, onClose, onSuccess }: PaywallProps) {
start={{ x: 0, y: 0 }}
end={{ x: 1, y: 0 }}
>
<Ionicons name="diamond" size={18} color={colors.background} style={{ marginRight: spacing.sm }} />
<Text style={styles.purchaseButtonText}>Unlock Pro</Text>
</LinearGradient>
)}
@@ -405,18 +405,19 @@ export function Paywall({ visible, onClose, onSuccess }: PaywallProps) {
<TouchableOpacity
onPress={() => Linking.openURL("https://sauci.app/terms")}
>
<Text style={styles.legalLinkText}>Terms of Use</Text>
<Text style={styles.legalLinkText}>Terms</Text>
</TouchableOpacity>
<Text style={styles.legalSeparator}>•</Text>
<TouchableOpacity
onPress={() => Linking.openURL("https://sauci.app/privacy")}
>
<Text style={styles.legalLinkText}>Privacy Policy</Text>
<Text style={styles.legalLinkText}>Privacy</Text>
</TouchableOpacity>
</View>
</View>
</ScrollView>
{/* Guest confirmation modal */}
{showGuestConfirm && (
<View style={styles.guestConfirmOverlay}>
<Pressable
@@ -425,8 +426,8 @@ export function Paywall({ visible, onClose, onSuccess }: PaywallProps) {
/>
<View style={styles.guestConfirmCard}>
<View style={styles.guestConfirmHeader}>
<View style={styles.guestConfirmIcon}>
<Ionicons name="shield-checkmark" size={22} color={colors.premium.gold} />
<View style={styles.guestConfirmIconWrapper}>
<Ionicons name="shield-checkmark" size={24} color={colors.premium.gold} />
</View>
<TouchableOpacity
onPress={() => setShowGuestConfirm(false)}
@@ -440,7 +441,7 @@ export function Paywall({ visible, onClose, onSuccess }: PaywallProps) {
<Text style={styles.guestConfirmTitle}>Save your account first?</Text>
<Text style={styles.guestConfirmBody}>
Unsaved accounts can't be recovered if you delete the app or switch devices.
Saving your account protects your access to Pro.
Saving protects your Pro access.
</Text>
<View style={styles.guestConfirmActions}>
@@ -452,7 +453,14 @@ export function Paywall({ visible, onClose, onSuccess }: PaywallProps) {
router.push("/(app)/settings/save-account" as any);
}}
>
<Text style={styles.guestConfirmPrimaryText}>Save account</Text>
<LinearGradient
colors={[colors.premium.gold, colors.premium.goldDark]}
style={styles.guestConfirmPrimaryGradient}
start={{ x: 0, y: 0 }}
end={{ x: 1, y: 0 }}
>
<Text style={styles.guestConfirmPrimaryText}>Save account</Text>
</LinearGradient>
</Pressable>
<Pressable
@@ -463,7 +471,7 @@ export function Paywall({ visible, onClose, onSuccess }: PaywallProps) {
await doPurchase();
}}
>
<Text style={styles.guestConfirmSecondaryText}>Continue purchase</Text>
<Text style={styles.guestConfirmSecondaryText}>Continue anyway</Text>
</Pressable>
</View>
</View>
@@ -485,132 +493,84 @@ const styles = StyleSheet.create({
backgroundColor: colors.background,
borderTopLeftRadius: radius.xxl,
borderTopRightRadius: radius.xxl,
maxHeight: "90%",
borderWidth: 1,
borderBottomWidth: 0,
borderColor: 'rgba(212, 175, 55, 0.2)',
maxHeight: "92%",
overflow: 'hidden',
},
silkHighlight: {
topAccent: {
position: 'absolute',
top: 0,
left: 0,
right: 0,
height: 150,
height: 3,
borderTopLeftRadius: radius.xxl,
borderTopRightRadius: radius.xxl,
},
premiumBorderAccent: {
position: 'absolute',
top: 0,
left: 0,
right: 0,
height: 2,
backgroundColor: colors.premium.gold,
opacity: 0.4,
},
scrollContent: {
padding: spacing.lg,
paddingTop: spacing.xl,
paddingBottom: Platform.OS === "ios" ? 40 : spacing.lg,
paddingTop: spacing.xxl,
paddingBottom: Platform.OS === "ios" ? 44 : spacing.xl,
},
closeButton: {
position: "absolute",
top: spacing.md,
right: spacing.md,
width: 40,
height: 40,
borderRadius: 20,
backgroundColor: 'rgba(255, 255, 255, 0.08)',
top: spacing.sm,
right: spacing.sm,
width: 36,
height: 36,
borderRadius: 18,
backgroundColor: colors.backgroundLight,
justifyContent: "center",
alignItems: "center",
zIndex: 10,
borderWidth: 1,
borderColor: 'rgba(255, 255, 255, 0.1)',
},
// Header
header: {
alignItems: "center",
marginBottom: spacing.xl,
},
label: {
...typography.caption1,
fontWeight: '600',
letterSpacing: 3,
color: colors.premium.gold,
textAlign: 'center',
marginBottom: spacing.md,
},
iconContainer: {
width: 72,
height: 72,
borderRadius: 36,
logoContainer: {
width: 88,
height: 88,
justifyContent: "center",
alignItems: "center",
overflow: 'hidden',
marginBottom: spacing.md,
shadowColor: colors.premium.gold,
shadowOffset: { width: 0, height: 0 },
shadowOpacity: 0.6,
shadowRadius: 20,
elevation: 12,
marginBottom: spacing.lg,
},
logo: {
width: 88,
height: 88,
},
title: {
...typography.title1,
...typography.largeTitle,
color: colors.text,
marginBottom: spacing.xs,
textShadowColor: 'rgba(212, 175, 55, 0.3)',
textShadowOffset: { width: 0, height: 0 },
textShadowRadius: 10,
},
separator: {
flexDirection: 'row',
alignItems: 'center',
justifyContent: 'center',
marginVertical: spacing.md,
width: 140,
},
separatorLine: {
flex: 1,
height: 1,
backgroundColor: 'rgba(212, 175, 55, 0.3)',
},
separatorDiamond: {
width: 6,
height: 6,
backgroundColor: colors.premium.gold,
transform: [{ rotate: '45deg' }],
marginHorizontal: spacing.md,
opacity: 0.8,
},
subtitle: {
...typography.body,
color: colors.textSecondary,
textAlign: "center",
lineHeight: 24,
},
// Features
features: {
marginBottom: spacing.xl,
backgroundColor: 'rgba(212, 175, 55, 0.05)',
borderRadius: radius.lg,
padding: spacing.md,
borderWidth: 1,
borderColor: 'rgba(212, 175, 55, 0.1)',
gap: spacing.sm,
},
featureRow: {
flexDirection: "row",
alignItems: "center",
paddingVertical: spacing.sm,
paddingHorizontal: spacing.md,
backgroundColor: colors.backgroundLight,
borderRadius: radius.md,
},
featureIcon: {
width: 40,
height: 40,
borderRadius: 20,
backgroundColor: 'rgba(212, 175, 55, 0.1)',
width: 44,
height: 44,
borderRadius: 22,
backgroundColor: colors.premium.goldLight,
justifyContent: "center",
alignItems: "center",
marginRight: spacing.md,
borderWidth: 1,
borderColor: 'rgba(212, 175, 55, 0.2)',
},
featureText: {
flex: 1,
@@ -622,7 +582,10 @@ const styles = StyleSheet.create({
featureDescription: {
...typography.subhead,
color: colors.textSecondary,
marginTop: 2,
},
// Packages
packages: {
marginBottom: spacing.lg,
gap: spacing.md,
@@ -638,27 +601,28 @@ const styles = StyleSheet.create({
},
packageCard: {
position: "relative",
backgroundColor: 'rgba(22, 33, 62, 0.4)',
backgroundColor: colors.backgroundLight,
borderRadius: radius.lg,
borderWidth: 1,
borderColor: 'rgba(255, 255, 255, 0.08)',
borderWidth: 2,
borderColor: colors.border,
padding: spacing.md,
overflow: "hidden",
},
packageCardSelected: {
borderColor: colors.premium.gold,
borderWidth: 2,
},
packageCardAnnual: {
borderColor: 'rgba(212, 175, 55, 0.3)',
borderColor: colors.premium.goldLight,
},
annualBackground: {
...StyleSheet.absoluteFillObject,
backgroundColor: colors.premium.goldLight,
opacity: 0.3,
},
savingsBadge: {
position: "absolute",
top: -1,
top: 0,
right: spacing.md,
flexDirection: 'row',
alignItems: 'center',
gap: 4,
paddingHorizontal: spacing.sm,
paddingVertical: 4,
borderBottomLeftRadius: radius.sm,
@@ -680,7 +644,7 @@ const styles = StyleSheet.create({
height: 24,
borderRadius: 12,
borderWidth: 2,
borderColor: 'rgba(255, 255, 255, 0.3)',
borderColor: colors.border,
justifyContent: "center",
alignItems: "center",
marginRight: spacing.md,
@@ -697,7 +661,7 @@ const styles = StyleSheet.create({
packageInfo: {
flex: 1,
},
packageTitleRow: {
packageHeader: {
flexDirection: 'row',
alignItems: 'center',
gap: spacing.sm,
@@ -707,19 +671,23 @@ const styles = StyleSheet.create({
color: colors.text,
},
packageTitleAnnual: {
...typography.headline,
color: colors.premium.champagne,
},
bestValueBadge: {
backgroundColor: 'rgba(212, 175, 55, 0.2)',
paddingHorizontal: spacing.xs,
recommendedBadge: {
flexDirection: 'row',
alignItems: 'center',
gap: 4,
backgroundColor: colors.premium.goldLight,
paddingHorizontal: spacing.sm,
paddingVertical: 2,
borderRadius: radius.xs,
},
bestValueText: {
recommendedText: {
...typography.caption2,
color: colors.premium.gold,
fontWeight: '700',
letterSpacing: 0.5,
letterSpacing: 0.3,
},
packagePrice: {
...typography.subhead,
@@ -727,9 +695,12 @@ const styles = StyleSheet.create({
marginTop: 2,
},
packagePriceAnnual: {
...typography.subhead,
color: colors.premium.champagne,
opacity: 0.8,
marginTop: 2,
},
// Error states
errorContainer: {
alignItems: "center",
padding: spacing.lg,
@@ -741,12 +712,10 @@ const styles = StyleSheet.create({
marginBottom: spacing.md,
},
retryButton: {
backgroundColor: 'rgba(212, 175, 55, 0.1)',
backgroundColor: colors.premium.goldLight,
paddingHorizontal: spacing.lg,
paddingVertical: spacing.sm,
borderRadius: radius.md,
borderWidth: 1,
borderColor: 'rgba(212, 175, 55, 0.2)',
},
retryButtonText: {
...typography.subhead,
@@ -764,17 +733,17 @@ const styles = StyleSheet.create({
color: colors.error,
textAlign: "center",
},
// Actions
actions: {
marginBottom: spacing.lg,
},
purchaseButton: {
borderRadius: radius.lg,
overflow: "hidden",
...shadows.lg,
shadowColor: colors.premium.gold,
shadowOffset: { width: 0, height: 4 },
shadowOpacity: 0.3,
shadowRadius: 8,
elevation: 8,
shadowOpacity: 0.4,
},
purchaseButtonDisabled: {
opacity: 0.5,
@@ -786,7 +755,6 @@ const styles = StyleSheet.create({
backgroundColor: colors.premium.gold,
},
purchaseButtonGradient: {
flexDirection: 'row',
paddingVertical: spacing.md,
alignItems: "center",
justifyContent: 'center',
@@ -795,6 +763,7 @@ const styles = StyleSheet.create({
...typography.headline,
color: colors.background,
fontWeight: '700',
fontSize: 18,
},
restoreButton: {
alignItems: "center",
@@ -803,11 +772,12 @@ const styles = StyleSheet.create({
},
restoreText: {
...typography.subhead,
color: colors.premium.gold,
opacity: 0.8,
color: colors.textSecondary,
},
// Legal
legal: {
paddingHorizontal: spacing.md,
paddingHorizontal: spacing.sm,
},
legalText: {
...typography.caption1,
@@ -820,11 +790,11 @@ const styles = StyleSheet.create({
justifyContent: "center",
alignItems: "center",
marginTop: spacing.sm,
gap: spacing.xs,
gap: spacing.sm,
},
legalLinkText: {
...typography.caption1,
color: colors.premium.gold,
color: colors.textSecondary,
textDecorationLine: "underline",
},
legalSeparator: {
@@ -832,21 +802,22 @@ const styles = StyleSheet.create({
color: colors.textTertiary,
},
// Guest confirm modal
guestConfirmOverlay: {
...StyleSheet.absoluteFillObject,
backgroundColor: "rgba(0, 0, 0, 0.7)",
backgroundColor: "rgba(0, 0, 0, 0.8)",
justifyContent: "center",
alignItems: "center",
padding: spacing.lg,
},
guestConfirmCard: {
width: "100%",
maxWidth: 420,
backgroundColor: "rgba(22, 33, 62, 0.98)",
maxWidth: 400,
backgroundColor: colors.backgroundLight,
borderRadius: radius.xl,
padding: spacing.lg,
borderWidth: 1,
borderColor: "rgba(212, 175, 55, 0.2)",
borderColor: colors.border,
},
guestConfirmHeader: {
flexDirection: "row",
@@ -854,23 +825,21 @@ const styles = StyleSheet.create({
justifyContent: "space-between",
marginBottom: spacing.md,
},
guestConfirmIcon: {
width: 40,
height: 40,
borderRadius: 20,
backgroundColor: "rgba(212, 175, 55, 0.12)",
guestConfirmIconWrapper: {
width: 48,
height: 48,
borderRadius: 24,
backgroundColor: colors.premium.goldLight,
justifyContent: "center",
alignItems: "center",
},
guestConfirmClose: {
width: 40,
height: 40,
borderRadius: 20,
backgroundColor: "rgba(255, 255, 255, 0.08)",
width: 36,
height: 36,
borderRadius: 18,
backgroundColor: colors.background,
justifyContent: "center",
alignItems: "center",
borderWidth: 1,
borderColor: "rgba(255, 255, 255, 0.1)",
},
guestConfirmTitle: {
...typography.title3,
@@ -878,19 +847,20 @@ const styles = StyleSheet.create({
marginBottom: spacing.sm,
},
guestConfirmBody: {
...typography.subhead,
...typography.body,
color: colors.textSecondary,
lineHeight: 20,
lineHeight: 22,
marginBottom: spacing.lg,
textAlign: "left",
},
guestConfirmActions: {
gap: spacing.sm,
},
guestConfirmPrimary: {
backgroundColor: colors.premium.gold,
paddingVertical: spacing.md,
borderRadius: radius.lg,
overflow: 'hidden',
},
guestConfirmPrimaryGradient: {
paddingVertical: spacing.md,
alignItems: "center",
},
guestConfirmPrimaryText: {
@@ -899,16 +869,16 @@ const styles = StyleSheet.create({
fontWeight: "700",
},
guestConfirmSecondary: {
backgroundColor: "rgba(255, 255, 255, 0.06)",
backgroundColor: colors.background,
paddingVertical: spacing.md,
borderRadius: radius.lg,
alignItems: "center",
borderWidth: 1,
borderColor: "rgba(255, 255, 255, 0.12)",
borderColor: colors.border,
},
guestConfirmSecondaryText: {
...typography.subhead,
color: colors.text,
color: colors.textSecondary,
fontWeight: "600",
},
});
@@ -0,0 +1,452 @@
import { useState, useEffect } from "react";
import { View, Text, StyleSheet, Platform, TouchableOpacity } from "react-native";
import { LinearGradient } from "expo-linear-gradient";
import Animated, {
useSharedValue,
useAnimatedStyle,
withSpring,
withRepeat,
withSequence,
withTiming,
interpolate,
Easing,
FadeInDown,
} from "react-native-reanimated";
import { Ionicons } from "@expo/vector-icons";
import { colors, gradients, radius, shadows, typography, spacing, animations } from "../../theme";
import { QuestionFeedbackModal } from "../feedback";
import { SharePreviewModal } from "../share";
const ACTION_BUTTON_SIZE = 72;
const ACTION_BUTTON_OVERHANG_PERCENT = 0.5; // 50% of button hangs below the card
const actionButtonOffset = ACTION_BUTTON_SIZE * ACTION_BUTTON_OVERHANG_PERCENT;
const actionButtonBackground = colors.muted;
// Haptics helper - not supported on web
const triggerHaptic = async (style: 'light' | 'medium' | 'heavy' = 'medium') => {
if (Platform.OS === 'web') return;
const Haptics = await import('expo-haptics');
const feedbackStyle = style === 'light'
? Haptics.ImpactFeedbackStyle.Light
: style === 'heavy'
? Haptics.ImpactFeedbackStyle.Heavy
: Haptics.ImpactFeedbackStyle.Medium;
await Haptics.impactAsync(feedbackStyle);
};
export interface QuestionCardProps {
question: {
id: string;
text: string;
intensity: number;
partner_text?: string | null;
is_two_part?: boolean;
partner_answered?: boolean;
question_type?: string;
};
packInfo?: { name: string; icon: string; color?: string } | null;
onAnswer: (answer: 'yes' | 'no' | 'skip') => void;
onReport?: () => void;
}
export default function QuestionCard({ question, packInfo, onAnswer, onReport }: QuestionCardProps) {
const [showFeedbackModal, setShowFeedbackModal] = useState(false);
const [showShareModal, setShowShareModal] = useState(false);
const idleBreathing = useSharedValue(0);
const handleSharePress = async () => {
await triggerHaptic('light');
setShowShareModal(true);
};
// Subtle idle breathing animation
useEffect(() => {
idleBreathing.value = withRepeat(
withSequence(
withTiming(1, { duration: 2000, easing: Easing.inOut(Easing.sin) }),
withTiming(0, { duration: 2000, easing: Easing.inOut(Easing.sin) })
),
-1,
true
);
}, []);
const animatedStyle = useAnimatedStyle(() => {
const idleOffset = interpolate(idleBreathing.value, [0, 1], [0, -4]);
return {
transform: [{ translateY: idleOffset }],
};
});
const handleButtonPress = async (answer: 'yes' | 'no' | 'skip') => {
await triggerHaptic(answer === 'skip' ? 'light' : 'medium');
onAnswer(answer);
};
const handleFeedbackPress = () => {
if (onReport) {
onReport();
} else {
setShowFeedbackModal(true);
}
};
return (
<>
<Animated.View
entering={FadeInDown.duration(400).springify()}
style={styles.cardWrapper}
>
<Animated.View style={[styles.cardOuter, animatedStyle, shadows.lg, { shadowColor: colors.primary }]}>
<View
style={[
styles.gradientContainer,
{ backgroundColor: packInfo?.color || colors.background }
]}
>
<CardContent
question={question}
packInfo={packInfo}
handleButtonPress={handleButtonPress}
onFeedbackPress={handleFeedbackPress}
onSharePress={handleSharePress}
/>
</View>
</Animated.View>
</Animated.View>
<QuestionFeedbackModal
visible={showFeedbackModal}
onClose={() => setShowFeedbackModal(false)}
questionId={question.id}
questionText={question.text}
/>
<SharePreviewModal
visible={showShareModal}
onClose={() => setShowShareModal(false)}
question={question}
packName={packInfo?.name}
cardColor={packInfo?.color}
/>
</>
);
}
function CardContent({
question,
packInfo,
handleButtonPress,
onFeedbackPress,
onSharePress,
}: {
question: QuestionCardProps['question'];
packInfo?: { name: string; icon: string } | null;
handleButtonPress: (answer: 'yes' | 'no' | 'skip') => void;
onFeedbackPress: () => void;
onSharePress: () => void;
}) {
return (
<View style={styles.cardInner}>
{/* Header Row - Pack badge and action buttons */}
<View style={styles.headerRow}>
{/* Pack Badge */}
{packInfo && (
<View style={styles.packBadge}>
<Ionicons name={packInfo.icon as any} size={14} color={colors.text} />
<Text style={styles.packBadgeText} numberOfLines={1}>
{packInfo.name}
</Text>
</View>
)}
{!packInfo && <View style={styles.headerSpacer} />}
{/* Action Buttons */}
<View style={styles.headerActions}>
<TouchableOpacity
style={styles.headerButton}
onPress={onSharePress}
hitSlop={{ top: 10, bottom: 10, left: 10, right: 10 }}
activeOpacity={0.7}
>
<Ionicons name="share-outline" size={20} color="rgba(255,255,255,0.8)" />
</TouchableOpacity>
<TouchableOpacity
style={styles.headerButton}
onPress={onFeedbackPress}
hitSlop={{ top: 10, bottom: 10, left: 10, right: 10 }}
activeOpacity={0.7}
>
<Ionicons name="flag-outline" size={20} color="rgba(255,255,255,0.6)" />
</TouchableOpacity>
</View>
</View>
{/* Content - Question text centered */}
<View style={styles.content}>
<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="rgba(255,255,255,0.8)" />
<Text style={styles.partnerLabel}>Partner sees</Text>
</View>
<View style={styles.partnerDividerLine} />
</View>
<Text style={styles.partnerText}>{question.partner_text}</Text>
</View>
)}
</View>
{/* Footer - Action Buttons pinned to bottom */}
<View style={styles.footer}>
<View style={styles.buttonContainer}>
<ActionButton
onPress={() => handleButtonPress("no")}
icon="close"
iconColor={colors.error}
label="NO"
/>
{/* Skip button in between */}
<TouchableOpacity
style={styles.skipButtonCenter}
onPress={() => handleButtonPress("skip")}
activeOpacity={0.7}
>
<Text style={styles.skipText}>Skip</Text>
</TouchableOpacity>
<ActionButton
onPress={() => handleButtonPress("yes")}
icon="checkmark"
iconColor={colors.success}
label="YES"
/>
</View>
</View>
</View>
);
}
function ActionButton({
onPress,
icon,
iconColor,
label,
small = false,
}: {
onPress: () => void;
icon: string;
iconColor: string;
label: string;
small?: boolean;
}) {
const buttonScale = useSharedValue(1);
const handlePressIn = () => {
buttonScale.value = withSpring(0.92, animations.spring);
};
const handlePressOut = () => {
buttonScale.value = withSpring(1, animations.spring);
};
const buttonAnimatedStyle = useAnimatedStyle(() => ({
transform: [{ scale: buttonScale.value }],
}));
return (
<TouchableOpacity
onPress={onPress}
onPressIn={handlePressIn}
onPressOut={handlePressOut}
activeOpacity={1}
style={styles.actionButtonWrapper}
>
<Animated.View style={[
styles.actionButton,
small && styles.actionButtonSmall,
buttonAnimatedStyle,
{ backgroundColor: actionButtonBackground }
]}>
<Ionicons
name={icon as any}
size={small ? 20 : 32}
color={iconColor}
style={styles.buttonIcon}
/>
</Animated.View>
</TouchableOpacity>
);
}
const styles = StyleSheet.create({
cardWrapper: {
width: "90%",
maxWidth: 400,
flex: 1,
maxHeight: 580 - actionButtonOffset, // Account for button overhang
},
cardOuter: {
flex: 1,
borderRadius: radius.xxl,
overflow: "visible",
},
gradientContainer: {
flex: 1,
borderRadius: radius.xxl,
overflow: "visible",
},
cardInner: {
flex: 1,
paddingTop: spacing.md,
paddingBottom: spacing.sm,
paddingHorizontal: spacing.lg,
},
// Header row styles
headerRow: {
flexDirection: 'row',
justifyContent: 'space-between',
alignItems: 'center',
marginBottom: spacing.md,
},
headerSpacer: {
flex: 1,
},
packBadge: {
flexDirection: 'row',
alignItems: 'center',
backgroundColor: 'rgba(255,255,255,0.15)',
paddingHorizontal: spacing.md,
paddingVertical: spacing.sm,
borderRadius: radius.full,
gap: spacing.xs,
maxWidth: '60%',
},
packBadgeText: {
...typography.caption1,
fontWeight: '600',
color: colors.text,
},
headerActions: {
flexDirection: 'row',
alignItems: 'center',
gap: spacing.xs,
},
headerButton: {
width: 40,
height: 40,
borderRadius: 20,
backgroundColor: 'rgba(255,255,255,0.12)',
justifyContent: 'center',
alignItems: 'center',
},
// Content styles
content: {
flex: 1,
justifyContent: "center",
alignItems: "center",
paddingHorizontal: spacing.sm,
},
text: {
...typography.title1,
color: colors.text,
textAlign: "center",
lineHeight: 36,
},
twoPartText: {
...typography.title2,
lineHeight: 32,
},
partnerTextContainer: {
marginTop: spacing.lg,
width: '100%',
paddingHorizontal: spacing.sm,
},
partnerDivider: {
flexDirection: 'row',
alignItems: 'center',
marginBottom: spacing.sm,
},
partnerDividerLine: {
flex: 1,
height: 1,
backgroundColor: 'rgba(255,255,255,0.2)',
},
partnerLabelContainer: {
flexDirection: 'row',
alignItems: 'center',
paddingHorizontal: spacing.md,
paddingVertical: spacing.xs,
backgroundColor: 'rgba(255,255,255,0.15)',
borderRadius: radius.full,
gap: spacing.xs,
},
partnerLabel: {
...typography.caption1,
color: 'rgba(255,255,255,0.8)',
fontWeight: '500',
letterSpacing: 0.3,
},
partnerText: {
...typography.subhead,
color: 'rgba(255,255,255,0.7)',
textAlign: 'center',
fontStyle: 'italic',
},
// Footer styles
footer: {
paddingTop: spacing.md,
transform: [{ translateY: actionButtonOffset }],
},
buttonContainer: {
flexDirection: "row",
justifyContent: "space-between",
alignItems: "flex-end",
width: '100%',
paddingHorizontal: spacing.md,
},
actionButtonWrapper: {
alignItems: 'center',
gap: spacing.xs,
},
actionButton: {
width: 72,
height: 72,
borderRadius: 36,
justifyContent: "center",
alignItems: "center",
overflow: "hidden",
elevation: 4,
shadowColor: "#000",
shadowOffset: { width: 0, height: 4 },
shadowOpacity: 0.3,
shadowRadius: 4,
},
actionButtonSmall: {
width: 52,
height: 52,
borderRadius: 26,
},
buttonIcon: {
zIndex: 1,
},
skipButtonCenter: {
paddingVertical: spacing.sm,
paddingHorizontal: spacing.md,
marginBottom: 30, // Align above center of 72px buttons
},
skipText: {
...typography.caption1,
color: 'rgba(255,255,255,0.5)',
fontWeight: '600',
},
});
File diff suppressed because it is too large. Load diff
@@ -0,0 +1,630 @@
import { useState, useEffect } from "react";
import { View, Text, StyleSheet, Platform, TouchableOpacity, Image } from "react-native";
import { LinearGradient } from "expo-linear-gradient";
import Animated, {
useSharedValue,
useAnimatedStyle,
withSpring,
withRepeat,
withSequence,
withTiming,
interpolate,
Easing,
FadeInDown,
FadeIn,
} from "react-native-reanimated";
import { Ionicons } from "@expo/vector-icons";
import { colors, gradients, radius, shadows, typography, spacing, animations } from "../../theme";
import { useMediaPicker } from "../../hooks/useMediaPicker";
import { SharePreviewModal } from "../share";
const ACTION_BUTTON_SIZE = 72;
const ACTION_BUTTON_OVERHANG_PERCENT = 0.5;
const actionButtonOffset = ACTION_BUTTON_SIZE * ACTION_BUTTON_OVERHANG_PERCENT;
const actionButtonBackground = colors.muted;
// Haptics helper - not supported on web
const triggerHaptic = async (style: 'light' | 'medium' | 'heavy' = 'medium') => {
if (Platform.OS === 'web') return;
const Haptics = await import('expo-haptics');
const feedbackStyle = style === 'light'
? Haptics.ImpactFeedbackStyle.Light
: style === 'heavy'
? Haptics.ImpactFeedbackStyle.Heavy
: Haptics.ImpactFeedbackStyle.Medium;
await Haptics.impactAsync(feedbackStyle);
};
export interface QuestionCardPhotoProps {
question: {
id: string;
text: string;
intensity: number;
partner_text?: string | null;
partner_answered?: boolean;
};
packInfo?: { name: string; icon: string; color?: string } | null;
onAnswer: (answer: 'yes' | 'skip', responseData?: { type: 'photo'; media_path: string }) => void;
onReport?: () => void;
}
export default function QuestionCardPhoto({ question, packInfo, onAnswer, onReport }: QuestionCardPhotoProps) {
const [selectedPhotoUri, setSelectedPhotoUri] = useState<string | null>(null);
const [showShareModal, setShowShareModal] = useState(false);
const { takePhoto, pickMedia } = useMediaPicker({ imageQuality: 0.8 });
const idleBreathing = useSharedValue(0);
const handleSharePress = async () => {
await triggerHaptic('light');
setShowShareModal(true);
};
// Subtle idle breathing animation
useEffect(() => {
idleBreathing.value = withRepeat(
withSequence(
withTiming(1, { duration: 2000, easing: Easing.inOut(Easing.sin) }),
withTiming(0, { duration: 2000, easing: Easing.inOut(Easing.sin) })
),
-1,
true
);
}, []);
const animatedStyle = useAnimatedStyle(() => {
const idleOffset = interpolate(idleBreathing.value, [0, 1], [0, -4]);
return {
transform: [{ translateY: idleOffset }],
};
});
const handleTakePhoto = async () => {
await triggerHaptic('light');
const result = await takePhoto();
if (result) {
setSelectedPhotoUri(result.uri);
}
};
const handlePickFromLibrary = async () => {
await triggerHaptic('light');
const result = await pickMedia();
if (result && result.mediaType === 'image') {
setSelectedPhotoUri(result.uri);
}
};
const handleConfirm = async () => {
if (!selectedPhotoUri) return;
await triggerHaptic('medium');
onAnswer('yes', { type: 'photo', media_path: selectedPhotoUri });
};
const handleRetake = async () => {
await triggerHaptic('light');
setSelectedPhotoUri(null);
};
const handleCancel = async () => {
await triggerHaptic('light');
setSelectedPhotoUri(null);
};
const handleSkip = async () => {
await triggerHaptic('light');
onAnswer('skip');
};
return (
<>
<Animated.View
entering={FadeInDown.duration(400).springify()}
style={styles.cardWrapper}
>
<Animated.View style={[styles.cardOuter, animatedStyle, shadows.lg, { shadowColor: colors.primary }]}>
<View
style={[
styles.gradientContainer,
{ backgroundColor: packInfo?.color || colors.background }
]}
>
{selectedPhotoUri ? (
<PhotoPreviewContent
question={question}
packInfo={packInfo}
photoUri={selectedPhotoUri}
onConfirm={handleConfirm}
onRetake={handleRetake}
onCancel={handleCancel}
onReport={onReport}
/>
) : (
<PhotoSelectContent
question={question}
packInfo={packInfo}
onTakePhoto={handleTakePhoto}
onPickFromLibrary={handlePickFromLibrary}
onSkip={handleSkip}
onReport={onReport}
onSharePress={handleSharePress}
/>
)}
</View>
</Animated.View>
</Animated.View>
<SharePreviewModal
visible={showShareModal}
onClose={() => setShowShareModal(false)}
question={question}
packName={packInfo?.name}
cardColor={packInfo?.color}
/>
</>
);
}
// State A - No photo selected
function PhotoSelectContent({
question,
packInfo,
onTakePhoto,
onPickFromLibrary,
onSkip,
onReport,
onSharePress,
}: {
question: QuestionCardPhotoProps['question'];
packInfo?: { name: string; icon: string } | null;
onTakePhoto: () => void;
onPickFromLibrary: () => void;
onSkip: () => void;
onReport?: () => void;
onSharePress: () => void;
}) {
return (
<View style={styles.cardInner}>
{/* Header Row */}
<View style={styles.headerRow}>
{packInfo && (
<View style={styles.packBadge}>
<Ionicons name={packInfo.icon as any} size={14} color={colors.text} />
<Text style={styles.packBadgeText} numberOfLines={1}>
{packInfo.name}
</Text>
</View>
)}
{!packInfo && <View style={styles.headerSpacer} />}
<View style={styles.headerActions}>
<TouchableOpacity
style={styles.headerButton}
onPress={onSharePress}
hitSlop={{ top: 10, bottom: 10, left: 10, right: 10 }}
activeOpacity={0.7}
>
<Ionicons name="share-outline" size={20} color="rgba(255,255,255,0.8)" />
</TouchableOpacity>
{onReport && (
<TouchableOpacity
style={styles.headerButton}
onPress={onReport}
hitSlop={{ top: 10, bottom: 10, left: 10, right: 10 }}
activeOpacity={0.7}
>
<Ionicons name="flag-outline" size={20} color="rgba(255,255,255,0.6)" />
</TouchableOpacity>
)}
</View>
</View>
{/* Content */}
<View style={styles.content}>
<Text style={styles.text}>
{question.text}
</Text>
</View>
{/* Footer */}
<View style={styles.footer}>
<View style={styles.photoButtonContainer}>
<PhotoActionButton
onPress={onPickFromLibrary}
icon="images"
label="Library"
colors={[colors.secondary, colors.secondaryDark]}
/>
<TouchableOpacity
style={styles.skipButtonCenter}
onPress={onSkip}
activeOpacity={0.7}
>
<Text style={styles.skipText}>Skip</Text>
</TouchableOpacity>
<PhotoActionButton
onPress={onTakePhoto}
icon="camera"
label="Camera"
colors={gradients.primary as [string, string]}
/>
</View>
</View>
</View>
);
}
// State B - Photo preview
function PhotoPreviewContent({
question,
packInfo,
photoUri,
onConfirm,
onRetake,
onCancel,
onReport,
}: {
question: QuestionCardPhotoProps['question'];
packInfo?: { name: string; icon: string } | null;
photoUri: string;
onConfirm: () => void;
onRetake: () => void;
onCancel: () => void;
onReport?: () => void;
}) {
return (
<View style={styles.cardInner}>
{/* Header Row */}
<View style={styles.headerRow}>
{packInfo && (
<View style={styles.packBadge}>
<Ionicons name={packInfo.icon as any} size={14} color={colors.text} />
<Text style={styles.packBadgeText} numberOfLines={1}>
{packInfo.name}
</Text>
</View>
)}
{!packInfo && <View style={styles.headerSpacer} />}
<View style={styles.headerActions}>
{onReport && (
<TouchableOpacity
style={styles.headerButton}
onPress={onReport}
hitSlop={{ top: 10, bottom: 10, left: 10, right: 10 }}
activeOpacity={0.7}
>
<Ionicons name="flag-outline" size={20} color="rgba(255,255,255,0.6)" />
</TouchableOpacity>
)}
</View>
</View>
{/* Content */}
<View style={styles.previewContent}>
<Text style={styles.previewText} numberOfLines={2}>
{question.text}
</Text>
{/* Photo Preview */}
<Animated.View
entering={FadeIn.duration(300)}
style={styles.photoPreviewContainer}
>
<Image
source={{ uri: photoUri }}
style={styles.photoPreview}
resizeMode="cover"
/>
<View style={styles.photoPreviewBorder} pointerEvents="none" />
</Animated.View>
</View>
{/* Footer */}
<View style={styles.previewFooter}>
<View style={styles.previewButtonRow}>
<TouchableOpacity
style={styles.secondaryButton}
onPress={onCancel}
activeOpacity={0.7}
>
<Ionicons name="close" size={20} color="rgba(255,255,255,0.7)" />
<Text style={styles.secondaryButtonText}>Cancel</Text>
</TouchableOpacity>
<ConfirmButton onPress={onConfirm} />
<TouchableOpacity
style={styles.secondaryButton}
onPress={onRetake}
activeOpacity={0.7}
>
<Ionicons name="refresh" size={20} color="rgba(255,255,255,0.7)" />
<Text style={styles.secondaryButtonText}>Retake</Text>
</TouchableOpacity>
</View>
</View>
</View>
);
}
function PhotoActionButton({
onPress,
colors: _buttonColors,
icon,
label,
}: {
onPress: () => void;
colors: [string, string];
icon: string;
label: string;
}) {
const buttonScale = useSharedValue(1);
const handlePressIn = () => {
buttonScale.value = withSpring(0.95, animations.spring);
};
const handlePressOut = () => {
buttonScale.value = withSpring(1, animations.spring);
};
const buttonAnimatedStyle = useAnimatedStyle(() => ({
transform: [{ scale: buttonScale.value }],
}));
return (
<TouchableOpacity
onPress={onPress}
onPressIn={handlePressIn}
onPressOut={handlePressOut}
activeOpacity={1}
style={styles.photoActionWrapper}
>
<Animated.View style={[
styles.photoActionButton,
buttonAnimatedStyle,
{ backgroundColor: actionButtonBackground }
]}>
<Ionicons
name={icon as any}
size={28}
color="white"
style={styles.buttonIcon}
/>
</Animated.View>
</TouchableOpacity>
);
}
function ConfirmButton({ onPress }: { onPress: () => void }) {
const buttonScale = useSharedValue(1);
const handlePressIn = () => {
buttonScale.value = withSpring(0.92, animations.spring);
};
const handlePressOut = () => {
buttonScale.value = withSpring(1, animations.spring);
};
const buttonAnimatedStyle = useAnimatedStyle(() => ({
transform: [{ scale: buttonScale.value }],
}));
return (
<TouchableOpacity
onPress={onPress}
onPressIn={handlePressIn}
onPressOut={handlePressOut}
activeOpacity={1}
>
<Animated.View style={[
styles.confirmButton,
buttonAnimatedStyle,
{ backgroundColor: colors.success }
]}>
<LinearGradient
colors={gradients.success as [string, string]}
style={StyleSheet.absoluteFill}
start={{ x: 0, y: 0 }}
end={{ x: 1, y: 1 }}
/>
<Ionicons
name="checkmark"
size={28}
color={colors.text}
style={styles.buttonIcon}
/>
</Animated.View>
</TouchableOpacity>
);
}
const styles = StyleSheet.create({
cardWrapper: {
width: "90%",
maxWidth: 400,
flex: 1,
maxHeight: 580 - actionButtonOffset, // Account for button overhang
},
cardOuter: {
flex: 1,
borderRadius: radius.xxl,
overflow: "visible",
},
gradientContainer: {
flex: 1,
borderRadius: radius.xxl,
overflow: "visible",
},
cardInner: {
flex: 1,
paddingTop: spacing.md,
paddingBottom: spacing.sm,
paddingHorizontal: spacing.lg,
},
// Header row styles
headerRow: {
flexDirection: 'row',
justifyContent: 'space-between',
alignItems: 'center',
marginBottom: spacing.md,
},
headerSpacer: {
flex: 1,
},
packBadge: {
flexDirection: 'row',
alignItems: 'center',
backgroundColor: 'rgba(255,255,255,0.15)',
paddingHorizontal: spacing.md,
paddingVertical: spacing.sm,
borderRadius: radius.full,
gap: spacing.xs,
maxWidth: '60%',
},
packBadgeText: {
...typography.caption1,
fontWeight: '600',
color: colors.text,
},
headerActions: {
flexDirection: 'row',
alignItems: 'center',
gap: spacing.xs,
},
headerButton: {
width: 40,
height: 40,
borderRadius: 20,
backgroundColor: 'rgba(255,255,255,0.12)',
justifyContent: 'center',
alignItems: 'center',
},
// Content styles
content: {
flex: 1,
justifyContent: "center",
alignItems: "center",
paddingHorizontal: spacing.sm,
},
text: {
...typography.title1,
color: colors.text,
textAlign: "center",
lineHeight: 36,
},
// Footer styles
footer: {
paddingTop: spacing.md,
transform: [{ translateY: actionButtonOffset }],
},
previewFooter: {
paddingTop: spacing.md,
paddingBottom: spacing.sm,
},
photoButtonContainer: {
flexDirection: "row",
justifyContent: "space-between",
alignItems: "flex-end",
width: '100%',
paddingHorizontal: spacing.md,
},
photoActionWrapper: {
alignItems: 'center',
gap: spacing.xs,
},
photoActionButton: {
width: 72,
height: 72,
borderRadius: 36,
justifyContent: "center",
alignItems: "center",
overflow: "hidden",
elevation: 4,
shadowColor: "#000",
shadowOffset: { width: 0, height: 4 },
shadowOpacity: 0.3,
shadowRadius: 4,
},
buttonIcon: {
zIndex: 1,
},
photoActionButtonLabel: {
...typography.caption1,
color: colors.text,
fontWeight: '600',
},
skipButtonCenter: {
paddingVertical: spacing.sm,
paddingHorizontal: spacing.md,
marginBottom: 30, // Align above center of 72px buttons
},
skipText: {
...typography.caption1,
color: 'rgba(255,255,255,0.5)',
fontWeight: '600',
},
// Preview state styles
previewContent: {
flex: 1,
paddingTop: spacing.sm,
},
previewText: {
...typography.headline,
color: colors.text,
textAlign: "center",
marginBottom: spacing.md,
},
photoPreviewContainer: {
flex: 1,
marginBottom: spacing.sm,
borderRadius: radius.lg,
overflow: 'hidden',
backgroundColor: 'rgba(0,0,0,0.2)',
},
photoPreview: {
width: '100%',
height: '100%',
borderRadius: radius.lg,
},
photoPreviewBorder: {
position: 'absolute',
top: 0,
left: 0,
right: 0,
bottom: 0,
borderRadius: radius.lg,
borderWidth: 2,
borderColor: 'rgba(255,255,255,0.2)',
},
previewButtonRow: {
flexDirection: 'row',
justifyContent: 'center',
alignItems: 'center',
gap: spacing.md,
},
secondaryButton: {
alignItems: 'center',
justifyContent: 'center',
paddingVertical: spacing.sm,
paddingHorizontal: spacing.md,
gap: spacing.xs,
},
secondaryButtonText: {
...typography.caption1,
color: 'rgba(255,255,255,0.7)',
},
confirmButton: {
width: 64,
height: 64,
borderRadius: 32,
justifyContent: "center",
alignItems: "center",
overflow: "hidden",
},
});
@@ -0,0 +1,496 @@
import { useState, useEffect } from "react";
import {
View,
Text,
StyleSheet,
Platform,
TouchableOpacity,
TextInput,
Keyboard,
TouchableWithoutFeedback,
} from "react-native";
import { LinearGradient } from "expo-linear-gradient";
import Animated, {
useSharedValue,
useAnimatedStyle,
withSpring,
withRepeat,
withSequence,
withTiming,
interpolate,
Easing,
FadeInDown,
} from "react-native-reanimated";
import { Ionicons } from "@expo/vector-icons";
import { colors, gradients, radius, shadows, typography, spacing, animations } from "../../theme";
import { QuestionFeedbackModal } from "../feedback";
import { SharePreviewModal } from "../share";
const ACTION_BUTTON_SIZE = 72;
const ACTION_BUTTON_OVERHANG_PERCENT = 0.5;
const actionButtonOffset = ACTION_BUTTON_SIZE * ACTION_BUTTON_OVERHANG_PERCENT;
const actionButtonBackground = colors.muted;
// Haptics helper - not supported on web
const triggerHaptic = async (style: 'light' | 'medium' | 'heavy' = 'medium') => {
if (Platform.OS === 'web') return;
const Haptics = await import('expo-haptics');
const feedbackStyle = style === 'light'
? Haptics.ImpactFeedbackStyle.Light
: style === 'heavy'
? Haptics.ImpactFeedbackStyle.Heavy
: Haptics.ImpactFeedbackStyle.Medium;
await Haptics.impactAsync(feedbackStyle);
};
const MAX_TEXT_LENGTH = 500;
export interface QuestionCardTextProps {
question: {
id: string;
text: string;
intensity: number;
partner_text?: string | null;
partner_answered?: boolean;
};
packInfo?: { name: string; icon: string; color?: string } | null;
onAnswer: (answer: 'yes' | 'no' | 'skip', responseData?: { type: 'text_answer'; text: string }) => void;
onReport?: () => void;
}
export default function QuestionCardText({ question, packInfo, onAnswer, onReport }: QuestionCardTextProps) {
const [showFeedbackModal, setShowFeedbackModal] = useState(false);
const [showShareModal, setShowShareModal] = useState(false);
const [textValue, setTextValue] = useState('');
const [isFocused, setIsFocused] = useState(false);
const idleBreathing = useSharedValue(0);
const handleSharePress = async () => {
await triggerHaptic('light');
setShowShareModal(true);
};
// Subtle idle breathing animation
useEffect(() => {
idleBreathing.value = withRepeat(
withSequence(
withTiming(1, { duration: 2000, easing: Easing.inOut(Easing.sin) }),
withTiming(0, { duration: 2000, easing: Easing.inOut(Easing.sin) })
),
-1,
true
);
}, []);
const animatedStyle = useAnimatedStyle(() => {
const idleOffset = interpolate(idleBreathing.value, [0, 1], [0, -4]);
return {
transform: [{ translateY: idleOffset }],
};
});
const handleButtonPress = async (answer: 'yes' | 'no' | 'skip') => {
Keyboard.dismiss();
await triggerHaptic(answer === 'skip' ? 'light' : 'medium');
if (answer === 'yes' && textValue.trim().length > 0) {
onAnswer(answer, { type: 'text_answer', text: textValue.trim() });
} else {
onAnswer(answer);
}
};
const handleFeedbackPress = () => {
if (onReport) {
onReport();
} else {
setShowFeedbackModal(true);
}
};
const isYesDisabled = textValue.trim().length === 0;
return (
<>
<Animated.View
entering={FadeInDown.duration(400).springify()}
style={styles.cardWrapper}
>
<Animated.View style={[styles.cardOuter, animatedStyle, shadows.lg, { shadowColor: colors.primary }]}>
<TouchableWithoutFeedback onPress={Keyboard.dismiss} accessible={false}>
<View
style={[
styles.gradientContainer,
{ backgroundColor: packInfo?.color || colors.background }
]}
>
<CardContent
question={question}
packInfo={packInfo}
textValue={textValue}
setTextValue={setTextValue}
isFocused={isFocused}
setIsFocused={setIsFocused}
isYesDisabled={isYesDisabled}
handleButtonPress={handleButtonPress}
onFeedbackPress={handleFeedbackPress}
onSharePress={handleSharePress}
/>
</View>
</TouchableWithoutFeedback>
</Animated.View>
</Animated.View>
<QuestionFeedbackModal
visible={showFeedbackModal}
onClose={() => setShowFeedbackModal(false)}
questionId={question.id}
questionText={question.text}
/>
<SharePreviewModal
visible={showShareModal}
onClose={() => setShowShareModal(false)}
question={question}
packName={packInfo?.name}
cardColor={packInfo?.color}
/>
</>
);
}
function CardContent({
question,
packInfo,
textValue,
setTextValue,
isFocused,
setIsFocused,
isYesDisabled,
handleButtonPress,
onFeedbackPress,
onSharePress,
}: {
question: QuestionCardTextProps['question'];
packInfo?: { name: string; icon: string } | null;
textValue: string;
setTextValue: (value: string) => void;
isFocused: boolean;
setIsFocused: (focused: boolean) => void;
isYesDisabled: boolean;
handleButtonPress: (answer: 'yes' | 'no' | 'skip') => void;
onFeedbackPress: () => void;
onSharePress: () => void;
}) {
return (
<View style={styles.cardInner}>
{/* Header Row - Pack badge and action buttons */}
<View style={styles.headerRow}>
{/* Pack Badge */}
{packInfo && (
<View style={styles.packBadge}>
<Ionicons name={packInfo.icon as any} size={14} color={colors.text} />
<Text style={styles.packBadgeText} numberOfLines={1}>
{packInfo.name}
</Text>
</View>
)}
{!packInfo && <View style={styles.headerSpacer} />}
{/* Action Buttons */}
<View style={styles.headerActions}>
<TouchableOpacity
style={styles.headerButton}
onPress={onSharePress}
hitSlop={{ top: 10, bottom: 10, left: 10, right: 10 }}
activeOpacity={0.7}
>
<Ionicons name="share-outline" size={20} color="rgba(255,255,255,0.8)" />
</TouchableOpacity>
<TouchableOpacity
style={styles.headerButton}
onPress={onFeedbackPress}
hitSlop={{ top: 10, bottom: 10, left: 10, right: 10 }}
activeOpacity={0.7}
>
<Ionicons name="flag-outline" size={20} color="rgba(255,255,255,0.6)" />
</TouchableOpacity>
</View>
</View>
{/* Content */}
<View style={styles.content}>
<Text style={styles.text}>
{question.text}
</Text>
{/* Text Input Area - Glass style */}
<View style={[
styles.inputContainer,
isFocused && styles.inputContainerFocused
]}>
<TextInput
style={styles.textInput}
value={textValue}
onChangeText={(text) => setTextValue(text.slice(0, MAX_TEXT_LENGTH))}
onFocus={() => setIsFocused(true)}
onBlur={() => setIsFocused(false)}
placeholder="Type your answer..."
placeholderTextColor="rgba(255,255,255,0.4)"
multiline
textAlignVertical="top"
maxLength={MAX_TEXT_LENGTH}
/>
<View style={styles.charCountContainer}>
<Text style={[
styles.charCount,
textValue.length >= MAX_TEXT_LENGTH && styles.charCountMax
]}>
{textValue.length}/{MAX_TEXT_LENGTH}
</Text>
</View>
</View>
</View>
{/* Footer - Action Buttons */}
<View style={styles.footer}>
<View style={styles.buttonContainer}>
<ActionButton
onPress={() => handleButtonPress("no")}
icon="close"
iconColor={colors.error}
label="NO"
/>
{/* Skip button in between */}
<TouchableOpacity
style={styles.skipButtonCenter}
onPress={() => handleButtonPress("skip")}
activeOpacity={0.7}
>
<Text style={styles.skipText}>Skip</Text>
</TouchableOpacity>
<ActionButton
onPress={() => handleButtonPress("yes")}
icon="checkmark"
iconColor={colors.success}
label="YES"
disabled={isYesDisabled}
/>
</View>
</View>
</View>
);
}
function ActionButton({
onPress,
icon,
iconColor,
label,
disabled = false,
}: {
onPress: () => void;
icon: string;
iconColor: string;
label: string;
disabled?: boolean;
}) {
const buttonScale = useSharedValue(1);
const handlePressIn = () => {
if (disabled) return;
buttonScale.value = withSpring(0.92, animations.spring);
};
const handlePressOut = () => {
if (disabled) return;
buttonScale.value = withSpring(1, animations.spring);
};
const handlePress = () => {
if (disabled) return;
onPress();
};
const buttonAnimatedStyle = useAnimatedStyle(() => ({
transform: [{ scale: buttonScale.value }],
}));
return (
<TouchableOpacity
onPress={handlePress}
onPressIn={handlePressIn}
onPressOut={handlePressOut}
activeOpacity={disabled ? 1 : 1}
disabled={disabled}
style={styles.actionButtonWrapper}
>
<Animated.View style={[
styles.actionButton,
buttonAnimatedStyle,
{ backgroundColor: actionButtonBackground }
]}>
<Ionicons
name={icon as any}
size={32}
color={iconColor}
style={[styles.buttonIcon, disabled && { opacity: 0.4 }]}
/>
</Animated.View>
</TouchableOpacity>
);
}
const styles = StyleSheet.create({
cardWrapper: {
width: "90%",
maxWidth: 400,
flex: 1,
maxHeight: 580 - actionButtonOffset, // Account for button overhang
},
cardOuter: {
flex: 1,
borderRadius: radius.xxl,
overflow: "visible",
},
gradientContainer: {
flex: 1,
borderRadius: radius.xxl,
overflow: "visible",
},
cardInner: {
flex: 1,
paddingTop: spacing.md,
paddingBottom: spacing.sm,
paddingHorizontal: spacing.lg,
},
// Header row styles
headerRow: {
flexDirection: 'row',
justifyContent: 'space-between',
alignItems: 'center',
marginBottom: spacing.md,
},
headerSpacer: {
flex: 1,
},
packBadge: {
flexDirection: 'row',
alignItems: 'center',
backgroundColor: 'rgba(255,255,255,0.15)',
paddingHorizontal: spacing.md,
paddingVertical: spacing.sm,
borderRadius: radius.full,
gap: spacing.xs,
maxWidth: '60%',
},
packBadgeText: {
...typography.caption1,
fontWeight: '600',
color: colors.text,
},
headerActions: {
flexDirection: 'row',
alignItems: 'center',
gap: spacing.xs,
},
headerButton: {
width: 40,
height: 40,
borderRadius: 20,
backgroundColor: 'rgba(255,255,255,0.12)',
justifyContent: 'center',
alignItems: 'center',
},
// Content styles
content: {
flex: 1,
justifyContent: "flex-start",
alignItems: "center",
paddingTop: spacing.sm,
},
text: {
...typography.title2,
color: colors.text,
textAlign: "center",
lineHeight: 30,
marginBottom: spacing.lg,
},
// Glass-style input
inputContainer: {
width: '100%',
flex: 1,
minHeight: 100,
maxHeight: 180,
backgroundColor: 'rgba(255,255,255,0.12)',
borderRadius: radius.lg,
borderWidth: 1,
borderColor: 'rgba(255,255,255,0.15)',
padding: spacing.md,
},
inputContainerFocused: {
borderColor: 'rgba(255,255,255,0.4)',
backgroundColor: 'rgba(255,255,255,0.18)',
},
textInput: {
flex: 1,
...typography.body,
color: colors.text,
textAlignVertical: 'top',
},
charCountContainer: {
alignItems: 'flex-end',
marginTop: spacing.xs,
},
charCount: {
...typography.caption2,
color: 'rgba(255,255,255,0.5)',
},
charCountMax: {
color: colors.error,
},
// Footer styles
footer: {
paddingTop: spacing.md,
transform: [{ translateY: actionButtonOffset }],
},
buttonContainer: {
flexDirection: "row",
justifyContent: "space-between",
alignItems: "flex-end",
width: '100%',
paddingHorizontal: spacing.md,
},
actionButtonWrapper: {
alignItems: 'center',
gap: spacing.xs,
},
actionButton: {
width: 72,
height: 72,
borderRadius: 36,
justifyContent: "center",
alignItems: "center",
overflow: "hidden",
elevation: 4,
shadowColor: "#000",
shadowOffset: { width: 0, height: 4 },
shadowOpacity: 0.3,
shadowRadius: 4,
},
buttonIcon: {
zIndex: 1,
},
skipButtonCenter: {
paddingVertical: spacing.sm,
paddingHorizontal: spacing.md,
marginBottom: 30, // Align above center of 72px buttons
},
skipText: {
...typography.caption1,
color: 'rgba(255,255,255,0.5)',
fontWeight: '600',
},
});
@@ -0,0 +1,478 @@
import { useState, useEffect } from "react";
import { View, Text, StyleSheet, Platform, TouchableOpacity } from "react-native";
import { Image } from "expo-image";
import { LinearGradient } from "expo-linear-gradient";
import Animated, {
useSharedValue,
useAnimatedStyle,
withSpring,
withRepeat,
withSequence,
withTiming,
interpolate,
Easing,
FadeInDown,
} from "react-native-reanimated";
import { Ionicons } from "@expo/vector-icons";
import { colors, gradients, radius, shadows, typography, spacing, animations } from "../../theme";
import { QuestionFeedbackModal } from "../feedback";
import { SharePreviewModal } from "../share";
const AVATAR_SIZE = 72;
const AVATAR_OVERHANG_PERCENT = 0.5; // 50% of avatar hangs below the card
const actionButtonOffset = AVATAR_SIZE * AVATAR_OVERHANG_PERCENT;
// Haptics helper - not supported on web
const triggerHaptic = async (style: 'light' | 'medium' | 'heavy' = 'medium') => {
if (Platform.OS === 'web') return;
const Haptics = await import('expo-haptics');
const feedbackStyle = style === 'light'
? Haptics.ImpactFeedbackStyle.Light
: style === 'heavy'
? Haptics.ImpactFeedbackStyle.Heavy
: Haptics.ImpactFeedbackStyle.Medium;
await Haptics.impactAsync(feedbackStyle);
};
export interface QuestionCardWhoLikelyProps {
question: {
id: string;
text: string;
intensity: number;
};
packInfo?: { name: string; icon: string; color?: string } | null;
user: {
id: string;
name?: string;
avatar_url?: string | null;
};
partner: {
id: string;
name?: string;
avatar_url?: string | null;
};
onAnswer: (responseData: { type: 'who_likely'; chosen_user_id: string }) => void;
onSkip?: () => void;
onReport?: () => void;
}
// Get initials from name
function getInitials(name?: string): string {
if (!name) return "?";
const parts = name.trim().split(/\s+/);
if (parts.length === 1) {
return parts[0].charAt(0).toUpperCase();
}
return (parts[0].charAt(0) + parts[parts.length - 1].charAt(0)).toUpperCase();
}
export default function QuestionCardWhoLikely({ question, packInfo, user, partner, onAnswer, onSkip, onReport }: QuestionCardWhoLikelyProps) {
const [showFeedbackModal, setShowFeedbackModal] = useState(false);
const [showShareModal, setShowShareModal] = useState(false);
const [selectedId, setSelectedId] = useState<string | null>(null);
const idleBreathing = useSharedValue(0);
const handleSharePress = async () => {
await triggerHaptic('light');
setShowShareModal(true);
};
// Subtle idle breathing animation
useEffect(() => {
idleBreathing.value = withRepeat(
withSequence(
withTiming(1, { duration: 2000, easing: Easing.inOut(Easing.sin) }),
withTiming(0, { duration: 2000, easing: Easing.inOut(Easing.sin) })
),
-1,
true
);
}, []);
const animatedStyle = useAnimatedStyle(() => {
const idleOffset = interpolate(idleBreathing.value, [0, 1], [0, -4]);
return {
transform: [{ translateY: idleOffset }],
};
});
const handleAvatarPress = async (userId: string) => {
await triggerHaptic('medium');
setSelectedId(userId);
// Brief delay to show selection before triggering answer
setTimeout(() => {
onAnswer({ type: 'who_likely', chosen_user_id: userId });
}, 200);
};
const handleSkipPress = async () => {
await triggerHaptic('light');
onSkip?.();
};
const handleFeedbackPress = () => {
if (onReport) {
onReport();
} else {
setShowFeedbackModal(true);
}
};
return (
<>
<Animated.View
entering={FadeInDown.duration(400).springify()}
style={styles.cardWrapper}
>
<Animated.View style={[styles.cardOuter, animatedStyle, shadows.lg, { shadowColor: colors.primary }]}>
<View
style={[styles.gradientContainer, { backgroundColor: packInfo?.color || colors.background }]}
>
<CardContent
question={question}
packInfo={packInfo}
user={user}
partner={partner}
selectedId={selectedId}
onAvatarPress={handleAvatarPress}
onSkipPress={handleSkipPress}
onFeedbackPress={handleFeedbackPress}
onSharePress={handleSharePress}
/>
</View>
</Animated.View>
</Animated.View>
<QuestionFeedbackModal
visible={showFeedbackModal}
onClose={() => setShowFeedbackModal(false)}
questionId={question.id}
questionText={question.text}
/>
<SharePreviewModal
visible={showShareModal}
onClose={() => setShowShareModal(false)}
question={question}
packName={packInfo?.name}
cardColor={packInfo?.color}
/>
</>
);
}
function CardContent({
question,
packInfo,
user,
partner,
selectedId,
onAvatarPress,
onSkipPress,
onFeedbackPress,
onSharePress,
}: {
question: QuestionCardWhoLikelyProps['question'];
packInfo?: { name: string; icon: string; color?: string } | null;
user: QuestionCardWhoLikelyProps['user'];
partner: QuestionCardWhoLikelyProps['partner'];
selectedId: string | null;
onAvatarPress: (userId: string) => void;
onSkipPress: () => void;
onFeedbackPress: () => void;
onSharePress: () => void;
}) {
return (
<View style={styles.cardInner}>
{/* Header Row */}
<View style={styles.headerRow}>
{packInfo && (
<View style={styles.packBadge}>
<Ionicons name={packInfo.icon as any} size={14} color={colors.text} />
<Text style={styles.packBadgeText} numberOfLines={1}>
{packInfo.name}
</Text>
</View>
)}
{!packInfo && <View style={styles.headerSpacer} />}
<View style={styles.headerActions}>
<TouchableOpacity
style={styles.headerButton}
onPress={onSharePress}
hitSlop={{ top: 10, bottom: 10, left: 10, right: 10 }}
activeOpacity={0.7}
>
<Ionicons name="share-outline" size={20} color="rgba(255,255,255,0.8)" />
</TouchableOpacity>
<TouchableOpacity
style={styles.headerButton}
onPress={onFeedbackPress}
hitSlop={{ top: 10, bottom: 10, left: 10, right: 10 }}
activeOpacity={0.7}
>
<Ionicons name="flag-outline" size={20} color="rgba(255,255,255,0.6)" />
</TouchableOpacity>
</View>
</View>
{/* Content */}
<View style={styles.content}>
<Text style={styles.text}>
{question.text}
</Text>
</View>
{/* Footer - Avatar Buttons */}
<View style={styles.footer}>
<View style={styles.avatarContainer}>
<AvatarButton
userId={user.id}
name={user.name}
avatarUrl={user.avatar_url}
isSelected={selectedId === user.id}
onPress={onAvatarPress}
/>
<TouchableOpacity
style={styles.skipButtonCenter}
onPress={onSkipPress}
activeOpacity={0.7}
>
<Text style={styles.skipText}>Skip</Text>
</TouchableOpacity>
<AvatarButton
userId={partner.id}
name={partner.name}
avatarUrl={partner.avatar_url}
isSelected={selectedId === partner.id}
onPress={onAvatarPress}
/>
</View>
</View>
</View>
);
}
function AvatarButton({
userId,
name,
avatarUrl,
isSelected,
onPress,
}: {
userId: string;
name?: string;
avatarUrl?: string | null;
isSelected: boolean;
onPress: (userId: string) => void;
}) {
const buttonScale = useSharedValue(1);
const buttonGlow = useSharedValue(0);
// Animate selection
useEffect(() => {
if (isSelected) {
buttonScale.value = withSequence(
withSpring(1.1, animations.springBouncy),
withSpring(1.05, animations.spring)
);
buttonGlow.value = withTiming(1, { duration: 200 });
} else {
buttonScale.value = withSpring(1, animations.spring);
buttonGlow.value = withTiming(0, { duration: 200 });
}
}, [isSelected]);
const handlePressIn = () => {
if (!isSelected) {
buttonScale.value = withSpring(0.95, animations.spring);
buttonGlow.value = withTiming(0.5, { duration: 100 });
}
};
const handlePressOut = () => {
if (!isSelected) {
buttonScale.value = withSpring(1, animations.spring);
buttonGlow.value = withTiming(0, { duration: 150 });
}
};
const buttonAnimatedStyle = useAnimatedStyle(() => ({
transform: [{ scale: buttonScale.value }],
shadowOpacity: interpolate(buttonGlow.value, [0, 1], [0.2, 0.6]),
shadowRadius: interpolate(buttonGlow.value, [0, 1], [8, 20]),
}));
const borderAnimatedStyle = useAnimatedStyle(() => ({
borderWidth: interpolate(buttonGlow.value, [0, 1], [3, 4]),
borderColor: colors.text, // Solid white border
}));
return (
<TouchableOpacity
onPress={() => onPress(userId)}
onPressIn={handlePressIn}
onPressOut={handlePressOut}
activeOpacity={1}
style={styles.avatarButtonWrapper}
>
<Animated.View style={[
styles.avatarButton,
buttonAnimatedStyle,
{ shadowColor: colors.text }
]}>
<Animated.View style={[styles.avatarBorder, borderAnimatedStyle]}>
{avatarUrl ? (
<Image
source={{ uri: avatarUrl }}
style={styles.avatarImage}
contentFit="cover"
cachePolicy="memory-disk"
transition={200}
/>
) : (
<View style={styles.avatarFallback}>
<Text style={styles.avatarInitials}>
{getInitials(name)}
</Text>
</View>
)}
</Animated.View>
</Animated.View>
</TouchableOpacity>
);
}
const styles = StyleSheet.create({
cardWrapper: {
width: "90%",
maxWidth: 400,
flex: 1,
maxHeight: 580 - actionButtonOffset, // Account for avatar overhang
},
cardOuter: {
flex: 1,
borderRadius: radius.xxl,
overflow: "visible",
},
gradientContainer: {
flex: 1,
borderRadius: radius.xxl,
overflow: "visible",
},
cardInner: {
flex: 1,
paddingTop: spacing.md,
paddingBottom: spacing.sm,
paddingHorizontal: spacing.lg,
},
// Header row styles
headerRow: {
flexDirection: 'row',
justifyContent: 'space-between',
alignItems: 'center',
marginBottom: spacing.md,
},
headerSpacer: {
flex: 1,
},
packBadge: {
flexDirection: 'row',
alignItems: 'center',
backgroundColor: 'rgba(255,255,255,0.15)',
paddingHorizontal: spacing.md,
paddingVertical: spacing.sm,
borderRadius: radius.full,
gap: spacing.xs,
maxWidth: '60%',
},
packBadgeText: {
...typography.caption1,
fontWeight: '600',
color: colors.text,
},
headerActions: {
flexDirection: 'row',
alignItems: 'center',
gap: spacing.xs,
},
headerButton: {
width: 40,
height: 40,
borderRadius: 20,
backgroundColor: 'rgba(255,255,255,0.12)',
justifyContent: 'center',
alignItems: 'center',
},
// Content styles
content: {
flex: 1,
justifyContent: "center",
alignItems: "center",
paddingHorizontal: spacing.sm,
},
text: {
...typography.title1,
color: colors.text,
textAlign: "center",
lineHeight: 36,
},
// Footer styles
footer: {
paddingTop: spacing.md,
transform: [{ translateY: actionButtonOffset }],
},
avatarContainer: {
flexDirection: 'row',
justifyContent: 'center',
alignItems: 'flex-end',
gap: spacing.lg,
},
avatarButtonWrapper: {
alignItems: 'center',
},
avatarButton: {
width: AVATAR_SIZE,
height: AVATAR_SIZE,
borderRadius: AVATAR_SIZE / 2,
justifyContent: 'center',
alignItems: 'center',
shadowOffset: { width: 0, height: 4 },
},
avatarBorder: {
width: AVATAR_SIZE,
height: AVATAR_SIZE,
borderRadius: AVATAR_SIZE / 2,
borderWidth: 3,
borderColor: colors.text,
},
avatarImage: {
width: AVATAR_SIZE - 6, // Account for border width (3px each side)
height: AVATAR_SIZE - 6,
borderRadius: (AVATAR_SIZE - 6) / 2,
},
avatarFallback: {
width: AVATAR_SIZE - 6,
height: AVATAR_SIZE - 6,
borderRadius: (AVATAR_SIZE - 6) / 2,
justifyContent: 'center',
alignItems: 'center',
backgroundColor: colors.primary,
},
avatarInitials: {
...typography.title1,
color: colors.text,
fontWeight: 'bold',
},
skipButtonCenter: {
paddingVertical: spacing.sm,
paddingHorizontal: spacing.md,
marginBottom: 30, // Align above center of 72px avatars
},
skipText: {
...typography.caption1,
color: 'rgba(255,255,255,0.5)',
fontWeight: '600',
},
});
@@ -0,0 +1,10 @@
export { default as QuestionCard } from './QuestionCard';
export type { QuestionCardProps } from './QuestionCard';
export { default as QuestionCardText } from './QuestionCardText';
export type { QuestionCardTextProps } from './QuestionCardText';
export { default as QuestionCardWhoLikely } from './QuestionCardWhoLikely';
export type { QuestionCardWhoLikelyProps } from './QuestionCardWhoLikely';
export { default as QuestionCardPhoto } from './QuestionCardPhoto';
export type { QuestionCardPhotoProps } from './QuestionCardPhoto';
export { default as QuestionCardAudio } from './QuestionCardAudio';
export type { QuestionCardAudioProps } from './QuestionCardAudio';
@@ -1,29 +1,33 @@
import { useState, useEffect } from "react";
import {
View,
Text,
StyleSheet,
Modal,
TouchableOpacity,
TouchableWithoutFeedback,
ActivityIndicator,
import { useState, useEffect, type ReactNode } from "react";
import {
View,
Text,
TextInput,
StyleSheet,
Modal,
TouchableOpacity,
TouchableWithoutFeedback,
ActivityIndicator,
Platform,
} from "react-native";
import { Ionicons } from "@expo/vector-icons";
import { LinearGradient } from "expo-linear-gradient";
import { BlurView } from "expo-blur";
import Animated, { FadeIn, FadeOut, SlideInDown, SlideOutDown } from "react-native-reanimated";
import type { ResponseWithQuestion, UpdateResponseResult } from "../../store";
import { useResponsesStore } from "../../store";
import type { AnswerType } from "@/types";
import { colors, spacing, typography, radius } from "../../theme";
import { DeleteMatchConfirmModal } from "./DeleteMatchConfirmModal";
import Animated, { FadeIn, FadeOut, SlideInDown, SlideOutDown } from "react-native-reanimated";
import type { ResponseWithQuestion } from "../../store";
import { useAuthStore, useResponsesStore } from "../../store";
import { useMediaPicker } from "../../hooks/useMediaPicker";
import { useResponseMediaUpload } from "../../hooks/useResponseMediaUpload";
import { useAudioRecorder } from "../../hooks/useAudioRecorder";
import type { AnswerType, ResponseData } from "@/types";
import { colors, spacing, typography, radius } from "../../theme";
import { DeleteMatchConfirmModal } from "./DeleteMatchConfirmModal";
// Premium color palette
const ACCENT = colors.premium.gold;
const ACCENT_RGBA = "rgba(212, 175, 55, ";
const ACCENT = colors.premium.gold;
const ACCENT_RGBA = "rgba(212, 175, 55, ";
const MAX_TEXT_LENGTH = 500;
// Answer button config
// Answer button config (includes 'maybe' for displaying existing responses)
const ANSWER_CONFIG = {
yes: {
color: colors.success,
@@ -31,20 +35,23 @@ const ANSWER_CONFIG = {
icon: "checkmark-circle" as const,
label: "Yes",
},
maybe: {
color: colors.warning,
rgba: "rgba(243, 156, 18, ",
icon: "help-circle" as const,
label: "Maybe",
},
no: {
color: colors.error,
rgba: "rgba(231, 76, 60, ",
icon: "close-circle" as const,
label: "No",
},
maybe: {
color: colors.warning,
rgba: "rgba(243, 156, 18, ",
icon: "help-circle" as const,
label: "Maybe",
},
};
// Only these answer types can be selected by users
type SelectableAnswerType = "yes" | "no";
interface EditResponseSheetProps {
visible: boolean;
response: ResponseWithQuestion | null;
@@ -52,33 +59,73 @@ interface EditResponseSheetProps {
onSuccess: () => void;
}
export function EditResponseSheet({ visible, response, onClose, onSuccess }: EditResponseSheetProps) {
const { updateResponse } = useResponsesStore();
const [isLoading, setIsLoading] = useState(false);
const [pendingAnswer, setPendingAnswer] = useState<AnswerType | null>(null);
const [confirmModalData, setConfirmModalData] = useState<{
matchId: string;
messageCount: number;
} | null>(null);
export function EditResponseSheet({ visible, response, onClose, onSuccess }: EditResponseSheetProps) {
const { updateResponse } = useResponsesStore();
const { user, partner } = useAuthStore();
const [isLoading, setIsLoading] = useState(false);
const [pendingAnswer, setPendingAnswer] = useState<AnswerType | null>(null);
const [confirmModalData, setConfirmModalData] = useState<{
matchId: string;
messageCount: number;
} | null>(null);
const [textValue, setTextValue] = useState("");
const [selectedWhoLikely, setSelectedWhoLikely] = useState<string | null>(null);
const questionId = response?.question_id ?? "";
const maxDurationSeconds = response?.question.config?.max_duration_seconds ?? 60;
const { takePhoto, pickMedia } = useMediaPicker({ imageQuality: 0.8 });
const { uploading, uploadPhoto, uploadAudio } = useResponseMediaUpload({
userId: user?.id ?? "",
questionId,
});
const {
state: audioState,
durationSeconds,
recordingUri,
startRecording,
stopRecording,
resetRecording,
} = useAudioRecorder({ maxDurationSeconds });
const isBusy = isLoading || uploading;
// Reset state when modal closes
useEffect(() => {
if (!visible) {
setIsLoading(false);
setPendingAnswer(null);
setConfirmModalData(null);
}
}, [visible]);
useEffect(() => {
if (!visible) {
setIsLoading(false);
setPendingAnswer(null);
setConfirmModalData(null);
setTextValue("");
setSelectedWhoLikely(null);
resetRecording();
}
}, [visible, resetRecording]);
useEffect(() => {
if (!visible || !response) return;
if (response.response_data?.type === "text_answer") {
setTextValue(response.response_data.text);
} else {
setTextValue("");
}
if (response.response_data?.type === "who_likely") {
setSelectedWhoLikely(response.response_data.chosen_user_id);
} else {
setSelectedWhoLikely(null);
}
}, [visible, response]);
if (!response) return null;
if (!response) return null;
const handleAnswerPress = async (newAnswer: AnswerType) => {
if (newAnswer === response.answer || isLoading) return;
setIsLoading(true);
setPendingAnswer(newAnswer);
const result = await updateResponse(response.question_id, newAnswer, false);
const handleAnswerPress = async (newAnswer: AnswerType, responseData?: ResponseData | null) => {
if (isBusy) return;
if (newAnswer === response.answer && typeof responseData === "undefined") return;
setIsLoading(true);
setPendingAnswer(newAnswer);
const result = await updateResponse(response.question_id, newAnswer, false, responseData);
if (result.requires_confirmation && result.match_id) {
// Show confirmation modal
@@ -120,32 +167,101 @@ export function EditResponseSheet({ visible, response, onClose, onSuccess }: Edi
}
};
const handleCancelDelete = () => {
setConfirmModalData(null);
setPendingAnswer(null);
};
const AnswerButton = ({ answer }: { answer: AnswerType }) => {
const config = ANSWER_CONFIG[answer];
const isSelected = response.answer === answer;
const isPending = pendingAnswer === answer;
return (
<TouchableOpacity
style={[
styles.answerButton,
isSelected && {
backgroundColor: `${config.rgba}0.2)`,
borderColor: `${config.rgba}0.4)`,
},
]}
onPress={() => handleAnswerPress(answer)}
disabled={isLoading}
activeOpacity={0.7}
>
{isPending && isLoading ? (
<ActivityIndicator color={config.color} size="small" />
) : (
const handleCancelDelete = () => {
setConfirmModalData(null);
setPendingAnswer(null);
};
const questionType = response.question.question_type ?? "swipe";
const isTextSaveDisabled = textValue.trim().length === 0 || isBusy;
const isNoSelected = response.answer === "no";
const userLabel = user?.name || "You";
const partnerLabel = partner?.name || "Partner";
const canUpload = !!user?.id && !!questionId;
const handleTextSave = async () => {
const trimmed = textValue.trim();
if (!trimmed) return;
await handleAnswerPress("yes", { type: "text_answer", text: trimmed });
};
const handleSetNo = async () => {
await handleAnswerPress("no", null);
};
const handleWhoLikelySelect = async (userId: string) => {
if (isBusy) return;
setSelectedWhoLikely(userId);
await handleAnswerPress("yes", { type: "who_likely", chosen_user_id: userId });
};
const handlePhotoPick = async (source: "camera" | "library") => {
if (isBusy || !canUpload) return;
const result = source === "camera" ? await takePhoto() : await pickMedia();
if (!result || result.mediaType !== "image") return;
const uploadResult = await uploadPhoto(result.uri);
if (!uploadResult.success || !uploadResult.mediaPath) {
console.error("Failed to upload photo response:", uploadResult.error);
return;
}
await handleAnswerPress("yes", { type: "photo", media_path: uploadResult.mediaPath });
};
const handleStartRecording = async () => {
if (isBusy) return;
await startRecording();
};
const handleStopRecording = async () => {
if (isBusy) return;
await stopRecording();
};
const handleAudioSave = async () => {
if (!recordingUri || isBusy || !canUpload) return;
const uploadResult = await uploadAudio(recordingUri, durationSeconds);
if (!uploadResult.success || !uploadResult.mediaPath) {
console.error("Failed to upload audio response:", uploadResult.error);
return;
}
await handleAnswerPress("yes", {
type: "audio",
media_path: uploadResult.mediaPath,
duration_seconds: durationSeconds,
});
resetRecording();
};
const handleDiscardRecording = () => {
if (isBusy) return;
resetRecording();
};
const AnswerButton = ({ answer }: { answer: SelectableAnswerType }) => {
const config = ANSWER_CONFIG[answer];
const isSelected = response.answer === answer;
const isPending = pendingAnswer === answer;
return (
<TouchableOpacity
style={[
styles.answerButton,
isSelected && {
backgroundColor: `${config.rgba}0.2)`,
borderColor: `${config.rgba}0.4)`,
},
]}
onPress={() => handleAnswerPress(answer)}
disabled={isBusy}
activeOpacity={0.7}
>
{isPending && isLoading ? (
<ActivityIndicator color={config.color} size="small" />
) : (
<Ionicons
name={config.icon}
size={28}
@@ -165,15 +281,252 @@ export function EditResponseSheet({ visible, response, onClose, onSuccess }: Edi
<Ionicons name="checkmark" size={14} color={config.color} />
</View>
)}
</TouchableOpacity>
);
};
return (
<Modal
visible={visible}
transparent
animationType="none"
</TouchableOpacity>
);
};
const ActionButton = ({
label,
icon,
color = ACCENT,
onPress,
disabled = false,
loading = false,
}: {
label: string;
icon: keyof typeof Ionicons.glyphMap;
color?: string;
onPress: () => void;
disabled?: boolean;
loading?: boolean;
}) => {
const isDisabled = disabled || loading;
const iconColor = isDisabled ? colors.textTertiary : color;
return (
<TouchableOpacity
style={[styles.actionButton, isDisabled && styles.actionButtonDisabled]}
onPress={onPress}
disabled={isDisabled}
activeOpacity={0.7}
>
{loading ? (
<ActivityIndicator color={iconColor} size="small" />
) : (
<Ionicons name={icon} size={20} color={iconColor} />
)}
<Text style={[styles.actionButtonText, { color: iconColor }]}>
{label}
</Text>
</TouchableOpacity>
);
};
const ChoiceButton = ({
label,
selected,
onPress,
disabled = false,
}: {
label: string;
selected: boolean;
onPress: () => void;
disabled?: boolean;
}) => (
<TouchableOpacity
style={[styles.choiceButton, selected && styles.choiceButtonSelected]}
onPress={onPress}
disabled={disabled}
activeOpacity={0.7}
>
<Text style={[styles.choiceButtonText, selected && styles.choiceButtonTextSelected]}>
{label}
</Text>
{selected && <Ionicons name="checkmark" size={16} color={ACCENT} />}
</TouchableOpacity>
);
const isSavingYes = isLoading && pendingAnswer === "yes";
const isSavingNo = isLoading && pendingAnswer === "no";
const actionContent = (() => {
if (questionType === "text_answer") {
return (
<View style={styles.editorContainer}>
<View style={styles.textInputContainer}>
<TextInput
style={styles.textInput}
value={textValue}
onChangeText={(value) => setTextValue(value.slice(0, MAX_TEXT_LENGTH))}
placeholder="Update your answer..."
placeholderTextColor={colors.textTertiary}
multiline
editable={!isBusy}
textAlignVertical="top"
maxLength={MAX_TEXT_LENGTH}
/>
<View style={styles.charCountContainer}>
<Text style={styles.charCount}>
{textValue.length}/{MAX_TEXT_LENGTH}
</Text>
</View>
</View>
<View style={styles.actionRow}>
<ActionButton
label="Set No"
icon="close"
color={ANSWER_CONFIG.no.color}
onPress={handleSetNo}
disabled={isBusy || isNoSelected}
loading={isSavingNo}
/>
<ActionButton
label="Save"
icon="checkmark"
color={ANSWER_CONFIG.yes.color}
onPress={handleTextSave}
disabled={isTextSaveDisabled}
loading={isSavingYes}
/>
</View>
</View>
);
}
if (questionType === "who_likely") {
return (
<View style={styles.editorContainer}>
<View style={styles.choiceRow}>
<ChoiceButton
label={userLabel}
selected={selectedWhoLikely === user?.id}
onPress={() => {
if (user?.id) {
handleWhoLikelySelect(user.id);
}
}}
disabled={isBusy || !user?.id}
/>
<ChoiceButton
label={partnerLabel}
selected={selectedWhoLikely === partner?.id}
onPress={() => {
if (partner?.id) {
handleWhoLikelySelect(partner.id);
}
}}
disabled={isBusy || !partner?.id}
/>
</View>
<View style={styles.actionRow}>
<ActionButton
label="Set No"
icon="close"
color={ANSWER_CONFIG.no.color}
onPress={handleSetNo}
disabled={isBusy || isNoSelected}
loading={isSavingNo}
/>
</View>
</View>
);
}
if (questionType === "photo") {
return (
<View style={styles.editorContainer}>
<View style={styles.actionRow}>
<ActionButton
label="Camera"
icon="camera"
onPress={() => handlePhotoPick("camera")}
disabled={isBusy || !canUpload}
loading={uploading}
/>
<ActionButton
label="Library"
icon="images"
onPress={() => handlePhotoPick("library")}
disabled={isBusy || !canUpload}
loading={uploading}
/>
</View>
<View style={styles.actionRow}>
<ActionButton
label="Set No"
icon="close"
color={ANSWER_CONFIG.no.color}
onPress={handleSetNo}
disabled={isBusy || isNoSelected}
loading={isSavingNo}
/>
</View>
</View>
);
}
if (questionType === "audio") {
const statusText = audioState === "recording"
? `Recording... ${durationSeconds}s`
: recordingUri
? `Recorded ${durationSeconds}s`
: "Tap record to start";
return (
<View style={styles.editorContainer}>
<Text style={styles.audioStatusText}>{statusText}</Text>
<View style={styles.actionRow}>
<ActionButton
label={audioState === "recording" ? "Stop" : recordingUri ? "Re-record" : "Record"}
icon={audioState === "recording" ? "square" : "mic"}
onPress={audioState === "recording" ? handleStopRecording : handleStartRecording}
disabled={isBusy}
/>
<ActionButton
label="Save"
icon="checkmark"
color={ANSWER_CONFIG.yes.color}
onPress={handleAudioSave}
disabled={!recordingUri || audioState === "recording" || isBusy || !canUpload}
loading={uploading || isSavingYes}
/>
</View>
{recordingUri && audioState !== "recording" && (
<TouchableOpacity
style={styles.linkButton}
onPress={handleDiscardRecording}
disabled={isBusy}
>
<Text style={styles.linkButtonText}>Discard recording</Text>
</TouchableOpacity>
)}
<View style={styles.actionRow}>
<ActionButton
label="Set No"
icon="close"
color={ANSWER_CONFIG.no.color}
onPress={handleSetNo}
disabled={isBusy || isNoSelected}
loading={isSavingNo}
/>
</View>
</View>
);
}
return (
<View style={styles.answerButtons}>
<AnswerButton answer="yes" />
<AnswerButton answer="no" />
</View>
);
})();
return (
<Modal
visible={visible}
transparent
animationType="none"
onRequestClose={onClose}
statusBarTranslucent
>
@@ -187,24 +540,24 @@ export function EditResponseSheet({ visible, response, onClose, onSuccess }: Edi
entering={SlideInDown.springify().damping(20)}
exiting={SlideOutDown.duration(200)}
>
{Platform.OS === "ios" ? (
<BlurView intensity={80} tint="dark" style={styles.sheet}>
<SheetContent
response={response}
AnswerButton={AnswerButton}
onClose={onClose}
/>
</BlurView>
) : (
<View style={[styles.sheet, styles.sheetAndroid]}>
<SheetContent
response={response}
AnswerButton={AnswerButton}
onClose={onClose}
/>
</View>
)}
</Animated.View>
{Platform.OS === "ios" ? (
<BlurView intensity={80} tint="dark" style={styles.sheet}>
<SheetContent
response={response}
actions={actionContent}
onClose={onClose}
/>
</BlurView>
) : (
<View style={[styles.sheet, styles.sheetAndroid]}>
<SheetContent
response={response}
actions={actionContent}
onClose={onClose}
/>
</View>
)}
</Animated.View>
</Animated.View>
{/* Confirmation Modal */}
@@ -219,15 +572,15 @@ export function EditResponseSheet({ visible, response, onClose, onSuccess }: Edi
}
// Sheet content extracted for reuse between iOS blur and Android solid background
function SheetContent({
response,
AnswerButton,
onClose,
}: {
response: ResponseWithQuestion;
AnswerButton: React.FC<{ answer: AnswerType }>;
onClose: () => void;
}) {
function SheetContent({
response,
actions,
onClose,
}: {
response: ResponseWithQuestion;
actions: ReactNode;
onClose: () => void;
}) {
return (
<>
{/* Handle */}
@@ -261,16 +614,12 @@ function SheetContent({
</View>
)}
{/* Answer buttons */}
<View style={styles.answerButtons}>
<AnswerButton answer="yes" />
<AnswerButton answer="maybe" />
<AnswerButton answer="no" />
</View>
{/* Bottom padding for safe area */}
<View style={styles.bottomPadding} />
</>
{/* Action content */}
<View style={styles.actionsContainer}>{actions}</View>
{/* Bottom padding for safe area */}
<View style={styles.bottomPadding} />
</>
);
}
@@ -354,19 +703,45 @@ const styles = StyleSheet.create({
marginBottom: spacing.lg,
gap: spacing.sm,
},
warningText: {
...typography.footnote,
color: colors.warning,
flex: 1,
},
answerButtons: {
flexDirection: "row",
gap: spacing.md,
marginBottom: spacing.lg,
},
answerButton: {
flex: 1,
alignItems: "center",
warningText: {
...typography.footnote,
color: colors.warning,
flex: 1,
},
actionsContainer: {
marginBottom: spacing.lg,
},
editorContainer: {
gap: spacing.md,
},
textInputContainer: {
backgroundColor: "rgba(255, 255, 255, 0.08)",
borderRadius: radius.lg,
borderWidth: 1,
borderColor: "rgba(255, 255, 255, 0.12)",
padding: spacing.md,
minHeight: 120,
},
textInput: {
...typography.body,
color: colors.text,
minHeight: 80,
},
charCountContainer: {
alignItems: "flex-end",
marginTop: spacing.xs,
},
charCount: {
...typography.caption2,
color: colors.textTertiary,
},
answerButtons: {
flexDirection: "row",
gap: spacing.md,
},
answerButton: {
flex: 1,
alignItems: "center",
justifyContent: "center",
paddingVertical: spacing.lg,
borderRadius: radius.lg,
@@ -380,18 +755,82 @@ const styles = StyleSheet.create({
color: colors.textSecondary,
fontWeight: "600",
},
selectedIndicator: {
position: "absolute",
top: spacing.sm,
right: spacing.sm,
width: 20,
selectedIndicator: {
position: "absolute",
top: spacing.sm,
right: spacing.sm,
width: 20,
height: 20,
borderRadius: 10,
backgroundColor: "rgba(255, 255, 255, 0.1)",
justifyContent: "center",
alignItems: "center",
},
bottomPadding: {
height: Platform.OS === "ios" ? 34 : 20,
},
});
justifyContent: "center",
alignItems: "center",
},
actionRow: {
flexDirection: "row",
gap: spacing.md,
alignItems: "center",
},
actionButton: {
flex: 1,
alignItems: "center",
justifyContent: "center",
paddingVertical: spacing.md,
borderRadius: radius.md,
backgroundColor: "rgba(255, 255, 255, 0.05)",
borderWidth: 1,
borderColor: "rgba(255, 255, 255, 0.1)",
gap: spacing.xs,
},
actionButtonDisabled: {
opacity: 0.6,
},
actionButtonText: {
...typography.caption1,
fontWeight: "600",
},
choiceRow: {
flexDirection: "row",
gap: spacing.md,
},
choiceButton: {
flex: 1,
alignItems: "center",
justifyContent: "center",
paddingVertical: spacing.md,
borderRadius: radius.md,
backgroundColor: "rgba(255, 255, 255, 0.06)",
borderWidth: 1,
borderColor: "rgba(255, 255, 255, 0.12)",
flexDirection: "row",
gap: spacing.xs,
},
choiceButtonSelected: {
borderColor: `${ACCENT_RGBA}0.6)`,
backgroundColor: `${ACCENT_RGBA}0.12)`,
},
choiceButtonText: {
...typography.callout,
color: colors.textSecondary,
fontWeight: "600",
},
choiceButtonTextSelected: {
color: ACCENT,
},
audioStatusText: {
...typography.caption1,
color: colors.textSecondary,
textAlign: "center",
},
linkButton: {
alignSelf: "center",
},
linkButtonText: {
...typography.caption1,
color: colors.textTertiary,
textDecorationLine: "underline",
},
bottomPadding: {
height: Platform.OS === "ios" ? 34 : 20,
},
});
@@ -6,17 +6,15 @@ import type { ResponseWithQuestion } from "../../store";
import { colors, spacing, typography, radius } from "../../theme";
// Premium color palette
const ACCENT = colors.premium.gold;
const ACCENT_RGBA = "rgba(212, 175, 55, ";
const ROSE = colors.premium.rose;
const ROSE_RGBA = "rgba(232, 164, 174, ";
const ACCENT = colors.premium.gold;
const ACCENT_RGBA = "rgba(212, 175, 55, ";
// Answer colors (semantic)
const ANSWER_COLORS = {
yes: {
color: colors.success,
rgba: "rgba(46, 204, 113, ",
label: "YES",
const ANSWER_COLORS = {
yes: {
color: colors.success,
rgba: "rgba(46, 204, 113, ",
label: "YES",
},
no: {
color: colors.error,
@@ -27,18 +25,100 @@ const ANSWER_COLORS = {
color: colors.warning,
rgba: "rgba(243, 156, 18, ",
label: "MAYBE",
},
};
interface ResponseCardProps {
response: ResponseWithQuestion;
index: number;
onEditPress: () => void;
onChatPress?: () => void;
}
export function ResponseCard({ response, index, onEditPress, onChatPress }: ResponseCardProps) {
const answerStyle = ANSWER_COLORS[response.answer];
},
};
const RESPONSE_TYPE_CONFIG = {
text_answer: {
icon: "chatbubble-ellipses-outline" as const,
label: "TEXT",
},
photo: {
icon: "image-outline" as const,
label: "PHOTO",
},
audio: {
icon: "mic-outline" as const,
label: "AUDIO",
},
who_likely: {
icon: "people-outline" as const,
label: "WHO",
},
};
interface ResponseCardProps {
response: ResponseWithQuestion;
index: number;
onEditPress: () => void;
onChatPress?: () => void;
viewerId?: string;
viewerName?: string | null;
partnerName?: string | null;
}
function truncateText(value: string, maxLength: number) {
if (value.length <= maxLength) return value;
return `${value.slice(0, maxLength - 3)}...`;
}
function getResponseSummary(
response: ResponseWithQuestion,
viewerId?: string,
viewerName?: string | null,
partnerName?: string | null
) {
const data = response.response_data;
if (!data) return null;
if (data.type === "text_answer") {
return `"${truncateText(data.text, 90)}"`;
}
if (data.type === "photo") {
return "Photo response saved";
}
if (data.type === "audio") {
const duration = data.duration_seconds ? `${data.duration_seconds}s` : "";
return duration ? `Audio response (${duration})` : "Audio response saved";
}
if (data.type === "who_likely") {
const chosenName = data.chosen_user_id === viewerId
? viewerName || "you"
: partnerName || "your partner";
return `You chose ${chosenName}`;
}
return null;
}
export function ResponseCard({
response,
index,
onEditPress,
onChatPress,
viewerId,
viewerName,
partnerName,
}: ResponseCardProps) {
const answerStyle = ANSWER_COLORS[response.answer];
const questionType = response.question.question_type ?? "swipe";
const responseSummary = getResponseSummary(response, viewerId, viewerName, partnerName);
const typeKey = response.response_data?.type || (questionType !== "swipe" ? questionType : null);
const typeConfig = typeKey ? RESPONSE_TYPE_CONFIG[typeKey as keyof typeof RESPONSE_TYPE_CONFIG] : null;
const showTypeBadge = questionType !== "swipe" && response.answer !== "no" && !!typeConfig;
const badgeColor = showTypeBadge ? ACCENT : answerStyle.color;
const badgeRgba = showTypeBadge ? ACCENT_RGBA : answerStyle.rgba;
const badgeIcon = showTypeBadge && typeConfig
? typeConfig.icon
: response.answer === "yes"
? "checkmark"
: response.answer === "no"
? "close"
: "help";
const tagLabel = showTypeBadge && typeConfig ? typeConfig.label : answerStyle.label;
return (
<Animated.View entering={FadeInRight.delay(index * 30).duration(250)}>
@@ -50,56 +130,55 @@ export function ResponseCard({ response, index, onEditPress, onChatPress }: Resp
start={{ x: 0, y: 0 }}
end={{ x: 1, y: 1 }}
/>
{/* Top silk highlight */}
<LinearGradient
colors={[`${answerStyle.rgba}0.06)`, "transparent"]}
style={styles.cardSilkHighlight}
start={{ x: 0.5, y: 0 }}
end={{ x: 0.5, y: 1 }}
/>
{/* Top silk highlight */}
<LinearGradient
colors={[`${badgeRgba}0.06)`, "transparent"]}
style={styles.cardSilkHighlight}
start={{ x: 0.5, y: 0 }}
end={{ x: 0.5, y: 1 }}
/>
<View style={styles.row}>
{/* Answer badge */}
<View
style={[
styles.answerBadge,
{ backgroundColor: `${answerStyle.rgba}0.15)`, borderColor: `${answerStyle.rgba}0.3)` },
]}
>
<Ionicons
name={
response.answer === "yes"
? "checkmark"
: response.answer === "no"
? "close"
: "help"
}
size={18}
color={answerStyle.color}
/>
</View>
<View
style={[
styles.answerBadge,
{ backgroundColor: `${badgeRgba}0.15)`, borderColor: `${badgeRgba}0.3)` },
]}
>
<Ionicons
name={badgeIcon}
size={18}
color={badgeColor}
/>
</View>
{/* Content */}
<View style={styles.content}>
<Text style={styles.questionText} numberOfLines={2}>
{response.question.text}
</Text>
<View style={styles.metaRow}>
{/* Answer tag */}
<View
style={[
styles.tag,
{
backgroundColor: `${answerStyle.rgba}0.1)`,
borderColor: `${answerStyle.rgba}0.2)`,
},
]}
>
<Text style={[styles.tagText, { color: answerStyle.color }]}>
{answerStyle.label}
</Text>
</View>
<View style={styles.content}>
<Text style={styles.questionText} numberOfLines={2}>
{response.question.text}
</Text>
{responseSummary && (
<Text style={styles.responseSummary} numberOfLines={2}>
{responseSummary}
</Text>
)}
<View style={styles.metaRow}>
{/* Answer tag */}
<View
style={[
styles.tag,
{
backgroundColor: `${badgeRgba}0.1)`,
borderColor: `${badgeRgba}0.2)`,
},
]}
>
<Text style={[styles.tagText, { color: badgeColor }]}>
{tagLabel}
</Text>
</View>
{/* Pack name */}
<View style={styles.packBadge}>
@@ -175,13 +254,18 @@ const styles = StyleSheet.create({
content: {
flex: 1,
},
questionText: {
...typography.subhead,
color: colors.text,
fontWeight: "500",
marginBottom: spacing.xs,
lineHeight: 20,
},
questionText: {
...typography.subhead,
color: colors.text,
fontWeight: "500",
marginBottom: spacing.xs,
lineHeight: 20,
},
responseSummary: {
...typography.caption1,
color: colors.textSecondary,
marginBottom: spacing.xs,
},
metaRow: {
flexDirection: "row",
alignItems: "center",
@@ -0,0 +1,534 @@
import { useRef, useEffect, useState } from "react";
import {
View,
Text,
StyleSheet,
Modal,
TouchableOpacity,
Platform,
Linking,
Share,
useWindowDimensions,
Image,
} from "react-native";
import { BlurView } from "expo-blur";
import { LinearGradient } from "expo-linear-gradient";
import Animated, {
FadeIn,
FadeOut,
SlideInDown,
SlideOutDown,
} from "react-native-reanimated";
import { Ionicons } from "@expo/vector-icons";
import { useSafeAreaInsets } from "react-native-safe-area-context";
import ViewShot from "react-native-view-shot";
import * as Sharing from "expo-sharing";
import * as FileSystem from "expo-file-system";
import { colors, gradients, radius, typography, spacing, blur } from "../../theme";
const logo = require("../../../assets/logo.png");
const MAX_CARD_WIDTH = 340;
interface SharePreviewModalProps {
visible: boolean;
onClose: () => void;
question: {
text: string;
partner_text?: string | null;
is_two_part?: boolean;
};
packName?: string;
cardColor?: string;
}
export function SharePreviewModal({
visible,
onClose,
question,
packName,
cardColor,
}: SharePreviewModalProps) {
const insets = useSafeAreaInsets();
const { width: screenWidth } = useWindowDimensions();
const cardWidth = Math.min(screenWidth - 64, MAX_CARD_WIDTH);
const shareViewRef = useRef<ViewShot>(null);
const [capturedUri, setCapturedUri] = useState<string | null>(null);
const [isCapturing, setIsCapturing] = useState(false);
// Capture the card when modal becomes visible
useEffect(() => {
if (visible && shareViewRef.current) {
setIsCapturing(true);
// Small delay to ensure the view is rendered
const timer = setTimeout(async () => {
try {
const uri = await shareViewRef.current?.capture?.();
if (uri) {
setCapturedUri(uri);
}
} catch (error) {
console.error('Error capturing share image:', error);
} finally {
setIsCapturing(false);
}
}, 100);
return () => clearTimeout(timer);
}
}, [visible]);
// Reset state when modal closes
useEffect(() => {
if (!visible) {
setCapturedUri(null);
}
}, [visible]);
const handleInstagramShare = async () => {
if (!capturedUri) return;
try {
// Check if Instagram is installed
const instagramUrl = 'instagram://app';
const canOpenInstagram = await Linking.canOpenURL(instagramUrl);
if (canOpenInstagram && Platform.OS === 'ios') {
// For iOS, we can use the Instagram Stories URL scheme
// First, we need to copy the image to a location Instagram can access
const base64 = await FileSystem.readAsStringAsync(capturedUri, {
encoding: FileSystem.EncodingType.Base64,
});
// Use Instagram Stories URL scheme
const instagramStoriesUrl = `instagram-stories://share?source_application=com.sauci.app`;
// Try to open Instagram Stories with the image
// Note: This requires the image to be in the pasteboard on iOS
await Linking.openURL(instagramStoriesUrl);
} else {
// Fallback to regular sharing
await handleMoreShare();
}
} catch (error) {
console.error('Instagram share error:', error);
// Fallback to regular sharing
await handleMoreShare();
}
};
const handleMessagesShare = async () => {
if (!capturedUri) {
// Fallback to text
await Share.share({
message: `${question.text}\n\nDiscover more on sauci.app`,
});
return;
}
try {
// On iOS, we can share directly to Messages
if (Platform.OS === 'ios') {
await Sharing.shareAsync(capturedUri, {
mimeType: 'image/png',
UTI: 'public.png',
});
} else {
await handleMoreShare();
}
} catch (error) {
console.error('Messages share error:', error);
await Share.share({
message: `${question.text}\n\nDiscover more on sauci.app`,
});
}
};
const handleMoreShare = async () => {
if (!capturedUri) {
await Share.share({
message: `${question.text}\n\nDiscover more on sauci.app`,
});
return;
}
try {
const isAvailable = await Sharing.isAvailableAsync();
if (isAvailable) {
await Sharing.shareAsync(capturedUri, {
mimeType: 'image/png',
dialogTitle: 'Share this question',
});
} else {
await Share.share({
message: `${question.text}\n\nDiscover more on sauci.app`,
});
}
} catch (error) {
console.error('Share error:', error);
}
};
const useBlur = Platform.OS === 'ios';
return (
<Modal
visible={visible}
transparent
animationType="none"
statusBarTranslucent
onRequestClose={onClose}
>
<View style={styles.overlay}>
{/* Backdrop */}
<Animated.View
entering={FadeIn.duration(200)}
exiting={FadeOut.duration(200)}
style={StyleSheet.absoluteFill}
>
{useBlur ? (
<BlurView
intensity={blur.heavy}
tint="dark"
style={StyleSheet.absoluteFill}
/>
) : (
<View style={styles.backdropFallback} />
)}
</Animated.View>
{/* Content */}
<Animated.View
entering={SlideInDown.springify().damping(20)}
exiting={SlideOutDown.duration(200)}
style={[styles.content, { paddingTop: insets.top }]}
>
{/* Header */}
<View style={styles.header}>
<TouchableOpacity
style={styles.closeButton}
onPress={onClose}
hitSlop={{ top: 20, bottom: 20, left: 20, right: 20 }}
>
<Ionicons name="close" size={28} color={colors.text} />
</TouchableOpacity>
<Text style={styles.title}>Share</Text>
<View style={styles.headerSpacer} />
</View>
{/* Card Preview */}
<View style={styles.previewContainer}>
<ViewShot
ref={shareViewRef}
options={{
format: 'png',
quality: 1,
result: 'tmpfile',
}}
>
<ShareableCard
question={question}
packName={packName}
cardWidth={cardWidth}
cardColor={cardColor}
/>
</ViewShot>
</View>
{/* Share Actions */}
<View style={[styles.actionsContainer, { paddingBottom: insets.bottom + spacing.lg }]}>
<ShareActionButton
icon="logo-instagram"
label="Instagram"
colors={['#833AB4', '#FD1D1D', '#F77737']}
onPress={handleInstagramShare}
disabled={isCapturing}
/>
<ShareActionButton
icon="chatbubble-ellipses"
label="Messages"
colors={['#34C759', '#30D158']}
onPress={handleMessagesShare}
disabled={isCapturing}
/>
<ShareActionButton
icon="share-outline"
label="More"
colors={[colors.glass.backgroundLight, colors.glass.backgroundLight]}
onPress={handleMoreShare}
disabled={isCapturing}
isOutline
/>
</View>
</Animated.View>
</View>
</Modal>
);
}
function ShareActionButton({
icon,
label,
colors: buttonColors,
onPress,
disabled,
isOutline,
}: {
icon: string;
label: string;
colors: string[];
onPress: () => void;
disabled?: boolean;
isOutline?: boolean;
}) {
return (
<TouchableOpacity
style={styles.actionButton}
onPress={onPress}
disabled={disabled}
activeOpacity={0.7}
>
<View style={[
styles.actionIconContainer,
isOutline && styles.actionIconOutline,
]}>
{!isOutline && (
<LinearGradient
colors={buttonColors as [string, string, ...string[]]}
style={StyleSheet.absoluteFill}
start={{ x: 0, y: 0 }}
end={{ x: 1, y: 1 }}
/>
)}
<Ionicons
name={icon as any}
size={28}
color={colors.text}
/>
</View>
<Text style={styles.actionLabel}>{label}</Text>
</TouchableOpacity>
);
}
function ShareableCard({
question,
packName,
cardWidth,
cardColor,
}: {
question: {
text: string;
partner_text?: string | null;
is_two_part?: boolean;
};
packName?: string;
cardWidth: number;
cardColor?: string;
}) {
// Use the card color if provided, otherwise fall back to primary gradient
const useGradient = !cardColor;
return (
<View style={[styles.shareableCard, { width: cardWidth }]}>
{/* Background - solid color matching what the user saw, or gradient fallback */}
{useGradient ? (
<LinearGradient
colors={gradients.primary as [string, string]}
style={[StyleSheet.absoluteFill, { borderRadius: radius.xl }]}
start={{ x: 0, y: 0 }}
end={{ x: 1, y: 1 }}
/>
) : (
<View
style={[
StyleSheet.absoluteFill,
{ backgroundColor: cardColor, borderRadius: radius.xl }
]}
/>
)}
{/* Subtle overlay for depth */}
<View style={styles.cardOverlay} />
{/* Pack name badge */}
{packName && (
<View style={styles.packBadge}>
<Text style={styles.packBadgeText}>{packName}</Text>
</View>
)}
{/* Content */}
<View style={styles.shareableContent}>
<Text style={[styles.questionText, question.is_two_part && styles.questionTextSmaller]}>
{question.text}
</Text>
{/* Partner's version of the question */}
{question.partner_text && (
<View style={styles.partnerSection}>
<View style={styles.partnerDivider}>
<View style={styles.partnerDividerLine} />
<Text style={styles.partnerLabel}>Partner sees</Text>
<View style={styles.partnerDividerLine} />
</View>
<Text style={styles.partnerText}>{question.partner_text}</Text>
</View>
)}
</View>
{/* Branding with logo */}
<View style={styles.branding}>
<Image source={logo} style={styles.brandingLogo} />
<Text style={styles.brandingText}>sauci.app</Text>
</View>
</View>
);
}
const styles = StyleSheet.create({
overlay: {
flex: 1,
backgroundColor: 'rgba(0, 0, 0, 0.5)',
},
backdropFallback: {
flex: 1,
backgroundColor: 'rgba(13, 13, 26, 0.95)',
},
content: {
flex: 1,
},
header: {
flexDirection: 'row',
alignItems: 'center',
justifyContent: 'space-between',
paddingHorizontal: spacing.lg,
paddingVertical: spacing.md,
},
closeButton: {
width: 44,
height: 44,
justifyContent: 'center',
alignItems: 'center',
},
title: {
...typography.headline,
color: colors.text,
fontWeight: '600',
},
headerSpacer: {
width: 44,
},
previewContainer: {
flex: 1,
justifyContent: 'center',
alignItems: 'center',
paddingHorizontal: spacing.lg,
},
actionsContainer: {
flexDirection: 'row',
justifyContent: 'center',
gap: spacing.xl,
paddingTop: spacing.xl,
paddingHorizontal: spacing.lg,
},
actionButton: {
alignItems: 'center',
gap: spacing.sm,
},
actionIconContainer: {
width: 64,
height: 64,
borderRadius: 32,
justifyContent: 'center',
alignItems: 'center',
overflow: 'hidden',
},
actionIconOutline: {
backgroundColor: colors.glass.backgroundLight,
borderWidth: 1,
borderColor: colors.glass.border,
},
actionLabel: {
...typography.caption1,
color: colors.text,
fontWeight: '500',
},
// Shareable card styles
shareableCard: {
aspectRatio: 0.7,
borderRadius: radius.xl,
overflow: 'hidden',
padding: spacing.lg,
},
cardOverlay: {
...StyleSheet.absoluteFillObject,
backgroundColor: 'rgba(0, 0, 0, 0.1)',
},
packBadge: {
alignSelf: 'flex-start',
backgroundColor: 'rgba(255, 255, 255, 0.2)',
paddingHorizontal: spacing.md,
paddingVertical: spacing.xs,
borderRadius: radius.full,
},
packBadgeText: {
...typography.caption1,
color: colors.text,
fontWeight: '600',
},
shareableContent: {
flex: 1,
justifyContent: 'center',
paddingVertical: spacing.xl,
},
questionText: {
...typography.title1,
color: colors.text,
fontWeight: '700',
lineHeight: 36,
},
questionTextSmaller: {
...typography.title2,
lineHeight: 30,
},
partnerSection: {
marginTop: spacing.lg,
},
partnerDivider: {
flexDirection: 'row',
alignItems: 'center',
marginBottom: spacing.sm,
gap: spacing.sm,
},
partnerDividerLine: {
flex: 1,
height: 1,
backgroundColor: 'rgba(255, 255, 255, 0.3)',
},
partnerLabel: {
...typography.caption1,
color: 'rgba(255, 255, 255, 0.7)',
fontWeight: '500',
},
partnerText: {
...typography.subhead,
color: 'rgba(255, 255, 255, 0.8)',
fontStyle: 'italic',
},
branding: {
flexDirection: 'row',
alignItems: 'center',
justifyContent: 'center',
gap: spacing.sm,
},
brandingLogo: {
width: 24,
height: 24,
resizeMode: 'contain',
},
brandingText: {
...typography.subhead,
color: colors.text,
fontWeight: '600',
},
});
@@ -0,0 +1 @@
export { SharePreviewModal } from './SharePreviewModal';
@@ -1,678 +0,0 @@
import { useState, useEffect } from "react";
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";
import Animated, {
useSharedValue,
useAnimatedStyle,
withSpring,
withRepeat,
withSequence,
runOnJS,
interpolate,
Extrapolate,
withTiming,
interpolateColor,
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 "../feedback";
// Haptics helper - not supported on web
const triggerHaptic = async (style: 'light' | 'medium' | 'heavy' = 'medium') => {
if (Platform.OS === 'web') return;
const Haptics = await import('expo-haptics');
const feedbackStyle = style === 'light'
? Haptics.ImpactFeedbackStyle.Light
: style === 'heavy'
? Haptics.ImpactFeedbackStyle.Heavy
: Haptics.ImpactFeedbackStyle.Medium;
await Haptics.impactAsync(feedbackStyle);
};
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; partner_answered?: boolean };
onSwipe: (direction: "left" | "right" | "up" | "down") => void;
}
const AnimatedBlurView = Animated.createAnimatedComponent(BlurView);
export default function SwipeCard({ question, onSwipe }: Props) {
const { width: screenWidth } = useWindowDimensions();
const cardWidth = Math.min(screenWidth - 48, MAX_CARD_WIDTH);
const [showFeedbackModal, setShowFeedbackModal] = useState(false);
const translateX = useSharedValue(0);
const translateY = useSharedValue(0);
const rotation = useSharedValue(0);
const scale = useSharedValue(1);
const shadowExpand = useSharedValue(0);
const idleBreathing = useSharedValue(0);
const isGesturing = useSharedValue(false);
const context = useSharedValue({ x: 0, y: 0 });
// Subtle idle breathing animation
useEffect(() => {
idleBreathing.value = withRepeat(
withSequence(
withTiming(1, { duration: 2000, easing: Easing.inOut(Easing.sin) }),
withTiming(0, { duration: 2000, easing: Easing.inOut(Easing.sin) })
),
-1,
true
);
}, []);
const gesture = Gesture.Pan()
.activeOffsetX([-15, 15])
.activeOffsetY([-15, 15])
.onStart(() => {
context.value = { x: translateX.value, y: translateY.value };
scale.value = withTiming(1.02, { duration: animations.timing.fast });
shadowExpand.value = withTiming(1, { duration: animations.timing.fast });
isGesturing.value = true;
})
.onUpdate((event) => {
translateX.value = event.translationX + context.value.x;
translateY.value = event.translationY + context.value.y;
rotation.value = interpolate(
translateX.value,
[-cardWidth / 2, cardWidth / 2],
[-12, 12],
Extrapolate.CLAMP
);
})
.onEnd(() => {
scale.value = withTiming(1, { duration: animations.timing.fast });
shadowExpand.value = withTiming(0, { duration: animations.timing.fast });
isGesturing.value = false;
if (Math.abs(translateX.value) > SWIPE_THRESHOLD) {
const direction = translateX.value > 0 ? "right" : "left";
runOnJS(triggerHaptic)('medium');
translateX.value = withSpring(
direction === "right" ? screenWidth * 1.5 : -screenWidth * 1.5,
animations.springGentle
);
runOnJS(onSwipe)(direction);
} else if (translateY.value < -SWIPE_THRESHOLD) {
runOnJS(triggerHaptic)('medium');
translateY.value = withSpring(-screenWidth * 1.5, animations.springGentle);
runOnJS(onSwipe)("up");
} else if (translateY.value > SWIPE_THRESHOLD) {
runOnJS(triggerHaptic)('light');
translateY.value = withSpring(screenWidth * 1.5, animations.springGentle);
runOnJS(onSwipe)("down");
} else {
translateX.value = withSpring(0, animations.spring);
translateY.value = withSpring(0, animations.spring);
rotation.value = withSpring(0, animations.spring);
}
});
const animatedStyle = useAnimatedStyle(() => {
// Apply idle breathing only when card is at rest
const idleOffset = isGesturing.value ? 0 : interpolate(idleBreathing.value, [0, 1], [0, -6]);
return {
transform: [
{ translateX: translateX.value },
{ translateY: translateY.value + idleOffset },
{ rotate: `${rotation.value}deg` },
{ scale: scale.value },
],
};
});
// Dynamic shadow for card lift effect
const cardShadowStyle = useAnimatedStyle(() => ({
shadowOpacity: interpolate(shadowExpand.value, [0, 1], [0.3, 0.5]),
shadowRadius: interpolate(shadowExpand.value, [0, 1], [24, 36]),
shadowOffset: {
width: 0,
height: interpolate(shadowExpand.value, [0, 1], [12, 18]),
},
}));
const overlayStyle = (direction: "left" | "right" | "up" | "down") =>
useAnimatedStyle(() => {
let opacity = 0;
let backgroundColor = 'transparent';
if (direction === "right") {
opacity = interpolate(translateX.value, [0, SWIPE_THRESHOLD], [0, 0.9]);
backgroundColor = interpolateColor(
opacity,
[0, 0.9],
['transparent', 'rgba(46, 204, 113, 0.4)']
) as string;
} else if (direction === "left") {
opacity = interpolate(translateX.value, [0, -SWIPE_THRESHOLD], [0, 0.9]);
backgroundColor = interpolateColor(
opacity,
[0, 0.9],
['transparent', 'rgba(231, 76, 60, 0.4)']
) as string;
} else if (direction === "up") {
opacity = interpolate(translateY.value, [0, -SWIPE_THRESHOLD], [0, 0.9]);
backgroundColor = interpolateColor(
opacity,
[0, 0.9],
['transparent', 'rgba(243, 156, 18, 0.4)']
) as string;
} else {
opacity = interpolate(translateY.value, [0, SWIPE_THRESHOLD], [0, 0.9]);
backgroundColor = interpolateColor(
opacity,
[0, 0.9],
['transparent', 'rgba(108, 117, 125, 0.4)']
) as string;
}
return {
opacity,
backgroundColor,
};
});
const handleButtonPress = async (direction: "left" | "right" | "up") => {
await triggerHaptic('light');
onSwipe(direction);
};
const useBlur = Platform.OS === 'ios';
const handleFeedbackPress = () => {
setShowFeedbackModal(true);
};
return (
<>
<View style={[styles.cardWrapper, { width: cardWidth }]}>
<GestureDetector gesture={gesture}>
<Animated.View style={[styles.cardOuter, animatedStyle, cardShadowStyle, { shadowColor: '#000' }]}>
{useBlur ? (
<BlurView
intensity={blur.medium}
tint="dark"
style={styles.blurContainer}
>
<CardContent
question={question}
overlayStyle={overlayStyle}
handleButtonPress={handleButtonPress}
onFeedbackPress={handleFeedbackPress}
translateX={translateX}
translateY={translateY}
/>
</BlurView>
) : (
<View style={styles.fallbackContainer}>
<CardContent
question={question}
overlayStyle={overlayStyle}
handleButtonPress={handleButtonPress}
onFeedbackPress={handleFeedbackPress}
translateX={translateX}
translateY={translateY}
/>
</View>
)}
{/* Inner border highlight for depth */}
<View style={styles.innerBorder} pointerEvents="none" />
</Animated.View>
</GestureDetector>
</View>
<QuestionFeedbackModal
visible={showFeedbackModal}
onClose={() => setShowFeedbackModal(false)}
questionId={question.id}
questionText={question.text}
/>
</>
);
}
function CardContent({
question,
overlayStyle,
handleButtonPress,
onFeedbackPress,
translateX,
translateY,
}: {
question: Props['question'];
overlayStyle: (direction: "left" | "right" | "up" | "down") => any;
handleButtonPress: (direction: "left" | "right" | "up") => void;
onFeedbackPress: () => void;
translateX: Animated.SharedValue<number>;
translateY: Animated.SharedValue<number>;
}) {
const isHighIntensity = question.intensity >= 3;
// Badge scale animation based on swipe progress
const badgeScaleStyle = (direction: "left" | "right" | "up" | "down") =>
useAnimatedStyle(() => {
let progress = 0;
if (direction === "right") {
progress = interpolate(translateX.value, [0, SWIPE_THRESHOLD], [0, 1], Extrapolate.CLAMP);
} else if (direction === "left") {
progress = interpolate(translateX.value, [-SWIPE_THRESHOLD, 0], [1, 0], Extrapolate.CLAMP);
} else if (direction === "up") {
progress = interpolate(translateY.value, [-SWIPE_THRESHOLD, 0], [1, 0], Extrapolate.CLAMP);
} else {
progress = interpolate(translateY.value, [0, SWIPE_THRESHOLD], [0, 1], Extrapolate.CLAMP);
}
return {
transform: [{ scale: interpolate(progress, [0, 0.5, 1], [0.8, 1, 1.1]) }],
};
});
return (
<>
{/* Top highlight */}
<LinearGradient
colors={[colors.glass.highlight, 'transparent']}
style={styles.highlight}
start={{ x: 0.5, y: 0 }}
end={{ x: 0.5, y: 1 }}
pointerEvents="none"
/>
{/* Left edge highlight */}
<LinearGradient
colors={['rgba(255,255,255,0.08)', 'transparent']}
style={styles.edgeHighlightLeft}
start={{ x: 0, y: 0.5 }}
end={{ x: 1, y: 0.5 }}
pointerEvents="none"
/>
{/* Right edge highlight */}
<LinearGradient
colors={['transparent', 'rgba(255,255,255,0.08)']}
style={styles.edgeHighlightRight}
start={{ x: 0, y: 0.5 }}
end={{ x: 1, y: 0.5 }}
pointerEvents="none"
/>
{/* Feedback Button */}
<RNTouchableOpacity
style={styles.feedbackButton}
onPress={onFeedbackPress}
hitSlop={{ top: 20, bottom: 20, left: 20, right: 20 }}
activeOpacity={0.7}
>
<Ionicons
name="flag-outline"
size={22}
color={colors.textTertiary}
/>
</RNTouchableOpacity>
{/* Overlays with gradient badges */}
<Animated.View style={[styles.overlay, overlayStyle("right")]} pointerEvents="none">
<Animated.View style={[styles.overlayBadge, badgeScaleStyle("right")]}>
<LinearGradient
colors={gradients.success as [string, string]}
style={StyleSheet.absoluteFill}
start={{ x: 0, y: 0 }}
end={{ x: 1, y: 1 }}
/>
<Text style={styles.overlayText}>YES</Text>
</Animated.View>
</Animated.View>
<Animated.View style={[styles.overlay, overlayStyle("left")]} pointerEvents="none">
<Animated.View style={[styles.overlayBadge, badgeScaleStyle("left")]}>
<LinearGradient
colors={gradients.error as [string, string]}
style={StyleSheet.absoluteFill}
start={{ x: 0, y: 0 }}
end={{ x: 1, y: 1 }}
/>
<Text style={styles.overlayText}>NO</Text>
</Animated.View>
</Animated.View>
<Animated.View style={[styles.overlay, overlayStyle("up")]} pointerEvents="none">
<Animated.View style={[styles.overlayBadge, badgeScaleStyle("up")]}>
<LinearGradient
colors={gradients.warning as [string, string]}
style={StyleSheet.absoluteFill}
start={{ x: 0, y: 0 }}
end={{ x: 1, y: 1 }}
/>
<Text style={styles.overlayText}>MAYBE</Text>
</Animated.View>
</Animated.View>
<Animated.View style={[styles.overlay, overlayStyle("down")]} pointerEvents="none">
<Animated.View style={[styles.overlayBadge, badgeScaleStyle("down")]}>
<View style={[StyleSheet.absoluteFill, { backgroundColor: colors.textTertiary }]} />
<Text style={styles.overlayText}>SKIP</Text>
</Animated.View>
</Animated.View>
{/* Content */}
<View style={styles.content}>
<View style={[
styles.intensityContainer,
isHighIntensity && styles.intensityContainerHigh
]}>
{[...Array(question.intensity)].map((_, i) => (
<Ionicons key={i} name="flame" size={16} color={colors.primary} />
))}
</View>
<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 */}
<View style={styles.footer}>
<View style={styles.buttonContainer}>
<ActionButton
onPress={() => handleButtonPress("left")}
colors={gradients.error as [string, string]}
icon="close"
/>
<ActionButton
onPress={() => handleButtonPress("up")}
colors={gradients.warning as [string, string]}
icon="help"
small
/>
<ActionButton
onPress={() => handleButtonPress("right")}
colors={gradients.success as [string, string]}
icon="heart"
/>
</View>
</View>
</>
);
}
function ActionButton({
onPress,
colors: buttonColors,
icon,
small = false,
}: {
onPress: () => void;
colors: [string, string];
icon: string;
small?: boolean;
}) {
const buttonScale = useSharedValue(1);
const buttonGlow = useSharedValue(0);
const handlePressIn = () => {
buttonScale.value = withSpring(0.92, animations.spring);
buttonGlow.value = withTiming(1, { duration: 150 });
};
const handlePressOut = () => {
buttonScale.value = withSpring(1, animations.spring);
buttonGlow.value = withTiming(0, { duration: 200 });
};
const buttonAnimatedStyle = useAnimatedStyle(() => ({
transform: [{ scale: buttonScale.value }],
shadowOpacity: interpolate(buttonGlow.value, [0, 1], [0.3, 0.6]),
shadowRadius: interpolate(buttonGlow.value, [0, 1], [8, 16]),
}));
return (
<TouchableOpacity
onPress={onPress}
onPressIn={handlePressIn}
onPressOut={handlePressOut}
activeOpacity={1}
>
<Animated.View style={[
styles.actionButton,
small && styles.actionButtonSmall,
buttonAnimatedStyle,
{ shadowColor: buttonColors[0] }
]}>
<LinearGradient
colors={buttonColors}
style={StyleSheet.absoluteFill}
start={{ x: 0, y: 0 }}
end={{ x: 1, y: 1 }}
/>
<LinearGradient
colors={['rgba(255,255,255,0.25)', 'rgba(255,255,255,0.05)']}
style={styles.buttonHighlight}
start={{ x: 0.5, y: 0 }}
end={{ x: 0.5, y: 1 }}
/>
<Ionicons
name={icon as any}
size={small ? 20 : 28}
color={colors.text}
style={styles.buttonIcon}
/>
</Animated.View>
</TouchableOpacity>
);
}
const styles = StyleSheet.create({
cardWrapper: {
position: "absolute",
maxWidth: MAX_CARD_WIDTH,
height: 480,
},
cardOuter: {
width: "100%",
height: "100%",
borderRadius: radius.xxl,
overflow: "hidden",
},
blurContainer: {
flex: 1,
backgroundColor: colors.glass.background,
borderWidth: 1,
borderColor: colors.glass.border,
borderRadius: radius.xxl,
overflow: "hidden",
},
fallbackContainer: {
flex: 1,
backgroundColor: colors.glass.backgroundLight,
borderWidth: 1,
borderColor: colors.glass.border,
borderRadius: radius.xxl,
overflow: "hidden",
},
highlight: {
position: 'absolute',
top: 0,
left: 0,
right: 0,
height: 80,
},
edgeHighlightLeft: {
position: 'absolute',
top: 0,
left: 0,
bottom: 0,
width: 60,
},
edgeHighlightRight: {
position: 'absolute',
top: 0,
right: 0,
bottom: 0,
width: 60,
},
innerBorder: {
position: 'absolute',
top: 2,
left: 2,
right: 2,
bottom: 2,
borderRadius: radius.xxl - 2,
borderWidth: 1,
borderColor: 'rgba(255, 255, 255, 0.06)',
},
feedbackButton: {
position: 'absolute',
top: spacing.md,
right: spacing.md,
width: 44,
height: 44,
borderRadius: 22,
backgroundColor: colors.glass.background,
justifyContent: 'center',
alignItems: 'center',
zIndex: 999,
elevation: 999,
borderWidth: 1,
borderColor: colors.glass.border,
},
content: {
flex: 1,
justifyContent: "center",
alignItems: "center",
padding: spacing.xl,
},
intensityContainer: {
flexDirection: "row",
marginBottom: spacing.lg,
backgroundColor: colors.primaryLight,
paddingHorizontal: spacing.md,
paddingVertical: spacing.sm,
borderRadius: radius.full,
gap: 2,
},
intensityContainerHigh: {
borderWidth: 1,
borderColor: colors.primary,
...shadows.glow(colors.primaryGlow),
},
text: {
...typography.title2,
color: colors.text,
textAlign: "center",
lineHeight: 32,
},
twoPartText: {
...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,
},
overlay: {
...StyleSheet.absoluteFillObject,
justifyContent: "center",
alignItems: "center",
zIndex: 10,
borderRadius: radius.xxl,
},
overlayBadge: {
paddingHorizontal: spacing.lg,
paddingVertical: spacing.sm,
borderRadius: radius.md,
transform: [{ rotate: "-15deg" }],
overflow: 'hidden',
},
overlayText: {
fontSize: 36,
fontWeight: "900",
color: colors.text,
letterSpacing: 2,
},
buttonContainer: {
flexDirection: "row",
justifyContent: "center",
alignItems: "center",
gap: spacing.md,
},
actionButton: {
width: 64,
height: 64,
borderRadius: 32,
justifyContent: "center",
alignItems: "center",
overflow: "hidden",
},
actionButtonSmall: {
width: 52,
height: 52,
borderRadius: 26,
},
buttonHighlight: {
position: "absolute",
top: 0,
left: 0,
right: 0,
height: "50%",
borderTopLeftRadius: 32,
borderTopRightRadius: 32,
},
buttonIcon: {
zIndex: 1,
},
});
@@ -1,17 +0,0 @@
/**
* SwipeCard component with platform-specific implementations.
*
* - On native (iOS/Android): Uses react-native-gesture-handler with Reanimated
* for smooth, native-level gesture performance (SwipeCard.native.tsx)
*
* - On web: Uses React Native's built-in PanResponder which is compatible with
* react-native-web (SwipeCard.web.tsx)
*
* Metro bundler automatically picks the right file based on the platform:
* - .native.tsx for iOS/Android
* - .web.tsx for web
*
* This file is a fallback that re-exports the web version for any other platform
* that might not have a specific implementation.
*/
export { default } from "./SwipeCard.web";
@@ -1,478 +0,0 @@
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 "../feedback";
const MAX_CARD_WIDTH = 400;
const SWIPE_THRESHOLD = 100;
interface Props {
question: { id: string; text: string; intensity: number; partner_text?: string | null; partner_answered?: boolean };
onSwipe: (direction: "left" | "right" | "up" | "down") => void;
}
/**
* Web-compatible SwipeCard using React Native's built-in PanResponder
* instead of react-native-gesture-handler which doesn't work on web.
*/
export default function SwipeCard({ question, onSwipe }: Props) {
const { width: screenWidth } = useWindowDimensions();
const cardWidth = Math.min(screenWidth - 48, MAX_CARD_WIDTH);
const [dismissed, setDismissed] = useState(false);
const [hoveredButton, setHoveredButton] = useState<string | null>(null);
const [showFeedbackModal, setShowFeedbackModal] = useState(false);
const translateX = useRef(new Animated.Value(0)).current;
const translateY = useRef(new Animated.Value(0)).current;
const scale = useRef(new Animated.Value(1)).current;
const rotation = translateX.interpolate({
inputRange: [-cardWidth / 2, cardWidth / 2],
outputRange: ["-15deg", "15deg"],
extrapolate: "clamp",
});
const rightOverlayOpacity = translateX.interpolate({
inputRange: [0, SWIPE_THRESHOLD],
outputRange: [0, 1],
extrapolate: "clamp",
});
const leftOverlayOpacity = translateX.interpolate({
inputRange: [-SWIPE_THRESHOLD, 0],
outputRange: [1, 0],
extrapolate: "clamp",
});
const upOverlayOpacity = translateY.interpolate({
inputRange: [-SWIPE_THRESHOLD, 0],
outputRange: [1, 0],
extrapolate: "clamp",
});
const downOverlayOpacity = translateY.interpolate({
inputRange: [0, SWIPE_THRESHOLD],
outputRange: [0, 1],
extrapolate: "clamp",
});
const panResponder = useRef(
PanResponder.create({
onStartShouldSetPanResponder: (_, gestureState) => {
// Don't activate if touch is in footer area (bottom 100px)
const CARD_HEIGHT = 500;
const FOOTER_START_Y = CARD_HEIGHT - 100;
return gestureState.y0 < FOOTER_START_Y;
},
onMoveShouldSetPanResponder: (_, gestureState) => {
// Don't activate if touch is in footer area
const CARD_HEIGHT = 500;
const FOOTER_START_Y = CARD_HEIGHT - 100;
return gestureState.y0 < FOOTER_START_Y && (Math.abs(gestureState.dx) > 5 || Math.abs(gestureState.dy) > 5);
},
onPanResponderGrant: () => {
Animated.timing(scale, {
toValue: 1.05,
duration: 100,
useNativeDriver: true,
}).start();
},
onPanResponderMove: (_, gestureState) => {
translateX.setValue(gestureState.dx);
translateY.setValue(gestureState.dy);
},
onPanResponderRelease: (_, gestureState) => {
Animated.timing(scale, {
toValue: 1,
duration: 100,
useNativeDriver: true,
}).start();
if (Math.abs(gestureState.dx) > SWIPE_THRESHOLD) {
// Horizontal swipe
const direction = gestureState.dx > 0 ? "right" : "left";
const targetX = direction === "right" ? screenWidth * 1.5 : -screenWidth * 1.5;
Animated.spring(translateX, {
toValue: targetX,
useNativeDriver: true,
}).start(() => {
setDismissed(true);
onSwipe(direction);
});
} else if (gestureState.dy < -SWIPE_THRESHOLD) {
// Vertical swipe up
Animated.spring(translateY, {
toValue: -screenWidth * 1.5,
useNativeDriver: true,
}).start(() => {
setDismissed(true);
onSwipe("up");
});
} else if (gestureState.dy > SWIPE_THRESHOLD) {
// Vertical swipe down - skip
Animated.spring(translateY, {
toValue: screenWidth * 1.5,
useNativeDriver: true,
}).start(() => {
setDismissed(true);
onSwipe("down");
});
} else {
// Reset position
Animated.parallel([
Animated.spring(translateX, {
toValue: 0,
useNativeDriver: true,
}),
Animated.spring(translateY, {
toValue: 0,
useNativeDriver: true,
}),
]).start();
}
},
})
).current;
if (dismissed) {
return null;
}
return (
<>
<Animated.View
style={[
styles.card,
webStyles.card as any,
{
width: cardWidth,
transform: [
{ translateX },
{ translateY },
{ rotate: rotation },
{ scale },
],
},
]}
{...panResponder.panHandlers}
>
{/* Feedback Button */}
<TouchableOpacity
style={styles.feedbackButton}
onPress={() => setShowFeedbackModal(true)}
activeOpacity={0.7}
>
<Ionicons name="flag-outline" size={18} color="#888" />
</TouchableOpacity>
{/* Overlays */}
<Animated.View
style={[
styles.overlay,
{ opacity: rightOverlayOpacity, backgroundColor: "rgba(76, 175, 80, 0.4)" },
]}
>
<Text style={styles.overlayText}>YES</Text>
</Animated.View>
<Animated.View
style={[
styles.overlay,
{ opacity: leftOverlayOpacity, backgroundColor: "rgba(244, 67, 54, 0.4)" },
]}
>
<Text style={styles.overlayText}>NO</Text>
</Animated.View>
<Animated.View
style={[
styles.overlay,
{ opacity: upOverlayOpacity, backgroundColor: "rgba(255, 152, 0, 0.4)" },
]}
>
<Text style={styles.overlayText}>MAYBE</Text>
</Animated.View>
<Animated.View
style={[
styles.overlay,
{ opacity: downOverlayOpacity, backgroundColor: "rgba(108, 117, 125, 0.4)" },
]}
>
<Text style={styles.overlayText}>SKIP</Text>
</Animated.View>
<View style={styles.content}>
<View style={styles.intensityContainer}>
{[...Array(question.intensity)].map((_, i) => (
<Ionicons key={i} name="flame" size={16} color="#e94560" />
))}
</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}>
<View style={styles.buttonContainer}>
<TouchableOpacity
style={[
styles.actionButton,
styles.noButton,
hoveredButton === "no" && styles.buttonHovered,
]}
onPress={() => onSwipe("left")}
// @ts-expect-error - web-only props
onMouseEnter={() => setHoveredButton("no")}
onMouseLeave={() => setHoveredButton(null)}
activeOpacity={0.7}
>
<View style={styles.buttonInnerGlow} />
<Ionicons name="thumbs-down" size={24} color="#fff" style={styles.buttonIcon} />
</TouchableOpacity>
<TouchableOpacity
style={[
styles.actionButton,
styles.maybeButton,
hoveredButton === "maybe" && styles.buttonHovered,
]}
onPress={() => onSwipe("up")}
// @ts-expect-error - web-only props
onMouseEnter={() => setHoveredButton("maybe")}
onMouseLeave={() => setHoveredButton(null)}
activeOpacity={0.7}
>
<View style={styles.buttonInnerGlow} />
<Ionicons name="help-circle" size={24} color="#fff" style={styles.buttonIcon} />
</TouchableOpacity>
<TouchableOpacity
style={[
styles.actionButton,
styles.yesButton,
hoveredButton === "yes" && styles.buttonHovered,
]}
onPress={() => onSwipe("right")}
// @ts-expect-error - web-only props
onMouseEnter={() => setHoveredButton("yes")}
onMouseLeave={() => setHoveredButton(null)}
activeOpacity={0.7}
>
<View style={styles.buttonInnerGlow} />
<Ionicons name="thumbs-up" size={24} color="#fff" style={styles.buttonIcon} />
</TouchableOpacity>
</View>
</View>
</Animated.View>
<QuestionFeedbackModal
visible={showFeedbackModal}
onClose={() => setShowFeedbackModal(false)}
questionId={question.id}
questionText={question.text}
/>
</>
);
}
// Separate web-only styles that TypeScript doesn't recognize
const webStyles = {
card: { cursor: "grab" as const },
actionButton: { transition: "transform 0.2s ease, box-shadow 0.2s ease" },
};
const styles = StyleSheet.create({
card: {
position: "absolute",
height: 500,
maxWidth: MAX_CARD_WIDTH,
backgroundColor: "#16213e",
borderRadius: 24,
borderWidth: 1,
borderColor: "#0f3460",
shadowColor: "#000",
shadowOffset: {
width: 0,
height: 4,
},
shadowOpacity: 0.3,
shadowRadius: 4.65,
elevation: 8,
overflow: "hidden",
},
feedbackButton: {
position: 'absolute',
top: 12,
right: 12,
width: 32,
height: 32,
borderRadius: 16,
backgroundColor: 'rgba(255, 255, 255, 0.1)',
justifyContent: 'center',
alignItems: 'center',
zIndex: 5,
borderWidth: 1,
borderColor: 'rgba(255, 255, 255, 0.2)',
},
content: {
flex: 1,
justifyContent: "center",
alignItems: "center",
padding: 32,
},
intensityContainer: {
flexDirection: "row",
marginBottom: 24,
backgroundColor: "rgba(233, 69, 96, 0.1)",
paddingHorizontal: 12,
paddingVertical: 6,
borderRadius: 12,
},
text: {
fontSize: 24,
fontWeight: "bold",
color: "#fff",
textAlign: "center",
lineHeight: 32,
},
twoPartText: {
fontSize: 20,
lineHeight: 28,
},
footer: {
paddingTop: 8,
paddingBottom: 16,
paddingHorizontal: 8,
alignItems: "center",
backgroundColor: "rgba(0,0,0,0.2)",
},
hint: {
color: "#666",
fontSize: 12,
marginBottom: 4,
},
overlay: {
...StyleSheet.absoluteFillObject,
justifyContent: "center",
alignItems: "center",
zIndex: 10,
},
overlayText: {
fontSize: 48,
fontWeight: "900",
color: "#fff",
textShadowColor: "rgba(0,0,0,0.5)",
textShadowOffset: { width: 0, height: 2 },
textShadowRadius: 4,
transform: [{ rotate: "-15deg" }],
borderWidth: 4,
borderColor: "#fff",
paddingHorizontal: 16,
paddingVertical: 8,
borderRadius: 12,
},
partnerTextContainer: {
marginTop: 24,
width: "100%",
paddingHorizontal: 8,
},
partnerDivider: {
flexDirection: "row",
alignItems: "center",
marginBottom: 8,
},
partnerDividerLine: {
flex: 1,
height: 1,
backgroundColor: "rgba(255, 255, 255, 0.08)",
},
partnerLabelContainer: {
flexDirection: "row",
alignItems: "center",
paddingHorizontal: 16,
paddingVertical: 4,
backgroundColor: "rgba(155, 89, 182, 0.15)",
borderRadius: 9999,
gap: 4,
},
partnerLabel: {
fontSize: 12,
color: "#9b59b6",
fontWeight: "500",
letterSpacing: 0.3,
},
partnerText: {
fontSize: 14,
color: "rgba(255, 255, 255, 0.7)",
textAlign: "center",
fontStyle: "italic",
},
buttonContainer: {
flexDirection: "row",
justifyContent: "space-between",
alignItems: "center",
paddingHorizontal: 32,
paddingVertical: 16,
gap: 20,
},
actionButton: {
alignItems: "center",
justifyContent: "center",
width: 64,
height: 64,
borderRadius: 32,
shadowColor: "#000",
shadowOffset: { width: 0, height: 4 },
shadowOpacity: 0.4,
shadowRadius: 8,
elevation: 8,
borderWidth: 2,
overflow: "hidden",
},
noButton: {
backgroundColor: "#E74C3C",
borderColor: "rgba(255, 255, 255, 0.3)",
},
maybeButton: {
backgroundColor: "#F39C12",
borderColor: "rgba(255, 255, 255, 0.3)",
},
yesButton: {
backgroundColor: "#2ECC71",
borderColor: "rgba(255, 255, 255, 0.3)",
},
buttonInnerGlow: {
position: "absolute",
top: 0,
left: 0,
right: 0,
height: "50%",
backgroundColor: "rgba(255, 255, 255, 0.15)",
borderTopLeftRadius: 32,
borderTopRightRadius: 32,
},
buttonIcon: {
zIndex: 1,
textShadowColor: "rgba(0, 0, 0, 0.3)",
textShadowOffset: { width: 0, height: 1 },
textShadowRadius: 2,
},
buttonHovered: {
transform: [{ scale: 1.1 }],
shadowOpacity: 0.6,
shadowRadius: 12,
},
});
@@ -1,587 +0,0 @@
/**
* SwipeCardPremium - PoC of premium/boutique styling for the swipe experience
*
* Inspired by the "Coming Soon" Ann Summers aesthetic
*/
import { useState, useEffect } from "react";
import { View, Text, StyleSheet, Platform, useWindowDimensions, Pressable } from "react-native";
import { Gesture, GestureDetector, TouchableOpacity } from "react-native-gesture-handler";
import { BlurView } from "expo-blur";
import { LinearGradient } from "expo-linear-gradient";
import Animated, {
useSharedValue,
useAnimatedStyle,
withSpring,
withRepeat,
withSequence,
runOnJS,
interpolate,
Extrapolate,
withTiming,
interpolateColor,
Easing,
FadeIn,
} from "react-native-reanimated";
import { Ionicons } from "@expo/vector-icons";
import { colors, gradients, featureColors, radius, shadows, blur, typography, spacing, animations } from "../../theme";
// Use feature colors for consistency
const { accent: ACCENT } = featureColors.dares;
const { accent: ROSE } = featureColors.quiz;
// RGBA helpers for custom opacity levels (derived from theme colors)
// 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})`,
};
// Haptics helper
const triggerHaptic = async (style: 'light' | 'medium' | 'heavy' = 'medium') => {
if (Platform.OS === 'web') return;
const Haptics = await import('expo-haptics');
const feedbackStyle = style === 'light'
? Haptics.ImpactFeedbackStyle.Light
: style === 'heavy'
? Haptics.ImpactFeedbackStyle.Heavy
: Haptics.ImpactFeedbackStyle.Medium;
await Haptics.impactAsync(feedbackStyle);
};
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; partner_answered?: boolean };
onSwipe: (direction: "left" | "right" | "up" | "down") => void;
onReport?: () => void;
}
export default function SwipeCardPremium({ question, onSwipe, onReport }: Props) {
const { width: screenWidth } = useWindowDimensions();
const cardWidth = Math.min(screenWidth - 48, MAX_CARD_WIDTH);
const translateX = useSharedValue(0);
const translateY = useSharedValue(0);
const rotation = useSharedValue(0);
const scale = useSharedValue(1);
const shadowExpand = useSharedValue(0);
const idleBreathing = useSharedValue(0);
const isGesturing = useSharedValue(false);
const context = useSharedValue({ x: 0, y: 0 });
// Subtle idle breathing animation
useEffect(() => {
idleBreathing.value = withRepeat(
withSequence(
withTiming(1, { duration: 3000, easing: Easing.inOut(Easing.sin) }),
withTiming(0, { duration: 3000, easing: Easing.inOut(Easing.sin) })
),
-1,
true
);
}, []);
const gesture = Gesture.Pan()
.activeOffsetX([-15, 15])
.activeOffsetY([-15, 15])
.onStart(() => {
context.value = { x: translateX.value, y: translateY.value };
scale.value = withTiming(1.02, { duration: animations.timing.fast });
shadowExpand.value = withTiming(1, { duration: animations.timing.fast });
isGesturing.value = true;
})
.onUpdate((event) => {
translateX.value = event.translationX + context.value.x;
translateY.value = event.translationY + context.value.y;
rotation.value = interpolate(
translateX.value,
[-cardWidth / 2, cardWidth / 2],
[-8, 8], // Reduced rotation for elegance
Extrapolate.CLAMP
);
})
.onEnd(() => {
scale.value = withTiming(1, { duration: animations.timing.fast });
shadowExpand.value = withTiming(0, { duration: animations.timing.fast });
isGesturing.value = false;
if (Math.abs(translateX.value) > SWIPE_THRESHOLD) {
const direction = translateX.value > 0 ? "right" : "left";
runOnJS(triggerHaptic)('medium');
translateX.value = withSpring(
direction === "right" ? screenWidth * 1.5 : -screenWidth * 1.5,
animations.springGentle
);
runOnJS(onSwipe)(direction);
} else if (translateY.value < -SWIPE_THRESHOLD) {
runOnJS(triggerHaptic)('medium');
translateY.value = withSpring(-screenWidth * 1.5, animations.springGentle);
runOnJS(onSwipe)("up");
} else if (translateY.value > SWIPE_THRESHOLD) {
runOnJS(triggerHaptic)('light');
translateY.value = withSpring(screenWidth * 1.5, animations.springGentle);
runOnJS(onSwipe)("down");
} else {
translateX.value = withSpring(0, animations.spring);
translateY.value = withSpring(0, animations.spring);
rotation.value = withSpring(0, animations.spring);
}
});
const animatedStyle = useAnimatedStyle(() => {
const idleOffset = isGesturing.value ? 0 : interpolate(idleBreathing.value, [0, 1], [0, -4]);
return {
transform: [
{ translateX: translateX.value },
{ translateY: translateY.value + idleOffset },
{ rotate: `${rotation.value}deg` },
{ scale: scale.value },
],
};
});
const cardShadowStyle = useAnimatedStyle(() => ({
shadowOpacity: interpolate(shadowExpand.value, [0, 1], [0.2, 0.4]),
shadowRadius: interpolate(shadowExpand.value, [0, 1], [20, 32]),
shadowOffset: {
width: 0,
height: interpolate(shadowExpand.value, [0, 1], [10, 16]),
},
}));
const overlayStyle = (direction: "left" | "right" | "up" | "down") =>
useAnimatedStyle(() => {
let opacity = 0;
if (direction === "right") {
opacity = interpolate(translateX.value, [0, SWIPE_THRESHOLD], [0, 1]);
} else if (direction === "left") {
opacity = interpolate(translateX.value, [0, -SWIPE_THRESHOLD], [0, 1]);
} else if (direction === "up") {
opacity = interpolate(translateY.value, [0, -SWIPE_THRESHOLD], [0, 1]);
} else {
opacity = interpolate(translateY.value, [0, SWIPE_THRESHOLD], [0, 1]);
}
return { opacity };
});
const handleButtonPress = async (direction: "left" | "right" | "up") => {
await triggerHaptic('light');
onSwipe(direction);
};
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 */}
<View style={styles.cardBackground}>
{/* Subtle gradient overlay */}
<LinearGradient
colors={['rgba(22, 33, 62, 0.95)', 'rgba(13, 13, 26, 0.98)']}
style={StyleSheet.absoluteFill}
start={{ x: 0.5, y: 0 }}
end={{ x: 0.5, y: 1 }}
/>
{/* Top silk highlight */}
<LinearGradient
colors={[rgba.gold(0.08), 'transparent']}
style={styles.silkHighlight}
start={{ x: 0.5, y: 0 }}
end={{ x: 0.5, y: 1 }}
/>
{/* Swipe Overlays - Premium style */}
<Animated.View style={[styles.overlay, styles.overlayYes, overlayStyle("right")]} pointerEvents="none">
<View style={styles.overlayBadgePremium}>
<Text style={styles.overlayTextPremium}>YES</Text>
</View>
</Animated.View>
<Animated.View style={[styles.overlay, styles.overlayNo, overlayStyle("left")]} pointerEvents="none">
<View style={[styles.overlayBadgePremium, styles.overlayBadgeNo]}>
<Text style={[styles.overlayTextPremium, styles.overlayTextNo]}>NO</Text>
</View>
</Animated.View>
<Animated.View style={[styles.overlay, styles.overlayMaybe, overlayStyle("up")]} pointerEvents="none">
<View style={[styles.overlayBadgePremium, styles.overlayBadgeMaybe]}>
<Text style={[styles.overlayTextPremium, styles.overlayTextMaybe]}>MAYBE</Text>
</View>
</Animated.View>
<Animated.View style={[styles.overlay, styles.overlaySkip, overlayStyle("down")]} pointerEvents="none">
<View style={[styles.overlayBadgePremium, styles.overlayBadgeSkip]}>
<Text style={[styles.overlayTextPremium, styles.overlayTextSkip]}>SKIP</Text>
</View>
</Animated.View>
{/* Content */}
<View style={styles.content}>
{/* Intensity badge - more prominent */}
<View style={styles.intensityBadge}>
<Text style={styles.intensityLabel}>INTENSITY</Text>
<View style={styles.intensityRow}>
{[...Array(5)].map((_, i) => (
<Ionicons
key={i}
name={i < question.intensity ? "flame" : "flame-outline"}
size={18}
color={i < question.intensity ? ACCENT : 'rgba(255, 255, 255, 0.2)'}
/>
))}
</View>
</View>
{/* Decorative separator */}
<View style={styles.separator}>
<View style={styles.separatorLine} />
<View style={styles.separatorDiamond} />
<View style={styles.separatorLine} />
</View>
{/* Question text */}
<Text style={styles.questionText}>
{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} />
<View style={styles.separatorDiamond} />
<View style={styles.separatorLine} />
</View>
</View>
{/* Premium Action Buttons */}
<View style={styles.footer}>
<View style={styles.buttonContainer}>
<PremiumButton
onPress={() => handleButtonPress("left")}
icon="close"
variant="no"
/>
<PremiumButton
onPress={() => handleButtonPress("up")}
icon="help"
variant="maybe"
small
/>
<PremiumButton
onPress={() => handleButtonPress("right")}
icon="heart"
variant="yes"
/>
</View>
</View>
</View>
{/* Premium border */}
<View style={styles.premiumBorder} pointerEvents="none" />
</Animated.View>
</GestureDetector>
</View>
);
}
function PremiumButton({
onPress,
icon,
variant,
small = false,
}: {
onPress: () => void;
icon: string;
variant: 'yes' | 'no' | 'maybe';
small?: boolean;
}) {
const buttonScale = useSharedValue(1);
const handlePressIn = () => {
buttonScale.value = withSpring(0.92, animations.spring);
};
const handlePressOut = () => {
buttonScale.value = withSpring(1, animations.spring);
};
const buttonAnimatedStyle = useAnimatedStyle(() => ({
transform: [{ scale: buttonScale.value }],
}));
const getColors = () => {
switch (variant) {
case 'yes': return { bg: rgba.gold(0.15), border: rgba.gold(0.3), icon: ACCENT };
case 'no': return { bg: rgba.rose(0.15), border: rgba.rose(0.3), icon: ROSE };
case 'maybe': return { bg: 'rgba(255, 255, 255, 0.08)', border: 'rgba(255, 255, 255, 0.15)', icon: colors.textSecondary };
}
};
const c = getColors();
return (
<TouchableOpacity
onPress={onPress}
onPressIn={handlePressIn}
onPressOut={handlePressOut}
activeOpacity={1}
>
<Animated.View style={[
styles.premiumButton,
small && styles.premiumButtonSmall,
{ backgroundColor: c.bg, borderColor: c.border },
buttonAnimatedStyle,
]}>
<Ionicons
name={icon as any}
size={small ? 18 : 24}
color={c.icon}
/>
</Animated.View>
</TouchableOpacity>
);
}
const styles = StyleSheet.create({
cardWrapper: {
position: "absolute",
maxWidth: MAX_CARD_WIDTH,
height: 500,
},
cardOuter: {
width: "100%",
height: "100%",
borderRadius: radius.xl,
overflow: "hidden",
},
cardBackground: {
flex: 1,
backgroundColor: '#0d0d1a',
borderRadius: radius.xl,
overflow: "hidden",
},
silkHighlight: {
position: 'absolute',
top: 0,
left: 0,
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,
left: 0,
right: 0,
bottom: 0,
borderRadius: radius.xl,
borderWidth: 1,
borderColor: 'rgba(212, 175, 55, 0.2)', // gold @ 0.2
},
content: {
flex: 1,
justifyContent: "center",
alignItems: "center",
paddingHorizontal: spacing.xl,
},
intensityBadge: {
alignItems: 'center',
backgroundColor: 'rgba(212, 175, 55, 0.08)', // gold @ 0.08
paddingHorizontal: spacing.lg,
paddingVertical: spacing.sm,
borderRadius: radius.lg,
borderWidth: 1,
borderColor: 'rgba(212, 175, 55, 0.15)', // gold @ 0.15
marginBottom: spacing.md,
},
intensityLabel: {
...typography.caption2,
fontWeight: '600',
letterSpacing: 2,
color: 'rgba(212, 175, 55, 0.7)', // gold @ 0.7
marginBottom: spacing.xs,
},
intensityRow: {
flexDirection: 'row',
gap: 4,
},
separator: {
flexDirection: 'row',
alignItems: 'center',
justifyContent: 'center',
marginVertical: spacing.lg,
width: 120,
},
separatorLine: {
flex: 1,
height: 1,
backgroundColor: 'rgba(212, 175, 55, 0.2)', // gold @ 0.2
},
separatorDiamond: {
width: 6,
height: 6,
backgroundColor: ACCENT,
transform: [{ rotate: '45deg' }],
marginHorizontal: spacing.md,
opacity: 0.5,
},
questionText: {
...typography.title2,
color: colors.text,
textAlign: "center",
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,
},
buttonContainer: {
flexDirection: "row",
justifyContent: "center",
alignItems: "center",
gap: spacing.lg,
},
premiumButton: {
width: 60,
height: 60,
borderRadius: 30,
justifyContent: "center",
alignItems: "center",
borderWidth: 1,
},
premiumButtonSmall: {
width: 48,
height: 48,
borderRadius: 24,
},
// Overlays
overlay: {
...StyleSheet.absoluteFillObject,
justifyContent: "center",
alignItems: "center",
zIndex: 10,
borderRadius: radius.xl,
},
overlayYes: {
backgroundColor: 'rgba(212, 175, 55, 0.15)', // gold @ 0.15
},
overlayNo: {
backgroundColor: 'rgba(232, 164, 174, 0.15)', // rose @ 0.15
},
overlayMaybe: {
backgroundColor: 'rgba(255, 255, 255, 0.08)',
},
overlaySkip: {
backgroundColor: 'rgba(0, 0, 0, 0.3)',
},
overlayBadgePremium: {
paddingHorizontal: spacing.xl,
paddingVertical: spacing.md,
borderRadius: radius.full,
backgroundColor: 'rgba(212, 175, 55, 0.2)', // gold @ 0.2
borderWidth: 1,
borderColor: 'rgba(212, 175, 55, 0.4)', // gold @ 0.4
transform: [{ rotate: "-12deg" }],
},
overlayBadgeNo: {
backgroundColor: 'rgba(232, 164, 174, 0.2)', // rose @ 0.2
borderColor: 'rgba(232, 164, 174, 0.4)', // rose @ 0.4
},
overlayBadgeMaybe: {
backgroundColor: 'rgba(255, 255, 255, 0.1)',
borderColor: 'rgba(255, 255, 255, 0.2)',
},
overlayBadgeSkip: {
backgroundColor: 'rgba(255, 255, 255, 0.1)',
borderColor: 'rgba(255, 255, 255, 0.15)',
},
overlayTextPremium: {
fontSize: 28,
fontWeight: "600",
letterSpacing: 4,
color: ACCENT,
},
overlayTextNo: {
color: ROSE,
},
overlayTextMaybe: {
color: colors.textSecondary,
},
overlayTextSkip: {
color: colors.textTertiary,
fontSize: 24,
},
});
@@ -1,2 +0,0 @@
export { default as SwipeCard } from './SwipeCard';
export { default as SwipeCardPremium } from './SwipeCardPremium';
@@ -34,7 +34,7 @@ const STEPS: TutorialStep[] = [
{
key: "match",
title: "You've Got a Match!",
description: "When both you and your partner swipe YES or MAYBE on the same question, it creates a match. This means you're both interested!",
description: "When both you and your partner swipe YES on the same question, it creates a match. This means you're both interested!",
icon: "heart",
gradient: gradients.primary as [string, string],
},
@@ -185,11 +185,6 @@ export default function MatchesTutorial({ onComplete }: Props) {
<Ionicons name="heart" size={16} color={colors.text} />
<Text style={styles.badgeText}>YES + YES</Text>
</LinearGradient>
<View style={styles.badgeConnector} />
<View style={[styles.matchBadge, styles.maybeBadge]}>
<Ionicons name="heart-half" size={16} color={colors.primary} />
<Text style={[styles.badgeText, styles.maybeBadgeText]}>YES + MAYBE</Text>
</View>
</View>
</View>
)}
@@ -249,9 +244,9 @@ export default function MatchesTutorial({ onComplete }: Props) {
<Text style={styles.answerCardQuestion} numberOfLines={1}>
"Try something new together"
</Text>
<View style={[styles.answerCardBadge, styles.answerCardBadgeMaybe]}>
<Ionicons name="help" size={12} color={colors.warning} />
<Text style={[styles.answerCardBadgeText, styles.answerCardBadgeTextMaybe]}>MAYBE</Text>
<View style={[styles.answerCardBadge, styles.answerCardBadgeNo]}>
<Ionicons name="close" size={12} color={colors.error} />
<Text style={[styles.answerCardBadgeText, styles.answerCardBadgeTextNo]}>NO</Text>
</View>
</View>
</View>
@@ -384,25 +379,12 @@ const styles = StyleSheet.create({
borderRadius: radius.full,
gap: spacing.xs,
},
maybeBadge: {
backgroundColor: colors.glass.background,
borderWidth: 1,
borderColor: colors.glass.border,
},
badgeText: {
...typography.caption1,
color: colors.text,
fontWeight: "700",
letterSpacing: 0.5,
},
maybeBadgeText: {
color: colors.primary,
},
badgeConnector: {
width: 20,
height: 2,
backgroundColor: colors.glass.border,
},
chatDemo: {
marginTop: spacing.lg,
gap: spacing.sm,
@@ -508,18 +490,18 @@ const styles = StyleSheet.create({
borderRadius: radius.full,
gap: 2,
},
answerCardBadgeMaybe: {
backgroundColor: "rgba(243, 156, 18, 0.2)",
answerCardBadgeNo: {
backgroundColor: "rgba(231, 76, 60, 0.2)",
borderWidth: 1,
borderColor: colors.warning,
borderColor: colors.error,
},
answerCardBadgeText: {
...typography.caption2,
fontWeight: "700",
color: colors.text,
},
answerCardBadgeTextMaybe: {
color: colors.warning,
answerCardBadgeTextNo: {
color: colors.error,
},
answerCardEdit: {
marginLeft: spacing.sm,
@@ -1,705 +0,0 @@
import { useEffect, useState } from "react";
import { View, Text, StyleSheet, Pressable, useWindowDimensions, Platform } from "react-native";
import { useSafeAreaInsets } from "react-native-safe-area-context";
import { BlurView } from "expo-blur";
import { LinearGradient } from "expo-linear-gradient";
import { Ionicons } from "@expo/vector-icons";
import Animated, {
useSharedValue,
useAnimatedStyle,
withTiming,
withRepeat,
withSequence,
withDelay,
withSpring,
FadeIn,
FadeOut,
interpolate,
Easing,
runOnJS,
} from "react-native-reanimated";
import { colors, gradients, radius, shadows, blur, typography, spacing, animations } from "../../theme";
import { Events } from "../../lib/analytics";
interface Props {
onComplete: () => void;
}
interface TutorialStep {
key: string;
label: string;
description: string;
icon: string;
color: string;
gradient: [string, string];
translateX: number;
translateY: number;
isSwipe: boolean;
}
const STEPS: TutorialStep[] = [
{
key: "right",
label: "YES",
description: "Swipe right if you're interested",
icon: "heart",
color: colors.success,
gradient: gradients.success as [string, string],
translateX: 80,
translateY: 0,
isSwipe: true,
},
{
key: "left",
label: "NO",
description: "Swipe left if you're not interested",
icon: "close",
color: colors.error,
gradient: gradients.error as [string, string],
translateX: -80,
translateY: 0,
isSwipe: true,
},
{
key: "up",
label: "MAYBE",
description: "Swipe up if you're unsure",
icon: "help",
color: colors.warning,
gradient: gradients.warning as [string, string],
translateX: 0,
translateY: -80,
isSwipe: true,
},
{
key: "down",
label: "SKIP",
description: "Swipe down to skip for now",
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) {
const { width: screenWidth, height: screenHeight } = useWindowDimensions();
const insets = useSafeAreaInsets();
const [currentStep, setCurrentStep] = useState(0);
const cardTranslateX = useSharedValue(0);
const cardTranslateY = useSharedValue(0);
const cardRotation = useSharedValue(0);
const cardScale = useSharedValue(1);
const overlayOpacity = useSharedValue(0);
const pulseScale = useSharedValue(1);
const handOffset = useSharedValue(0);
const currentStep_ = STEPS[currentStep];
const isSwipeStep = currentStep_.isSwipe;
// Track tutorial started on mount
useEffect(() => {
Events.tutorialStarted("swipe");
Events.tutorialStepViewed("swipe", 0, STEPS[0].key);
}, []);
// Track step changes
useEffect(() => {
if (currentStep > 0) {
Events.tutorialStepViewed("swipe", currentStep, STEPS[currentStep].key);
}
}, [currentStep]);
useEffect(() => {
// Reset animations for current step
cardTranslateX.value = 0;
cardTranslateY.value = 0;
cardRotation.value = 0;
overlayOpacity.value = 0;
handOffset.value = 0;
// Start the swipe demonstration animation
const animateSwipe = () => {
if (!isSwipeStep) {
// For non-swipe steps (feedback), just pulse the card
cardScale.value = withRepeat(
withSequence(
withTiming(1.02, { duration: 600 }),
withTiming(1, { duration: 600 })
),
-1,
true
);
return;
}
// Pulse the card first
cardScale.value = withSequence(
withTiming(1.02, { duration: 200 }),
withTiming(1, { duration: 200 })
);
// Animate hand movement
handOffset.value = withRepeat(
withSequence(
withTiming(1, { duration: 800, easing: Easing.out(Easing.cubic) }),
withDelay(300, withTiming(0, { duration: 400 }))
),
-1,
false
);
// Animate card swipe
cardTranslateX.value = withRepeat(
withSequence(
withDelay(200, withTiming(currentStep_.translateX * 0.7, {
duration: 600,
easing: Easing.out(Easing.cubic)
})),
withDelay(400, withSpring(0, animations.spring))
),
-1,
false
);
cardTranslateY.value = withRepeat(
withSequence(
withDelay(200, withTiming(currentStep_.translateY * 0.7, {
duration: 600,
easing: Easing.out(Easing.cubic)
})),
withDelay(400, withSpring(0, animations.spring))
),
-1,
false
);
// Rotate card for horizontal swipes
if (currentStep_.translateX !== 0) {
cardRotation.value = withRepeat(
withSequence(
withDelay(200, withTiming(currentStep_.translateX > 0 ? 8 : -8, {
duration: 600
})),
withDelay(400, withSpring(0, animations.spring))
),
-1,
false
);
}
// Show overlay during swipe
overlayOpacity.value = withRepeat(
withSequence(
withDelay(400, withTiming(0.8, { duration: 400 })),
withDelay(200, withTiming(0, { duration: 400 }))
),
-1,
false
);
};
// Pulse animation for the indicator
pulseScale.value = withRepeat(
withSequence(
withTiming(1.2, { duration: 800 }),
withTiming(1, { duration: 800 })
),
-1,
true
);
const timeout = setTimeout(animateSwipe, 300);
return () => clearTimeout(timeout);
}, [currentStep]);
const handleNext = () => {
if (currentStep < STEPS.length - 1) {
setCurrentStep(currentStep + 1);
} else {
Events.tutorialCompleted("swipe");
onComplete();
}
};
const handleSkip = () => {
Events.tutorialSkipped("swipe", currentStep);
onComplete();
};
const cardAnimatedStyle = useAnimatedStyle(() => ({
transform: [
{ translateX: cardTranslateX.value },
{ translateY: cardTranslateY.value },
{ rotate: `${cardRotation.value}deg` },
{ scale: cardScale.value },
],
}));
const overlayAnimatedStyle = useAnimatedStyle(() => ({
opacity: overlayOpacity.value,
}));
const pulseAnimatedStyle = useAnimatedStyle(() => ({
transform: [{ scale: pulseScale.value }],
opacity: interpolate(pulseScale.value, [1, 1.2], [0.6, 0.2]),
}));
const handAnimatedStyle = useAnimatedStyle(() => {
const progress = handOffset.value;
return {
transform: [
{ translateX: currentStep_.translateX * progress * 0.5 },
{ translateY: currentStep_.translateY * progress * 0.5 },
],
opacity: interpolate(progress, [0, 0.2, 0.8, 1], [0, 1, 1, 0]),
};
});
return (
<Animated.View
entering={FadeIn.duration(300)}
exiting={FadeOut.duration(200)}
style={styles.container}
>
<BlurView intensity={blur.heavy} tint="dark" style={styles.blur}>
<View style={[styles.content, { paddingBottom: Math.max(40, insets.bottom + (Platform.OS === 'ios' ? 100 : 80)) }]}>
{/* Skip button */}
<Pressable style={styles.skipButton} onPress={handleSkip}>
<Text style={styles.skipText}>Skip</Text>
</Pressable>
{/* Title */}
<Animated.View entering={FadeIn.delay(100).duration(400)}>
<Text style={styles.title}>How to Play</Text>
<Text style={styles.subtitle}>
Swipe to answer questions and find your matches
</Text>
</Animated.View>
{/* Demo Card Area */}
<View style={styles.demoArea}>
{/* Direction indicators around the card - only for swipe steps */}
{isSwipeStep && (
<View style={styles.indicatorsContainer}>
{STEPS.filter(s => s.isSwipe).map((dir, index) => (
<View
key={dir.key}
style={[
styles.directionIndicator,
dir.key === "right" && styles.indicatorRight,
dir.key === "left" && styles.indicatorLeft,
dir.key === "up" && styles.indicatorUp,
dir.key === "down" && styles.indicatorDown,
]}
>
{index === currentStep && (
<Animated.View style={[styles.pulseRing, pulseAnimatedStyle]}>
<View style={[styles.pulseInner, { backgroundColor: dir.color }]} />
</Animated.View>
)}
<LinearGradient
colors={index === currentStep ? dir.gradient : ['rgba(255,255,255,0.1)', 'rgba(255,255,255,0.05)'] as [string, string]}
style={[
styles.indicatorBadge,
index === currentStep && styles.indicatorActive,
]}
>
<Ionicons
name={dir.icon as any}
size={20}
color={index === currentStep ? colors.text : colors.textTertiary}
/>
</LinearGradient>
</View>
))}
</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 && (
<Animated.View
style={[
styles.demoOverlay,
overlayAnimatedStyle,
{ backgroundColor: `${currentStep_.color}40` }
]}
>
<View style={[styles.overlayBadge, { backgroundColor: currentStep_.color }]}>
<Text style={styles.overlayText}>{currentStep_.label}</Text>
</View>
</Animated.View>
)}
{/* Card content */}
<View style={styles.demoCardContent}>
<View style={styles.intensityDemo}>
<Ionicons name="flame" size={14} color={colors.primary} />
<Ionicons name="flame" size={14} color={colors.primary} />
</View>
<Text style={styles.demoQuestion}>
Would you try a new restaurant together?
</Text>
</View>
</LinearGradient>
{/* Hand gesture indicator - only for swipe steps */}
{isSwipeStep && (
<Animated.View style={[styles.handIndicator, handAnimatedStyle]}>
<View style={styles.handCircle}>
<Ionicons name="hand-left" size={24} color={colors.text} />
</View>
</Animated.View>
)}
</Animated.View>
</View>
{/* Current instruction */}
<Animated.View
key={currentStep}
entering={FadeIn.duration(300)}
style={styles.instructionContainer}
>
<LinearGradient
colors={currentStep_.gradient}
style={styles.instructionBadge}
start={{ x: 0, y: 0 }}
end={{ x: 1, y: 0 }}
>
<Ionicons name={currentStep_.icon as any} size={24} color={colors.text} />
<Text style={styles.instructionLabel}>{currentStep_.label}</Text>
</LinearGradient>
<Text style={styles.instructionText}>{currentStep_.description}</Text>
</Animated.View>
{/* Progress dots */}
<View style={styles.dotsContainer}>
{STEPS.map((_, index) => (
<View
key={index}
style={[
styles.dot,
index === currentStep && styles.dotActive,
index < currentStep && styles.dotCompleted,
]}
/>
))}
</View>
{/* Next button */}
<Pressable onPress={handleNext} style={({ pressed }) => [
styles.nextButton,
pressed && styles.nextButtonPressed
]}>
<LinearGradient
colors={gradients.primary as [string, string]}
style={styles.nextButtonGradient}
start={{ x: 0, y: 0 }}
end={{ x: 1, y: 0 }}
>
<Text style={styles.nextButtonText}>
{currentStep < STEPS.length - 1 ? "Next" : "Got it!"}
</Text>
<Ionicons
name={currentStep < STEPS.length - 1 ? "arrow-forward" : "checkmark"}
size={20}
color={colors.text}
/>
</LinearGradient>
</Pressable>
</View>
</BlurView>
</Animated.View>
);
}
const styles = StyleSheet.create({
container: {
...StyleSheet.absoluteFillObject,
zIndex: 1000,
},
blur: {
flex: 1,
backgroundColor: 'rgba(13, 13, 26, 0.85)',
},
content: {
flex: 1,
paddingHorizontal: spacing.lg,
paddingTop: 80,
alignItems: "center",
},
skipButton: {
position: "absolute",
top: 60,
right: spacing.lg,
paddingVertical: spacing.sm,
paddingHorizontal: spacing.md,
},
skipText: {
...typography.callout,
color: colors.textSecondary,
},
title: {
...typography.title1,
color: colors.text,
textAlign: "center",
marginBottom: spacing.sm,
},
subtitle: {
...typography.body,
color: colors.textSecondary,
textAlign: "center",
marginBottom: spacing.xl,
},
demoArea: {
flex: 1,
justifyContent: "center",
alignItems: "center",
width: "100%",
maxHeight: 350,
},
indicatorsContainer: {
position: "absolute",
width: 280,
height: 280,
},
directionIndicator: {
position: "absolute",
alignItems: "center",
justifyContent: "center",
},
indicatorRight: {
right: -20,
top: "50%",
marginTop: -24,
},
indicatorLeft: {
left: -20,
top: "50%",
marginTop: -24,
},
indicatorUp: {
top: -20,
left: "50%",
marginLeft: -24,
},
indicatorDown: {
bottom: -20,
left: "50%",
marginLeft: -24,
},
pulseRing: {
position: "absolute",
width: 64,
height: 64,
borderRadius: 32,
justifyContent: "center",
alignItems: "center",
},
pulseInner: {
width: "100%",
height: "100%",
borderRadius: 32,
},
indicatorBadge: {
width: 48,
height: 48,
borderRadius: 24,
justifyContent: "center",
alignItems: "center",
borderWidth: 1,
borderColor: colors.glass.border,
},
indicatorActive: {
borderColor: "transparent",
},
demoCard: {
width: 220,
height: 280,
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: {
...StyleSheet.absoluteFillObject,
justifyContent: "center",
alignItems: "center",
zIndex: 10,
borderRadius: radius.xl,
},
overlayBadge: {
paddingHorizontal: spacing.md,
paddingVertical: spacing.xs,
borderRadius: radius.sm,
transform: [{ rotate: "-15deg" }],
},
overlayText: {
fontSize: 24,
fontWeight: "900",
color: colors.text,
letterSpacing: 2,
},
demoCardContent: {
flex: 1,
justifyContent: "center",
alignItems: "center",
padding: spacing.lg,
},
intensityDemo: {
flexDirection: "row",
backgroundColor: colors.primaryLight,
paddingHorizontal: spacing.sm,
paddingVertical: spacing.xs,
borderRadius: radius.full,
gap: 2,
marginBottom: spacing.md,
},
demoQuestion: {
...typography.headline,
color: colors.text,
textAlign: "center",
},
handIndicator: {
position: "absolute",
bottom: 40,
left: "50%",
marginLeft: -20,
},
handCircle: {
width: 40,
height: 40,
borderRadius: 20,
backgroundColor: colors.primary,
justifyContent: "center",
alignItems: "center",
...shadows.md,
},
instructionContainer: {
alignItems: "center",
marginTop: spacing.xl,
marginBottom: spacing.lg,
},
instructionBadge: {
flexDirection: "row",
alignItems: "center",
paddingHorizontal: spacing.lg,
paddingVertical: spacing.sm,
borderRadius: radius.full,
gap: spacing.sm,
marginBottom: spacing.md,
},
instructionLabel: {
...typography.headline,
color: colors.text,
fontWeight: "700",
},
instructionText: {
...typography.body,
color: colors.textSecondary,
textAlign: "center",
},
dotsContainer: {
flexDirection: "row",
gap: spacing.sm,
marginBottom: spacing.xl,
},
dot: {
width: 8,
height: 8,
borderRadius: 4,
backgroundColor: colors.glass.border,
},
dotActive: {
backgroundColor: colors.primary,
width: 24,
},
dotCompleted: {
backgroundColor: colors.primary,
},
nextButton: {
width: "100%",
maxWidth: 280,
borderRadius: radius.lg,
overflow: "hidden",
},
nextButtonPressed: {
opacity: 0.9,
transform: [{ scale: 0.98 }],
},
nextButtonGradient: {
flexDirection: "row",
alignItems: "center",
justifyContent: "center",
paddingVertical: spacing.md,
gap: spacing.sm,
},
nextButtonText: {
...typography.headline,
color: colors.text,
},
});
@@ -1,2 +1 @@
export { default as SwipeTutorial } from './SwipeTutorial';
export { default as MatchesTutorial } from './MatchesTutorial';
@@ -2,10 +2,10 @@ import React from 'react';
import { View, StyleSheet, ViewStyle, DimensionValue } from 'react-native';
import { colors, spacing } from '../../theme';
export type SeparatorVariant = 'rose' | 'gold' | 'muted';
export type SeparatorVariant = 'primary' | 'rose' | 'gold' | 'muted';
export interface DecorativeSeparatorProps {
/** Color variant: 'rose' (default), 'gold' (premium/yes-yes), 'muted' */
/** Color variant: 'primary' (default), 'rose' (quiz), 'gold' (premium/dares), 'muted' */
variant?: SeparatorVariant;
/** Width of the separator. Default: 140 */
width?: DimensionValue;
@@ -16,6 +16,10 @@ export interface DecorativeSeparatorProps {
}
const VARIANT_COLORS: Record<SeparatorVariant, { line: string; diamond: string }> = {
primary: {
line: 'rgba(225, 48, 108, 0.3)',
diamond: colors.primary,
},
rose: {
line: 'rgba(232, 164, 174, 0.3)',
diamond: colors.premium.rose,
@@ -35,7 +39,7 @@ const VARIANT_COLORS: Record<SeparatorVariant, { line: string; diamond: string }
* Used for visual separation in waiting states, headers, and empty states.
*/
export const DecorativeSeparator: React.FC<DecorativeSeparatorProps> = ({
variant = 'rose',
variant = 'primary',
width = 140,
marginVertical = spacing.lg,
style,
+52 -98
View File
@@ -1,98 +1,52 @@
import React from 'react';
import { StyleSheet, View, ViewStyle, Platform } from 'react-native';
import { BlurView } from 'expo-blur';
import { LinearGradient } from 'expo-linear-gradient';
import { colors, radius, shadows, blur } from '../../theme';
interface GlassCardProps {
children: React.ReactNode;
style?: ViewStyle;
intensity?: 'light' | 'medium' | 'heavy';
variant?: 'default' | 'elevated' | 'subtle';
noPadding?: boolean;
}
export function GlassCard({
children,
style,
intensity = 'medium',
variant = 'default',
noPadding = false,
}: GlassCardProps) {
const blurIntensity = blur[intensity];
const shadowStyle = variant === 'elevated' ? shadows.lg : variant === 'subtle' ? shadows.sm : shadows.md;
const borderColor = variant === 'elevated' ? colors.glass.borderLight : colors.glass.border;
// Use BlurView on iOS, fallback to solid background on Android for performance
const useBlur = Platform.OS === 'ios';
return (
<View style={[styles.container, shadowStyle, style]}>
{useBlur ? (
<BlurView
intensity={blurIntensity}
tint="dark"
style={[styles.blurContainer, { borderColor }]}
>
{/* Top highlight gradient */}
<LinearGradient
colors={[colors.glass.highlight, 'transparent']}
style={styles.highlight}
start={{ x: 0.5, y: 0 }}
end={{ x: 0.5, y: 1 }}
/>
<View style={[styles.content, noPadding && styles.noPadding]}>
{children}
</View>
</BlurView>
) : (
// Android fallback with solid background
<View style={[styles.fallbackContainer, { borderColor }]}>
<LinearGradient
colors={[colors.glass.highlight, 'transparent']}
style={styles.highlight}
start={{ x: 0.5, y: 0 }}
end={{ x: 0.5, y: 1 }}
/>
<View style={[styles.content, noPadding && styles.noPadding]}>
{children}
</View>
</View>
)}
</View>
);
}
const styles = StyleSheet.create({
container: {
borderRadius: radius.lg,
overflow: 'hidden',
},
blurContainer: {
overflow: 'hidden',
borderRadius: radius.lg,
borderWidth: 1,
backgroundColor: colors.glass.background,
},
fallbackContainer: {
overflow: 'hidden',
borderRadius: radius.lg,
borderWidth: 1,
backgroundColor: colors.glass.backgroundLight,
},
highlight: {
position: 'absolute',
top: 0,
left: 0,
right: 0,
height: 60,
},
content: {
padding: 16,
},
noPadding: {
padding: 0,
},
});
export default GlassCard;
import React from 'react';
import { StyleSheet, View, ViewStyle } from 'react-native';
import { colors, radius, shadows } from '../../theme';
interface GlassCardProps {
children: React.ReactNode;
style?: ViewStyle;
intensity?: 'light' | 'medium' | 'heavy'; // Kept for API compatibility
variant?: 'default' | 'elevated' | 'subtle';
noPadding?: boolean;
}
export function GlassCard({
children,
style,
variant = 'default',
noPadding = false,
}: GlassCardProps) {
const shadowStyle = variant === 'elevated' ? shadows.lg : variant === 'subtle' ? shadows.sm : shadows.md;
const borderColor = variant === 'elevated' ? colors.glass.borderLight : colors.glass.border;
return (
<View style={[styles.container, shadowStyle, style]}>
<View style={[styles.flatContainer, { borderColor }]}>
<View style={[styles.content, noPadding && styles.noPadding]}>
{children}
</View>
</View>
</View>
);
}
const styles = StyleSheet.create({
container: {
borderRadius: radius.lg,
overflow: 'hidden',
},
flatContainer: {
overflow: 'hidden',
borderRadius: radius.lg,
backgroundColor: colors.backgroundLight, // Use solid card color
// Removed border for flatter look
},
content: {
padding: 16,
},
noPadding: {
padding: 0,
},
});
export default GlassCard;
+117 -140
View File
@@ -1,140 +1,117 @@
import React, { useState } from 'react';
import { StyleSheet, TextInput, View, Text, TextInputProps, ViewStyle, Platform } from 'react-native';
import { BlurView } from 'expo-blur';
import Animated, {
useSharedValue,
useAnimatedStyle,
withTiming,
interpolateColor,
} from 'react-native-reanimated';
import { colors, radius, blur, animations, typography } from '../../theme';
interface GlassInputProps extends Omit<TextInputProps, 'style'> {
label?: string;
error?: string;
containerStyle?: ViewStyle;
icon?: React.ReactNode;
}
const AnimatedBlurView = Animated.createAnimatedComponent(BlurView);
const AnimatedView = Animated.View;
export function GlassInput({
label,
error,
containerStyle,
icon,
...props
}: GlassInputProps) {
const [isFocused, setIsFocused] = useState(false);
const focusAnim = useSharedValue(0);
const handleFocus = (e: any) => {
setIsFocused(true);
focusAnim.value = withTiming(1, { duration: animations.timing.fast });
props.onFocus?.(e);
};
const handleBlur = (e: any) => {
setIsFocused(false);
focusAnim.value = withTiming(0, { duration: animations.timing.fast });
props.onBlur?.(e);
};
const animatedBorderStyle = useAnimatedStyle(() => ({
borderColor: interpolateColor(
focusAnim.value,
[0, 1],
[error ? colors.error : colors.glass.border, error ? colors.error : colors.primary]
),
}));
const useBlur = Platform.OS === 'ios';
const inputContent = (
<View style={styles.inputWrapper}>
{icon && <View style={styles.icon}>{icon}</View>}
<TextInput
{...props}
style={[styles.input, icon ? styles.inputWithIcon : undefined]}
placeholderTextColor={colors.textTertiary}
onFocus={handleFocus}
onBlur={handleBlur}
selectionColor={colors.primary}
/>
</View>
);
return (
<View style={[styles.container, containerStyle]}>
{label && <Text style={styles.label}>{label}</Text>}
{useBlur ? (
<AnimatedView style={[styles.blurWrapper, animatedBorderStyle]}>
<BlurView
intensity={blur.light}
tint="dark"
style={styles.blurContainer}
>
{inputContent}
</BlurView>
</AnimatedView>
) : (
<AnimatedView style={[styles.fallbackContainer, animatedBorderStyle]}>
{inputContent}
</AnimatedView>
)}
{error && <Text style={styles.error}>{error}</Text>}
</View>
);
}
const styles = StyleSheet.create({
container: {
marginBottom: 16,
},
label: {
...typography.subhead,
color: colors.textSecondary,
marginBottom: 8,
marginLeft: 4,
},
blurWrapper: {
borderRadius: radius.md,
borderWidth: 1,
overflow: 'hidden',
},
blurContainer: {
backgroundColor: colors.glass.background,
},
fallbackContainer: {
borderRadius: radius.md,
borderWidth: 1,
backgroundColor: colors.glass.backgroundLight,
},
inputWrapper: {
flexDirection: 'row',
alignItems: 'center',
},
icon: {
paddingLeft: 16,
},
input: {
flex: 1,
padding: 16,
fontSize: 16,
color: colors.text,
},
inputWithIcon: {
paddingLeft: 12,
},
error: {
...typography.caption1,
color: colors.error,
marginTop: 6,
marginLeft: 4,
},
});
export default GlassInput;
import React, { useState } from 'react';
import { StyleSheet, TextInput, View, Text, TextInputProps, ViewStyle, Platform } from 'react-native';
import Animated, {
useSharedValue,
useAnimatedStyle,
withTiming,
interpolateColor,
} from 'react-native-reanimated';
import { colors, radius, animations, typography } from '../../theme';
interface GlassInputProps extends Omit<TextInputProps, 'style'> {
label?: string;
error?: string;
containerStyle?: ViewStyle;
icon?: React.ReactNode;
}
const AnimatedView = Animated.View;
export function GlassInput({
label,
error,
containerStyle,
icon,
...props
}: GlassInputProps) {
const [isFocused, setIsFocused] = useState(false);
const focusAnim = useSharedValue(0);
const handleFocus = (e: any) => {
setIsFocused(true);
focusAnim.value = withTiming(1, { duration: animations.timing.fast });
props.onFocus?.(e);
};
const handleBlur = (e: any) => {
setIsFocused(false);
focusAnim.value = withTiming(0, { duration: animations.timing.fast });
props.onBlur?.(e);
};
const animatedContainerStyle = useAnimatedStyle(() => ({
backgroundColor: interpolateColor(
focusAnim.value,
[0, 1],
[colors.backgroundLight, colors.backgroundLight] // Consistent flat background
),
borderColor: interpolateColor(
focusAnim.value,
[0, 1],
[error ? colors.error : colors.border, error ? colors.error : colors.primary]
),
}));
return (
<View style={[styles.container, containerStyle]}>
{label && <Text style={styles.label}>{label}</Text>}
<AnimatedView style={[styles.inputContainer, animatedContainerStyle]}>
<View style={styles.inputWrapper}>
{icon && <View style={styles.icon}>{icon}</View>}
<TextInput
{...props}
style={[styles.input, icon ? styles.inputWithIcon : undefined]}
placeholderTextColor={colors.textTertiary}
onFocus={handleFocus}
onBlur={handleBlur}
selectionColor={colors.primary}
/>
</View>
</AnimatedView>
{error && <Text style={styles.error}>{error}</Text>}
</View>
);
}
const styles = StyleSheet.create({
container: {
marginBottom: 16,
},
label: {
...typography.subhead,
color: colors.textSecondary,
marginBottom: 8,
marginLeft: 4,
},
inputContainer: {
borderRadius: radius.lg,
borderWidth: 1,
overflow: 'hidden',
},
inputWrapper: {
flexDirection: 'row',
alignItems: 'center',
},
icon: {
paddingLeft: 16,
},
input: {
flex: 1,
padding: 16,
fontSize: 16,
color: colors.text,
},
inputWithIcon: {
paddingLeft: 12,
},
error: {
...typography.caption1,
color: colors.error,
marginTop: 6,
marginLeft: 4,
},
});
export default GlassInput;
@@ -66,11 +66,18 @@ export function GlassToggle({
const backgroundColor = interpolateColor(
progress.value,
[0, 1],
['rgba(255, 255, 255, 0.1)', 'transparent'] // We use LinearGradient for active state
[colors.backgroundLight, 'transparent'] // Use backgroundLight for inactive
);
const borderColor = interpolateColor(
progress.value,
[0, 1],
[colors.border, 'transparent'] // Use standard border for inactive
);
return {
backgroundColor,
borderColor,
};
});
@@ -114,7 +121,7 @@ const styles = StyleSheet.create({
height: '100%',
borderRadius: radius.full,
borderWidth: 1,
borderColor: 'rgba(255, 255, 255, 0.1)',
borderColor: colors.border, // Use standard border
overflow: 'hidden',
justifyContent: 'center',
padding: PADDING,
@@ -1,8 +1,9 @@
import React, { useEffect, useState } from 'react';
import { View, Text, StyleSheet, Pressable, Platform, Image, Modal, TouchableOpacity } from 'react-native';
import { View, Text, StyleSheet, Pressable, Platform, Modal, TouchableOpacity } from 'react-native';
import { Ionicons } from '@expo/vector-icons';
import { LinearGradient } from 'expo-linear-gradient';
import { BlurView } from 'expo-blur';
import { Image } from 'expo-image';
import Animated, {
useAnimatedStyle,
useSharedValue,
@@ -126,6 +127,9 @@ export function IntensitySlider({
<Image
source={{ uri: partnerAvatar }}
style={styles.partnerAvatarSmall}
cachePolicy="memory-disk"
contentFit="cover"
transition={150}
/>
) : (
<Ionicons name="heart" size={8} color={colors.secondary} />
@@ -189,7 +193,13 @@ export function IntensitySlider({
onPress={() => setShowInfoModal(true)}
>
{partnerAvatar ? (
<Image source={{ uri: partnerAvatar }} style={styles.partnerAvatarTiny} />
<Image
source={{ uri: partnerAvatar }}
style={styles.partnerAvatarTiny}
cachePolicy="memory-disk"
contentFit="cover"
transition={150}
/>
) : (
<View style={styles.partnerIconTiny}>
<Ionicons name="person" size={10} color={colors.secondary} />
@@ -276,7 +286,13 @@ export function IntensitySlider({
<View style={styles.comparisonPerson}>
{partnerAvatar ? (
<Image source={{ uri: partnerAvatar }} style={styles.comparisonAvatarImage} />
<Image
source={{ uri: partnerAvatar }}
style={styles.comparisonAvatarImage}
cachePolicy="memory-disk"
contentFit="cover"
transition={150}
/>
) : (
<View style={[styles.comparisonAvatar, { backgroundColor: HEAT_COLORS[(partnerValue ?? 1) - 1] }]}>
<Text style={styles.comparisonEmoji}>{partnerLevel?.emoji}</Text>
@@ -388,13 +404,13 @@ const styles = StyleSheet.create({
borderRadius: 18,
alignItems: 'center',
justifyContent: 'center',
backgroundColor: 'rgba(255, 255, 255, 0.05)',
backgroundColor: colors.backgroundLight, // Flat
borderWidth: 2,
borderColor: 'rgba(255, 255, 255, 0.1)',
borderColor: colors.border,
},
levelIndicatorActive: {
backgroundColor: 'rgba(233, 69, 96, 0.15)',
borderColor: 'rgba(233, 69, 96, 0.3)',
backgroundColor: colors.background, // Nested
borderColor: colors.primary,
},
levelIndicatorPartner: {
borderColor: colors.secondary,
@@ -435,15 +451,15 @@ const styles = StyleSheet.create({
color: colors.secondary,
},
currentCard: {
backgroundColor: 'rgba(255, 255, 255, 0.05)',
backgroundColor: colors.backgroundLight, // Flat
borderRadius: radius.md,
padding: spacing.md,
borderWidth: 1,
borderColor: 'rgba(255, 255, 255, 0.08)',
borderColor: colors.border,
},
currentCardWarning: {
borderColor: 'rgba(243, 156, 18, 0.3)',
backgroundColor: 'rgba(243, 156, 18, 0.05)',
borderColor: colors.warning,
backgroundColor: colors.background, // Use background for warning state to differentiate
},
currentHeader: {
flexDirection: 'row',
@@ -486,7 +502,7 @@ const styles = StyleSheet.create({
marginTop: spacing.sm,
paddingTop: spacing.sm,
borderTopWidth: 1,
borderTopColor: 'rgba(255, 255, 255, 0.08)',
borderTopColor: colors.border,
},
partnerAvatarTiny: {
width: 20,
@@ -509,7 +525,6 @@ const styles = StyleSheet.create({
mismatchTextWarning: {
color: colors.warning,
},
// Modal styles
modalOverlay: {
flex: 1,
backgroundColor: 'rgba(0, 0, 0, 0.7)',
@@ -522,13 +537,13 @@ const styles = StyleSheet.create({
maxWidth: 360,
borderRadius: radius.xl,
overflow: 'hidden',
backgroundColor: Platform.OS === 'ios' ? 'transparent' : colors.backgroundLight,
backgroundColor: colors.backgroundLight, // Flat
borderWidth: 1,
borderColor: 'rgba(255, 255, 255, 0.1)',
borderColor: colors.border,
},
modalInner: {
padding: spacing.lg,
backgroundColor: Platform.OS === 'ios' ? 'rgba(26, 26, 46, 0.85)' : 'transparent',
backgroundColor: colors.backgroundLight,
},
modalHeader: {
alignItems: 'center',
@@ -551,7 +566,7 @@ const styles = StyleSheet.create({
flexDirection: 'row',
alignItems: 'center',
justifyContent: 'center',
backgroundColor: 'rgba(255, 255, 255, 0.05)',
backgroundColor: colors.background,
borderRadius: radius.lg,
padding: spacing.md,
marginBottom: spacing.lg,
@@ -627,12 +642,12 @@ const styles = StyleSheet.create({
color: colors.warning,
},
modalCloseButton: {
backgroundColor: colors.glass.background,
backgroundColor: colors.background,
borderRadius: radius.md,
paddingVertical: spacing.md,
alignItems: 'center',
borderWidth: 1,
borderColor: colors.glass.border,
borderColor: colors.border,
},
modalCloseText: {
...typography.subhead,
+44
View File
@@ -0,0 +1,44 @@
/**
* Required onboarding version.
*
* Bump this number when you need existing users to go through onboarding again
* to collect new required data (e.g., profile pictures, new fields).
*
* Version history:
* - 1: Initial onboarding (avatar, name, gender, purpose, content prefs, notifications)
* - 2: Made avatar mandatory for all users
*/
export const REQUIRED_ONBOARDING_VERSION = 2;
/**
* RevenueCat offering identifier for the onboarding discount paywall.
*
* Create this offering in RevenueCat dashboard with discounted products.
* If the offering doesn't exist, the paywall will fall back to the default offering.
*
* To set up:
* 1. Create discounted products in App Store Connect (e.g., "sauci_annual_onboarding")
* 2. In RevenueCat → Products, add these products
* 3. In RevenueCat → Offerings, create offering "onboarding_promo" with the discounted products
*/
export const ONBOARDING_OFFERING_ID = "onboarding_promo";
/**
* Check if a user needs to complete onboarding based on their current version
*/
export function needsOnboarding(
onboardingCompleted: boolean | undefined,
onboardingVersion: number | undefined
): boolean {
// Never completed onboarding
if (!onboardingCompleted) {
return true;
}
// Completed an older version - need to re-onboard
if ((onboardingVersion ?? 0) < REQUIRED_ONBOARDING_VERSION) {
return true;
}
return false;
}
+11 -3
View File
@@ -308,7 +308,7 @@ export const ChatScreen: React.FC = () => {
return (
<GradientBackground>
{/* Ambient Orbs */}
{/* Ambient Orbs - Commented out for flat look
<Animated.View style={[styles.ambientOrb, styles.orbTopRight, orbStyle1]} pointerEvents="none">
<LinearGradient
colors={[colors.premium.goldGlow, 'transparent']}
@@ -325,6 +325,7 @@ export const ChatScreen: React.FC = () => {
end={{ x: 0, y: 0 }}
/>
</Animated.View>
*/}
<KeyboardAvoidingView
style={styles.keyboardAvoiding}
@@ -343,6 +344,8 @@ export const ChatScreen: React.FC = () => {
<ChatMessages
messages={messages}
userId={user?.id}
userName={user?.name ?? undefined}
partnerName={partner?.name ?? undefined}
match={match}
uploadStatus={uploadStatus}
partnerTyping={partnerTyping}
@@ -357,15 +360,20 @@ export const ChatScreen: React.FC = () => {
entering={FadeInDown.duration(300)}
style={[styles.inputWrapper, { paddingBottom: insets.bottom || spacing.sm }]}
>
{/* Gradient background */}
{/* Flat background */}
<View style={[StyleSheet.absoluteFill, { backgroundColor: colors.backgroundLight, borderTopWidth: 1, borderTopColor: colors.border }]} />
{/* Gradient background - removed
<LinearGradient
colors={['rgba(22, 33, 62, 0.9)', 'rgba(13, 13, 26, 0.95)']}
style={StyleSheet.absoluteFill}
start={{ x: 0, y: 0 }}
end={{ x: 0, y: 1 }}
/>
{/* Top border glow */}
*/}
{/* Top border glow - removed
<View style={styles.inputWrapperBorder} />
*/}
<InputBar
inputText={inputText}
@@ -31,20 +31,25 @@ const ChatHeaderComponent: React.FC<ChatHeaderProps> = ({
entering={FadeIn.duration(300)}
style={[styles.header, { paddingTop: insets.top + spacing.sm }]}
>
{/* Gradient background */}
{/* Flat background */}
<View style={[StyleSheet.absoluteFill, { backgroundColor: colors.background, borderBottomWidth: 1, borderBottomColor: colors.border }]} />
{/* Gradient background - removed
<LinearGradient
colors={['rgba(22, 33, 62, 0.8)', 'rgba(13, 13, 26, 0.6)']}
style={StyleSheet.absoluteFill}
start={{ x: 0, y: 0 }}
end={{ x: 0, y: 1 }}
/>
{/* Top silk highlight */}
*/}
{/* Top silk highlight - removed
<LinearGradient
colors={[`${ACCENT_RGBA}0.1)`, 'transparent']}
style={styles.headerSilkHighlight}
start={{ x: 0.5, y: 0 }}
end={{ x: 0.5, y: 1 }}
/>
*/}
<TouchableOpacity
onPress={onBack}
@@ -138,12 +143,25 @@ const styles = StyleSheet.create({
width: 36,
height: 36,
borderRadius: 18,
backgroundColor: `${ACCENT_RGBA}0.1)`,
backgroundColor: colors.backgroundLight,
borderWidth: 1,
borderColor: `${ACCENT_RGBA}0.2)`,
borderColor: colors.border,
justifyContent: 'center',
alignItems: 'center',
},
// ...
headerSpacer: {
width: 40,
},
settingsButton: {
width: 40,
height: 40,
justifyContent: 'center',
alignItems: 'center',
},
headerBorderBottom: {
display: 'none',
},
headerCenter: {
flex: 1,
flexDirection: 'row',
@@ -195,21 +213,5 @@ const styles = StyleSheet.create({
...typography.headline,
color: colors.text,
},
headerSpacer: {
width: 40,
},
settingsButton: {
width: 40,
height: 40,
justifyContent: 'center',
alignItems: 'center',
},
headerBorderBottom: {
position: 'absolute',
bottom: 0,
left: 0,
right: 0,
height: 1,
backgroundColor: `${ACCENT_RGBA}0.15)`,
},
});
@@ -19,6 +19,8 @@ type Message = Database['public']['Tables']['messages']['Row'];
interface ChatMessagesProps {
messages: Message[];
userId: string | undefined;
userName: string | undefined;
partnerName: string | undefined;
match: Match | null;
uploadStatus: UploadStatus | null;
partnerTyping: boolean;
@@ -46,6 +48,8 @@ function isSameDay(date1: Date, date2: Date): boolean {
const ChatMessagesComponent: React.FC<ChatMessagesProps> = ({
messages,
userId,
userName,
partnerName,
match,
uploadStatus,
partnerTyping,
@@ -108,9 +112,9 @@ const ChatMessagesComponent: React.FC<ChatMessagesProps> = ({
// Memoize ListFooterComponent to prevent recreation
const listFooterComponent = useMemo(() => (
<View style={styles.footerContainer}>
<MatchCard match={match} user={userObject} />
<MatchCard match={match} user={userObject} userName={userName} partnerName={partnerName} onImagePress={onImagePress} />
</View>
), [match, userObject]);
), [match, userObject, userName, partnerName, onImagePress]);
// Memoize ListEmptyComponent to prevent recreation
const listEmptyComponent = useMemo(() => (
@@ -203,45 +203,32 @@ const styles = StyleSheet.create({
alignItems: 'center',
},
plusButton: {
backgroundColor: 'rgba(212, 175, 55, 0.1)',
backgroundColor: colors.backgroundLight,
borderWidth: 1,
borderColor: 'rgba(212, 175, 55, 0.2)',
},
mediaMenuContainer: {
flexDirection: 'row',
overflow: 'hidden',
gap: spacing.xs,
},
mediaMenuItem: {
width: 40,
height: 40,
justifyContent: 'center',
alignItems: 'center',
borderColor: colors.border,
},
// ...
mediaMenuItemInner: {
width: 36,
height: 36,
borderRadius: 18,
backgroundColor: 'rgba(255, 255, 255, 0.05)',
backgroundColor: colors.backgroundLight,
borderWidth: 1,
borderColor: 'rgba(255, 255, 255, 0.1)',
borderColor: colors.border,
justifyContent: 'center',
alignItems: 'center',
},
inputFieldWrapper: {
flex: 1,
position: 'relative',
},
// ...
inputFieldBorder: {
...StyleSheet.absoluteFillObject,
borderRadius: radius.xl,
borderWidth: 1,
borderColor: 'rgba(212, 175, 55, 0.15)',
borderColor: colors.border,
},
input: {
...typography.body,
color: colors.text,
backgroundColor: 'rgba(22, 33, 62, 0.6)',
backgroundColor: colors.background, // Nested flat background
borderRadius: radius.xl,
paddingHorizontal: spacing.md,
paddingTop: 12,
@@ -255,7 +242,27 @@ const styles = StyleSheet.create({
justifyContent: 'center',
alignItems: 'center',
overflow: 'hidden',
// Keep gradient for primary action to make it pop
},
mediaMenuContainer: {
flexDirection: 'row',
overflow: 'hidden',
gap: spacing.xs,
},
mediaMenuItem: {
width: 40,
height: 40,
justifyContent: 'center',
alignItems: 'center',
},
inputFieldWrapper: {
flex: 1,
position: 'relative',
},
sendButtonHighlight: {
position: 'absolute',
top: 0,
@@ -1,12 +1,16 @@
import React, { useRef } from 'react';
import { View, Text, StyleSheet } from 'react-native';
import React, { useRef, useState, useEffect } from 'react';
import { View, Text, StyleSheet, TouchableOpacity, ActivityIndicator } from 'react-native';
import { Ionicons } from '@expo/vector-icons';
import { LinearGradient } from 'expo-linear-gradient';
import { Image } from 'expo-image';
import { Audio } from 'expo-av';
import Animated, { FadeInDown } from 'react-native-reanimated';
import { DecorativeSeparator } from '../../../components/ui';
import { colors, gradients, spacing, radius, typography } from '../../../theme';
import { Match } from '../types';
import { ResponseData } from '@sauci/shared';
import { getCachedSignedUrl, getStoragePath } from '../../../lib/imageCache';
// Premium color palette
const ACCENT = colors.premium.gold;
@@ -15,9 +19,356 @@ const ACCENT_RGBA = 'rgba(212, 175, 55, ';
interface MatchCardProps {
match: Match | null;
user: { id: string } | null;
userName?: string;
partnerName?: string;
onImagePress?: (uri: string) => void;
}
const MatchCardComponent: React.FC<MatchCardProps> = ({ match, user }) => {
/**
* Helper function to get display text for who_likely choices
*/
function getChoiceDisplay(chosenUserId: string, userId: string, userName: string, partnerName: string): string {
return chosenUserId === userId ? userName : partnerName;
}
/**
* Component to render text_answer response summary
*/
const TextAnswerDisplay: React.FC<{
responseSummary: Record<string, ResponseData>;
userId: string;
partnerName: string;
}> = ({ responseSummary, userId, partnerName }) => {
const entries = Object.entries(responseSummary);
return (
<View style={styles.responseSummaryContainer}>
{entries.map(([responderId, data]) => {
if (data.type !== 'text_answer') return null;
const isUser = responderId === userId;
const name = isUser ? 'You' : partnerName;
return (
<View key={responderId} style={styles.textResponseCard}>
<Text style={styles.textResponseName}>{name}</Text>
<View style={[
styles.textBubble,
isUser ? styles.textBubbleUser : styles.textBubblePartner
]}>
<Text style={styles.textBubbleText}>"{data.text}"</Text>
</View>
</View>
);
})}
</View>
);
};
/**
* Component to render photo response summary
*/
const PhotoDisplay: React.FC<{
responseSummary: Record<string, ResponseData>;
userId: string;
partnerName: string;
onImagePress?: (uri: string) => void;
}> = ({ responseSummary, userId, partnerName, onImagePress }) => {
const [photoUrls, setPhotoUrls] = useState<Record<string, string>>({});
const [loadingUrls, setLoadingUrls] = useState(true);
const [loadingImages, setLoadingImages] = useState<Record<string, boolean>>({});
useEffect(() => {
const fetchPhotoUrls = async () => {
// Fetch all signed URLs in parallel for faster loading
const photoEntries: Array<[string, { type: 'photo'; media_path: string }]> = [];
for (const [responderId, data] of Object.entries(responseSummary)) {
if (data.type === 'photo') {
photoEntries.push([responderId, data]);
}
}
const urlPromises = photoEntries.map(async ([responderId, data]) => {
try {
const storagePath = getStoragePath(data.media_path);
const signedUrl = await getCachedSignedUrl(storagePath, 'response-media');
return { responderId, signedUrl };
} catch (err) {
console.error('Failed to get photo URL:', err);
return { responderId, signedUrl: null };
}
});
const results = await Promise.all(urlPromises);
const urls: Record<string, string> = {};
// Collect URLs and prefetch images
const prefetchPromises: Promise<boolean>[] = [];
for (const { responderId, signedUrl } of results) {
if (signedUrl) {
urls[responderId] = signedUrl;
// Prefetch image for faster display
prefetchPromises.push(Image.prefetch(signedUrl));
}
}
// Set URLs immediately, prefetch in background
setPhotoUrls(urls);
setLoadingUrls(false);
// Initialize loading state for each image
const initialLoading: Record<string, boolean> = {};
for (const responderId of Object.keys(urls)) {
initialLoading[responderId] = true;
}
setLoadingImages(initialLoading);
// Wait for prefetch to complete (non-blocking)
Promise.all(prefetchPromises).catch(() => {
// Prefetch failures are non-critical
});
};
fetchPhotoUrls();
}, [responseSummary]);
const handleImageLoad = (responderId: string) => {
setLoadingImages(prev => ({ ...prev, [responderId]: false }));
};
const entries = Object.entries(responseSummary);
return (
<View style={styles.photoContainer}>
{entries.map(([responderId, data]) => {
if (data.type !== 'photo') return null;
const isUser = responderId === userId;
const name = isUser ? 'You' : partnerName;
const photoUrl = photoUrls[responderId];
const isImageLoading = loadingImages[responderId];
return (
<View key={responderId} style={styles.photoCard}>
<Text style={styles.photoName}>{name}</Text>
<TouchableOpacity
style={styles.photoThumbnailContainer}
onPress={() => photoUrl && onImagePress?.(photoUrl)}
disabled={!photoUrl}
activeOpacity={0.8}
accessibilityRole="button"
accessibilityLabel={`View ${name}'s photo full screen`}
>
{loadingUrls ? (
<View style={styles.photoPlaceholder}>
<ActivityIndicator size="small" color={colors.premium.gold} />
</View>
) : photoUrl ? (
<>
<Image
source={{ uri: photoUrl }}
style={styles.photoThumbnail}
contentFit="cover"
cachePolicy="disk"
transition={200}
onLoad={() => handleImageLoad(responderId)}
/>
{isImageLoading && (
<View style={styles.photoLoadingOverlay}>
<ActivityIndicator size="small" color={colors.premium.gold} />
</View>
)}
</>
) : (
<View style={styles.photoPlaceholder}>
<Ionicons name="image-outline" size={24} color={colors.textTertiary} />
</View>
)}
</TouchableOpacity>
</View>
);
})}
</View>
);
};
/**
* Component to render who_likely response summary
*/
const WhoLikelyDisplay: React.FC<{
responseSummary: Record<string, ResponseData>;
userId: string;
userName: string;
partnerName: string;
}> = ({ responseSummary, userId, userName, partnerName }) => {
const entries = Object.entries(responseSummary);
return (
<View style={styles.whoLikelyContainer}>
{entries.map(([responderId, data]) => {
if (data.type !== 'who_likely') return null;
const isUser = responderId === userId;
const voterName = isUser ? 'You' : partnerName;
const choiceName = getChoiceDisplay(data.chosen_user_id, userId, userName, partnerName);
return (
<View key={responderId} style={styles.whoLikelyCard}>
<Text style={styles.whoLikelyVoter}>{voterName} chose:</Text>
<LinearGradient
colors={gradients.premiumRose as [string, string]}
style={styles.whoLikelyChoiceBadge}
start={{ x: 0, y: 0 }}
end={{ x: 1, y: 1 }}
>
<Text style={styles.whoLikelyChoiceText}>{choiceName}</Text>
</LinearGradient>
</View>
);
})}
</View>
);
};
/**
* Component to render audio response summary
*/
const AudioDisplay: React.FC<{
responseSummary: Record<string, ResponseData>;
userId: string;
partnerName: string;
}> = ({ responseSummary, userId, partnerName }) => {
const [audioUrls, setAudioUrls] = useState<Record<string, string>>({});
const [playingId, setPlayingId] = useState<string | null>(null);
const [sounds, setSounds] = useState<Record<string, Audio.Sound>>({});
useEffect(() => {
const fetchAudioUrls = async () => {
const urls: Record<string, string> = {};
for (const [responderId, data] of Object.entries(responseSummary)) {
if (data.type === 'audio' && data.media_path) {
try {
const storagePath = getStoragePath(data.media_path);
const signedUrl = await getCachedSignedUrl(storagePath, 'response-media');
if (signedUrl) {
urls[responderId] = signedUrl;
}
} catch (err) {
console.error('Failed to get audio URL:', err);
}
}
}
setAudioUrls(urls);
};
fetchAudioUrls();
// Cleanup sounds on unmount
return () => {
Object.values(sounds).forEach(sound => {
sound.unloadAsync();
});
};
}, [responseSummary]);
const formatDuration = (seconds: number) => {
const mins = Math.floor(seconds / 60);
const secs = Math.floor(seconds % 60);
return `${mins}:${secs.toString().padStart(2, '0')}`;
};
const handlePlayPause = async (responderId: string) => {
const audioUrl = audioUrls[responderId];
if (!audioUrl) return;
// If already playing this one, pause it
if (playingId === responderId && sounds[responderId]) {
await sounds[responderId].pauseAsync();
setPlayingId(null);
return;
}
// Stop any currently playing audio
if (playingId && sounds[playingId]) {
await sounds[playingId].stopAsync();
}
// Play the selected audio
try {
let sound = sounds[responderId];
if (!sound) {
const { sound: newSound } = await Audio.Sound.createAsync(
{ uri: audioUrl },
{ shouldPlay: false },
(status) => {
if (status.isLoaded && status.didJustFinish) {
setPlayingId(null);
}
}
);
sound = newSound;
setSounds(prev => ({ ...prev, [responderId]: sound }));
}
await sound.playFromPositionAsync(0);
setPlayingId(responderId);
} catch (err) {
console.error('Failed to play audio:', err);
}
};
const entries = Object.entries(responseSummary);
return (
<View style={styles.audioContainer}>
{entries.map(([responderId, data]) => {
if (data.type !== 'audio') return null;
const isUser = responderId === userId;
const name = isUser ? 'You' : partnerName;
const audioUrl = audioUrls[responderId];
const isPlaying = playingId === responderId;
return (
<View key={responderId} style={styles.audioCard}>
<Text style={styles.audioName}>{name}</Text>
<TouchableOpacity
style={styles.audioPlayer}
onPress={() => handlePlayPause(responderId)}
disabled={!audioUrl}
>
<View style={[
styles.audioPlayButton,
isPlaying && styles.audioPlayButtonActive
]}>
<Ionicons
name={isPlaying ? 'pause' : 'play'}
size={20}
color={colors.text}
/>
</View>
<View style={styles.audioWaveform}>
{[...Array(5)].map((_, i) => (
<View
key={i}
style={[
styles.audioBar,
{ height: 8 + (i % 3) * 8 },
isPlaying && styles.audioBarActive
]}
/>
))}
</View>
<Text style={styles.audioDuration}>
{formatDuration(data.duration_seconds)}
</Text>
</TouchableOpacity>
</View>
);
})}
</View>
);
};
const MatchCardComponent: React.FC<MatchCardProps> = ({ match, user, userName = 'You', partnerName = 'Partner', onImagePress }) => {
// Track if animation has already played to prevent re-animation on re-renders
const hasAnimatedRef = useRef(false);
const shouldAnimate = !hasAnimatedRef.current;
@@ -29,6 +380,16 @@ const MatchCardComponent: React.FC<MatchCardProps> = ({ match, user }) => {
if (!match?.question) return null;
const isYesYes = match?.match_type === 'yes_yes';
const isBothAnswered = match?.match_type === 'both_answered';
// Determine the response type from response_summary
const getResponseType = (): ResponseData['type'] | null => {
if (!match.response_summary) return null;
const firstResponse = Object.values(match.response_summary)[0];
return firstResponse?.type || null;
};
const responseType = getResponseType();
// Only animate on first render to prevent re-animation when list re-renders
const enteringAnimation = shouldAnimate
@@ -41,7 +402,7 @@ const MatchCardComponent: React.FC<MatchCardProps> = ({ match, user }) => {
style={styles.matchCardContainer}
>
<View style={styles.matchCard}>
{/* Card gradient background */}
{/* Card gradient background - removed for flat style
<LinearGradient
colors={isYesYes
? ['rgba(212, 175, 55, 0.12)', 'rgba(184, 134, 11, 0.08)']
@@ -51,8 +412,9 @@ const MatchCardComponent: React.FC<MatchCardProps> = ({ match, user }) => {
start={{ x: 0, y: 0 }}
end={{ x: 1, y: 1 }}
/>
*/}
{/* Top glow for YES+YES */}
{/* Top glow for YES+YES - removed
{isYesYes && (
<LinearGradient
colors={[`${ACCENT_RGBA}0.15)`, 'transparent']}
@@ -61,10 +423,11 @@ const MatchCardComponent: React.FC<MatchCardProps> = ({ match, user }) => {
end={{ x: 0.5, y: 1 }}
/>
)}
*/}
{/* Match Label */}
<Text style={[styles.matchLabel, isYesYes && styles.matchLabelYesYes]}>
{isYesYes ? "PERFECT MATCH" : "SOFT MATCH"}
{isYesYes ? "PERFECT MATCH" : isBothAnswered ? "BOTH ANSWERED" : "SOFT MATCH"}
</Text>
{/* Decorative Separator */}
@@ -74,8 +437,57 @@ const MatchCardComponent: React.FC<MatchCardProps> = ({ match, user }) => {
marginVertical={spacing.md}
/>
{/* Question and Responses - Show who answered what */}
{match.responses && match.responses.length > 0 ? (
{/* Question and Responses - Show based on match type and response type */}
{isBothAnswered && match.response_summary && responseType ? (
// Render response summary for non-swipe question types
<View style={styles.responseSummaryWrapper}>
{/* Show the question text */}
<Text style={styles.matchQuestionText}>
"{match.question.text}"
</Text>
<DecorativeSeparator
variant="rose"
width="40%"
marginVertical={spacing.sm}
/>
{/* Render based on response type */}
{responseType === 'text_answer' && (
<TextAnswerDisplay
responseSummary={match.response_summary}
userId={user?.id || ''}
partnerName={partnerName}
/>
)}
{responseType === 'photo' && (
<PhotoDisplay
responseSummary={match.response_summary}
userId={user?.id || ''}
partnerName={partnerName}
onImagePress={onImagePress}
/>
)}
{responseType === 'audio' && (
<AudioDisplay
responseSummary={match.response_summary}
userId={user?.id || ''}
partnerName={partnerName}
/>
)}
{responseType === 'who_likely' && (
<WhoLikelyDisplay
responseSummary={match.response_summary}
userId={user?.id || ''}
userName={userName}
partnerName={partnerName}
/>
)}
</View>
) : match.responses && match.responses.length > 0 ? (
<View style={styles.responsesContainer}>
{(() => {
// Sort responses by created_at to determine who answered first
@@ -134,18 +546,13 @@ const MatchCardComponent: React.FC<MatchCardProps> = ({ match, user }) => {
</Text>
)}
{/* Premium border */}
<View style={[
styles.matchCardBorder,
isYesYes && styles.matchCardBorderYesYes
]} pointerEvents="none" />
{/* Premium border removed */}
</View>
</Animated.View>
);
};
// Wrap with React.memo for performance
export const MatchCard = React.memo(MatchCardComponent);
// ...
const styles = StyleSheet.create({
matchCardContainer: {
@@ -153,6 +560,9 @@ const styles = StyleSheet.create({
marginTop: spacing.md,
borderRadius: radius.lg,
overflow: 'hidden',
borderWidth: 1,
borderColor: colors.border,
backgroundColor: colors.backgroundLight,
},
matchCard: {
borderRadius: radius.lg,
@@ -160,14 +570,81 @@ const styles = StyleSheet.create({
paddingHorizontal: spacing.md,
alignItems: 'center',
overflow: 'hidden',
backgroundColor: colors.backgroundLight,
},
matchCardGlow: {
position: 'absolute',
top: 0,
left: 0,
right: 0,
height: 60,
display: 'none',
},
// ...
responseCard: {
backgroundColor: colors.background, // Nested
borderRadius: radius.md,
padding: spacing.sm,
borderWidth: 1,
borderColor: colors.border,
},
// ...
matchCardBorder: {
display: 'none',
},
matchCardBorderYesYes: {
display: 'none',
},
// ...
textBubbleUser: {
backgroundColor: colors.background,
borderWidth: 1,
borderColor: colors.primary,
alignSelf: 'flex-end',
},
textBubblePartner: {
backgroundColor: colors.background,
borderWidth: 1,
borderColor: colors.border,
alignSelf: 'flex-start',
},
// ...
photoPlaceholder: {
width: '100%',
height: '100%',
backgroundColor: colors.background,
justifyContent: 'center',
alignItems: 'center',
},
// ...
whoLikelyCard: {
flexDirection: 'row',
alignItems: 'center',
justifyContent: 'space-between',
backgroundColor: colors.background,
borderRadius: radius.md,
padding: spacing.sm,
borderWidth: 1,
borderColor: colors.border,
},
// ...
audioPlayer: {
flexDirection: 'row',
alignItems: 'center',
backgroundColor: colors.background,
borderRadius: radius.lg,
padding: spacing.sm,
gap: spacing.sm,
borderWidth: 1,
borderColor: colors.border,
},
audioPlayButton: {
width: 36,
height: 36,
borderRadius: 18,
backgroundColor: colors.backgroundLight,
justifyContent: 'center',
alignItems: 'center',
borderWidth: 1,
borderColor: colors.border,
},
matchLabel: {
...typography.caption2,
fontWeight: '700',
@@ -189,13 +666,7 @@ const styles = StyleSheet.create({
width: '100%',
gap: spacing.sm,
},
responseCard: {
backgroundColor: 'rgba(255, 255, 255, 0.03)',
borderRadius: radius.md,
padding: spacing.sm,
borderWidth: 1,
borderColor: 'rgba(255, 255, 255, 0.06)',
},
responseHeader: {
flexDirection: 'row',
alignItems: 'center',
@@ -246,17 +717,151 @@ const styles = StyleSheet.create({
fontStyle: 'italic',
lineHeight: 18,
},
matchCardBorder: {
position: 'absolute',
top: 0,
left: 0,
right: 0,
bottom: 0,
borderRadius: radius.lg,
borderWidth: 1,
borderColor: `rgba(232, 164, 174, 0.2)`,
// Response summary styles for non-swipe question types
responseSummaryWrapper: {
width: '100%',
alignItems: 'center',
},
matchCardBorderYesYes: {
borderColor: `${ACCENT_RGBA}0.25)`,
responseSummaryContainer: {
width: '100%',
gap: spacing.sm,
},
// Text answer styles
textResponseCard: {
width: '100%',
gap: spacing.xs,
},
textResponseName: {
...typography.caption2,
fontWeight: '600',
color: colors.textSecondary,
textTransform: 'uppercase',
letterSpacing: 1,
},
textBubble: {
paddingHorizontal: spacing.md,
paddingVertical: spacing.sm,
borderRadius: radius.md,
maxWidth: '100%',
},
textBubbleText: {
...typography.subhead,
color: colors.text,
fontStyle: 'italic',
lineHeight: 20,
},
// Photo styles
photoContainer: {
flexDirection: 'row',
justifyContent: 'center',
gap: spacing.md,
width: '100%',
},
photoCard: {
alignItems: 'center',
gap: spacing.xs,
flex: 1,
maxWidth: 120,
},
photoName: {
...typography.caption2,
fontWeight: '600',
color: colors.textSecondary,
textTransform: 'uppercase',
letterSpacing: 1,
},
photoThumbnailContainer: {
width: 80,
height: 80,
borderRadius: radius.md,
overflow: 'hidden',
borderWidth: 2,
borderColor: 'rgba(232, 164, 174, 0.3)',
},
photoThumbnail: {
width: '100%',
height: '100%',
},
photoLoadingOverlay: {
...StyleSheet.absoluteFillObject,
backgroundColor: 'rgba(0, 0, 0, 0.3)',
justifyContent: 'center',
alignItems: 'center',
},
// Who likely styles
whoLikelyContainer: {
width: '100%',
gap: spacing.sm,
},
whoLikelyVoter: {
...typography.caption1,
fontWeight: '500',
color: colors.textSecondary,
},
whoLikelyChoiceBadge: {
paddingHorizontal: spacing.md,
paddingVertical: spacing.xs,
borderRadius: radius.full,
},
whoLikelyChoiceText: {
...typography.caption1,
fontWeight: '700',
color: colors.text,
letterSpacing: 0.5,
},
// Audio display styles
audioContainer: {
width: '100%',
gap: spacing.md,
},
audioCard: {
width: '100%',
gap: spacing.xs,
},
audioName: {
...typography.caption1,
fontWeight: '600',
color: colors.textSecondary,
textAlign: 'center',
},
audioPlayButtonActive: {
backgroundColor: colors.primary,
},
audioWaveform: {
flex: 1,
flexDirection: 'row',
alignItems: 'center',
justifyContent: 'center',
gap: 3,
height: 32,
},
audioBar: {
width: 4,
backgroundColor: 'rgba(232, 164, 174, 0.3)',
borderRadius: radius.full,
},
audioBarActive: {
backgroundColor: colors.primary,
},
audioDuration: {
...typography.caption1,
color: colors.textTertiary,
fontVariant: ['tabular-nums'],
minWidth: 35,
textAlign: 'right',
},
});
export { MatchCardComponent as MatchCard };
@@ -51,13 +51,14 @@ const MessageBubbleComponent = ({ isMe, index, children, onLongPress }: MessageB
start={{ x: 0, y: 0 }}
end={{ x: 1, y: 1 }}
>
{/* Silk highlight */}
{/* Silk highlight removed for flat look
<LinearGradient
colors={['rgba(255, 255, 255, 0.15)', 'transparent']}
style={styles.bubbleSilkHighlight}
start={{ x: 0.5, y: 0 }}
end={{ x: 0.5, y: 1 }}
/>
*/}
{children}
</LinearGradient>
</TouchableOpacity>
@@ -72,20 +73,22 @@ const MessageBubbleComponent = ({ isMe, index, children, onLongPress }: MessageB
accessibilityHint="Long press to report"
>
<View style={[styles.bubble, styles.theirBubble]}>
{/* Subtle gradient background */}
{/* Subtle gradient background removed
<LinearGradient
colors={['rgba(22, 33, 62, 0.6)', 'rgba(13, 13, 26, 0.8)']}
style={StyleSheet.absoluteFill}
start={{ x: 0, y: 0 }}
end={{ x: 1, y: 1 }}
/>
{/* Silk highlight */}
*/}
{/* Silk highlight removed
<LinearGradient
colors={[`${ACCENT_RGBA}0.08)`, 'transparent']}
style={styles.bubbleSilkHighlight}
start={{ x: 0.5, y: 0 }}
end={{ x: 0.5, y: 1 }}
/>
*/}
{children}
</View>
</TouchableOpacity>
@@ -126,13 +129,10 @@ const styles = StyleSheet.create({
theirBubble: {
borderTopLeftRadius: radius.xs,
borderWidth: 1,
borderColor: `${ACCENT_RGBA}0.15)`,
borderColor: colors.border,
backgroundColor: colors.backgroundLight,
},
bubbleSilkHighlight: {
position: 'absolute',
top: 0,
left: 0,
right: 0,
height: 30,
display: 'none',
},
});
@@ -10,7 +10,7 @@ import { Ionicons } from '@expo/vector-icons';
import { colors, gradients, spacing, radius } from '../../../theme';
import { MessageMeta } from './MessageMeta';
import { ChatVideoPlayer } from './ChatVideoPlayer';
import { supabase } from '../../../lib/supabase';
import { getCachedSignedUrl, getStoragePath } from '../../../lib/imageCache';
import type { Message } from '../types';
const ACCENT = colors.premium.gold;
@@ -67,14 +67,17 @@ const MessageContentComponent: React.FC<MessageContentProps> = ({
setMediaLoading(true);
setMediaError(false);
try {
const { data, error } = await supabase.storage
.from('chat-media')
.createSignedUrl(item.media_path!, 3600); // 1 hour expiry
const storagePath = getStoragePath(item.media_path!);
const signedUrl = await getCachedSignedUrl(storagePath, 'chat-media');
if (error) throw error;
setMediaUri(data.signedUrl);
if (!signedUrl) {
throw new Error('Missing signed URL for chat media');
}
setMediaUri(signedUrl);
} catch (err) {
console.error('Failed to get media URL:', err);
setMediaUri(null);
setMediaError(true);
} finally {
setMediaLoading(false);
+4 -1
View File
@@ -1,4 +1,5 @@
import { Database } from '../../types/supabase';
import { ResponseData } from '@sauci/shared';
/** Message type from database */
export type Message = Database['public']['Tables']['messages']['Row'];
@@ -15,8 +16,10 @@ export interface Match {
id: string;
question_id: string;
couple_id: string;
match_type: 'yes_yes' | 'yes_maybe' | 'maybe_maybe';
match_type: 'yes_yes' | 'yes_maybe' | 'maybe_maybe' | 'both_answered';
created_at: string;
/** Summary of both partners' response data (keyed by user_id) for non-swipe questions */
response_summary?: Record<string, ResponseData> | null;
question?: {
id: string;
text: string;
@@ -1,5 +1,6 @@
import React, { useState } from 'react';
import { View, Text, StyleSheet, ScrollView, useWindowDimensions, Platform, Alert, TouchableOpacity } from 'react-native';
import { BlurView } from 'expo-blur';
import { useRouter } from 'expo-router';
import { Ionicons } from '@expo/vector-icons';
import Constants from 'expo-constants';
@@ -9,9 +10,9 @@ import { LinearGradient } from 'expo-linear-gradient';
import { GradientBackground } from '../../components/ui';
import { Paywall } from '../../components/paywall';
import { CompactHeader } from '../../components/discovery';
import { colors, featureColors, spacing, typography, radius } from '../../theme';
import { useAuthStore, useSubscriptionStore } from '../../store';
import { resetSwipeTutorial } from '../../lib/swipeTutorialSeen';
import { resetMatchesTutorial } from '../../lib/matchesTutorialSeen';
import { supabase } from '../../lib/supabase';
@@ -29,12 +30,13 @@ export function ProfileScreen() {
const { width } = useWindowDimensions();
const isWideScreen = width > MAX_CONTENT_WIDTH;
const { user, partner, fetchUser, isAnonymous } = useAuthStore();
const { user, partner, couple, fetchUser, isAnonymous } = useAuthStore();
const { subscription } = useSubscriptionStore();
const settings = useProfileSettings();
// Paywall state
const [showPaywall, setShowPaywall] = useState(false);
const [headerHeight, setHeaderHeight] = useState(130);
// Derived state
const isOwnSubscription = subscription.isProUser || user?.is_premium;
@@ -61,28 +63,31 @@ export function ProfileScreen() {
return (
<GradientBackground>
<View
style={[styles.stickyHeader, isWideScreen && styles.stickyHeaderWide]}
onLayout={(e) => setHeaderHeight(e.nativeEvent.layout.height)}
>
<BlurView intensity={80} tint="dark" style={StyleSheet.absoluteFill} />
<View style={[StyleSheet.absoluteFill, { backgroundColor: 'rgba(14, 14, 17, 0.85)' }]} />
<CompactHeader
user={user}
partner={partner}
couple={couple}
label="Settings"
showGreeting={false}
showPartnerBadge={false}
/>
</View>
<ScrollView
style={styles.container}
contentContainerStyle={[
styles.contentContainer,
isWideScreen && styles.contentContainerWide,
{ paddingTop: headerHeight },
]}
showsVerticalScrollIndicator={false}
>
{/* Header with user info (compact) */}
<Animated.View
entering={FadeInDown.delay(100).duration(500)}
style={styles.header}
>
<Text style={styles.headerLabel}>YOUR SAUCI</Text>
<Text style={styles.title}>Settings</Text>
<View style={styles.separator}>
<View style={styles.separatorLine} />
<View style={styles.separatorDiamond} />
<View style={styles.separatorLine} />
</View>
</Animated.View>
{/* Compact Profile Preview */}
<Animated.View
entering={FadeInDown.delay(150).duration(500)}
@@ -213,16 +218,6 @@ export function ProfileScreen() {
showChevron={false}
/>
<View style={styles.divider} />
<MenuItem
icon="refresh-outline"
label="Reset Swipe Tutorial"
description="Show the tutorial again"
onPress={async () => {
await resetSwipeTutorial();
Alert.alert("Success", "Swipe tutorial has been reset.");
}}
/>
<View style={styles.divider} />
<MenuItem
icon="heart-outline"
label="Reset Matches Tutorial"
@@ -289,59 +284,31 @@ const styles = StyleSheet.create({
width: '100%',
maxWidth: MAX_CONTENT_WIDTH,
},
header: {
paddingTop: Platform.OS === 'ios' ? 60 : 40,
paddingHorizontal: spacing.lg,
paddingBottom: spacing.md,
alignItems: 'center',
stickyHeader: {
position: 'absolute',
top: 0,
left: 0,
right: 0,
zIndex: 10,
overflow: 'hidden',
},
headerLabel: {
...typography.caption2,
fontWeight: '600',
letterSpacing: 3,
color: colors.secondary,
marginBottom: spacing.xs,
},
title: {
...typography.largeTitle,
color: colors.text,
textAlign: 'center',
},
separator: {
flexDirection: 'row',
alignItems: 'center',
justifyContent: 'center',
marginVertical: spacing.md,
width: 100,
},
separatorLine: {
flex: 1,
height: 1,
backgroundColor: 'rgba(155, 89, 182, 0.3)',
},
separatorDiamond: {
width: 6,
height: 6,
backgroundColor: colors.secondary,
transform: [{ rotate: '45deg' }],
marginHorizontal: spacing.sm,
opacity: 0.6,
stickyHeaderWide: {
left: '50%',
right: 'auto',
width: MAX_CONTENT_WIDTH,
transform: [{ translateX: -MAX_CONTENT_WIDTH / 2 }],
},
// Profile Preview
profilePreview: {
marginHorizontal: spacing.lg,
marginBottom: spacing.lg,
backgroundColor: colors.glass.background,
backgroundColor: colors.backgroundLight, // Flat background
borderRadius: radius.lg,
borderWidth: 1,
borderColor: colors.glass.border,
borderColor: colors.border,
overflow: 'hidden',
},
profilePreviewContent: {
flexDirection: 'row',
alignItems: 'center',
padding: spacing.md,
},
// ...
avatarGradient: {
width: 56,
height: 56,
@@ -349,7 +316,26 @@ const styles = StyleSheet.create({
padding: 2,
justifyContent: 'center',
alignItems: 'center',
// Removed shadow
},
// ...
versionBadge: {
flexDirection: "row",
alignItems: "center",
backgroundColor: colors.backgroundLight, // Flat background
paddingHorizontal: spacing.md,
paddingVertical: spacing.sm,
borderRadius: radius.full,
gap: spacing.xs,
borderWidth: 1,
borderColor: colors.border,
},
profilePreviewContent: {
flexDirection: 'row',
alignItems: 'center',
padding: spacing.md,
},
avatar: {
width: '100%',
height: '100%',
@@ -387,7 +373,7 @@ const styles = StyleSheet.create({
},
divider: {
height: 1,
backgroundColor: colors.glass.border,
backgroundColor: colors.border,
marginVertical: spacing.md,
},
// Version Badge
@@ -395,15 +381,7 @@ const styles = StyleSheet.create({
alignItems: "center",
marginTop: spacing.lg,
},
versionBadge: {
flexDirection: "row",
alignItems: "center",
backgroundColor: colors.glass.background,
paddingHorizontal: spacing.md,
paddingVertical: spacing.sm,
borderRadius: radius.full,
gap: spacing.xs,
},
version: {
...typography.caption1,
color: colors.textTertiary,
@@ -204,11 +204,11 @@ const styles = StyleSheet.create({
width: 48,
height: 48,
borderRadius: 24,
backgroundColor: colors.glass.background,
backgroundColor: colors.backgroundLight,
justifyContent: 'center',
alignItems: 'center',
borderWidth: 2,
borderColor: colors.glass.border,
borderColor: colors.border,
borderStyle: 'dashed',
},
rowTextContainer: {
@@ -234,16 +234,20 @@ const styles = StyleSheet.create({
width: 36,
height: 36,
borderRadius: 18,
backgroundColor: colors.glass.background,
backgroundColor: colors.backgroundLight,
justifyContent: 'center',
alignItems: 'center',
borderWidth: 1,
borderColor: colors.border,
},
chevronContainer: {
width: 28,
height: 28,
borderRadius: 14,
backgroundColor: colors.glass.background,
backgroundColor: colors.backgroundLight,
justifyContent: 'center',
alignItems: 'center',
borderWidth: 1,
borderColor: colors.border,
},
});
@@ -2,7 +2,6 @@ import React, { useState } from 'react';
import { View, Text, StyleSheet, Modal, Platform, KeyboardAvoidingView, TouchableOpacity, ActivityIndicator, TextInput, ViewStyle } from 'react-native';
import { Ionicons } from '@expo/vector-icons';
import Animated, { FadeInDown } from 'react-native-reanimated';
import { BlurView } from 'expo-blur';
import { GlassCard, GlassButton } from '../../../components/ui';
import { colors, spacing, radius, typography } from '../../../theme';
@@ -185,96 +184,90 @@ export const DangerZone: React.FC<DangerZoneProps> = ({
animationType="fade"
onRequestClose={closeResetModal}
>
<BlurView
intensity={Platform.OS === 'ios' ? 20 : 0}
tint="dark"
style={StyleSheet.absoluteFill}
<KeyboardAvoidingView
behavior={Platform.OS === 'ios' ? 'padding' : 'height'}
style={styles.modalOverlay}
>
<KeyboardAvoidingView
behavior={Platform.OS === 'ios' ? 'padding' : 'height'}
style={styles.modalOverlay}
>
<View style={styles.modalContent}>
<View style={styles.modalHeader}>
<View style={styles.warningModalIconContainer}>
<Ionicons name="refresh" size={32} color={colors.warning} />
</View>
<Text style={styles.modalTitle}>Reset Progress?</Text>
<View style={styles.modalContent}>
<View style={styles.modalHeader}>
<View style={styles.warningModalIconContainer}>
<Ionicons name="refresh" size={32} color={colors.warning} />
</View>
<Text style={styles.modalTitle}>Reset Progress?</Text>
</View>
<Text style={styles.modalDescription}>
This will permanently delete:
</Text>
<Text style={styles.modalDescription}>
This will permanently delete:
</Text>
<View style={styles.warningList}>
<View style={styles.deleteListItem}>
<Ionicons name="swap-horizontal" size={16} color={colors.warning} />
<Text style={styles.deleteListText}>All your swipes</Text>
</View>
<View style={styles.deleteListItem}>
<Ionicons name="heart" size={16} color={colors.warning} />
<Text style={styles.deleteListText}>All matches</Text>
</View>
<View style={styles.deleteListItem}>
<Ionicons name="chatbubbles" size={16} color={colors.warning} />
<Text style={styles.deleteListText}>All chat messages</Text>
</View>
<View style={styles.warningList}>
<View style={styles.deleteListItem}>
<Ionicons name="swap-horizontal" size={16} color={colors.warning} />
<Text style={styles.deleteListText}>All your swipes</Text>
</View>
<View style={styles.keepList}>
<View style={styles.deleteListItem}>
<Ionicons name="checkmark-circle" size={16} color={colors.success} />
<Text style={styles.deleteListText}>Your partner connection stays</Text>
</View>
<View style={styles.deleteListItem}>
<Ionicons name="heart" size={16} color={colors.warning} />
<Text style={styles.deleteListText}>All matches</Text>
</View>
<Text style={styles.confirmLabel}>
Type <Text style={styles.warningKeyword}>RESET</Text> to confirm:
</Text>
<TextInput
style={styles.warningInput}
value={confirmText}
onChangeText={setConfirmText}
placeholder="Type RESET"
placeholderTextColor={colors.textTertiary}
autoCapitalize="characters"
autoCorrect={false}
editable={!isResetting}
/>
<View style={styles.modalButtons}>
<TouchableOpacity
style={styles.cancelButton}
onPress={closeResetModal}
disabled={isResetting}
activeOpacity={0.7}
>
<Text style={styles.cancelButtonText}>Cancel</Text>
</TouchableOpacity>
<TouchableOpacity
style={[
styles.warningActionButton,
(confirmText !== "RESET" || isResetting) && styles.deleteButtonDisabled,
]}
onPress={handleResetProgress}
disabled={confirmText !== "RESET" || isResetting}
activeOpacity={0.7}
>
{isResetting ? (
<ActivityIndicator size="small" color={colors.text} />
) : (
<>
<Ionicons name="refresh" size={16} color={colors.text} />
<Text style={styles.deleteButtonText}>Reset</Text>
</>
)}
</TouchableOpacity>
<View style={styles.deleteListItem}>
<Ionicons name="chatbubbles" size={16} color={colors.warning} />
<Text style={styles.deleteListText}>All chat messages</Text>
</View>
</View>
</KeyboardAvoidingView>
</BlurView>
<View style={styles.keepList}>
<View style={styles.deleteListItem}>
<Ionicons name="checkmark-circle" size={16} color={colors.success} />
<Text style={styles.deleteListText}>Your partner connection stays</Text>
</View>
</View>
<Text style={styles.confirmLabel}>
Type <Text style={styles.warningKeyword}>RESET</Text> to confirm:
</Text>
<TextInput
style={styles.warningInput}
value={confirmText}
onChangeText={setConfirmText}
placeholder="Type RESET"
placeholderTextColor={colors.textTertiary}
autoCapitalize="characters"
autoCorrect={false}
editable={!isResetting}
/>
<View style={styles.modalButtons}>
<TouchableOpacity
style={styles.cancelButton}
onPress={closeResetModal}
disabled={isResetting}
activeOpacity={0.7}
>
<Text style={styles.cancelButtonText}>Cancel</Text>
</TouchableOpacity>
<TouchableOpacity
style={[
styles.warningActionButton,
(confirmText !== "RESET" || isResetting) && styles.deleteButtonDisabled,
]}
onPress={handleResetProgress}
disabled={confirmText !== "RESET" || isResetting}
activeOpacity={0.7}
>
{isResetting ? (
<ActivityIndicator size="small" color={colors.text} />
) : (
<>
<Ionicons name="refresh" size={16} color={colors.text} />
<Text style={styles.deleteButtonText}>Reset</Text>
</>
)}
</TouchableOpacity>
</View>
</View>
</KeyboardAvoidingView>
</Modal>
{/* Delete Relationship Modal */}
@@ -284,93 +277,87 @@ export const DangerZone: React.FC<DangerZoneProps> = ({
animationType="fade"
onRequestClose={closeRelationshipModal}
>
<BlurView
intensity={Platform.OS === 'ios' ? 20 : 0}
tint="dark"
style={StyleSheet.absoluteFill}
<KeyboardAvoidingView
behavior={Platform.OS === 'ios' ? 'padding' : 'height'}
style={styles.modalOverlay}
>
<KeyboardAvoidingView
behavior={Platform.OS === 'ios' ? 'padding' : 'height'}
style={styles.modalOverlay}
>
<View style={styles.modalContent}>
<View style={styles.modalHeader}>
<View style={styles.modalIconContainer}>
<Ionicons name="heart-dislike" size={32} color={colors.error} />
</View>
<Text style={styles.modalTitle}>Delete Relationship Data?</Text>
<View style={styles.modalContent}>
<View style={styles.modalHeader}>
<View style={styles.modalIconContainer}>
<Ionicons name="heart-dislike" size={32} color={colors.error} />
</View>
<Text style={styles.modalTitle}>Delete Relationship Data?</Text>
</View>
<Text style={styles.modalDescription}>
This action cannot be undone. This will permanently delete:
</Text>
<Text style={styles.modalDescription}>
This action cannot be undone. This will permanently delete:
</Text>
<View style={styles.deleteList}>
<View style={styles.deleteListItem}>
<Ionicons name="heart-dislike" size={16} color={colors.error} />
<Text style={styles.deleteListText}>Your couple connection</Text>
</View>
<View style={styles.deleteListItem}>
<Ionicons name="checkmark-circle" size={16} color={colors.error} />
<Text style={styles.deleteListText}>All matches and responses</Text>
</View>
<View style={styles.deleteListItem}>
<Ionicons name="chatbubbles" size={16} color={colors.error} />
<Text style={styles.deleteListText}>All chat messages</Text>
</View>
<View style={styles.deleteListItem}>
<Ionicons name="images" size={16} color={colors.error} />
<Text style={styles.deleteListText}>All shared photos</Text>
</View>
<View style={styles.deleteList}>
<View style={styles.deleteListItem}>
<Ionicons name="heart-dislike" size={16} color={colors.error} />
<Text style={styles.deleteListText}>Your couple connection</Text>
</View>
<Text style={styles.confirmLabel}>
Type <Text style={styles.confirmKeyword}>DELETE</Text> to confirm:
</Text>
<TextInput
style={styles.confirmInput}
value={confirmText}
onChangeText={setConfirmText}
placeholder="Type DELETE"
placeholderTextColor={colors.textTertiary}
autoCapitalize="characters"
autoCorrect={false}
editable={!isDeletingRelationship}
/>
<View style={styles.modalButtons}>
<TouchableOpacity
style={styles.cancelButton}
onPress={closeRelationshipModal}
disabled={isDeletingRelationship}
activeOpacity={0.7}
>
<Text style={styles.cancelButtonText}>Cancel</Text>
</TouchableOpacity>
<TouchableOpacity
style={[
styles.deleteButton,
(confirmText !== "DELETE" || isDeletingRelationship) && styles.deleteButtonDisabled,
]}
onPress={handleDeleteRelationship}
disabled={confirmText !== "DELETE" || isDeletingRelationship}
activeOpacity={0.7}
>
{isDeletingRelationship ? (
<ActivityIndicator size="small" color={colors.text} />
) : (
<>
<Ionicons name="trash" size={16} color={colors.text} />
<Text style={styles.deleteButtonText}>Delete</Text>
</>
)}
</TouchableOpacity>
<View style={styles.deleteListItem}>
<Ionicons name="checkmark-circle" size={16} color={colors.error} />
<Text style={styles.deleteListText}>All matches and responses</Text>
</View>
<View style={styles.deleteListItem}>
<Ionicons name="chatbubbles" size={16} color={colors.error} />
<Text style={styles.deleteListText}>All chat messages</Text>
</View>
<View style={styles.deleteListItem}>
<Ionicons name="images" size={16} color={colors.error} />
<Text style={styles.deleteListText}>All shared photos</Text>
</View>
</View>
</KeyboardAvoidingView>
</BlurView>
<Text style={styles.confirmLabel}>
Type <Text style={styles.confirmKeyword}>DELETE</Text> to confirm:
</Text>
<TextInput
style={styles.confirmInput}
value={confirmText}
onChangeText={setConfirmText}
placeholder="Type DELETE"
placeholderTextColor={colors.textTertiary}
autoCapitalize="characters"
autoCorrect={false}
editable={!isDeletingRelationship}
/>
<View style={styles.modalButtons}>
<TouchableOpacity
style={styles.cancelButton}
onPress={closeRelationshipModal}
disabled={isDeletingRelationship}
activeOpacity={0.7}
>
<Text style={styles.cancelButtonText}>Cancel</Text>
</TouchableOpacity>
<TouchableOpacity
style={[
styles.deleteButton,
(confirmText !== "DELETE" || isDeletingRelationship) && styles.deleteButtonDisabled,
]}
onPress={handleDeleteRelationship}
disabled={confirmText !== "DELETE" || isDeletingRelationship}
activeOpacity={0.7}
>
{isDeletingRelationship ? (
<ActivityIndicator size="small" color={colors.text} />
) : (
<>
<Ionicons name="trash" size={16} color={colors.text} />
<Text style={styles.deleteButtonText}>Delete</Text>
</>
)}
</TouchableOpacity>
</View>
</View>
</KeyboardAvoidingView>
</Modal>
{/* Delete Account Modal */}
@@ -380,101 +367,95 @@ export const DangerZone: React.FC<DangerZoneProps> = ({
animationType="fade"
onRequestClose={closeAccountModal}
>
<BlurView
intensity={Platform.OS === 'ios' ? 20 : 0}
tint="dark"
style={StyleSheet.absoluteFill}
<KeyboardAvoidingView
behavior={Platform.OS === 'ios' ? 'padding' : 'height'}
style={styles.modalOverlay}
>
<KeyboardAvoidingView
behavior={Platform.OS === 'ios' ? 'padding' : 'height'}
style={styles.modalOverlay}
>
<View style={styles.modalContent}>
<View style={styles.modalHeader}>
<View style={styles.modalIconContainer}>
<Ionicons name="person-remove" size={32} color={colors.error} />
</View>
<Text style={styles.modalTitle}>Delete Account?</Text>
<View style={styles.modalContent}>
<View style={styles.modalHeader}>
<View style={styles.modalIconContainer}>
<Ionicons name="person-remove" size={32} color={colors.error} />
</View>
<Text style={styles.modalTitle}>Delete Account?</Text>
</View>
<Text style={styles.modalDescription}>
This action cannot be undone. This will permanently delete:
</Text>
<Text style={styles.modalDescription}>
This action cannot be undone. This will permanently delete:
</Text>
<View style={styles.deleteList}>
<View style={styles.deleteListItem}>
<Ionicons name="person" size={16} color={colors.error} />
<Text style={styles.deleteListText}>Your account and profile</Text>
</View>
{hasRelationship && (
<>
<View style={styles.deleteListItem}>
<Ionicons name="heart-dislike" size={16} color={colors.error} />
<Text style={styles.deleteListText}>Your relationship data</Text>
</View>
<View style={styles.deleteListItem}>
<Ionicons name="chatbubbles" size={16} color={colors.error} />
<Text style={styles.deleteListText}>All chat messages and photos</Text>
</View>
</>
)}
<View style={styles.deleteListItem}>
<Ionicons name="key" size={16} color={colors.error} />
<Text style={styles.deleteListText}>Your encryption keys</Text>
</View>
<View style={styles.deleteListItem}>
<Ionicons name="cloud-offline" size={16} color={colors.error} />
<Text style={styles.deleteListText}>All cloud-stored data</Text>
</View>
<View style={styles.deleteList}>
<View style={styles.deleteListItem}>
<Ionicons name="person" size={16} color={colors.error} />
<Text style={styles.deleteListText}>Your account and profile</Text>
</View>
<Text style={styles.confirmLabel}>
Type <Text style={styles.confirmKeyword}>DELETE</Text> to confirm:
</Text>
<TextInput
style={styles.confirmInput}
value={confirmText}
onChangeText={setConfirmText}
placeholder="Type DELETE"
placeholderTextColor={colors.textTertiary}
autoCapitalize="characters"
autoCorrect={false}
editable={!isDeletingAccount}
/>
<View style={styles.modalButtons}>
<TouchableOpacity
style={styles.cancelButton}
onPress={closeAccountModal}
disabled={isDeletingAccount}
activeOpacity={0.7}
>
<Text style={styles.cancelButtonText}>Cancel</Text>
</TouchableOpacity>
<TouchableOpacity
style={[
styles.deleteButton,
(confirmText !== "DELETE" || isDeletingAccount) && styles.deleteButtonDisabled,
]}
onPress={handleDeleteAccount}
disabled={confirmText !== "DELETE" || isDeletingAccount}
activeOpacity={0.7}
>
{isDeletingAccount ? (
<ActivityIndicator size="small" color={colors.text} />
) : (
<>
<Ionicons name="trash" size={16} color={colors.text} />
<Text style={styles.deleteButtonText}>Delete Account</Text>
</>
)}
</TouchableOpacity>
{hasRelationship && (
<>
<View style={styles.deleteListItem}>
<Ionicons name="heart-dislike" size={16} color={colors.error} />
<Text style={styles.deleteListText}>Your relationship data</Text>
</View>
<View style={styles.deleteListItem}>
<Ionicons name="chatbubbles" size={16} color={colors.error} />
<Text style={styles.deleteListText}>All chat messages and photos</Text>
</View>
</>
)}
<View style={styles.deleteListItem}>
<Ionicons name="key" size={16} color={colors.error} />
<Text style={styles.deleteListText}>Your encryption keys</Text>
</View>
<View style={styles.deleteListItem}>
<Ionicons name="cloud-offline" size={16} color={colors.error} />
<Text style={styles.deleteListText}>All cloud-stored data</Text>
</View>
</View>
</KeyboardAvoidingView>
</BlurView>
<Text style={styles.confirmLabel}>
Type <Text style={styles.confirmKeyword}>DELETE</Text> to confirm:
</Text>
<TextInput
style={styles.confirmInput}
value={confirmText}
onChangeText={setConfirmText}
placeholder="Type DELETE"
placeholderTextColor={colors.textTertiary}
autoCapitalize="characters"
autoCorrect={false}
editable={!isDeletingAccount}
/>
<View style={styles.modalButtons}>
<TouchableOpacity
style={styles.cancelButton}
onPress={closeAccountModal}
disabled={isDeletingAccount}
activeOpacity={0.7}
>
<Text style={styles.cancelButtonText}>Cancel</Text>
</TouchableOpacity>
<TouchableOpacity
style={[
styles.deleteButton,
(confirmText !== "DELETE" || isDeletingAccount) && styles.deleteButtonDisabled,
]}
onPress={handleDeleteAccount}
disabled={confirmText !== "DELETE" || isDeletingAccount}
activeOpacity={0.7}
>
{isDeletingAccount ? (
<ActivityIndicator size="small" color={colors.text} />
) : (
<>
<Ionicons name="trash" size={16} color={colors.text} />
<Text style={styles.deleteButtonText}>Delete Account</Text>
</>
)}
</TouchableOpacity>
</View>
</View>
</KeyboardAvoidingView>
</Modal>
</>
);
@@ -577,7 +558,7 @@ const styles = StyleSheet.create({
backgroundColor: colors.backgroundLight,
borderRadius: radius.xl,
borderWidth: 1,
borderColor: colors.glass.border,
borderColor: colors.border,
},
modalHeader: {
alignItems: "center",
@@ -644,7 +625,7 @@ const styles = StyleSheet.create({
color: colors.warning,
},
warningInput: {
backgroundColor: colors.glass.background,
backgroundColor: colors.background,
borderRadius: radius.md,
borderWidth: 1,
borderColor: colors.warning,
@@ -684,7 +665,7 @@ const styles = StyleSheet.create({
color: colors.error,
},
confirmInput: {
backgroundColor: colors.glass.background,
backgroundColor: colors.background,
borderRadius: radius.md,
borderWidth: 1,
borderColor: colors.error,
@@ -700,12 +681,12 @@ const styles = StyleSheet.create({
},
cancelButton: {
flex: 1,
backgroundColor: colors.glass.backgroundLight,
backgroundColor: colors.background,
paddingVertical: spacing.md,
borderRadius: radius.md,
alignItems: "center",
borderWidth: 1,
borderColor: colors.glass.borderLight,
borderColor: colors.border,
},
cancelButtonText: {
...typography.body,
@@ -127,6 +127,8 @@ const styles = StyleSheet.create({
alignItems: 'center',
marginRight: spacing.md,
backgroundColor: 'rgba(239, 68, 68, 0.1)',
borderWidth: 1,
borderColor: 'rgba(239, 68, 68, 0.2)', // Added border
},
textContainer: {
flex: 1,
@@ -34,6 +34,7 @@ export const NotificationSettings: React.FC<NotificationSettingsProps> = ({
onMatchesToggle: (value: boolean) => updatePreference('matches_enabled', value),
onMessagesToggle: (value: boolean) => updatePreference('messages_enabled', value),
onPartnerActivityToggle: (value: boolean) => updatePreference('partner_activity_enabled', value),
onNudgesToggle: (value: boolean) => updatePreference('nudges_enabled', value),
onPackChangesToggle: (value: boolean) => updatePreference('pack_changes_enabled', value),
onNewPacksToggle: (value: boolean) => updatePreference('new_packs_enabled', value),
onStreakMilestonesToggle: (value: boolean) => updatePreference('streak_milestones_enabled', value),
@@ -91,6 +92,17 @@ export const NotificationSettings: React.FC<NotificationSettingsProps> = ({
<View style={styles.itemDivider} />
<SwitchItem
icon="hand-left-outline"
label="Partner Nudges"
description="When your partner sends you a reminder"
value={preferences.nudges_enabled}
onValueChange={handlers.onNudgesToggle}
disabled={isUpdating}
/>
<View style={styles.itemDivider} />
<SwitchItem
icon="layers-outline"
label="Pack Updates"
@@ -133,7 +145,7 @@ const styles = StyleSheet.create({
marginBottom: spacing.md,
paddingTop: spacing.md,
borderTopWidth: 1,
borderTopColor: colors.glass.border,
borderTopColor: colors.border,
},
subsectionLabel: {
...typography.caption1,
@@ -144,7 +156,7 @@ const styles = StyleSheet.create({
},
itemDivider: {
height: 1,
backgroundColor: colors.glass.border,
backgroundColor: colors.border,
marginVertical: spacing.sm,
},
});
@@ -58,7 +58,7 @@ export const PrivacySettings: React.FC<PrivacySettingsProps> = ({
const styles = StyleSheet.create({
divider: {
height: 1,
backgroundColor: colors.glass.border,
backgroundColor: colors.border,
marginVertical: spacing.lg,
},
});
@@ -155,11 +155,32 @@ const styles = StyleSheet.create({
padding: 3,
justifyContent: 'center',
alignItems: 'center',
shadowColor: featureColors.profile.accent,
shadowOffset: { width: 0, height: 4 },
shadowOpacity: 0.3,
shadowRadius: 8,
elevation: 6,
// Removed shadows for flat look
},
// ...
avatarEditBadge: {
position: 'absolute',
bottom: 0,
right: 0,
backgroundColor: colors.backgroundLight,
width: 28,
height: 28,
borderRadius: 14,
justifyContent: 'center',
alignItems: 'center',
borderWidth: 2,
borderColor: colors.background,
},
// ...
cancelEditButton: {
width: 32,
height: 32,
borderRadius: 16,
backgroundColor: colors.backgroundLight,
justifyContent: 'center',
alignItems: 'center',
borderWidth: 1,
borderColor: colors.border,
},
avatarImage: {
width: '100%',
@@ -191,19 +212,7 @@ const styles = StyleSheet.create({
justifyContent: 'center',
alignItems: 'center',
},
avatarEditBadge: {
position: 'absolute',
bottom: 0,
right: 0,
backgroundColor: colors.surfaceSolid,
width: 28,
height: 28,
borderRadius: 14,
justifyContent: 'center',
alignItems: 'center',
borderWidth: 2,
borderColor: colors.background,
},
profileInfo: {
flex: 1,
justifyContent: 'center',
@@ -253,12 +262,5 @@ const styles = StyleSheet.create({
alignItems: 'center',
marginLeft: spacing.xs,
},
cancelEditButton: {
width: 32,
height: 32,
borderRadius: 16,
backgroundColor: colors.glass.highlight,
justifyContent: 'center',
alignItems: 'center',
},
});
@@ -2,8 +2,7 @@ import React, { useState } from 'react';
import { View, Text, StyleSheet, Modal, Platform, KeyboardAvoidingView, TouchableOpacity, ActivityIndicator, TextInput, ViewStyle } from 'react-native';
import { Ionicons } from '@expo/vector-icons';
import Animated, { FadeInDown } from 'react-native-reanimated';
import { BlurView } from 'expo-blur';
import { GlassCard, GlassButton } from '../../../components/ui';
import { GlassCard, GlassButton } from '../../../components/ui';
import { colors, spacing, radius, typography } from '../../../theme';
interface RelationshipDangerZoneProps {
@@ -132,199 +131,187 @@ export const RelationshipDangerZone: React.FC<RelationshipDangerZoneProps> = ({
</Animated.View>
{/* Reset Progress Modal */}
<Modal
visible={showResetModal}
transparent
animationType="fade"
onRequestClose={closeResetModal}
>
<BlurView
intensity={Platform.OS === 'ios' ? 20 : 0}
tint="dark"
style={StyleSheet.absoluteFill}
>
<KeyboardAvoidingView
behavior={Platform.OS === 'ios' ? 'padding' : 'height'}
style={styles.modalOverlay}
>
<View style={styles.modalContent}>
<View style={styles.modalHeader}>
<View style={styles.warningModalIconContainer}>
<Ionicons name="refresh" size={32} color={colors.warning} />
</View>
<Text style={styles.modalTitle}>Reset Progress?</Text>
</View>
<Text style={styles.modalDescription}>
This will permanently delete:
</Text>
<View style={styles.warningList}>
<View style={styles.deleteListItem}>
<Ionicons name="swap-horizontal" size={16} color={colors.warning} />
<Text style={styles.deleteListText}>All your swipes</Text>
</View>
<View style={styles.deleteListItem}>
<Ionicons name="heart" size={16} color={colors.warning} />
<Text style={styles.deleteListText}>All matches</Text>
</View>
<View style={styles.deleteListItem}>
<Ionicons name="chatbubbles" size={16} color={colors.warning} />
<Text style={styles.deleteListText}>All chat messages</Text>
</View>
</View>
<View style={styles.keepList}>
<View style={styles.deleteListItem}>
<Ionicons name="checkmark-circle" size={16} color={colors.success} />
<Text style={styles.deleteListText}>Your partner connection stays</Text>
</View>
</View>
<Text style={styles.confirmLabel}>
Type <Text style={styles.warningKeyword}>RESET</Text> to confirm:
</Text>
<TextInput
style={styles.warningInput}
value={confirmText}
onChangeText={setConfirmText}
placeholder="Type RESET"
placeholderTextColor={colors.textTertiary}
autoCapitalize="characters"
autoCorrect={false}
editable={!isResetting}
/>
<View style={styles.modalButtons}>
<TouchableOpacity
style={styles.cancelButton}
onPress={closeResetModal}
disabled={isResetting}
activeOpacity={0.7}
>
<Text style={styles.cancelButtonText}>Cancel</Text>
</TouchableOpacity>
<TouchableOpacity
style={[
styles.warningActionButton,
(confirmText !== "RESET" || isResetting) && styles.deleteButtonDisabled,
]}
onPress={handleResetProgress}
disabled={confirmText !== "RESET" || isResetting}
activeOpacity={0.7}
>
{isResetting ? (
<ActivityIndicator size="small" color={colors.text} />
) : (
<>
<Ionicons name="refresh" size={16} color={colors.text} />
<Text style={styles.deleteButtonText}>Reset</Text>
</>
)}
</TouchableOpacity>
</View>
</View>
</KeyboardAvoidingView>
</BlurView>
</Modal>
<Modal
visible={showResetModal}
transparent
animationType="fade"
onRequestClose={closeResetModal}
>
<KeyboardAvoidingView
behavior={Platform.OS === 'ios' ? 'padding' : 'height'}
style={styles.modalOverlay}
>
<View style={styles.modalContent}>
<View style={styles.modalHeader}>
<View style={styles.warningModalIconContainer}>
<Ionicons name="refresh" size={32} color={colors.warning} />
</View>
<Text style={styles.modalTitle}>Reset Progress?</Text>
</View>
<Text style={styles.modalDescription}>
This will permanently delete:
</Text>
<View style={styles.warningList}>
<View style={styles.deleteListItem}>
<Ionicons name="swap-horizontal" size={16} color={colors.warning} />
<Text style={styles.deleteListText}>All your swipes</Text>
</View>
<View style={styles.deleteListItem}>
<Ionicons name="heart" size={16} color={colors.warning} />
<Text style={styles.deleteListText}>All matches</Text>
</View>
<View style={styles.deleteListItem}>
<Ionicons name="chatbubbles" size={16} color={colors.warning} />
<Text style={styles.deleteListText}>All chat messages</Text>
</View>
</View>
<View style={styles.keepList}>
<View style={styles.deleteListItem}>
<Ionicons name="checkmark-circle" size={16} color={colors.success} />
<Text style={styles.deleteListText}>Your partner connection stays</Text>
</View>
</View>
<Text style={styles.confirmLabel}>
Type <Text style={styles.warningKeyword}>RESET</Text> to confirm:
</Text>
<TextInput
style={styles.warningInput}
value={confirmText}
onChangeText={setConfirmText}
placeholder="Type RESET"
placeholderTextColor={colors.textTertiary}
autoCapitalize="characters"
autoCorrect={false}
editable={!isResetting}
/>
<View style={styles.modalButtons}>
<TouchableOpacity
style={styles.cancelButton}
onPress={closeResetModal}
disabled={isResetting}
activeOpacity={0.7}
>
<Text style={styles.cancelButtonText}>Cancel</Text>
</TouchableOpacity>
<TouchableOpacity
style={[
styles.warningActionButton,
(confirmText !== "RESET" || isResetting) && styles.deleteButtonDisabled,
]}
onPress={handleResetProgress}
disabled={confirmText !== "RESET" || isResetting}
activeOpacity={0.7}
>
{isResetting ? (
<ActivityIndicator size="small" color={colors.text} />
) : (
<>
<Ionicons name="refresh" size={16} color={colors.text} />
<Text style={styles.deleteButtonText}>Reset</Text>
</>
)}
</TouchableOpacity>
</View>
</View>
</KeyboardAvoidingView>
</Modal>
{/* Delete Relationship Modal */}
<Modal
visible={showDeleteRelationshipModal}
transparent
animationType="fade"
onRequestClose={closeRelationshipModal}
>
<BlurView
intensity={Platform.OS === 'ios' ? 20 : 0}
tint="dark"
style={StyleSheet.absoluteFill}
>
<KeyboardAvoidingView
behavior={Platform.OS === 'ios' ? 'padding' : 'height'}
style={styles.modalOverlay}
>
<View style={styles.modalContent}>
<View style={styles.modalHeader}>
<View style={styles.modalIconContainer}>
<Ionicons name="heart-dislike" size={32} color={colors.error} />
</View>
<Text style={styles.modalTitle}>Delete Relationship Data?</Text>
</View>
<Text style={styles.modalDescription}>
This action cannot be undone. This will permanently delete:
</Text>
<View style={styles.deleteList}>
<View style={styles.deleteListItem}>
<Ionicons name="heart-dislike" size={16} color={colors.error} />
<Text style={styles.deleteListText}>Your couple connection</Text>
</View>
<View style={styles.deleteListItem}>
<Ionicons name="checkmark-circle" size={16} color={colors.error} />
<Text style={styles.deleteListText}>All matches and responses</Text>
</View>
<View style={styles.deleteListItem}>
<Ionicons name="chatbubbles" size={16} color={colors.error} />
<Text style={styles.deleteListText}>All chat messages</Text>
</View>
<View style={styles.deleteListItem}>
<Ionicons name="images" size={16} color={colors.error} />
<Text style={styles.deleteListText}>All shared photos</Text>
</View>
</View>
<Text style={styles.confirmLabel}>
Type <Text style={styles.confirmKeyword}>DELETE</Text> to confirm:
</Text>
<TextInput
style={styles.confirmInput}
value={confirmText}
onChangeText={setConfirmText}
placeholder="Type DELETE"
placeholderTextColor={colors.textTertiary}
autoCapitalize="characters"
autoCorrect={false}
editable={!isDeletingRelationship}
/>
<View style={styles.modalButtons}>
<TouchableOpacity
style={styles.cancelButton}
onPress={closeRelationshipModal}
disabled={isDeletingRelationship}
activeOpacity={0.7}
>
<Text style={styles.cancelButtonText}>Cancel</Text>
</TouchableOpacity>
<TouchableOpacity
style={[
styles.deleteButton,
(confirmText !== "DELETE" || isDeletingRelationship) && styles.deleteButtonDisabled,
]}
onPress={handleDeleteRelationship}
disabled={confirmText !== "DELETE" || isDeletingRelationship}
activeOpacity={0.7}
>
{isDeletingRelationship ? (
<ActivityIndicator size="small" color={colors.text} />
) : (
<>
<Ionicons name="trash" size={16} color={colors.text} />
<Text style={styles.deleteButtonText}>Delete</Text>
</>
)}
</TouchableOpacity>
</View>
</View>
</KeyboardAvoidingView>
</BlurView>
</Modal>
<Modal
visible={showDeleteRelationshipModal}
transparent
animationType="fade"
onRequestClose={closeRelationshipModal}
>
<KeyboardAvoidingView
behavior={Platform.OS === 'ios' ? 'padding' : 'height'}
style={styles.modalOverlay}
>
<View style={styles.modalContent}>
<View style={styles.modalHeader}>
<View style={styles.modalIconContainer}>
<Ionicons name="heart-dislike" size={32} color={colors.error} />
</View>
<Text style={styles.modalTitle}>Delete Relationship Data?</Text>
</View>
<Text style={styles.modalDescription}>
This action cannot be undone. This will permanently delete:
</Text>
<View style={styles.deleteList}>
<View style={styles.deleteListItem}>
<Ionicons name="heart-dislike" size={16} color={colors.error} />
<Text style={styles.deleteListText}>Your couple connection</Text>
</View>
<View style={styles.deleteListItem}>
<Ionicons name="checkmark-circle" size={16} color={colors.error} />
<Text style={styles.deleteListText}>All matches and responses</Text>
</View>
<View style={styles.deleteListItem}>
<Ionicons name="chatbubbles" size={16} color={colors.error} />
<Text style={styles.deleteListText}>All chat messages</Text>
</View>
<View style={styles.deleteListItem}>
<Ionicons name="images" size={16} color={colors.error} />
<Text style={styles.deleteListText}>All shared photos</Text>
</View>
</View>
<Text style={styles.confirmLabel}>
Type <Text style={styles.confirmKeyword}>DELETE</Text> to confirm:
</Text>
<TextInput
style={styles.confirmInput}
value={confirmText}
onChangeText={setConfirmText}
placeholder="Type DELETE"
placeholderTextColor={colors.textTertiary}
autoCapitalize="characters"
autoCorrect={false}
editable={!isDeletingRelationship}
/>
<View style={styles.modalButtons}>
<TouchableOpacity
style={styles.cancelButton}
onPress={closeRelationshipModal}
disabled={isDeletingRelationship}
activeOpacity={0.7}
>
<Text style={styles.cancelButtonText}>Cancel</Text>
</TouchableOpacity>
<TouchableOpacity
style={[
styles.deleteButton,
(confirmText !== "DELETE" || isDeletingRelationship) && styles.deleteButtonDisabled,
]}
onPress={handleDeleteRelationship}
disabled={confirmText !== "DELETE" || isDeletingRelationship}
activeOpacity={0.7}
>
{isDeletingRelationship ? (
<ActivityIndicator size="small" color={colors.text} />
) : (
<>
<Ionicons name="trash" size={16} color={colors.text} />
<Text style={styles.deleteButtonText}>Delete</Text>
</>
)}
</TouchableOpacity>
</View>
</View>
</KeyboardAvoidingView>
</Modal>
</>
);
};
@@ -419,15 +406,15 @@ const styles = StyleSheet.create({
alignItems: "center",
padding: spacing.lg,
},
modalContent: {
width: "100%",
maxWidth: 400,
padding: spacing.xl,
backgroundColor: colors.backgroundLight,
borderRadius: radius.xl,
borderWidth: 1,
borderColor: colors.glass.border,
},
modalContent: {
width: "100%",
maxWidth: 400,
padding: spacing.xl,
backgroundColor: colors.backgroundLight,
borderRadius: radius.xl,
borderWidth: 1,
borderColor: colors.border,
},
modalHeader: {
alignItems: "center",
marginBottom: spacing.lg,
@@ -492,13 +479,13 @@ const styles = StyleSheet.create({
fontWeight: "bold",
color: colors.warning,
},
warningInput: {
backgroundColor: colors.glass.background,
borderRadius: radius.md,
borderWidth: 1,
borderColor: colors.warning,
padding: spacing.md,
...typography.body,
warningInput: {
backgroundColor: colors.background,
borderRadius: radius.md,
borderWidth: 1,
borderColor: colors.warning,
padding: spacing.md,
...typography.body,
color: colors.text,
textAlign: 'center',
marginBottom: spacing.xl,
@@ -532,13 +519,13 @@ const styles = StyleSheet.create({
fontWeight: "bold",
color: colors.error,
},
confirmInput: {
backgroundColor: colors.glass.background,
borderRadius: radius.md,
borderWidth: 1,
borderColor: colors.error,
padding: spacing.md,
...typography.body,
confirmInput: {
backgroundColor: colors.background,
borderRadius: radius.md,
borderWidth: 1,
borderColor: colors.error,
padding: spacing.md,
...typography.body,
color: colors.text,
textAlign: 'center',
marginBottom: spacing.xl,
@@ -547,15 +534,15 @@ const styles = StyleSheet.create({
flexDirection: "row",
gap: spacing.md,
},
cancelButton: {
flex: 1,
backgroundColor: colors.glass.backgroundLight,
paddingVertical: spacing.md,
borderRadius: radius.md,
alignItems: "center",
borderWidth: 1,
borderColor: colors.glass.borderLight,
},
cancelButton: {
flex: 1,
backgroundColor: colors.background,
paddingVertical: spacing.md,
borderRadius: radius.md,
alignItems: "center",
borderWidth: 1,
borderColor: colors.border,
},
cancelButtonText: {
...typography.body,
color: colors.textSecondary,
@@ -2,8 +2,7 @@ import React, { useState } from 'react';
import { View, Text, StyleSheet, Modal, Platform, KeyboardAvoidingView, TouchableOpacity, ActivityIndicator, TextInput } from 'react-native';
import { Ionicons } from '@expo/vector-icons';
import Animated, { FadeInDown } from 'react-native-reanimated';
import { BlurView } from 'expo-blur';
import { GlassCard } from '../../../components/ui';
import { GlassCard } from '../../../components/ui';
import { colors, spacing, radius, typography } from '../../../theme';
interface ResetProgressProps {
@@ -61,106 +60,100 @@ export const ResetProgress: React.FC<ResetProgressProps> = ({ onResetProgress })
</GlassCard>
</Animated.View>
<Modal
visible={showResetModal}
transparent
animationType="fade"
onRequestClose={() => !isResetting && setShowResetModal(false)}
>
<BlurView
intensity={Platform.OS === 'ios' ? 20 : 0}
tint="dark"
style={StyleSheet.absoluteFill}
>
<KeyboardAvoidingView
behavior={Platform.OS === 'ios' ? 'padding' : 'height'}
style={styles.modalOverlay}
>
<View style={styles.modalContent}>
<View style={styles.modalHeader}>
<View style={styles.modalIconContainer}>
<Ionicons name="refresh" size={32} color={colors.warning} />
</View>
<Text style={styles.modalTitle}>Reset Progress?</Text>
</View>
<Text style={styles.modalDescription}>
This action cannot be undone. This will permanently delete:
</Text>
<View style={styles.resetList}>
<View style={styles.resetListItem}>
<Ionicons name="swap-horizontal" size={16} color={colors.warning} />
<Text style={styles.resetListText}>All your swipes</Text>
</View>
<View style={styles.resetListItem}>
<Ionicons name="heart" size={16} color={colors.warning} />
<Text style={styles.resetListText}>All matches</Text>
</View>
<View style={styles.resetListItem}>
<Ionicons name="chatbubbles" size={16} color={colors.warning} />
<Text style={styles.resetListText}>All chat messages</Text>
</View>
</View>
<View style={styles.keepList}>
<View style={styles.keepListItem}>
<Ionicons name="checkmark-circle" size={16} color={colors.success} />
<Text style={styles.keepListText}>Your partner connection stays</Text>
</View>
</View>
<Text style={styles.confirmLabel}>
Type <Text style={styles.confirmKeyword}>RESET</Text> to confirm:
</Text>
<TextInput
style={styles.confirmInput}
value={resetConfirmText}
onChangeText={setResetConfirmText}
placeholder="Type RESET"
placeholderTextColor={colors.textTertiary}
autoCapitalize="characters"
autoCorrect={false}
editable={!isResetting}
/>
<View style={styles.modalButtons}>
<TouchableOpacity
style={styles.cancelButton}
onPress={() => {
setShowResetModal(false);
setResetConfirmText("");
}}
disabled={isResetting}
activeOpacity={0.7}
>
<Text style={styles.cancelButtonText}>Cancel</Text>
</TouchableOpacity>
<TouchableOpacity
style={[
styles.confirmResetButton,
(resetConfirmText !== "RESET" || isResetting) && styles.confirmResetButtonDisabled,
]}
onPress={handleReset}
disabled={resetConfirmText !== "RESET" || isResetting}
activeOpacity={0.7}
>
{isResetting ? (
<ActivityIndicator size="small" color={colors.text} />
) : (
<>
<Ionicons name="refresh" size={16} color={colors.text} />
<Text style={styles.confirmResetButtonText}>Reset</Text>
</>
)}
</TouchableOpacity>
</View>
</View>
</KeyboardAvoidingView>
</BlurView>
</Modal>
<Modal
visible={showResetModal}
transparent
animationType="fade"
onRequestClose={() => !isResetting && setShowResetModal(false)}
>
<KeyboardAvoidingView
behavior={Platform.OS === 'ios' ? 'padding' : 'height'}
style={styles.modalOverlay}
>
<View style={styles.modalContent}>
<View style={styles.modalHeader}>
<View style={styles.modalIconContainer}>
<Ionicons name="refresh" size={32} color={colors.warning} />
</View>
<Text style={styles.modalTitle}>Reset Progress?</Text>
</View>
<Text style={styles.modalDescription}>
This action cannot be undone. This will permanently delete:
</Text>
<View style={styles.resetList}>
<View style={styles.resetListItem}>
<Ionicons name="swap-horizontal" size={16} color={colors.warning} />
<Text style={styles.resetListText}>All your swipes</Text>
</View>
<View style={styles.resetListItem}>
<Ionicons name="heart" size={16} color={colors.warning} />
<Text style={styles.resetListText}>All matches</Text>
</View>
<View style={styles.resetListItem}>
<Ionicons name="chatbubbles" size={16} color={colors.warning} />
<Text style={styles.resetListText}>All chat messages</Text>
</View>
</View>
<View style={styles.keepList}>
<View style={styles.keepListItem}>
<Ionicons name="checkmark-circle" size={16} color={colors.success} />
<Text style={styles.keepListText}>Your partner connection stays</Text>
</View>
</View>
<Text style={styles.confirmLabel}>
Type <Text style={styles.confirmKeyword}>RESET</Text> to confirm:
</Text>
<TextInput
style={styles.confirmInput}
value={resetConfirmText}
onChangeText={setResetConfirmText}
placeholder="Type RESET"
placeholderTextColor={colors.textTertiary}
autoCapitalize="characters"
autoCorrect={false}
editable={!isResetting}
/>
<View style={styles.modalButtons}>
<TouchableOpacity
style={styles.cancelButton}
onPress={() => {
setShowResetModal(false);
setResetConfirmText("");
}}
disabled={isResetting}
activeOpacity={0.7}
>
<Text style={styles.cancelButtonText}>Cancel</Text>
</TouchableOpacity>
<TouchableOpacity
style={[
styles.confirmResetButton,
(resetConfirmText !== "RESET" || isResetting) && styles.confirmResetButtonDisabled,
]}
onPress={handleReset}
disabled={resetConfirmText !== "RESET" || isResetting}
activeOpacity={0.7}
>
{isResetting ? (
<ActivityIndicator size="small" color={colors.text} />
) : (
<>
<Ionicons name="refresh" size={16} color={colors.text} />
<Text style={styles.confirmResetButtonText}>Reset</Text>
</>
)}
</TouchableOpacity>
</View>
</View>
</KeyboardAvoidingView>
</Modal>
</>
);
};
@@ -233,15 +226,15 @@ const styles = StyleSheet.create({
alignItems: "center",
padding: spacing.lg,
},
modalContent: {
width: "100%",
maxWidth: 400,
padding: spacing.xl,
backgroundColor: colors.backgroundLight,
borderRadius: radius.xl,
borderWidth: 1,
borderColor: colors.glass.border,
},
modalContent: {
width: "100%",
maxWidth: 400,
padding: spacing.xl,
backgroundColor: colors.backgroundLight,
borderRadius: radius.xl,
borderWidth: 1,
borderColor: colors.border,
},
modalHeader: {
alignItems: "center",
marginBottom: spacing.lg,
@@ -312,13 +305,13 @@ const styles = StyleSheet.create({
fontWeight: "bold",
color: colors.warning,
},
confirmInput: {
backgroundColor: colors.glass.background,
borderRadius: radius.md,
borderWidth: 1,
borderColor: colors.warning,
padding: spacing.md,
...typography.body,
confirmInput: {
backgroundColor: colors.background,
borderRadius: radius.md,
borderWidth: 1,
borderColor: colors.warning,
padding: spacing.md,
...typography.body,
color: colors.text,
textAlign: 'center',
marginBottom: spacing.xl,
@@ -327,15 +320,15 @@ const styles = StyleSheet.create({
flexDirection: "row",
gap: spacing.md,
},
cancelButton: {
flex: 1,
backgroundColor: colors.glass.backgroundLight,
paddingVertical: spacing.md,
borderRadius: radius.md,
alignItems: "center",
borderWidth: 1,
borderColor: colors.glass.borderLight,
},
cancelButton: {
flex: 1,
backgroundColor: colors.background,
paddingVertical: spacing.md,
borderRadius: radius.md,
alignItems: "center",
borderWidth: 1,
borderColor: colors.border,
},
cancelButtonText: {
...typography.body,
color: colors.textSecondary,
@@ -53,14 +53,16 @@ const styles = StyleSheet.create({
borderBottomWidth: 1,
borderBottomColor: `${featureColors.profile.accent}15`,
},
backButton: {
width: 40,
height: 40,
borderRadius: 20,
backgroundColor: colors.glass.background,
justifyContent: 'center',
alignItems: 'center',
},
backButton: {
width: 40,
height: 40,
borderRadius: 20,
backgroundColor: colors.backgroundLight,
borderWidth: 1,
borderColor: colors.border,
justifyContent: 'center',
alignItems: 'center',
},
title: {
...typography.headline,
color: colors.text,
@@ -14,7 +14,7 @@ export interface SettingsSectionProps {
}
/**
* Reusable settings section with title and glass card container.
* Reusable settings section with title and solid card container.
*/
export function SettingsSection({ title, delay = 200, children }: SettingsSectionProps) {
return (
@@ -242,24 +242,57 @@ const styles = StyleSheet.create({
...StyleSheet.absoluteFillObject,
opacity: 0.3,
},
proCardInner: {
backgroundColor: 'rgba(22, 33, 62, 0.8)',
margin: 1,
borderRadius: radius.lg - 1,
padding: spacing.md,
},
proCardInner: {
backgroundColor: colors.backgroundLight, // Solid/Flat background
margin: 1,
borderRadius: radius.lg - 1,
padding: spacing.md,
},
// ...
proBadge: {
width: 36,
height: 36,
borderRadius: 18,
justifyContent: 'center',
alignItems: 'center',
// Removed glow
},
// ...
manageButton: {
paddingHorizontal: spacing.md,
paddingVertical: spacing.sm,
borderRadius: radius.md,
backgroundColor: colors.background,
borderWidth: 1,
borderColor: colors.border,
},
// ...
proStatusRow: {
flexDirection: 'row',
marginTop: spacing.md,
paddingTop: spacing.md,
borderTopWidth: 1,
borderTopColor: colors.border,
gap: spacing.lg,
},
// ...
freeCardInner: {
backgroundColor: colors.backgroundLight, // Solid/Flat background
margin: 1,
borderRadius: radius.lg - 1,
padding: spacing.lg,
},
// ...
ctaButtonContainer: {
borderRadius: radius.md,
overflow: 'hidden',
// Removed glow
},
proHeader: {
flexDirection: 'row',
alignItems: 'center',
},
proBadge: {
width: 36,
height: 36,
borderRadius: 18,
justifyContent: 'center',
alignItems: 'center',
...shadows.glow(colors.premium.gold),
},
proHeaderText: {
flex: 1,
marginLeft: spacing.md,
@@ -274,27 +307,13 @@ const styles = StyleSheet.create({
color: colors.textTertiary,
marginTop: 2,
},
manageButton: {
paddingHorizontal: spacing.md,
paddingVertical: spacing.sm,
borderRadius: radius.md,
backgroundColor: colors.glass.background,
borderWidth: 1,
borderColor: `${colors.premium.gold}40`,
},
manageButtonText: {
...typography.subhead,
color: colors.premium.gold,
fontWeight: '600',
},
proStatusRow: {
flexDirection: 'row',
marginTop: spacing.md,
paddingTop: spacing.md,
borderTopWidth: 1,
borderTopColor: colors.glass.border,
gap: spacing.lg,
},
proStatusItem: {
flexDirection: 'row',
alignItems: 'center',
@@ -313,12 +332,7 @@ const styles = StyleSheet.create({
freeBorderGradient: {
...StyleSheet.absoluteFillObject,
},
freeCardInner: {
backgroundColor: 'rgba(22, 33, 62, 0.85)',
margin: 1,
borderRadius: radius.lg - 1,
padding: spacing.lg,
},
freeHeader: {
alignItems: 'center',
marginBottom: spacing.lg,
@@ -358,11 +372,7 @@ const styles = StyleSheet.create({
...typography.body,
color: colors.text,
},
ctaButtonContainer: {
borderRadius: radius.md,
overflow: 'hidden',
...shadows.glow(colors.premium.gold),
},
ctaButton: {
flexDirection: 'row',
alignItems: 'center',
@@ -16,7 +16,7 @@ import { Ionicons } from '@expo/vector-icons';
import { LinearGradient } from 'expo-linear-gradient';
import Animated, { FadeInDown } from 'react-native-reanimated';
import { GradientBackground, GlassCard, GlassButton } from '../../../components/ui';
import { GradientBackground } from '../../../components/ui';
import { colors, featureColors, spacing, typography, radius } from '../../../theme';
import { useAuthStore } from '../../../store';
import { supabase } from '../../../lib/supabase';
@@ -53,7 +53,7 @@ const GENDER_OPTIONS: { value: Gender; label: string; icon: string }[] = [
* Profile settings sub-screen for editing avatar, display name, and gender.
*/
export function ProfileSettingsScreen() {
const { user, fetchUser } = useAuthStore();
const { user, fetchUser, isAnonymous } = useAuthStore();
const settings = useProfileSettings();
const authEmail = useAuthEmail();
@@ -260,10 +260,12 @@ export function ProfileSettingsScreen() {
color={colors.textTertiary}
style={styles.inputIcon}
/>
<Text style={styles.readOnlyText}>{authEmail || 'Loading...'}</Text>
<Text style={styles.readOnlyText}>
{authEmail || (isAnonymous ? 'Guest account' : 'Loading...')}
</Text>
</View>
<Text style={styles.inputHint}>
Email cannot be changed
{isAnonymous ? 'Save your account to add an email' : 'Email cannot be changed'}
</Text>
</SettingsSection>
@@ -300,11 +302,41 @@ const styles = StyleSheet.create({
padding: 4,
justifyContent: 'center',
alignItems: 'center',
shadowColor: featureColors.profile.accent,
shadowOffset: { width: 0, height: 4 },
shadowOpacity: 0.3,
shadowRadius: 8,
elevation: 6,
// Removed shadows
},
// ...
inputContainer: {
flexDirection: 'row',
alignItems: 'center',
backgroundColor: colors.background, // Flat
borderRadius: radius.md,
borderWidth: 1,
borderColor: colors.border,
paddingHorizontal: spacing.md,
},
// ...
readOnlyField: {
flexDirection: 'row',
alignItems: 'center',
backgroundColor: colors.backgroundLight,
borderRadius: radius.md,
borderWidth: 1,
borderColor: colors.border,
paddingHorizontal: spacing.md,
paddingVertical: spacing.md,
opacity: 0.7,
},
// ...
genderOption: {
width: '48%',
alignItems: 'center',
backgroundColor: colors.backgroundLight,
borderRadius: radius.lg,
borderWidth: 2,
borderColor: colors.border,
paddingVertical: spacing.md,
paddingHorizontal: spacing.sm,
gap: spacing.xs,
},
avatarImage: {
width: '100%',
@@ -354,15 +386,7 @@ const styles = StyleSheet.create({
color: colors.textTertiary,
},
// Input
inputContainer: {
flexDirection: 'row',
alignItems: 'center',
backgroundColor: colors.glass.background,
borderRadius: radius.md,
borderWidth: 1,
borderColor: colors.glass.border,
paddingHorizontal: spacing.md,
},
inputIcon: {
marginRight: spacing.sm,
},
@@ -379,17 +403,7 @@ const styles = StyleSheet.create({
marginLeft: spacing.xs,
},
// Read-only field
readOnlyField: {
flexDirection: 'row',
alignItems: 'center',
backgroundColor: colors.glass.background,
borderRadius: radius.md,
borderWidth: 1,
borderColor: colors.glass.border,
paddingHorizontal: spacing.md,
paddingVertical: spacing.md,
opacity: 0.7,
},
readOnlyText: {
...typography.body,
color: colors.textSecondary,
@@ -405,17 +419,7 @@ const styles = StyleSheet.create({
flexWrap: 'wrap',
gap: spacing.sm,
},
genderOption: {
width: '48%',
alignItems: 'center',
backgroundColor: colors.glass.background,
borderRadius: radius.lg,
borderWidth: 2,
borderColor: colors.glass.border,
paddingVertical: spacing.md,
paddingHorizontal: spacing.sm,
gap: spacing.xs,
},
genderOptionSelected: {
borderColor: colors.primary,
backgroundColor: colors.primaryLight,
@@ -510,7 +510,7 @@ const styles = StyleSheet.create({
},
modeToggleContainer: {
flexDirection: "row",
backgroundColor: colors.glass.background,
backgroundColor: colors.backgroundLight,
borderRadius: radius.md,
padding: 4,
marginBottom: spacing.md,
@@ -58,11 +58,11 @@ const styles = StyleSheet.create({
paddingTop: spacing.lg,
paddingBottom: Platform.OS === 'ios' ? 40 : 20,
},
divider: {
height: 1,
backgroundColor: colors.glass.border,
marginVertical: spacing.md,
},
});
divider: {
height: 1,
backgroundColor: colors.border,
marginVertical: spacing.md,
},
});
export default SupportScreen;
+8
View File
@@ -8,3 +8,11 @@ export type { UseTypingIndicatorConfig, UseTypingIndicatorReturn } from './useTy
export { useMessageSubscription } from './useMessageSubscription';
export type { UseMessageSubscriptionConfig, UseMessageSubscriptionReturn } from './useMessageSubscription';
export { useMediaSaver } from './useMediaSaver';
export { useResponseMediaUpload } from './useResponseMediaUpload';
export type {
UseResponseMediaUploadOptions,
UseResponseMediaUploadReturn,
UploadResult,
} from './useResponseMediaUpload';
export { useAudioRecorder } from './useAudioRecorder';
export type { UseAudioRecorderOptions, RecordingState } from './useAudioRecorder';
+240
View File
@@ -0,0 +1,240 @@
import { useState, useRef, useCallback, useEffect } from 'react';
import { Alert } from 'react-native';
import { Audio } from 'expo-av';
export interface UseAudioRecorderOptions {
/** Maximum recording duration in seconds. Default: 60 */
maxDurationSeconds?: number;
/** Callback when recording completes (either manually or via auto-stop) */
onRecordingComplete?: (uri: string, durationSeconds: number) => void;
}
export type RecordingState = 'idle' | 'recording' | 'stopped';
const DEFAULT_OPTIONS: Required<Omit<UseAudioRecorderOptions, 'onRecordingComplete'>> = {
maxDurationSeconds: 60,
};
/**
* Hook for audio recording functionality using expo-av.
* Handles microphone permissions, recording lifecycle, and cleanup.
*
* @param options - Optional configuration for recording
* @returns Audio recorder state and controls
*/
export const useAudioRecorder = (options?: UseAudioRecorderOptions) => {
const settings = {
...DEFAULT_OPTIONS,
...options,
};
const [state, setState] = useState<RecordingState>('idle');
const [durationSeconds, setDurationSeconds] = useState(0);
const [recordingUri, setRecordingUri] = useState<string | null>(null);
// Refs to track recording instance and cleanup
const recordingRef = useRef<Audio.Recording | null>(null);
const onRecordingCompleteRef = useRef(options?.onRecordingComplete);
// Keep the callback ref up to date
useEffect(() => {
onRecordingCompleteRef.current = options?.onRecordingComplete;
}, [options?.onRecordingComplete]);
/**
* Request microphone permissions with user-friendly error handling.
* @returns true if granted, false otherwise
*/
const requestPermission = useCallback(async (): Promise<boolean> => {
const { status } = await Audio.requestPermissionsAsync();
if (status !== 'granted') {
Alert.alert(
'Microphone Permission',
'Please allow microphone access to record audio.'
);
return false;
}
return true;
}, []);
/**
* Stop the current recording and return the result.
* @returns Object with uri and duration, or null if no recording
*/
const stopRecording = useCallback(async (): Promise<{ uri: string; duration: number } | null> => {
const recording = recordingRef.current;
if (!recording) {
return null;
}
try {
// Get final status before stopping
const status = await recording.getStatusAsync();
const finalDurationMs = status.isRecording ? status.durationMillis : 0;
const finalDurationSeconds = Math.round(finalDurationMs / 1000);
// Stop and unload the recording
await recording.stopAndUnloadAsync();
// Reset audio mode to allow playback
await Audio.setAudioModeAsync({
allowsRecordingIOS: false,
playsInSilentModeIOS: true,
});
const uri = recording.getURI();
recordingRef.current = null;
if (uri) {
setRecordingUri(uri);
setState('stopped');
setDurationSeconds(finalDurationSeconds);
// Call the completion callback if provided
if (onRecordingCompleteRef.current) {
onRecordingCompleteRef.current(uri, finalDurationSeconds);
}
return { uri, duration: finalDurationSeconds };
}
setState('idle');
return null;
} catch (error) {
console.error('Error stopping recording:', error);
recordingRef.current = null;
setState('idle');
return null;
}
}, []);
/**
* Start a new audio recording.
* @returns true if recording started successfully, false otherwise
*/
const startRecording = useCallback(async (): Promise<boolean> => {
// Don't start if already recording
if (recordingRef.current) {
return false;
}
try {
// Request permission
const hasPermission = await requestPermission();
if (!hasPermission) {
return false;
}
// Configure audio mode for recording
await Audio.setAudioModeAsync({
allowsRecordingIOS: true,
playsInSilentModeIOS: true,
});
// Reset state
setDurationSeconds(0);
setRecordingUri(null);
setState('recording');
// Create and start the recording
const { recording } = await Audio.Recording.createAsync(
Audio.RecordingOptionsPresets.HIGH_QUALITY
);
recordingRef.current = recording;
// Set up status update callback to track duration
recording.setOnRecordingStatusUpdate((status) => {
if (status.isRecording) {
const currentSeconds = Math.round(status.durationMillis / 1000);
setDurationSeconds(currentSeconds);
// Auto-stop if max duration reached
if (currentSeconds >= settings.maxDurationSeconds) {
stopRecording();
}
}
});
// Set progress update interval (100ms for smooth duration updates)
recording.setProgressUpdateInterval(100);
return true;
} catch (error) {
console.error('Error starting recording:', error);
recordingRef.current = null;
setState('idle');
// Reset audio mode on error
try {
await Audio.setAudioModeAsync({
allowsRecordingIOS: false,
playsInSilentModeIOS: true,
});
} catch {
// Ignore errors during cleanup
}
return false;
}
}, [requestPermission, settings.maxDurationSeconds, stopRecording]);
/**
* Reset the recording state to idle.
* Cleans up any existing recording.
*/
const resetRecording = useCallback(() => {
// Clean up any existing recording
if (recordingRef.current) {
try {
recordingRef.current.stopAndUnloadAsync().catch(() => {
// Ignore errors during cleanup
});
} catch {
// Ignore errors during cleanup
}
recordingRef.current = null;
}
setState('idle');
setDurationSeconds(0);
setRecordingUri(null);
}, []);
// Cleanup on unmount
useEffect(() => {
return () => {
if (recordingRef.current) {
recordingRef.current.stopAndUnloadAsync().catch(() => {
// Ignore errors during cleanup
});
recordingRef.current = null;
}
// Reset audio mode
Audio.setAudioModeAsync({
allowsRecordingIOS: false,
playsInSilentModeIOS: true,
}).catch(() => {
// Ignore errors during cleanup
});
};
}, []);
return {
/** Current recording state: 'idle', 'recording', or 'stopped' */
state,
/** Current recording duration in seconds */
durationSeconds,
/** URI of the recorded audio file (available when state is 'stopped') */
recordingUri,
/** Start a new recording. Returns true if successful. */
startRecording,
/** Stop the current recording. Returns the uri and duration, or null. */
stopRecording,
/** Reset state to idle and clean up any recording. */
resetRecording,
/** Request microphone permission. Returns true if granted. */
requestPermission,
};
};
@@ -0,0 +1,191 @@
import { useState, useCallback, useRef } from 'react';
import { Platform } from 'react-native';
import * as FileSystem from 'expo-file-system';
import { decode } from 'base64-arraybuffer';
import { supabase } from '../lib/supabase';
export interface UseResponseMediaUploadOptions {
userId: string;
questionId: string;
}
export interface UploadResult {
success: boolean;
/** Storage path, e.g., "user123/question456_1706189234.jpg" */
mediaPath?: string;
error?: string;
}
export interface UseResponseMediaUploadReturn {
/** Whether an upload is in progress */
uploading: boolean;
/** Upload a photo to response-media storage */
uploadPhoto: (localUri: string) => Promise<UploadResult>;
/** Upload an audio file to response-media storage */
uploadAudio: (localUri: string, durationSeconds: number) => Promise<UploadResult>;
}
/**
* Hook for uploading response media (photos and audio) to Supabase storage.
* Uploads to the `response-media` bucket with path format: {userId}/{questionId}_{timestamp}.{ext}
*/
export function useResponseMediaUpload(
options: UseResponseMediaUploadOptions
): UseResponseMediaUploadReturn {
const [uploading, setUploading] = useState(false);
// Use ref for options to avoid stale closures
const optionsRef = useRef(options);
optionsRef.current = options;
const uploadPhoto = useCallback(async (localUri: string): Promise<UploadResult> => {
const { userId, questionId } = optionsRef.current;
if (!userId || !questionId) {
return { success: false, error: 'Missing userId or questionId' };
}
setUploading(true);
try {
let fileBody;
let ext = 'jpg';
if (Platform.OS === 'web') {
const response = await fetch(localUri);
const blob = await response.blob();
fileBody = blob;
// Detect extension from blob type
if (blob.type === 'image/png') ext = 'png';
else if (blob.type === 'image/webp') ext = 'webp';
else if (blob.type === 'image/gif') ext = 'gif';
} else {
const base64 = await FileSystem.readAsStringAsync(localUri, {
encoding: FileSystem.EncodingType.Base64,
});
fileBody = decode(base64);
// Try to get extension from URI
const uriExt = localUri.split('.').pop()?.toLowerCase();
if (uriExt && ['jpg', 'jpeg', 'png', 'webp', 'gif'].includes(uriExt)) {
ext = uriExt === 'jpeg' ? 'jpg' : uriExt;
}
}
const timestamp = Date.now();
const mediaPath = `${userId}/${questionId}_${timestamp}.${ext}`;
const contentType = `image/${ext === 'jpg' ? 'jpeg' : ext}`;
const { error: uploadError } = await supabase.storage
.from('response-media')
.upload(mediaPath, fileBody, {
contentType,
upsert: false,
});
if (uploadError) {
console.error('Error uploading photo:', uploadError);
return { success: false, error: uploadError.message };
}
return { success: true, mediaPath };
} catch (error) {
console.error('Error uploading photo:', error);
return {
success: false,
error: error instanceof Error ? error.message : 'Unknown error',
};
} finally {
setUploading(false);
}
}, []);
const uploadAudio = useCallback(
async (localUri: string, _durationSeconds: number): Promise<UploadResult> => {
const { userId, questionId } = optionsRef.current;
if (!userId || !questionId) {
return { success: false, error: 'Missing userId or questionId' };
}
setUploading(true);
try {
let fileBody;
let ext = 'm4a';
let contentType = 'audio/m4a';
if (Platform.OS === 'web') {
const response = await fetch(localUri);
const blob = await response.blob();
fileBody = blob;
// Detect extension from blob type
if (blob.type === 'audio/mpeg' || blob.type === 'audio/mp3') {
ext = 'mp3';
contentType = 'audio/mpeg';
} else if (blob.type === 'audio/wav') {
ext = 'wav';
contentType = 'audio/wav';
} else if (blob.type === 'audio/webm') {
ext = 'webm';
contentType = 'audio/webm';
} else if (blob.type === 'audio/mp4' || blob.type === 'audio/x-m4a') {
ext = 'm4a';
contentType = 'audio/m4a';
}
} else {
const base64 = await FileSystem.readAsStringAsync(localUri, {
encoding: FileSystem.EncodingType.Base64,
});
fileBody = decode(base64);
// Try to get extension from URI
const uriExt = localUri.split('.').pop()?.toLowerCase();
if (uriExt && ['m4a', 'mp3', 'wav', 'aac', 'caf'].includes(uriExt)) {
ext = uriExt;
if (ext === 'mp3') contentType = 'audio/mpeg';
else if (ext === 'wav') contentType = 'audio/wav';
else if (ext === 'aac') contentType = 'audio/aac';
else if (ext === 'caf') contentType = 'audio/x-caf';
else contentType = 'audio/m4a';
}
}
const timestamp = Date.now();
const mediaPath = `${userId}/${questionId}_${timestamp}.${ext}`;
const { error: uploadError } = await supabase.storage
.from('response-media')
.upload(mediaPath, fileBody, {
contentType,
upsert: false,
});
if (uploadError) {
console.error('Error uploading audio:', uploadError);
return { success: false, error: uploadError.message };
}
return { success: true, mediaPath };
} catch (error) {
console.error('Error uploading audio:', error);
return {
success: false,
error: error instanceof Error ? error.message : 'Unknown error',
};
} finally {
setUploading(false);
}
},
[]
);
return {
uploading,
uploadPhoto,
uploadAudio,
};
}
+8 -8
View File
@@ -60,14 +60,14 @@ export async function getCachedSignedUrl(
/**
* Extract storage path from media_path (handles both old full URLs and new path-only format)
*/
export function getStoragePath(mediaPath: string): string {
if (mediaPath.startsWith('http')) {
// Old format: extract path from full URL
const match = mediaPath.match(/\/chat-media\/(.+)$/);
return match ? match[1] : mediaPath;
}
return mediaPath;
}
export function getStoragePath(mediaPath: string): string {
if (mediaPath.startsWith('http')) {
// Old format: extract path from full URL
const match = mediaPath.match(/\/(chat-media|response-media)\/(.+)$/);
return match ? match[2] : mediaPath;
}
return mediaPath;
}
/**
* Clear expired URLs from the cache to free memory.
+44 -29
View File
@@ -5,6 +5,7 @@ import { router } from "expo-router";
import { supabase } from "./supabase";
import { captureError } from "./sentry";
import { Events } from "./analytics";
import { useMatchStore } from "../store/matchStore";
// Configure notification behavior for foreground
// We don't show system alerts since the app handles notifications with in-app toasts
@@ -17,13 +18,13 @@ Notifications.setNotificationHandler({
});
// Types
export interface NotificationData {
type: "match" | "message" | "match_digest";
match_id?: string;
message_id?: string;
count?: number;
}
export interface NotificationData {
type: "match" | "message" | "match_digest" | "partner_activity" | "nudge";
match_id?: string;
message_id?: string;
count?: number;
}
/**
* Request notification permissions and get Expo push token.
@@ -221,28 +222,42 @@ export async function checkAndRegisterPushToken(userId: string): Promise<boolean
/**
* Handle notification tap - navigate to appropriate screen.
*/
export function handleNotificationResponse(
response: Notifications.NotificationResponse
): void {
const data = response.notification.request.content.data as NotificationData;
if (!data) {
return;
}
if (data.type === "match_digest") {
router.push("/(app)/matches");
return;
}
if (!data.match_id) {
return;
}
// Both match and message notifications navigate to chat
router.push(`/(app)/chat/${data.match_id}`);
}
export function handleNotificationResponse(
response: Notifications.NotificationResponse
): void {
const data = response.notification.request.content.data as NotificationData;
if (!data) {
return;
}
if (data.type === "match_digest") {
router.push("/(app)/matches");
return;
}
if (data.type === "nudge") {
// Navigate to Matches > Your Turn tab
useMatchStore.getState().setCurrentView('pending');
router.push("/(app)/matches");
return;
}
if (data.type === "partner_activity") {
// Navigate to Matches > Your Turn tab (questions to answer)
useMatchStore.getState().setCurrentView('pending');
router.push("/(app)/matches");
return;
}
if (!data.match_id) {
return;
}
// Both match and message notifications navigate to chat
router.push(`/(app)/chat/${data.match_id}`);
}
/**
* Set up listener for notification taps (when app is running).
@@ -0,0 +1,49 @@
import * as SecureStore from "expo-secure-store";
import { Platform } from "react-native";
const STORAGE_KEY_PREFIX = "onboarding_paywall_seen";
const buildKey = (userId: string): string => `${STORAGE_KEY_PREFIX}_${userId}`;
async function getStorage(key: string): Promise<boolean> {
try {
let data: string | null = null;
if (Platform.OS === "web") {
if (typeof window !== "undefined" && typeof window.localStorage !== "undefined") {
data = window.localStorage.getItem(key);
}
} else {
data = await SecureStore.getItemAsync(key);
}
return data === "true";
} catch (error) {
console.error("Error reading onboarding paywall state:", error);
}
return false;
}
async function setStorage(key: string, seen: boolean): Promise<void> {
try {
const data = seen ? "true" : "false";
if (Platform.OS === "web") {
if (typeof window !== "undefined" && typeof window.localStorage !== "undefined") {
window.localStorage.setItem(key, data);
}
} else {
await SecureStore.setItemAsync(key, data);
}
} catch (error) {
console.error("Error saving onboarding paywall state:", error);
}
}
export async function hasSeenOnboardingPaywall(userId: string): Promise<boolean> {
return getStorage(buildKey(userId));
}
export async function markOnboardingPaywallSeen(userId: string): Promise<void> {
await setStorage(buildKey(userId), true);
}
+11 -2
View File
@@ -145,7 +145,7 @@ class RevenueCatService {
return result.current;
}
async getOfferingsDebug(): Promise<{
async getOfferingsDebug(offeringIdentifier?: string): Promise<{
current: PurchasesOffering | null;
availableOfferings: string[] | null;
error: string | null;
@@ -162,8 +162,17 @@ class RevenueCatService {
const offerings = await this.Purchases.getOfferings();
const availableOfferings = Object.keys(offerings.all || {});
// If a specific offering is requested, try to get it
let targetOffering = offerings.current;
if (offeringIdentifier && offerings.all?.[offeringIdentifier]) {
console.log(`RevenueCat: Using offering "${offeringIdentifier}"`);
targetOffering = offerings.all[offeringIdentifier];
} else if (offeringIdentifier) {
console.log(`RevenueCat: Offering "${offeringIdentifier}" not found, falling back to current`);
}
return {
current: offerings.current,
current: targetOffering,
availableOfferings,
error: null
};
-51
View File
@@ -1,51 +0,0 @@
import * as SecureStore from "expo-secure-store";
import { Platform } from "react-native";
const STORAGE_KEY = "swipe_tutorial_seen";
async function getStorage(): Promise<boolean> {
try {
let data: string | null = null;
if (Platform.OS === "web") {
if (typeof window !== "undefined" && typeof window.localStorage !== "undefined") {
data = window.localStorage.getItem(STORAGE_KEY);
}
} else {
data = await SecureStore.getItemAsync(STORAGE_KEY);
}
return data === "true";
} catch (error) {
console.error("Error reading swipe tutorial state:", error);
}
return false;
}
async function setStorage(seen: boolean): Promise<void> {
try {
const data = seen ? "true" : "false";
if (Platform.OS === "web") {
if (typeof window !== "undefined" && typeof window.localStorage !== "undefined") {
window.localStorage.setItem(STORAGE_KEY, data);
}
} else {
await SecureStore.setItemAsync(STORAGE_KEY, data);
}
} catch (error) {
console.error("Error saving swipe tutorial state:", error);
}
}
export async function hasSeenSwipeTutorial(): Promise<boolean> {
return getStorage();
}
export async function markSwipeTutorialSeen(): Promise<void> {
await setStorage(true);
}
export async function resetSwipeTutorial(): Promise<void> {
await setStorage(false);
}
+209 -1
View File
@@ -12,7 +12,19 @@ export interface PendingQuestion {
partnerAnsweredAt: string;
}
type MatchViewType = 'active' | 'archived' | 'pending';
type MatchViewType = 'active' | 'archived' | 'pending' | 'their_turn';
// Rate limit: 12 hours in milliseconds
const NUDGE_COOLDOWN_MS = 12 * 60 * 60 * 1000;
interface NudgeResponse {
success: boolean;
notification_sent?: boolean;
reason?: string;
next_nudge_available_at?: string;
error?: string;
cooldown_remaining_seconds?: number;
}
interface MatchState {
matches: Match[];
@@ -31,7 +43,13 @@ interface MatchState {
// Pending (Your Turn) state
pendingQuestions: PendingQuestion[];
isLoadingPending: boolean;
// Their Turn state (user answered, partner hasn't)
theirTurnQuestions: PendingQuestion[];
isLoadingTheirTurn: boolean;
currentView: MatchViewType;
// Nudge state
nudgeCooldownUntil: Date | null;
isNudging: boolean;
// Methods
fetchMatches: (refresh?: boolean) => Promise<void>;
markAsSeen: (matchId: string) => Promise<void>;
@@ -47,7 +65,11 @@ interface MatchState {
isMatchArchived: (matchId: string) => boolean;
// Pending methods
fetchPendingQuestions: () => Promise<void>;
fetchTheirTurnQuestions: () => Promise<void>;
setCurrentView: (view: MatchViewType) => void;
// Nudge methods
sendNudge: () => Promise<{ success: boolean; notificationSent: boolean }>;
checkNudgeCooldown: () => Promise<void>;
}
export const useMatchStore = create<MatchState>((set, get) => ({
@@ -67,7 +89,13 @@ export const useMatchStore = create<MatchState>((set, get) => ({
// Pending (Your Turn) state
pendingQuestions: [],
isLoadingPending: false,
// Their Turn state
theirTurnQuestions: [],
isLoadingTheirTurn: false,
currentView: 'pending' as MatchViewType,
// Nudge state
nudgeCooldownUntil: null,
isNudging: false,
fetchMatches: async (refresh = false) => {
const userId = useAuthStore.getState().user?.id;
@@ -291,7 +319,11 @@ clearMatches: () => {
isLoadingArchived: false,
pendingQuestions: [],
isLoadingPending: false,
theirTurnQuestions: [],
isLoadingTheirTurn: false,
currentView: 'pending',
nudgeCooldownUntil: null,
isNudging: false,
});
},
@@ -547,6 +579,81 @@ clearMatches: () => {
}
},
// Their Turn methods (user answered, partner hasn't yet)
fetchTheirTurnQuestions: async () => {
const userId = useAuthStore.getState().user?.id;
const coupleId = useAuthStore.getState().user?.couple_id;
if (!coupleId || !userId) {
set({ theirTurnQuestions: [], isLoadingTheirTurn: false });
return;
}
const state = get();
if (state.isLoadingTheirTurn) return;
set({ isLoadingTheirTurn: true });
try {
// Get partner's user_id
const { data: coupleProfiles } = await supabase
.from("profiles")
.select("id")
.eq("couple_id", coupleId)
.neq("id", userId);
const partnerId = coupleProfiles?.[0]?.id;
if (!partnerId) {
set({ theirTurnQuestions: [], isLoadingTheirTurn: false });
return;
}
// Get questions the partner has already answered
const { data: partnerResponses } = await supabase
.from("responses")
.select("question_id")
.eq("user_id", partnerId)
.eq("couple_id", coupleId);
const partnerAnsweredQuestionIds = new Set(partnerResponses?.map(r => r.question_id) ?? []);
// Get user's responses (questions they answered that partner hasn't)
const { data: userResponses, error } = await supabase
.from("responses")
.select(`
id,
question_id,
created_at,
question:questions(
*,
pack:question_packs(id, name, icon)
)
`)
.eq("user_id", userId)
.eq("couple_id", coupleId)
.order("created_at", { ascending: false });
if (error) throw error;
// Filter to only questions partner hasn't answered, excluding deleted questions
const theirTurnQuestions: PendingQuestion[] = (userResponses ?? [])
.filter(r => {
const question = r.question as unknown as PendingQuestion['question'] | null;
return !partnerAnsweredQuestionIds.has(r.question_id) && question && !(question as any).deleted_at;
})
.map(r => ({
id: r.id,
question: r.question as unknown as PendingQuestion['question'],
partnerAnsweredAt: r.created_at, // Using user's answer time here
}));
set({ theirTurnQuestions, isLoadingTheirTurn: false });
} catch (err) {
console.error("Error fetching their turn questions:", err);
set({ isLoadingTheirTurn: false });
}
},
setCurrentView: (view: MatchViewType) => {
const state = get();
@@ -559,6 +666,107 @@ clearMatches: () => {
get().fetchArchivedMatches();
} else if (view === 'pending' && state.pendingQuestions.length === 0) {
get().fetchPendingQuestions();
} else if (view === 'their_turn' && state.theirTurnQuestions.length === 0) {
get().fetchTheirTurnQuestions();
}
},
// Nudge methods
sendNudge: async () => {
const state = get();
if (state.isNudging) {
return { success: false, notificationSent: false };
}
// Check if still in cooldown
if (state.nudgeCooldownUntil && new Date() < state.nudgeCooldownUntil) {
return { success: false, notificationSent: false };
}
set({ isNudging: true });
try {
const { data: sessionData } = await supabase.auth.getSession();
const token = sessionData?.session?.access_token;
if (!token) {
set({ isNudging: false });
return { success: false, notificationSent: false };
}
const response = await fetch(
`${process.env.EXPO_PUBLIC_SUPABASE_URL}/functions/v1/send-nudge-notification`,
{
method: "POST",
headers: {
"Content-Type": "application/json",
Authorization: `Bearer ${token}`,
},
}
);
const data: NudgeResponse = await response.json();
if (response.status === 429) {
// Rate limited - update cooldown
const cooldownUntil = data.next_nudge_available_at
? new Date(data.next_nudge_available_at)
: new Date(Date.now() + (data.cooldown_remaining_seconds || 0) * 1000);
set({ nudgeCooldownUntil: cooldownUntil, isNudging: false });
return { success: false, notificationSent: false };
}
if (!response.ok) {
console.error("Nudge error:", data.error);
set({ isNudging: false });
return { success: false, notificationSent: false };
}
// Success - update cooldown
const cooldownUntil = data.next_nudge_available_at
? new Date(data.next_nudge_available_at)
: new Date(Date.now() + NUDGE_COOLDOWN_MS);
set({ nudgeCooldownUntil: cooldownUntil, isNudging: false });
return {
success: true,
notificationSent: data.notification_sent ?? false,
};
} catch (err) {
console.error("Error sending nudge:", err);
set({ isNudging: false });
return { success: false, notificationSent: false };
}
},
checkNudgeCooldown: async () => {
const userId = useAuthStore.getState().user?.id;
if (!userId) return;
try {
const { data: profile, error } = await supabase
.from("profiles")
.select("last_nudge_sent_at")
.eq("id", userId)
.maybeSingle();
if (error || !profile?.last_nudge_sent_at) {
set({ nudgeCooldownUntil: null });
return;
}
const lastNudge = new Date(profile.last_nudge_sent_at);
const cooldownUntil = new Date(lastNudge.getTime() + NUDGE_COOLDOWN_MS);
// Only set cooldown if it's still in the future
if (cooldownUntil > new Date()) {
set({ nudgeCooldownUntil: cooldownUntil });
} else {
set({ nudgeCooldownUntil: null });
}
} catch (err) {
console.error("Error checking nudge cooldown:", err);
}
},
}));
@@ -7,6 +7,7 @@ export interface NotificationPreferences {
matches_enabled: boolean;
messages_enabled: boolean;
partner_activity_enabled: boolean;
nudges_enabled: boolean;
pack_changes_enabled: boolean;
new_packs_enabled: boolean;
streak_milestones_enabled: boolean;
+20 -3
View File
@@ -1,7 +1,7 @@
import { create } from "zustand";
import { supabase } from "../lib/supabase";
import { invokeWithAuthRetry } from "../lib/authErrorHandler";
import type { Question, QuestionPack, AnswerType } from "@/types";
import type { Question, QuestionPack, AnswerType, ResponseData } from "@/types";
import { useAuthStore } from "./authStore";
import { useMatchStore } from "./matchStore";
@@ -10,6 +10,7 @@ export interface ResponseWithQuestion {
id: string;
question_id: string;
answer: AnswerType;
response_data?: ResponseData | null;
created_at: string;
question: Question & {
pack: Pick<QuestionPack, "id" | "name" | "icon">;
@@ -49,7 +50,8 @@ interface ResponsesState {
updateResponse: (
questionId: string,
newAnswer: AnswerType,
confirmDelete?: boolean
confirmDelete?: boolean,
responseData?: ResponseData | null
) => Promise<UpdateResponseResult>;
setGroupBy: (groupBy: GroupByOption) => void;
setDateSortOrder: (order: DateSortOrder) => void;
@@ -108,6 +110,7 @@ export const useResponsesStore = create<ResponsesState>((set, get) => ({
id,
question_id,
answer,
response_data,
created_at,
question:questions!inner(
id,
@@ -115,6 +118,8 @@ export const useResponsesStore = create<ResponsesState>((set, get) => ({
partner_text,
intensity,
pack_id,
question_type,
config,
pack:question_packs!inner(id, name, icon)
)
`)
@@ -180,6 +185,7 @@ export const useResponsesStore = create<ResponsesState>((set, get) => ({
id: r.id,
question_id: r.question_id,
answer: r.answer as AnswerType,
response_data: (r as any).response_data as ResponseData | null,
created_at: r.created_at,
question: {
id: question.id,
@@ -187,6 +193,8 @@ export const useResponsesStore = create<ResponsesState>((set, get) => ({
partner_text: question.partner_text,
intensity: question.intensity,
pack_id: question.pack_id,
question_type: question.question_type,
config: question.config ?? null,
created_at: question.created_at || "",
pack: {
id: pack.id,
@@ -219,13 +227,15 @@ export const useResponsesStore = create<ResponsesState>((set, get) => ({
updateResponse: async (
questionId: string,
newAnswer: AnswerType,
confirmDelete = false
confirmDelete = false,
responseData?: ResponseData | null
): Promise<UpdateResponseResult> => {
const { data, error } = await invokeWithAuthRetry("update-response", {
body: {
question_id: questionId,
new_answer: newAnswer,
confirm_delete_match: confirmDelete,
response_data: responseData,
},
});
@@ -242,9 +252,16 @@ export const useResponsesStore = create<ResponsesState>((set, get) => ({
set((state) => ({
responses: state.responses.map((r) => {
if (r.question_id === questionId) {
const nextResponseData =
newAnswer === "no"
? null
: typeof responseData !== "undefined"
? responseData
: r.response_data;
return {
...r,
answer: newAnswer,
response_data: nextResponseData,
// Update match status based on result
has_match: result.match_deleted
? false
+54 -54
View File
@@ -7,28 +7,28 @@ import revenueCatService, {
} from "../lib/revenuecat";
import { useAuthStore } from "./authStore";
export type PurchaseResult = {
success: boolean;
cancelled?: boolean;
errorCode?: unknown;
errorMessage?: unknown;
};
interface SubscriptionStoreState {
subscription: SubscriptionState;
offerings: PurchasesOffering | null;
isLoadingOfferings: boolean;
isPurchasing: boolean;
error: string | null;
// Actions
initializeRevenueCat: (userId: string) => Promise<void>;
fetchOfferings: () => Promise<void>;
purchasePackage: (pkg: PurchasesPackage) => Promise<PurchaseResult>;
restorePurchases: () => Promise<boolean>;
refreshSubscriptionStatus: () => Promise<void>;
clearSubscription: () => void;
}
export type PurchaseResult = {
success: boolean;
cancelled?: boolean;
errorCode?: unknown;
errorMessage?: unknown;
};
interface SubscriptionStoreState {
subscription: SubscriptionState;
offerings: PurchasesOffering | null;
isLoadingOfferings: boolean;
isPurchasing: boolean;
error: string | null;
// Actions
initializeRevenueCat: (userId: string) => Promise<void>;
fetchOfferings: (offeringIdentifier?: string) => Promise<void>;
purchasePackage: (pkg: PurchasesPackage) => Promise<PurchaseResult>;
restorePurchases: () => Promise<boolean>;
refreshSubscriptionStatus: () => Promise<void>;
clearSubscription: () => void;
}
const defaultSubscription: SubscriptionState = {
isProUser: false,
@@ -77,7 +77,7 @@ export const useSubscriptionStore = create<SubscriptionStoreState>((set, get) =>
}
},
fetchOfferings: async () => {
fetchOfferings: async (offeringIdentifier?: string) => {
set({ isLoadingOfferings: true, error: null });
try {
// Check if RevenueCat is initialized
@@ -90,7 +90,7 @@ export const useSubscriptionStore = create<SubscriptionStoreState>((set, get) =>
return;
}
const result = await revenueCatService.getOfferingsDebug();
const result = await revenueCatService.getOfferingsDebug(offeringIdentifier);
if (result.error) {
set({
@@ -128,36 +128,36 @@ export const useSubscriptionStore = create<SubscriptionStoreState>((set, get) =>
}
},
purchasePackage: async (pkg: PurchasesPackage) => {
set({ isPurchasing: true, error: null });
try {
const customerInfo = await revenueCatService.purchasePackage(pkg);
const subscription = revenueCatService.parseCustomerInfo(customerInfo);
set({ subscription, isPurchasing: false });
// Refresh user profile to get updated is_premium
await useAuthStore.getState().fetchUser();
return { success: true };
} catch (error: any) {
const isCancelled =
error?.userCancelled === true ||
error?.message === "Purchase cancelled" ||
error?.code === "PURCHASE_CANCELLED";
const message = isCancelled
? "Purchase was cancelled"
: "Purchase failed. Please try again.";
set({ error: message, isPurchasing: false });
return {
success: false,
cancelled: isCancelled,
errorCode: error?.code,
errorMessage: error?.message,
};
}
},
purchasePackage: async (pkg: PurchasesPackage) => {
set({ isPurchasing: true, error: null });
try {
const customerInfo = await revenueCatService.purchasePackage(pkg);
const subscription = revenueCatService.parseCustomerInfo(customerInfo);
set({ subscription, isPurchasing: false });
// Refresh user profile to get updated is_premium
await useAuthStore.getState().fetchUser();
return { success: true };
} catch (error: any) {
const isCancelled =
error?.userCancelled === true ||
error?.message === "Purchase cancelled" ||
error?.code === "PURCHASE_CANCELLED";
const message = isCancelled
? "Purchase was cancelled"
: "Purchase failed. Please try again.";
set({ error: message, isPurchasing: false });
return {
success: false,
cancelled: isCancelled,
errorCode: error?.code,
errorMessage: error?.message,
};
}
},
restorePurchases: async () => {
set({ isPurchasing: true, error: null });
+32 -29
View File
@@ -1,26 +1,29 @@
import { Platform } from 'react-native';
export const colors = {
// Backgrounds
background: '#0d0d1a',
backgroundLight: '#1a1a2e',
surface: 'rgba(22, 33, 62, 0.6)',
surfaceSolid: '#16213e',
// Glass effects
glass: {
background: 'rgba(22, 33, 62, 0.4)',
backgroundLight: 'rgba(22, 33, 62, 0.6)',
border: 'rgba(255, 255, 255, 0.08)',
borderLight: 'rgba(255, 255, 255, 0.12)',
highlight: 'rgba(255, 255, 255, 0.05)',
},
// Brand
primary: '#e94560',
primaryDark: '#c73a52',
primaryLight: 'rgba(233, 69, 96, 0.15)',
primaryGlow: 'rgba(233, 69, 96, 0.3)',
export const colors = {
// Backgrounds - Updated to Flat/Admin Palette
background: '#0e0e11', // hsl(240 10% 6%)
backgroundLight: '#17171c', // hsl(240 8% 10%)
surface: '#17171c', // Mapped to card color
surfaceSolid: '#17171c',
// Border
border: '#303036', // hsl(240 6% 20%)
// Glass effects - Retained keys for compat, but mapped to flat/subtle values
glass: {
background: '#17171c', // Solid card color
backgroundLight: '#222228', // Slightly lighter
border: '#303036',
borderLight: '#404048',
highlight: 'transparent', // Removed highlight
},
// Brand
primary: '#e1306c', // Adjusted to match admin rose approximately
primaryDark: '#c12055',
primaryLight: 'rgba(225, 48, 108, 0.15)',
primaryGlow: 'rgba(225, 48, 108, 0.3)',
secondary: '#9b59b6',
secondaryDark: '#8e44ad',
secondaryLight: 'rgba(155, 89, 182, 0.15)',
@@ -67,14 +70,14 @@ export const colors = {
mutedLight: 'rgba(108, 117, 125, 0.15)',
};
export const gradients = {
primary: ['#e94560', '#9b59b6'],
primarySubtle: ['rgba(233, 69, 96, 0.8)', 'rgba(155, 89, 182, 0.8)'],
primaryReverse: ['#9b59b6', '#e94560'],
primaryVertical: ['#e94560', '#9b59b6'],
background: ['#1a1a2e', '#0d0d1a'],
backgroundReverse: ['#0d0d1a', '#1a1a2e'],
glass: ['rgba(255, 255, 255, 0.08)', 'rgba(255, 255, 255, 0)'],
export const gradients = {
primary: ['#e1306c', '#9b59b6'],
primarySubtle: ['rgba(225, 48, 108, 0.8)', 'rgba(155, 89, 182, 0.8)'],
primaryReverse: ['#9b59b6', '#e1306c'],
primaryVertical: ['#e1306c', '#9b59b6'],
background: ['#0e0e11', '#1f1417', '#0e0e11'], // Premium subtle gradient from admin
backgroundReverse: ['#0e0e11', '#1f1417', '#0e0e11'],
glass: ['#17171c', '#17171c'], // Solid fallback
success: ['#2ECC71', '#27ae60'],
warning: ['#F39C12', '#e67e22'],
error: ['#E74C3C', '#c0392b'],
Loaded 100 of 114 files, more files were not shown because too many files have changed in this diff. Show more