mirror of
https://github.com/jjbrunton/Sauci.git
synced 2026-10-31 10:19:43 +00:00
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:
1 parent
8612b16b93
commit
1daf386765
114 files changed
+14877
-7911
No files matched your search
@@ -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}
|
||||
/>
|
||||
|
||||
@@ -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">
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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">
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -0,0 +1,5 @@
|
||||
// https://docs.expo.dev/guides/using-eslint/
|
||||
module.exports = {
|
||||
extends: 'expo',
|
||||
ignorePatterns: ['/dist/*'],
|
||||
};
|
||||
+119
-110
@@ -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
@@ -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
|
||||
@@ -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,
|
||||
},
|
||||
});
|
||||
@@ -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
@@ -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 }],
|
||||
},
|
||||
});
|
||||
|
||||
+555
-258
File diff suppressed because it is too large.
Load diff
@@ -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,
|
||||
|
||||
@@ -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,
|
||||
},
|
||||
|
||||
@@ -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,
|
||||
}}
|
||||
/>
|
||||
);
|
||||
}
|
||||
@@ -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,
|
||||
},
|
||||
});
|
||||
@@ -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,
|
||||
},
|
||||
});
|
||||
@@ -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,
|
||||
|
||||
@@ -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
@@ -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,
|
||||
},
|
||||
});
|
||||
@@ -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
@@ -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
@@ -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,
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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;
|
||||
};
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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;
|
||||
@@ -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,
|
||||
|
||||
@@ -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;
|
||||
@@ -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,
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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);
|
||||
|
||||
@@ -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,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';
|
||||
@@ -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,
|
||||
};
|
||||
}
|
||||
@@ -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.
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
@@ -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
|
||||
};
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
@@ -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;
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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 });
|
||||
|
||||
@@ -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
Reference in new issue
Block a user