Compare commits

..
6 Commits
Author SHA1 Message Date
jjbrunton de0d667f4f fix: move migration to correct apps/supabase path 2026-02-01 17:22:54 +00:00
jjbrunton 458f18be03 fix: add admin RLS policy for live draw sessions 2026-02-01 17:19:13 +00:00
jjbrunton 785e73d6c0 Update 2026-02-01 17:01:09 +00:00
jjbruntonandClaude Opus 4.5 4c18afaeae feat: add live draw feature and clean up docs
Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-02-01 14:35:16 +00:00
jjbrunton 520d0a8444 fix: handle missing cron jobs in migration unschedule
Use DO block with exception handling so cron.unschedule doesn't
fail when jobs were already removed (e.g. on non-prod).
2026-02-01 10:15:19 +00:00
jjbrunton 964fc25fc7 fix: use dynamic URLs for cron jobs to prevent cross-env firing
Non-prod cron jobs were hardcoding production URLs, causing double
notifications. Adds get_supabase_edge_function_url() helper that
resolves from app_config.supabase_url per environment. Also adds
text_answer question type to pack generation docs.
2026-02-01 10:12:30 +00:00
75 changed files with 6551 additions and 13366 deletions

No files matched your search

+25 -6
View File
@@ -1,6 +1,6 @@
--- ---
allowed-tools: Read, mcp__sauci-prod__execute_sql, mcp__sauci-prod__list_tables allowed-tools: Read, mcp__sauci-prod__execute_sql, mcp__sauci-prod__list_tables
argument-hint: <pack-name> --category=<category> [--count=30] [--premium] [--types=swipe,who_likely,audio,photo] argument-hint: <pack-name> --category=<category> [--count=30] [--premium] [--types=swipe,who_likely,audio,photo,text_answer]
description: Generate a question pack for Sauci following established content guidelines description: Generate a question pack for Sauci following established content guidelines
--- ---
@@ -31,7 +31,7 @@ Extract from $ARGUMENTS:
- **Category**: Required - which category this belongs to (or "new" to create) - **Category**: Required - which category this belongs to (or "new" to create)
- **Question count**: Optional - default 30, can be 25-50 - **Question count**: Optional - default 30, can be 25-50
- **Premium**: Optional flag - if pack should be premium - **Premium**: Optional flag - if pack should be premium
- **Types**: Optional - comma-separated question types to generate. Default: `swipe,who_likely,audio,photo`. Options: `swipe`, `who_likely`, `audio`, `photo` - **Types**: Optional - comma-separated question types to generate. Default: `swipe,who_likely,audio,photo,text_answer`. Options: `swipe`, `who_likely`, `audio`, `photo`, `text_answer`
### Step 2: Understand Context ### Step 2: Understand Context
@@ -120,13 +120,26 @@ Each question MUST have a `question_type`. The default mix should include variet
- "Take a selfie showing how you feel right now" - "Take a selfie showing how you feel right now"
- "Share a screenshot of your last text about your partner" - "Share a screenshot of your last text about your partner"
**`text_answer`** - Written response prompt
- ALWAYS symmetric (partner_text = NULL, no inverse needed)
- Partners type a written response
- `config`: `{"max_length": 500}` (default 500)
- Great for: reflections, love notes, confessions, creative writing, finish-the-sentence prompts
- Writing can feel safer than speaking for vulnerable topics
- Format: prompts that invite a thoughtful written response
- Examples:
- "Describe your partner in three words"
- "Write a short love letter to your partner"
- "Finish this sentence: When I'm with you I feel..."
#### 3.4 Type Distribution Guidelines #### 3.4 Type Distribution Guidelines
For a standard 30-question pack, aim for roughly: For a standard 30-question pack, aim for roughly:
- **20-22 swipe** questions (the core experience) - **18-20 swipe** questions (the core experience)
- **4-5 who_likely** questions - **4-5 who_likely** questions
- **2-3 audio** questions - **2-3 audio** questions
- **2-3 photo** questions - **2-3 photo** questions
- **2-3 text_answer** questions
Adjust based on what fits the pack's theme. Some packs may lean heavier on certain types. Adjust based on what fits the pack's theme. Some packs may lean heavier on certain types.
@@ -147,7 +160,7 @@ Adjust based on what fits the pack's theme. Some packs may lean heavier on certa
#### 3.6 Critical: Inverse Questions and Database Linking (swipe questions only) #### 3.6 Critical: Inverse Questions and Database Linking (swipe questions only)
Non-swipe types (who_likely, audio, photo) are ALWAYS symmetric — no inverse needed. Non-swipe types (who_likely, audio, photo, text_answer) are ALWAYS symmetric — no inverse needed.
For EVERY asymmetric **swipe** question, create its inverse AND link them using the `inverse_of` column: For EVERY asymmetric **swipe** question, create its inverse AND link them using the `inverse_of` column:
@@ -225,7 +238,7 @@ Before outputting, verify each question against this checklist:
- [ ] who_likely questions use "Who is more likely to..." format - [ ] who_likely questions use "Who is more likely to..." format
- [ ] audio questions prompt a natural spoken response - [ ] audio questions prompt a natural spoken response
- [ ] photo questions prompt something visual and shareable - [ ] photo questions prompt something visual and shareable
- [ ] Non-swipe types have partner_text = NULL and no inverse - [ ] Non-swipe types (who_likely, audio, photo, text_answer) have partner_text = NULL and no inverse
- [ ] Type distribution is balanced (see 3.4) - [ ] Type distribution is balanced (see 3.4)
**Structure:** **Structure:**
@@ -291,6 +304,11 @@ INSERT INTO questions (pack_id, text, partner_text, intensity, question_type, co
-- photo questions (always symmetric, no inverse) -- photo questions (always symmetric, no inverse)
INSERT INTO questions (pack_id, text, partner_text, intensity, question_type, config, inverse_of) VALUES INSERT INTO questions (pack_id, text, partner_text, intensity, question_type, config, inverse_of) VALUES
('[pack_id]', 'Share a photo that makes you think of your partner', NULL, 1, 'photo', '{}', NULL); ('[pack_id]', 'Share a photo that makes you think of your partner', NULL, 1, 'photo', '{}', NULL);
-- text_answer questions (always symmetric, no inverse)
INSERT INTO questions (pack_id, text, partner_text, intensity, question_type, config, inverse_of) VALUES
('[pack_id]', 'Describe your partner in three words', NULL, 1, 'text_answer', '{"max_length": 500}', NULL),
('[pack_id]', 'Write a short love letter to your partner', NULL, 1, 'text_answer', '{"max_length": 500}', NULL);
``` ```
Also provide a summary table: Also provide a summary table:
@@ -303,6 +321,7 @@ Also provide a summary table:
| who_likely questions | X | | who_likely questions | X |
| audio questions | X | | audio questions | X |
| photo questions | X | | photo questions | X |
| text_answer questions | X |
| Symmetric (null partner_text) | X | | Symmetric (null partner_text) | X |
| Asymmetric swipe pairs (with inverse_of links) | X pairs (X questions) | | Asymmetric swipe pairs (with inverse_of links) | X pairs (X questions) |
| With couple targeting | X | | With couple targeting | X |
@@ -329,7 +348,7 @@ Also provide a summary table:
- DON'T use cheesy or cliche language - DON'T use cheesy or cliche language
- DON'T assume heterosexual couples - DON'T assume heterosexual couples
- DON'T skip the inverse question requirement for asymmetric swipe questions - DON'T skip the inverse question requirement for asymmetric swipe questions
- DON'T create inverses for who_likely, audio, or photo questions (they're always symmetric) - DON'T create inverses for who_likely, audio, photo, or text_answer questions (they're always symmetric)
- DON'T forget to set question_type - every question needs one - DON'T forget to set question_type - every question needs one
- DON'T use interview-style questions ("Would you like to...") - DON'T use interview-style questions ("Would you like to...")
- DON'T treat inverse pairs as duplicates - they are REQUIRED (one's `text` = other's `partner_text`) - DON'T treat inverse pairs as duplicates - they are REQUIRED (one's `text` = other's `partner_text`)
+29
View File
@@ -204,6 +204,35 @@ This approach:
- **Profile creation trigger**: The `on_auth_user_created` trigger automatically creates a profile when a user signs up. Never assume a profile exists without checking. - **Profile creation trigger**: The `on_auth_user_created` trigger automatically creates a profile when a user signs up. Never assume a profile exists without checking.
- **Check for null profiles** in edge functions before performing operations. - **Check for null profiles** in edge functions before performing operations.
### Cron Jobs (pg_cron) - IMPORTANT
**NEVER hardcode Supabase URLs in cron job migrations.** Migrations run on both prod and non-prod, so a hardcoded production URL will cause non-prod cron jobs to hit production (double-firing notifications, duplicate processing, etc.).
**Correct pattern** — use `get_supabase_edge_function_url()`:
```sql
SELECT cron.schedule(
'my-cron-job',
'*/5 * * * *',
format(
$cmd$
SELECT net.http_post(
url := %L,
headers := jsonb_build_object('Content-Type', 'application/json'),
body := '{}'::jsonb
)
$cmd$,
get_supabase_edge_function_url('my-edge-function')
)
);
```
This resolves the URL from `app_config.supabase_url` at migration-time, so each environment targets its own edge functions.
**If adding a new environment**, you must seed the URL before running migrations:
```sql
UPDATE app_config SET supabase_url = 'https://<project-ref>.supabase.co';
```
### Key Patterns ### Key Patterns
- Responses use UPSERT with `onConflict: "user_id,question_id"` - Responses use UPSERT with `onConflict: "user_id,question_id"`
- Match detection happens in `submit-response` edge function after each response - Match detection happens in `submit-response` edge function after each response
+33 -8
View File
@@ -79,13 +79,27 @@ Partners take or share a photo. Great for selfies, throwbacks, creative challeng
- "Share a screenshot of your last text about your partner" - "Share a screenshot of your last text about your partner"
- "Show the view from where you are right now" - "Show the view from where you are right now"
### `text_answer` - Written Response Prompt
Partners type a written response. Great for reflections, love notes, confessions, creative writing, finish-the-sentence prompts.
- **ALWAYS symmetric** — partner_text = NULL, no inverse needed
- `config`: `{"max_length": 500}`
- Writing can feel safer than speaking for vulnerable topics
- Format: prompts that invite a thoughtful written response
- Examples:
- "Describe your partner in three words"
- "Write a short love letter to your partner"
- "Finish this sentence: When I'm with you I feel..."
- "Write down a fantasy you haven't said out loud"
### Type Distribution ### Type Distribution
For a standard 30-question pack, aim for roughly: For a standard 30-question pack, aim for roughly:
- **20-22 swipe** questions (the core experience) - **18-20 swipe** questions (the core experience)
- **4-5 who_likely** questions - **4-5 who_likely** questions
- **2-3 audio** questions - **2-3 audio** questions
- **2-3 photo** questions - **2-3 photo** questions
- **2-3 text_answer** questions
Adjust based on the pack's theme. A "Getting to Know You" pack might lean heavier on who_likely and audio. An explicit pack might be mostly swipe. Adjust based on the pack's theme. A "Getting to Know You" pack might lean heavier on who_likely and audio. An explicit pack might be mostly swipe.
@@ -97,7 +111,7 @@ Each question has:
- `text` - What Partner A sees - `text` - What Partner A sees
- `partner_text` - What Partner B sees (NULL for symmetric questions and all non-swipe types) - `partner_text` - What Partner B sees (NULL for symmetric questions and all non-swipe types)
- `intensity` - 1-5 scale (for backend filtering, not shown in UI) - `intensity` - 1-5 scale (for backend filtering, not shown in UI)
- `question_type` - `swipe`, `who_likely`, `audio`, or `photo` - `question_type` - `swipe`, `who_likely`, `audio`, `photo`, or `text_answer`
- `config` - JSONB for type-specific settings (e.g., `{"max_duration_seconds": 60}` for audio) - `config` - JSONB for type-specific settings (e.g., `{"max_duration_seconds": 60}` for audio)
- `allowed_couple_genders` - Which couple types see this (NULL = all) - `allowed_couple_genders` - Which couple types see this (NULL = all)
- `target_user_genders` - Which gender initiates (NULL = any) - `target_user_genders` - Which gender initiates (NULL = any)
@@ -148,7 +162,7 @@ This ensures:
**IMPORTANT:** **IMPORTANT:**
- Inverse pairs are NOT duplicates. When one question's `text` matches another's `partner_text`, they form a valid inverse pair. This is intentional and required - do not flag or remove these as duplicates. - Inverse pairs are NOT duplicates. When one question's `text` matches another's `partner_text`, they form a valid inverse pair. This is intentional and required - do not flag or remove these as duplicates.
- Non-swipe types (who_likely, audio, photo) NEVER have inverses — they are always symmetric. - Non-swipe types (who_likely, audio, photo, text_answer) NEVER have inverses — they are always symmetric.
--- ---
@@ -167,7 +181,7 @@ Controls which couple types see the question. Options: `male+male`, `female+male
| Requires vagina | `['female+male', 'female+female']` | Exclude M+M | | Requires vagina | `['female+male', 'female+female']` | Exclude M+M |
| Sex toys | NULL | Toys work for everyone | | Sex toys | NULL | Toys work for everyone |
**Note:** who_likely, audio, and photo questions are always NULL (they're gender-neutral by nature). **Note:** who_likely, audio, photo, and text_answer questions are always NULL (they're gender-neutral by nature).
### Initiator Targeting (target_user_genders) ### Initiator Targeting (target_user_genders)
@@ -278,7 +292,7 @@ Advanced/BDSM/extreme exploration
- Use cheesy phrases ("make love", "intimate connection", "souls intertwining") - Use cheesy phrases ("make love", "intimate connection", "souls intertwining")
- Assume heterosexual couples by default - Assume heterosexual couples by default
- Create one-sided asymmetric swipe questions (always make the inverse) - Create one-sided asymmetric swipe questions (always make the inverse)
- Create inverses for who_likely, audio, or photo (they're symmetric) - Create inverses for who_likely, audio, photo, or text_answer (they're symmetric)
- Use time-specific language - Use time-specific language
- Be preachy or educational in tone - Be preachy or educational in tone
- Include judgment about activities - Include judgment about activities
@@ -309,7 +323,7 @@ When asked to create a pack:
- What question types fit best for this theme? - What question types fit best for this theme?
3. **Generate questions** 3. **Generate questions**
- Mix of swipe, who_likely, audio, and photo types - Mix of swipe, who_likely, audio, photo, and text_answer types
- For swipe: mix of symmetric (null partner_text) and asymmetric questions - For swipe: mix of symmetric (null partner_text) and asymmetric questions
- For EVERY asymmetric swipe question, immediately create its inverse - For EVERY asymmetric swipe question, immediately create its inverse
- Vary sentence structure and openers - Vary sentence structure and openers
@@ -349,7 +363,9 @@ INSERT INTO questions (id, pack_id, text, partner_text, intensity, question_type
-- audio (always symmetric, no inverse) -- audio (always symmetric, no inverse)
(gen_random_uuid(), '[pack_id]', 'Describe your perfect date in 30 seconds', NULL, 1, 'audio', '{"max_duration_seconds": 60}', NULL, NULL, NULL, NULL), (gen_random_uuid(), '[pack_id]', 'Describe your perfect date in 30 seconds', NULL, 1, 'audio', '{"max_duration_seconds": 60}', NULL, NULL, NULL, NULL),
-- photo (always symmetric, no inverse) -- photo (always symmetric, no inverse)
(gen_random_uuid(), '[pack_id]', 'Share a photo that makes you smile', NULL, 1, 'photo', '{}', NULL, NULL, NULL, NULL); (gen_random_uuid(), '[pack_id]', 'Share a photo that makes you smile', NULL, 1, 'photo', '{}', NULL, NULL, NULL, NULL),
-- text_answer (always symmetric, no inverse)
(gen_random_uuid(), '[pack_id]', 'Describe your partner in three words', NULL, 1, 'text_answer', '{"max_length": 500}', NULL, NULL, NULL, NULL);
``` ```
--- ---
@@ -424,6 +440,15 @@ question_type: photo
config: {} config: {}
``` ```
### text_answer
```
text: "Write a short love letter to your partner"
partner_text: NULL
intensity: 1
question_type: text_answer
config: {"max_length": 500}
```
### Swipe - Steamy (Intensity 4) - With Couple Targeting ### Swipe - Steamy (Intensity 4) - With Couple Targeting
``` ```
text: "Give your partner a blowjob in a risky location" text: "Give your partner a blowjob in a risky location"
@@ -470,7 +495,7 @@ config: {}
- [ ] who_likely uses "Who is more likely to..." format - [ ] who_likely uses "Who is more likely to..." format
- [ ] audio prompts invite a natural spoken response - [ ] audio prompts invite a natural spoken response
- [ ] photo prompts invite a visual response - [ ] photo prompts invite a visual response
- [ ] Non-swipe types have partner_text = NULL and inverse_of = NULL - [ ] Non-swipe types (who_likely, audio, photo, text_answer) have partner_text = NULL and inverse_of = NULL
- [ ] Type distribution is balanced - [ ] Type distribution is balanced
**Structure:** **Structure:**
+153 -2
View File
@@ -1,4 +1,4 @@
import { useState, useEffect, useCallback } from 'react'; import { useState, useEffect, useCallback, useRef } from 'react';
import { useParams, Link } from 'react-router-dom'; import { useParams, Link } from 'react-router-dom';
import { supabase, supabaseConfig } from '@/config'; import { supabase, supabaseConfig } from '@/config';
import { useRealtimeSubscription } from '@/hooks/useRealtimeSubscription'; import { useRealtimeSubscription } from '@/hooks/useRealtimeSubscription';
@@ -37,7 +37,7 @@ import {
SelectTrigger, SelectTrigger,
SelectValue, SelectValue,
} from '@/components/ui/select'; } from '@/components/ui/select';
import { Crown, Users, MessageCircle, ChevronRight, ThumbsUp, ThumbsDown, Minus, Gift, Image, Video as VideoIcon, Target, User, Eye, EyeOff, CheckCircle, Package, Sparkles, Flame, Trophy, Calendar, AlertCircle, Clock } from 'lucide-react'; import { Crown, Users, MessageCircle, ChevronRight, ThumbsUp, ThumbsDown, Minus, Gift, Image, Video as VideoIcon, Target, User, Eye, EyeOff, CheckCircle, Package, Sparkles, Flame, Trophy, Calendar, AlertCircle, Clock, Pencil } from 'lucide-react';
import { format } from 'date-fns'; import { format } from 'date-fns';
import { IconPreview } from '@/components/ui/icon-picker'; import { IconPreview } from '@/components/ui/icon-picker';
@@ -280,6 +280,146 @@ function AdminDecryptedVideo({ messageId }: { messageId: string }) {
return <video src={url} controls className="w-full h-full object-cover" preload="metadata" />; return <video src={url} controls className="w-full h-full object-cover" preload="metadata" />;
} }
interface StrokePoint {
x: number;
y: number;
}
interface StrokeSegment {
id: string;
userId: string;
points: StrokePoint[];
color: string;
width: number;
timestamp: number;
isEraser: boolean;
}
const CANVAS_BACKGROUND = '#1a1a2e';
function LiveDrawCanvas({ coupleId }: { coupleId: string }) {
const canvasRef = useRef<HTMLCanvasElement>(null);
const [strokes, setStrokes] = useState<StrokeSegment[]>([]);
const [loading, setLoading] = useState(true);
const [error, setError] = useState<string | null>(null);
useEffect(() => {
let cancelled = false;
const fetchStrokes = async () => {
setLoading(true);
setError(null);
const { data, error: err } = await supabase
.from('live_draw_sessions')
.select('strokes')
.eq('couple_id', coupleId)
.maybeSingle();
if (cancelled) return;
if (err) {
setError(err.message);
setLoading(false);
return;
}
setStrokes((data?.strokes as StrokeSegment[]) || []);
setLoading(false);
};
fetchStrokes();
// Subscribe to real-time updates
const channel = supabase
.channel(`admin-livedraw-${coupleId}`)
.on('postgres_changes', {
event: '*',
schema: 'public',
table: 'live_draw_sessions',
filter: `couple_id=eq.${coupleId}`,
}, (payload: any) => {
if (payload.new?.strokes) {
setStrokes(payload.new.strokes as StrokeSegment[]);
}
})
.subscribe();
return () => {
cancelled = true;
supabase.removeChannel(channel);
};
}, [coupleId]);
// Render strokes to canvas
useEffect(() => {
const canvas = canvasRef.current;
if (!canvas) return;
const ctx = canvas.getContext('2d');
if (!ctx) return;
const dpr = window.devicePixelRatio || 1;
const w = canvas.clientWidth;
const h = canvas.clientHeight;
canvas.width = w * dpr;
canvas.height = h * dpr;
ctx.scale(dpr, dpr);
ctx.fillStyle = CANVAS_BACKGROUND;
ctx.fillRect(0, 0, w, h);
for (const stroke of strokes) {
if (stroke.points.length === 0) continue;
ctx.strokeStyle = stroke.isEraser ? CANVAS_BACKGROUND : stroke.color;
ctx.lineWidth = stroke.width;
ctx.lineCap = 'round';
ctx.lineJoin = 'round';
ctx.beginPath();
const first = stroke.points[0];
ctx.moveTo(first.x * w, first.y * h);
if (stroke.points.length === 1) {
ctx.lineTo(first.x * w + 0.1, first.y * h + 0.1);
} else {
for (let i = 1; i < stroke.points.length; i++) {
const prev = stroke.points[i - 1];
const curr = stroke.points[i];
const midX = ((prev.x + curr.x) / 2) * w;
const midY = ((prev.y + curr.y) / 2) * h;
if (i === 1) {
ctx.lineTo(midX, midY);
} else {
ctx.quadraticCurveTo(prev.x * w, prev.y * h, midX, midY);
}
}
const last = stroke.points[stroke.points.length - 1];
ctx.lineTo(last.x * w, last.y * h);
}
ctx.stroke();
}
}, [strokes]);
if (loading) return <Skeleton className="h-64 w-full" />;
if (error) return <div className="text-sm text-red-500">Failed to load drawing: {error}</div>;
return (
<div className="space-y-3">
{strokes.length === 0 ? (
<Card className="flex flex-col items-center justify-center py-12">
<Pencil className="h-12 w-12 text-muted-foreground mb-4" />
<p className="text-muted-foreground">No drawing yet</p>
</Card>
) : (
<>
<div className="rounded-xl overflow-hidden border" style={{ background: CANVAS_BACKGROUND }}>
<canvas
ref={canvasRef}
style={{ width: '100%', aspectRatio: '4/3', display: 'block' }}
/>
</div>
<p className="text-xs text-muted-foreground">{strokes.length} stroke{strokes.length !== 1 ? 's' : ''} — updates in real-time</p>
</>
)}
</div>
);
}
export function UserDetailPage() { export function UserDetailPage() {
const { userId } = useParams<{ userId: string }>(); const { userId } = useParams<{ userId: string }>();
const { hasPermission } = useAuth(); const { hasPermission } = useAuth();
@@ -755,6 +895,7 @@ export function UserDetailPage() {
{canViewMedia && <TabsTrigger value="media">Media ({mediaTotal})</TabsTrigger>} {canViewMedia && <TabsTrigger value="media">Media ({mediaTotal})</TabsTrigger>}
{canViewResponses && <TabsTrigger value="outstanding" disabled={!profile.couple_id || !partner}>Outstanding ({outstandingTotal}){gapDisabled && <AlertCircle className="h-3 w-3 ml-1 text-destructive" />}</TabsTrigger>} {canViewResponses && <TabsTrigger value="outstanding" disabled={!profile.couple_id || !partner}>Outstanding ({outstandingTotal}){gapDisabled && <AlertCircle className="h-3 w-3 ml-1 text-destructive" />}</TabsTrigger>}
<TabsTrigger value="packs" disabled={!profile.couple_id}>Enabled Packs ({enabledPacks.length})</TabsTrigger> <TabsTrigger value="packs" disabled={!profile.couple_id}>Enabled Packs ({enabledPacks.length})</TabsTrigger>
<TabsTrigger value="livedraw" disabled={!profile.couple_id}>Live Draw</TabsTrigger>
</TabsList> </TabsList>
{canViewResponses && <TabsContent value="responses" className="mt-4">{responses.length === 0 ? <Card className="flex flex-col items-center justify-center py-12"><p className="text-muted-foreground">No responses yet</p></Card> : <div className="space-y-4"><div className="rounded-md border"><Table><TableHeader><TableRow><TableHead>Question</TableHead><TableHead>Pack</TableHead><TableHead className="w-24">Answer</TableHead><TableHead className="w-32">Date</TableHead></TableRow></TableHeader><TableBody>{responses.map((r) => (<TableRow key={r.id}><TableCell className="max-w-md">{r.question.pack?.id ? (<Link to={`/packs/${r.question.pack.id}/questions`} className="line-clamp-2 text-primary hover:underline cursor-pointer">{r.question.text}</Link>) : (<span className="line-clamp-2">{r.question.text}</span>)}</TableCell><TableCell>{r.question.pack?.id ? (<Link to={`/packs/${r.question.pack.id}/questions`} className="text-primary hover:underline">{r.question.pack.name}</Link>) : (<span className="text-muted-foreground">{r.question.pack?.name || '—'}</span>)}</TableCell><TableCell><div className="flex items-center gap-2">{answerIcons[r.answer]}<span className="capitalize">{r.answer}</span></div></TableCell><TableCell className="text-muted-foreground text-sm">{r.created_at ? format(new Date(r.created_at), 'MMM d, yyyy') : '—'}</TableCell></TableRow>))}</TableBody></Table></div><PaginationControls page={responsesPage} pageSize={responsesPageSize} totalCount={responsesTotal} onPageChange={setResponsesPage} onPageSizeChange={(size) => { setResponsesPage(1); setResponsesPageSize(size); }} /></div>}</TabsContent>} {canViewResponses && <TabsContent value="responses" className="mt-4">{responses.length === 0 ? <Card className="flex flex-col items-center justify-center py-12"><p className="text-muted-foreground">No responses yet</p></Card> : <div className="space-y-4"><div className="rounded-md border"><Table><TableHeader><TableRow><TableHead>Question</TableHead><TableHead>Pack</TableHead><TableHead className="w-24">Answer</TableHead><TableHead className="w-32">Date</TableHead></TableRow></TableHeader><TableBody>{responses.map((r) => (<TableRow key={r.id}><TableCell className="max-w-md">{r.question.pack?.id ? (<Link to={`/packs/${r.question.pack.id}/questions`} className="line-clamp-2 text-primary hover:underline cursor-pointer">{r.question.text}</Link>) : (<span className="line-clamp-2">{r.question.text}</span>)}</TableCell><TableCell>{r.question.pack?.id ? (<Link to={`/packs/${r.question.pack.id}/questions`} className="text-primary hover:underline">{r.question.pack.name}</Link>) : (<span className="text-muted-foreground">{r.question.pack?.name || '—'}</span>)}</TableCell><TableCell><div className="flex items-center gap-2">{answerIcons[r.answer]}<span className="capitalize">{r.answer}</span></div></TableCell><TableCell className="text-muted-foreground text-sm">{r.created_at ? format(new Date(r.created_at), 'MMM d, yyyy') : '—'}</TableCell></TableRow>))}</TableBody></Table></div><PaginationControls page={responsesPage} pageSize={responsesPageSize} totalCount={responsesTotal} onPageChange={setResponsesPage} onPageSizeChange={(size) => { setResponsesPage(1); setResponsesPageSize(size); }} /></div>}</TabsContent>}
{canViewMatches && <TabsContent value="matches" className="mt-4">{matches.length === 0 ? <Card className="flex flex-col items-center justify-center py-12"><p className="text-muted-foreground">No matches yet</p></Card> : <div className="space-y-4"><div className="grid gap-4 md:grid-cols-2">{matches.map((match) => (<Card key={match.id} className="hover:shadow-md transition-shadow"><CardHeader className="pb-2"><div className="flex items-start justify-between"><Badge variant="secondary">{matchTypeLabels[match.match_type]}</Badge>{match.is_new && <Badge variant="default" className="text-xs">New</Badge>}</div><CardDescription className="line-clamp-2 mt-2">{match.question?.text || 'Unknown question'}</CardDescription></CardHeader><CardContent><div className="flex items-center justify-between"><div className="flex items-center gap-1 text-sm text-muted-foreground"><MessageCircle className="h-4 w-4" />{match.message_count} message{match.message_count !== 1 ? 's' : ''}</div>{canViewChats && <Link to={`/users/${userId}/matches/${match.id}`}><Button variant="ghost" size="sm">View Chat<ChevronRight className="ml-1 h-4 w-4" /></Button></Link>}</div></CardContent></Card>))}</div><PaginationControls page={matchesPage} pageSize={matchesPageSize} totalCount={matchesTotal} onPageChange={setMatchesPage} onPageSizeChange={(size) => { setMatchesPage(1); setMatchesPageSize(size); }} /></div>}</TabsContent>} {canViewMatches && <TabsContent value="matches" className="mt-4">{matches.length === 0 ? <Card className="flex flex-col items-center justify-center py-12"><p className="text-muted-foreground">No matches yet</p></Card> : <div className="space-y-4"><div className="grid gap-4 md:grid-cols-2">{matches.map((match) => (<Card key={match.id} className="hover:shadow-md transition-shadow"><CardHeader className="pb-2"><div className="flex items-start justify-between"><Badge variant="secondary">{matchTypeLabels[match.match_type]}</Badge>{match.is_new && <Badge variant="default" className="text-xs">New</Badge>}</div><CardDescription className="line-clamp-2 mt-2">{match.question?.text || 'Unknown question'}</CardDescription></CardHeader><CardContent><div className="flex items-center justify-between"><div className="flex items-center gap-1 text-sm text-muted-foreground"><MessageCircle className="h-4 w-4" />{match.message_count} message{match.message_count !== 1 ? 's' : ''}</div>{canViewChats && <Link to={`/users/${userId}/matches/${match.id}`}><Button variant="ghost" size="sm">View Chat<ChevronRight className="ml-1 h-4 w-4" /></Button></Link>}</div></CardContent></Card>))}</div><PaginationControls page={matchesPage} pageSize={matchesPageSize} totalCount={matchesTotal} onPageChange={setMatchesPage} onPageSizeChange={(size) => { setMatchesPage(1); setMatchesPageSize(size); }} /></div>}</TabsContent>}
@@ -898,6 +1039,16 @@ export function UserDetailPage() {
</div> </div>
)} )}
</TabsContent> </TabsContent>
<TabsContent value="livedraw" className="mt-4">
{profile.couple_id ? (
<LiveDrawCanvas coupleId={profile.couple_id} />
) : (
<Card className="flex flex-col items-center justify-center py-12">
<Pencil className="h-12 w-12 text-muted-foreground mb-4" />
<p className="text-muted-foreground">User is not in a couple</p>
</Card>
)}
</TabsContent>
</Tabs> </Tabs>
</div> </div>
); );
-573
View File
@@ -1,573 +0,0 @@
# Mobile App Refactoring Plan
A systematic plan to improve code maintainability, reduce duplication, and establish patterns for long-term scalability.
---
## Executive Summary
| Metric | Current | Target |
|--------|---------|--------|
| Largest file (chat) | 2,213 lines | <400 lines per file |
| Store file | 666 lines (5 stores) | ~150 lines per store |
| Animation duplication | 3+ files | 1 shared hook |
| UI component duplication | 6+ instances | Shared components |
---
## Phase 1: Extract Shared Hooks (Low Risk, High Impact)
**Goal:** Eliminate animation and logic duplication across the app.
### 1.1 Create `useAmbientOrbAnimation` Hook
**Files affected:**
- `app/(app)/swipe.tsx` (lines 41-85)
- `app/(app)/chat/[id].tsx` (lines 70-122)
- `src/components/SwipeCard.native.tsx` (lines 60-70)
**New file:** `src/hooks/useAmbientOrbAnimation.ts`
```typescript
import { useEffect } from 'react';
import { useSharedValue, withRepeat, withSequence, withTiming, useAnimatedStyle } from 'react-native-reanimated';
interface AmbientOrbConfig {
color1?: string;
color2?: string;
size?: number;
opacity?: number;
}
export const useAmbientOrbAnimation = (config?: AmbientOrbConfig) => {
const orbBreathing1 = useSharedValue(0);
const orbBreathing2 = useSharedValue(0);
const orbDrift1X = useSharedValue(0);
const orbDrift1Y = useSharedValue(0);
const orbDrift2X = useSharedValue(0);
const orbDrift2Y = useSharedValue(0);
useEffect(() => {
// Breathing animations
orbBreathing1.value = withRepeat(
withSequence(
withTiming(1, { duration: 3000 }),
withTiming(0, { duration: 3000 })
),
-1,
true
);
// ... rest of animation setup
}, []);
const orbStyle1 = useAnimatedStyle(() => ({
// ... transform and opacity logic
}));
const orbStyle2 = useAnimatedStyle(() => ({
// ... transform and opacity logic
}));
return { orbStyle1, orbStyle2 };
};
```
### 1.2 Create `useMediaPicker` Hook
**Extract from:** `app/(app)/chat/[id].tsx` (lines 296-380)
**New file:** `src/hooks/useMediaPicker.ts`
```typescript
interface MediaPickerOptions {
allowsVideo?: boolean;
maxDuration?: number;
quality?: number;
onSelect: (uri: string, type: 'image' | 'video') => void;
onError?: (error: Error) => void;
}
export const useMediaPicker = (options: MediaPickerOptions) => {
const pickImage = async () => { /* ... */ };
const takePhoto = async () => { /* ... */ };
const pickVideo = async () => { /* ... */ };
const recordVideo = async () => { /* ... */ };
return { pickImage, takePhoto, pickVideo, recordVideo };
};
```
### 1.3 Create `useTypingIndicator` Hook
**Extract from:** `app/(app)/chat/[id].tsx` (lines 200-260)
**New file:** `src/hooks/useTypingIndicator.ts`
```typescript
export const useTypingIndicator = (matchId: string, userId: string) => {
const [isPartnerTyping, setIsPartnerTyping] = useState(false);
const sendTypingStatus = useCallback((isTyping: boolean) => { /* ... */ }, []);
// Subscribe to typing channel
useEffect(() => { /* ... */ }, [matchId]);
return { isPartnerTyping, sendTypingStatus };
};
```
### 1.4 Create `useMessageSubscription` Hook
**Extract from:** `app/(app)/chat/[id].tsx` (lines 450-550)
**New file:** `src/hooks/useMessageSubscription.ts`
```typescript
export const useMessageSubscription = (matchId: string, userId: string) => {
const [messages, setMessages] = useState<Message[]>([]);
// Real-time subscription
// Message status updates
// Optimistic updates
return { messages, sendMessage, markAsRead };
};
```
---
## Phase 2: Create Shared UI Components (Low Risk, Medium Impact)
**Goal:** Extract repeated UI patterns into reusable components.
### 2.1 Create `DecorativeSeparator` Component
**Duplicate instances found in:**
- `app/(app)/swipe.tsx` (6+ instances)
- `app/(app)/chat/[id].tsx` (lines 648-652)
- `src/components/SwipeTutorial.tsx`
**New file:** `src/components/ui/DecorativeSeparator.tsx`
```typescript
interface DecorativeSeparatorProps {
variant?: 'diamond' | 'dot' | 'line';
color?: string;
width?: number | string;
}
export const DecorativeSeparator: React.FC<DecorativeSeparatorProps> = ({
variant = 'diamond',
color = theme.colors.glass.border,
width = '60%',
}) => (
<View style={[styles.container, { width }]}>
<View style={[styles.line, { backgroundColor: color }]} />
{variant === 'diamond' && <View style={[styles.diamond, { backgroundColor: color }]} />}
{variant === 'dot' && <View style={[styles.dot, { backgroundColor: color }]} />}
<View style={[styles.line, { backgroundColor: color }]} />
</View>
);
```
### 2.2 Create `LoadingOverlay` Component
**Similar patterns in:**
- `app/(app)/chat/[id].tsx` (UploadingSkeleton)
- `src/components/Paywall.tsx`
- Various loading states
**New file:** `src/components/ui/LoadingOverlay.tsx`
```typescript
interface LoadingOverlayProps {
visible: boolean;
message?: string;
progress?: number;
variant?: 'spinner' | 'shimmer' | 'progress';
}
export const LoadingOverlay: React.FC<LoadingOverlayProps> = ({ ... });
```
### 2.3 Create `ReadReceipt` Component
**Extract from:** `app/(app)/chat/[id].tsx` (lines 1354-1365)
**New file:** `src/components/ui/ReadReceipt.tsx`
```typescript
type ReceiptStatus = 'sent' | 'delivered' | 'read';
interface ReadReceiptProps {
status: ReceiptStatus;
size?: number;
}
export const ReadReceipt: React.FC<ReadReceiptProps> = ({ status, size = 14 }) => {
const icon = status === 'read' ? 'checkmark-done' : status === 'delivered' ? 'checkmark-done' : 'checkmark';
const color = status === 'read' ? theme.colors.primary : theme.colors.text.secondary;
return <Ionicons name={icon} size={size} color={color} />;
};
```
### 2.4 Create `AmbientOrbs` Component
**New file:** `src/components/ui/AmbientOrbs.tsx`
```typescript
interface AmbientOrbsProps {
variant?: 'default' | 'chat' | 'premium';
}
export const AmbientOrbs: React.FC<AmbientOrbsProps> = ({ variant = 'default' }) => {
const { orbStyle1, orbStyle2 } = useAmbientOrbAnimation();
return (
<>
<Animated.View style={[styles.orb1, orbStyle1]} />
<Animated.View style={[styles.orb2, orbStyle2]} />
</>
);
};
```
---
## Phase 3: Split the Zustand Store (Medium Risk, High Impact)
**Goal:** Improve maintainability by splitting the monolithic store into domain-specific modules.
### Current Structure (666 lines, 1 file)
```
src/store/index.ts
├── useAuthStore
├── useMatchStore
├── usePacksStore
├── useMessageStore
└── useSubscriptionStore
```
### Target Structure (5 files, ~100-150 lines each)
```
src/store/
├── index.ts # Re-exports all stores
├── authStore.ts # Auth, user, couple, partner
├── matchStore.ts # Matches, sorting, filtering
├── packsStore.ts # Question packs, enabled packs
├── messageStore.ts # Messages, unread counts
└── subscriptionStore.ts # RevenueCat, offerings, purchases
```
### Implementation Steps
1. **Create store directory structure**
2. **Move `useAuthStore` to `authStore.ts`**
- Keep auth refresh logic isolated
- Export type definitions
3. **Move `useMatchStore` to `matchStore.ts`**
4. **Move `usePacksStore` to `packsStore.ts`**
5. **Move `useMessageStore` to `messageStore.ts`**
6. **Move `useSubscriptionStore` to `subscriptionStore.ts`**
7. **Update `index.ts` to re-export all stores**
```typescript
// src/store/index.ts
export { useAuthStore } from './authStore';
export { useMatchStore } from './matchStore';
export { usePacksStore } from './packsStore';
export { useMessageStore } from './messageStore';
export { useSubscriptionStore } from './subscriptionStore';
```
**Import changes:** None required - all imports from `@/store` will continue to work.
---
## Phase 4: Decompose Chat Screen (Medium Risk, Highest Impact)
**Goal:** Break the 2,213-line chat screen into maintainable, testable components.
### Current Structure
```
app/(app)/chat/[id].tsx (2,213 lines)
├── Animations (lines 70-122)
├── State management (lines 130-200)
├── Typing indicator logic (lines 200-260)
├── Media handling (lines 296-450)
├── Message subscription (lines 450-550)
├── UI Components (inline)
│ ├── MessageContent
│ ├── ChatVideoPlayer
│ ├── MessageMeta
│ ├── InputBar
│ └── UploadingSkeleton
└── Main render (lines 600-2213)
```
### Target Structure
```
src/features/chat/
├── index.ts # Public exports
├── ChatScreen.tsx # Main screen (orchestration only, <400 lines)
├── components/
│ ├── ChatHeader.tsx # Header with match info
│ ├── ChatMessages.tsx # Message list + scroll logic
│ ├── MessageBubble.tsx # Single message rendering
│ ├── MessageContent.tsx # Text/image/video content
│ ├── ChatVideoPlayer.tsx # Video player with controls
│ ├── InputBar.tsx # Text input + media buttons
│ ├── MediaMenu.tsx # Expandable media picker
│ ├── TypingIndicator.tsx # Animated typing dots
│ └── UploadProgress.tsx # Upload skeleton/progress
├── hooks/
│ ├── useChatMessages.ts # Message fetching & subscription
│ ├── useTypingIndicator.ts # Typing broadcast channel
│ ├── useMediaUpload.ts # Image/video compression & upload
│ └── useVideoCache.ts # Video download & caching
└── types.ts # Chat-specific types
```
### Implementation Steps
#### Step 1: Create Feature Directory
```bash
mkdir -p src/features/chat/components
mkdir -p src/features/chat/hooks
```
#### Step 2: Extract Components (in order of dependency)
**2a. Extract `MessageContent.tsx`**
- Lines 680-800 from current file
- Handles text, image, video rendering
- Props: `{ message, onImagePress, onVideoPress }`
**2b. Extract `ChatVideoPlayer.tsx`**
- Lines 740-800 from current file
- Custom video player with controls
- Props: `{ uri, onClose, visible }`
**2c. Extract `MessageBubble.tsx`**
- Lines 640-900 from current file
- Single message with content + meta
- Props: `{ message, isOwn, onMediaPress }`
**2d. Extract `TypingIndicator.tsx`**
- Lines 870-910 from current file
- Animated typing dots
- Props: `{ visible }`
**2e. Extract `InputBar.tsx`**
- Lines 1380-1510 from current file
- Text input + send button + media toggle
- Props: `{ onSend, onMediaSelect, disabled }`
**2f. Extract `MediaMenu.tsx`**
- Lines 1510-1600 from current file
- Expandable media picker buttons
- Props: `{ visible, onSelect, onClose }`
**2g. Extract `ChatHeader.tsx`**
- Lines 600-640 from current file
- Match card + question display
- Props: `{ match, onBack }`
**2h. Extract `ChatMessages.tsx`**
- Lines 1600-1800 from current file
- FlatList with message rendering
- Props: `{ messages, userId, onMediaPress }`
#### Step 3: Extract Hooks
**3a. Create `useChatMessages.ts`**
```typescript
export const useChatMessages = (matchId: string) => {
// Fetch initial messages
// Subscribe to new messages
// Handle optimistic updates
// Mark messages as read
return { messages, sendMessage, loading, error };
};
```
**3b. Create `useMediaUpload.ts`**
```typescript
export const useMediaUpload = (matchId: string) => {
// Compress image/video
// Upload to storage
// Track progress
return { upload, progress, uploading };
};
```
#### Step 4: Create Main Screen
```typescript
// src/features/chat/ChatScreen.tsx (~300-400 lines)
export const ChatScreen: React.FC = () => {
const { id } = useLocalSearchParams();
const { messages, sendMessage } = useChatMessages(id);
const { isPartnerTyping, sendTypingStatus } = useTypingIndicator(id);
const { upload, progress } = useMediaUpload(id);
return (
<View style={styles.container}>
<AmbientOrbs variant="chat" />
<ChatHeader match={match} onBack={router.back} />
<ChatMessages messages={messages} userId={userId} />
{isPartnerTyping && <TypingIndicator />}
<InputBar onSend={sendMessage} onMediaSelect={upload} />
</View>
);
};
```
#### Step 5: Update Route File
```typescript
// app/(app)/chat/[id].tsx
export { ChatScreen as default } from '@/features/chat';
```
---
## Phase 5: Decompose Profile Screen (Medium Risk, High Impact)
**Goal:** Break the 1,703-line profile screen into manageable sections.
### Target Structure
```
src/features/profile/
├── index.ts
├── ProfileScreen.tsx # Main orchestration (<400 lines)
├── components/
│ ├── ProfileHeader.tsx # Avatar, name, edit
│ ├── CoupleStatus.tsx # Partner info, invite code
│ ├── SettingsSection.tsx # Generic settings group
│ ├── NotificationSettings.tsx
│ ├── PrivacySettings.tsx
│ ├── AppearanceSettings.tsx
│ └── DangerZone.tsx # Delete account, leave couple
└── hooks/
├── useProfileSettings.ts
└── useCoupleManagement.ts
```
---
## Phase 6: Improve Component Organization (Low Risk)
**Goal:** Establish clear component categories and naming conventions.
### Current Structure
```
src/components/
├── ui/ # Primitive UI components
├── [Feature].tsx # Mixed feature components
└── [Tutorial].tsx # Tutorial components
```
### Target Structure
```
src/components/
├── ui/ # Primitive UI (buttons, cards, inputs)
│ ├── GlassButton.tsx
│ ├── GlassCard.tsx
│ ├── GlassInput.tsx
│ ├── DecorativeSeparator.tsx
│ ├── LoadingOverlay.tsx
│ ├── ReadReceipt.tsx
│ ├── AmbientOrbs.tsx
│ └── ShimmerEffect.tsx
├── feedback/ # User feedback components
│ ├── FeedbackModal.tsx
│ └── QuestionFeedbackModal.tsx
├── tutorials/ # Onboarding tutorials
│ ├── SwipeTutorial.tsx
│ └── MatchesTutorial.tsx
├── paywall/ # Subscription components
│ ├── Paywall.tsx
│ └── PremiumBadge.tsx
└── swipe/ # Swipe card variants
├── SwipeCard.native.tsx
├── SwipeCard.web.tsx
└── SwipeCardPremium.tsx
```
---
## Phase 7: Add Testing Infrastructure (Long-term)
**Goal:** Enable confident refactoring with test coverage.
### Recommended Test Structure
```
src/
├── __tests__/
│ ├── components/
│ ├── hooks/
│ ├── stores/
│ └── utils/
└── features/
└── chat/
└── __tests__/
├── ChatMessages.test.tsx
└── useChatMessages.test.ts
```
### Testing Priorities
1. **Hooks** - Pure logic, easy to test
2. **Store actions** - Critical business logic
3. **Utility functions** - Edge cases
4. **Components** - Snapshot + interaction tests
---
## Implementation Order
| Phase | Risk | Impact | Effort | Priority |
|-------|------|--------|--------|----------|
| 1. Shared Hooks | Low | High | 2-3 days | 1 |
| 2. Shared UI Components | Low | Medium | 1-2 days | 2 |
| 3. Split Store | Medium | High | 1 day | 3 |
| 4. Decompose Chat | Medium | Highest | 3-5 days | 4 |
| 5. Decompose Profile | Medium | High | 2-3 days | 5 |
| 6. Component Organization | Low | Low | 1 day | 6 |
| 7. Testing | Low | High | Ongoing | 7 |
---
## Migration Strategy
### For Each Phase:
1. **Create new files** without modifying existing code
2. **Import and use** new components/hooks in the target file
3. **Verify functionality** works identically
4. **Remove old code** from the original file
5. **Update imports** across the codebase if needed
### Rollback Strategy:
- Each phase can be rolled back independently
- Keep original files until phase is complete
- Use feature flags for large changes if needed
---
## Success Metrics
| Metric | Current | Target |
|--------|---------|--------|
| Max lines per file | 2,213 | <500 |
| Duplicate animation code | 3 files | 1 hook |
| Store files | 1 (666 lines) | 5 (~130 lines each) |
| Test coverage | 0% | >50% for hooks/stores |
| Component reuse | Low | High (shared UI components) |
---
## Notes
- **Do not change** the tab bar background styling (see CLAUDE.md)
- **Follow** DESIGN.md for all UI changes
- **Use** theme imports, never hardcode colors
- **Preserve** all existing functionality during refactoring
-237
View File
@@ -1,237 +0,0 @@
# Mobile App Refactoring Tasks
Track progress by checking off completed items.
---
## Phase 1: Extract Shared Hooks
### 1.1 Ambient Orb Animation Hook
- [x] Create `src/hooks/useAmbientOrbAnimation.ts`
- [x] Extract animation logic from `app/(app)/swipe.tsx`
- [x] Update `app/(app)/swipe.tsx` to use hook
- [x] Update `app/(app)/chat/[id].tsx` to use hook
- [x] Update `app/(app)/matches.tsx` to use hook
- [x] Remove duplicate animation code from all files
### 1.2 Media Picker Hook
- [x] Create `src/hooks/useMediaPicker.ts`
- [x] Extract image picker logic from chat screen
- [x] Extract video picker logic from chat screen
- [x] Extract camera logic from chat screen
- [x] Update chat screen to use hook
### 1.3 Typing Indicator Hook
- [x] Create `src/hooks/useTypingIndicator.ts`
- [x] Extract broadcast channel logic from chat
- [x] Extract typing state management
- [x] Update chat screen to use hook
### 1.4 Message Subscription Hook
- [x] Create `src/hooks/useMessageSubscription.ts`
- [x] Extract real-time subscription logic
- [x] Extract message status update logic
- [x] Extract optimistic update logic
- [x] Update chat screen to use hook
---
## Phase 2: Shared UI Components
### 2.1 Decorative Separator
- [x] Create `src/components/ui/DecorativeSeparator.tsx`
- [x] Support rose, gold, and muted variants
- [x] Update `app/(app)/swipe.tsx` (4 instances)
- [x] Update `app/(app)/chat/[id].tsx` (2 instances)
- [x] Update `app/(app)/matches.tsx` (2 instances)
- [x] Remove duplicate separator styles
### 2.2 Loading Overlay
- [x] Create `src/components/ui/LoadingOverlay.tsx`
- [x] Support spinner, shimmer, and progress variants
- [x] Extract UploadingSkeleton from chat screen
- [x] Update usage across app
### 2.3 Read Receipt
- [x] Create `src/components/ui/ReadReceipt.tsx`
- [x] Support sent, delivered, read states
- [x] Update chat screen MessageMeta
- [x] Remove inline receipt logic
### 2.4 Ambient Orbs Component
- [x] Create `src/components/ui/AmbientOrbs.tsx`
- [x] Use `useAmbientOrbAnimation` hook
- [x] Support default, chat, premium variants
- [x] Update swipe screen to use component
- [x] Update chat screen to use component
---
## Phase 3: Split Zustand Store
### 3.1 Create Store Structure
- [x] Create `src/store/` directory structure
- [x] Create `src/store/authStore.ts`
- [x] Create `src/store/matchStore.ts`
- [x] Create `src/store/packsStore.ts`
- [x] Create `src/store/messageStore.ts`
- [x] Create `src/store/subscriptionStore.ts`
### 3.2 Migrate Stores
- [x] Move `useAuthStore` to `authStore.ts`
- [x] Move `useMatchStore` to `matchStore.ts`
- [x] Move `usePacksStore` to `packsStore.ts`
- [x] Move `useMessageStore` to `messageStore.ts`
- [x] Move `useSubscriptionStore` to `subscriptionStore.ts`
### 3.3 Update Exports
- [x] Update `src/store/index.ts` to re-export all stores
- [x] Verify all imports still work (type check passes)
- [x] Replace monolithic code with re-exports
---
## Phase 4: Decompose Chat Screen
### 4.1 Create Feature Structure
- [x] Create `src/features/chat/` directory
- [x] Create `src/features/chat/components/` directory
- [x] Create `src/features/chat/hooks/` directory
- [x] Create `src/features/chat/types.ts`
### 4.2 Extract Components
- [x] Extract `ChatHeader.tsx`
- [x] Extract `MessageContent.tsx`
- [x] Extract `ChatVideoPlayer.tsx`
- [x] Extract `MessageBubble.tsx`
- [x] Extract `TypingIndicator.tsx`
- [x] Extract `InputBar.tsx`
- [x] Extract `MediaMenu.tsx`
- [x] Extract `ChatMessages.tsx`
- [x] Extract `UploadProgress.tsx`
- [x] Extract `MessageMeta.tsx`
### 4.3 Extract Hooks
- [x] Create `useChatMessages.ts`
- [x] Create `useMediaUpload.ts`
- [x] Create `useVideoCache.ts`
### 4.4 Create Main Screen
- [x] Create `src/features/chat/ChatScreen.tsx`
- [x] Wire up all components and hooks
- [x] Create `src/features/chat/index.ts` exports
- [x] Update `app/(app)/chat/[id].tsx` to use feature
### 4.5 Cleanup
- [x] Remove old code from route file
- [x] Verify all functionality works
- [x] Test real-time messaging
- [x] Test media upload/download
- [x] Test video playback
---
## Phase 5: Decompose Profile Screen
### 5.1 Create Feature Structure
- [x] Create `src/features/profile/` directory
- [x] Create `src/features/profile/components/` directory
- [x] Create `src/features/profile/hooks/` directory
### 5.2 Extract Components
- [x] Extract `ProfileHeader.tsx`
- [x] Extract `CoupleStatus.tsx`
- [x] Extract `SettingsSection.tsx`
- [x] Extract `NotificationSettings.tsx`
- [x] Extract `PrivacySettings.tsx`
- [x] Extract `AppearanceSettings.tsx`
- [x] Extract `DangerZone.tsx`
- [x] Extract `SwitchItem.tsx`
- [x] Extract `MenuItem.tsx`
### 5.3 Extract Hooks
- [x] Create `useProfileSettings.ts`
- [x] Create `useCoupleManagement.ts`
### 5.4 Create Main Screen
- [x] Create `src/features/profile/ProfileScreen.tsx`
- [x] Create `src/features/profile/index.ts` exports
- [x] Update `app/(app)/profile.tsx` to use feature
---
## Phase 6: Component Organization
### 6.1 Reorganize Components
- [x] Create `src/components/feedback/` directory
- [x] Move `FeedbackModal.tsx` to feedback/
- [x] Move `QuestionFeedbackModal.tsx` to feedback/
- [x] Create `src/components/tutorials/` directory
- [x] Move `SwipeTutorial.tsx` to tutorials/
- [x] Move `MatchesTutorial.tsx` to tutorials/
- [x] Create `src/components/paywall/` directory
- [x] Move `Paywall.tsx` to paywall/
- [x] Create `src/components/swipe/` directory
- [x] Move `SwipeCard.native.tsx` to swipe/
- [x] Move `SwipeCard.web.tsx` to swipe/
- [x] Move `SwipeCardPremium.tsx` to swipe/
### 6.2 Update Imports
- [x] Update all imports for moved components
- [x] Create index.ts files for each directory
- [x] Verify no broken imports
---
## Phase 7: Testing Infrastructure
### 7.1 Setup
- [x] Install testing dependencies (jest, testing-library)
- [x] Configure jest for React Native
- [x] Create test utilities and mocks
### 7.2 Hook Tests
- [x] Test `useAmbientOrbAnimation`
- [x] Test `useMediaPicker`
- [x] Test `useTypingIndicator`
- [x] Test `useMessageSubscription`
- [x] Test `useChatMessages`
- [x] Test `useMediaUpload`
### 7.3 Store Tests
- [x] Test `authStore` actions
- [x] Test `matchStore` actions
- [x] Test `packsStore` actions
- [x] Test `messageStore` actions
- [x] Test `subscriptionStore` actions
### 7.4 Component Tests
- [x] Test `DecorativeSeparator` variants
- [x] Test `ReadReceipt` states
- [x] Test `InputBar` interactions
- [x] Test `MessageBubble` rendering
---
## Progress Summary
| Phase | Status | Completion |
|-------|--------|------------|
| 1. Shared Hooks | Complete | 100% |
| 2. Shared UI Components | Complete | 100% |
| 3. Split Store | Complete | 100% |
| 4. Decompose Chat | Complete | 100% |
| 5. Decompose Profile | Complete | 100% |
| 6. Component Organization | Complete | 100% |
| 7. Testing | Complete | 100% |
---
## Notes
- Each phase can be completed independently
- Test after each component extraction
- Keep original files until phase is verified
- Follow DESIGN.md for any UI changes
- Do not modify tab bar background styling
+2 -2
View File
@@ -91,8 +91,8 @@ android {
applicationId 'com.sauci.app' applicationId 'com.sauci.app'
minSdkVersion rootProject.ext.minSdkVersion minSdkVersion rootProject.ext.minSdkVersion
targetSdkVersion rootProject.ext.targetSdkVersion targetSdkVersion rootProject.ext.targetSdkVersion
versionCode 42 versionCode 44
versionName "1.0.4" versionName "1.0.5"
} }
signingConfigs { signingConfigs {
debug { debug {
@@ -25,6 +25,7 @@
<meta-data android:name="expo.modules.updates.ENABLED" android:value="false"/> <meta-data android:name="expo.modules.updates.ENABLED" android:value="false"/>
<meta-data android:name="expo.modules.updates.EXPO_UPDATES_CHECK_ON_LAUNCH" android:value="ALWAYS"/> <meta-data android:name="expo.modules.updates.EXPO_UPDATES_CHECK_ON_LAUNCH" android:value="ALWAYS"/>
<meta-data android:name="expo.modules.updates.EXPO_UPDATES_LAUNCH_WAIT_MS" android:value="0"/> <meta-data android:name="expo.modules.updates.EXPO_UPDATES_LAUNCH_WAIT_MS" android:value="0"/>
<service android:name="com.reactnativeandroidwidget.RNWidgetCollectionService" android:permission="android.permission.BIND_REMOTEVIEWS"/>
<activity android:name=".MainActivity" android:configChanges="keyboard|keyboardHidden|orientation|screenSize|screenLayout|uiMode" android:launchMode="singleTask" android:windowSoftInputMode="adjustResize" android:theme="@style/Theme.App.SplashScreen" android:exported="true" android:screenOrientation="portrait"> <activity android:name=".MainActivity" android:configChanges="keyboard|keyboardHidden|orientation|screenSize|screenLayout|uiMode" android:launchMode="singleTask" android:windowSoftInputMode="adjustResize" android:theme="@style/Theme.App.SplashScreen" android:exported="true" android:screenOrientation="portrait">
<intent-filter> <intent-filter>
<action android:name="android.intent.action.MAIN"/> <action android:name="android.intent.action.MAIN"/>
@@ -39,5 +40,12 @@
<data android:scheme="exp+sauci"/> <data android:scheme="exp+sauci"/>
</intent-filter> </intent-filter>
</activity> </activity>
<receiver android:name=".widget.LiveDraw" android:exported="true" android:label="Live Draw">
<intent-filter>
<action android:name="android.appwidget.action.APPWIDGET_UPDATE"/>
<action android:name="com.sauci.app.WIDGET_CLICK"/>
</intent-filter>
<meta-data android:name="android.appwidget.provider" android:resource="@xml/widgetprovider_livedraw"/>
</receiver>
</application> </application>
</manifest> </manifest>
@@ -0,0 +1,84 @@
package com.sauci.app.widget;
import android.appwidget.AppWidgetManager;
import android.appwidget.AppWidgetProvider;
import android.content.Context;
import android.content.SharedPreferences;
import android.graphics.Bitmap;
import android.graphics.BitmapFactory;
import android.graphics.Canvas;
import android.graphics.Paint;
import android.graphics.RectF;
import android.util.Base64;
import android.util.TypedValue;
import android.widget.RemoteViews;
import com.sauci.app.R;
public class LiveDraw extends AppWidgetProvider {
private static final String PREFS_NAME_SUFFIX = ".widgetdata";
private static final String KEY_IMAGE = "LiveDrawImage";
private static final int BG_COLOR = 0xFF1A1A2E;
private static final int CORNER_RADIUS_DP = 16;
@Override
public void onUpdate(Context context, AppWidgetManager appWidgetManager, int[] appWidgetIds) {
for (int widgetId : appWidgetIds) {
updateWidget(context, appWidgetManager, widgetId);
}
}
private void updateWidget(Context context, AppWidgetManager appWidgetManager, int widgetId) {
RemoteViews views = new RemoteViews(context.getPackageName(), R.layout.widget_live_draw);
String base64 = getStoredImage(context);
if (base64 != null && !base64.isEmpty()) {
try {
byte[] bytes = Base64.decode(base64, Base64.DEFAULT);
Bitmap decoded = BitmapFactory.decodeByteArray(bytes, 0, bytes.length);
if (decoded != null) {
Bitmap rounded = addRoundedBackground(context, decoded);
views.setImageViewBitmap(R.id.widget_image, rounded);
decoded.recycle();
}
} catch (Exception e) {
setPlaceholder(views);
}
} else {
setPlaceholder(views);
}
appWidgetManager.updateAppWidget(widgetId, views);
}
private String getStoredImage(Context context) {
SharedPreferences prefs = context.getSharedPreferences(
context.getPackageName() + PREFS_NAME_SUFFIX, Context.MODE_PRIVATE);
return prefs.getString(KEY_IMAGE, null);
}
private void setPlaceholder(RemoteViews views) {
views.setImageViewResource(R.id.widget_image, R.drawable.widget_placeholder);
}
private Bitmap addRoundedBackground(Context context, Bitmap drawing) {
float radius = TypedValue.applyDimension(
TypedValue.COMPLEX_UNIT_DIP, CORNER_RADIUS_DP,
context.getResources().getDisplayMetrics());
int w = drawing.getWidth();
int h = drawing.getHeight();
Bitmap output = Bitmap.createBitmap(w, h, Bitmap.Config.ARGB_8888);
Canvas canvas = new Canvas(output);
Paint bgPaint = new Paint(Paint.ANTI_ALIAS_FLAG);
bgPaint.setColor(BG_COLOR);
canvas.drawRoundRect(new RectF(0, 0, w, h), radius, radius, bgPaint);
canvas.drawBitmap(drawing, 0, 0, null);
return output;
}
}
Binary file not shown.

After

Width:  |  Height:  |  Size: 688 KiB

@@ -0,0 +1,9 @@
<?xml version="1.0" encoding="utf-8"?>
<layer-list xmlns:android="http://schemas.android.com/apk/res/android">
<item>
<shape android:shape="rectangle">
<solid android:color="#1A1A2E" />
<corners android:radius="16dp" />
</shape>
</item>
</layer-list>
@@ -0,0 +1,13 @@
<?xml version="1.0" encoding="utf-8"?>
<FrameLayout xmlns:android="http://schemas.android.com/apk/res/android"
android:layout_width="match_parent"
android:layout_height="match_parent"
android:background="#1A1A2E">
<ImageView
android:id="@+id/widget_image"
android:layout_width="match_parent"
android:layout_height="match_parent"
android:scaleType="centerInside"
android:contentDescription="@string/widget_livedraw_description" />
</FrameLayout>
@@ -2,4 +2,5 @@
<string name="app_name">Sauci</string> <string name="app_name">Sauci</string>
<string name="expo_splash_screen_resize_mode" translatable="false">contain</string> <string name="expo_splash_screen_resize_mode" translatable="false">contain</string>
<string name="expo_splash_screen_status_bar_translucent" translatable="false">false</string> <string name="expo_splash_screen_status_bar_translucent" translatable="false">false</string>
<string name="widget_livedraw_description" translatable="false">See your shared drawing with your partner.</string>
</resources> </resources>
@@ -0,0 +1,13 @@
<?xml version="1.0" encoding="utf-8"?>
<appwidget-provider xmlns:android="http://schemas.android.com/apk/res/android"
android:minWidth="250dp"
android:minHeight="110dp"
android:targetCellWidth="4"
android:targetCellHeight="2"
android:resizeMode="none"
android:description="@string/widget_livedraw_description"
android:initialLayout="@layout/widget_live_draw"
android:previewImage="@drawable/livedraw_preview"
android:updatePeriodMillis="1800000"
android:widgetCategory="home_screen">
</appwidget-provider>
-4
View File
@@ -1,4 +0,0 @@
defaults.url=https://sentry.io/
defaults.org=molten-prints
defaults.project=sauci
# Using SENTRY_AUTH_TOKEN environment variable
+37 -4
View File
@@ -2,7 +2,7 @@
"expo": { "expo": {
"name": "Sauci", "name": "Sauci",
"slug": "sauci", "slug": "sauci",
"version": "1.0.4", "version": "1.0.5",
"orientation": "portrait", "orientation": "portrait",
"icon": "./assets/icon.png", "icon": "./assets/icon.png",
"scheme": "app.sauci", "scheme": "app.sauci",
@@ -16,7 +16,12 @@
"ios": { "ios": {
"supportsTablet": false, "supportsTablet": false,
"bundleIdentifier": "com.sauci.app", "bundleIdentifier": "com.sauci.app",
"buildNumber": "35", "entitlements": {
"com.apple.security.application-groups": [
"group.com.sauci.app"
]
},
"buildNumber": "42",
"usesAppleSignIn": true, "usesAppleSignIn": true,
"googleServicesFile": "./GoogleService-Info.plist", "googleServicesFile": "./GoogleService-Info.plist",
"infoPlist": { "infoPlist": {
@@ -178,7 +183,7 @@
"backgroundColor": "#1a1a2e" "backgroundColor": "#1a1a2e"
}, },
"package": "com.sauci.app", "package": "com.sauci.app",
"versionCode": 42, "versionCode": 44,
"googleServicesFile": "./google-services.json", "googleServicesFile": "./google-services.json",
"permissions": [ "permissions": [
"RECEIVE_BOOT_COMPLETED", "RECEIVE_BOOT_COMPLETED",
@@ -194,6 +199,7 @@
"favicon": "./assets/favicon.png" "favicon": "./assets/favicon.png"
}, },
"plugins": [ "plugins": [
"./plugins/withFixHoisting.js",
"@react-native-firebase/app", "@react-native-firebase/app",
[ [
"expo-build-properties", "expo-build-properties",
@@ -237,7 +243,34 @@
"iosUrlScheme": "com.googleusercontent.apps.861972684340-0u2as8i5equfhmhmvjihottkf0ig7237" "iosUrlScheme": "com.googleusercontent.apps.861972684340-0u2as8i5equfhmhmvjihottkf0ig7237"
} }
], ],
"./plugins/withKotlinVersion.js" "./plugins/withKotlinVersion.js",
[
"@bacons/apple-targets",
{
"appleTeamId": "BXVPBATDZY"
}
],
[
"react-native-android-widget",
{
"widgets": [
{
"name": "LiveDraw",
"label": "Live Draw",
"minWidth": "250dp",
"minHeight": "110dp",
"targetCellWidth": 4,
"targetCellHeight": 2,
"description": "See your shared drawing with your partner.",
"previewImage": "./assets/widget-preview/live-draw.png",
"updatePeriodMillis": 1800000,
"nativeProvider": true
}
]
}
],
"./plugins/withWidgetExported.js",
"./plugins/withNativeWidget.js"
], ],
"experiments": { "experiments": {
"typedRoutes": true "typedRoutes": true
+8 -1
View File
@@ -76,8 +76,9 @@ export default function AppLayout() {
const isOnOnboarding = segmentStrings.includes("onboarding"); const isOnOnboarding = segmentStrings.includes("onboarding");
const isOnPairing = segmentStrings.includes("pairing"); const isOnPairing = segmentStrings.includes("pairing");
const isOnChat = segmentStrings.includes("chat"); const isOnChat = segmentStrings.includes("chat");
const isOnLiveDraw = segmentStrings.includes("live-draw");
const isOnSettingsSubscreen = segmentStrings.includes("settings") && segmentStrings.length > 2; const isOnSettingsSubscreen = segmentStrings.includes("settings") && segmentStrings.length > 2;
const shouldHideTabBar = isOnOnboarding || isOnPairing || isOnChat || isOnSettingsSubscreen; const shouldHideTabBar = isOnOnboarding || isOnPairing || isOnChat || isOnLiveDraw || isOnSettingsSubscreen;
// Redirect to login when not authenticated, or to onboarding if not completed/outdated // Redirect to login when not authenticated, or to onboarding if not completed/outdated
useEffect(() => { useEffect(() => {
@@ -539,6 +540,12 @@ export default function AppLayout() {
href: null, // Hide from tab bar href: null, // Hide from tab bar
}} }}
/> />
<Tabs.Screen
name="live-draw"
options={{
href: null, // Hide from tab bar
}}
/>
</Tabs> </Tabs>
{/* Message Toast Notification */} {/* Message Toast Notification */}
+4 -1
View File
@@ -7,7 +7,7 @@ import { BlurView } from 'expo-blur';
import { useAuthStore, usePacksStore, useSubscriptionStore } from '../../src/store'; import { useAuthStore, usePacksStore, useSubscriptionStore } from '../../src/store';
import { GradientBackground } from '../../src/components/ui'; import { GradientBackground } from '../../src/components/ui';
import { Paywall } from '../../src/components/paywall'; import { Paywall } from '../../src/components/paywall';
import { CompactHeader, ContentRow } from '../../src/components/discovery'; import { CompactHeader, ContentRow, LiveDrawTile } from '../../src/components/discovery';
import { colors, spacing, typography, radius } from '../../src/theme'; import { colors, spacing, typography, radius } from '../../src/theme';
import type { QuestionPack, Category } from '../../src/types'; import type { QuestionPack, Category } from '../../src/types';
@@ -114,6 +114,9 @@ export default function DiscoveryScreen() {
showsVerticalScrollIndicator={false} showsVerticalScrollIndicator={false}
> >
<View style={[styles.innerContainer, isWideScreen && styles.innerContainerWide]}> <View style={[styles.innerContainer, isWideScreen && styles.innerContainerWide]}>
{/* Live Draw Activity Tile */}
<LiveDrawTile delay={100} />
{/* Content Rows by Category */} {/* Content Rows by Category */}
{packsByCategory.map((group, index) => ( {packsByCategory.map((group, index) => (
<ContentRow <ContentRow
+3
View File
@@ -0,0 +1,3 @@
import { LiveDrawScreen } from '../../src/features/live-draw';
export default LiveDrawScreen;
Binary file not shown.

After

Width:  |  Height:  |  Size: 688 KiB

+6
View File
@@ -0,0 +1,6 @@
import { registerWidgetTaskHandler } from 'react-native-android-widget';
import { widgetTaskHandler } from './src/features/live-draw/widgets/widgetTaskHandler';
registerWidgetTaskHandler(widgetTaskHandler);
import 'expo-router/entry';
+14
View File
@@ -64,3 +64,17 @@ target 'Sauci' do
end end
end end
end end
# apple-targets-extension-loader -- Dynamic loading of target configurations
Dir.glob(File.join(__dir__, '..', 'targets', '**', 'pods.rb')).each do |target_file|
target_name = File.basename(File.dirname(target_file))
target target_name do
# Create a new binding with access to necessary methods and variables
target_binding = binding
target_binding.local_variable_set(:podfile_properties, podfile_properties)
# Evaluate the target file content in the new binding
eval(File.read(target_file), target_binding, target_file)
end
end
+137 -53
View File
@@ -26,7 +26,7 @@ PODS:
- ExpoModulesCore - ExpoModulesCore
- EXNotifications (0.29.14): - EXNotifications (0.29.14):
- ExpoModulesCore - ExpoModulesCore
- Expo (52.0.48): - Expo (52.0.49):
- ExpoModulesCore - ExpoModulesCore
- expo-dev-client (5.0.20): - expo-dev-client (5.0.20):
- EXManifests - EXManifests
@@ -300,7 +300,11 @@ PODS:
- Yoga - Yoga
- ExpoSecureStore (14.0.1): - ExpoSecureStore (14.0.1):
- ExpoModulesCore - ExpoModulesCore
- ExpoSharing (14.0.8): - ExpoSharing (13.0.1):
- ExpoModulesCore
- ExpoWidgets (3.0.2):
- ExpoModulesCore
- ExtensionStorage (1.0.0):
- ExpoModulesCore - ExpoModulesCore
- EXUpdatesInterface (1.0.0): - EXUpdatesInterface (1.0.0):
- ExpoModulesCore - ExpoModulesCore
@@ -308,6 +312,9 @@ PODS:
- FBLazyVector (0.76.9) - FBLazyVector (0.76.9)
- Firebase/CoreOnly (12.8.0): - Firebase/CoreOnly (12.8.0):
- FirebaseCore (~> 12.8.0) - FirebaseCore (~> 12.8.0)
- Firebase/Crashlytics (12.8.0):
- Firebase/CoreOnly
- FirebaseCrashlytics (~> 12.8.0)
- FirebaseAnalytics/Core (12.8.0): - FirebaseAnalytics/Core (12.8.0):
- FirebaseCore (~> 12.8.0) - FirebaseCore (~> 12.8.0)
- FirebaseInstallations (~> 12.8.0) - FirebaseInstallations (~> 12.8.0)
@@ -330,13 +337,34 @@ PODS:
- FirebaseCoreInternal (~> 12.8.0) - FirebaseCoreInternal (~> 12.8.0)
- GoogleUtilities/Environment (~> 8.1) - GoogleUtilities/Environment (~> 8.1)
- GoogleUtilities/Logger (~> 8.1) - GoogleUtilities/Logger (~> 8.1)
- FirebaseCoreExtension (12.8.0):
- FirebaseCore (~> 12.8.0)
- FirebaseCoreInternal (12.8.0): - FirebaseCoreInternal (12.8.0):
- "GoogleUtilities/NSData+zlib (~> 8.1)" - "GoogleUtilities/NSData+zlib (~> 8.1)"
- FirebaseCrashlytics (12.8.0):
- FirebaseCore (~> 12.8.0)
- FirebaseInstallations (~> 12.8.0)
- FirebaseRemoteConfigInterop (~> 12.8.0)
- FirebaseSessions (~> 12.8.0)
- GoogleDataTransport (~> 10.1)
- GoogleUtilities/Environment (~> 8.1)
- nanopb (~> 3.30910.0)
- PromisesObjC (~> 2.4)
- FirebaseInstallations (12.8.0): - FirebaseInstallations (12.8.0):
- FirebaseCore (~> 12.8.0) - FirebaseCore (~> 12.8.0)
- GoogleUtilities/Environment (~> 8.1) - GoogleUtilities/Environment (~> 8.1)
- GoogleUtilities/UserDefaults (~> 8.1) - GoogleUtilities/UserDefaults (~> 8.1)
- PromisesObjC (~> 2.4) - PromisesObjC (~> 2.4)
- FirebaseRemoteConfigInterop (12.8.0)
- FirebaseSessions (12.8.0):
- FirebaseCore (~> 12.8.0)
- FirebaseCoreExtension (~> 12.8.0)
- FirebaseInstallations (~> 12.8.0)
- GoogleDataTransport (~> 10.1)
- GoogleUtilities/Environment (~> 8.1)
- GoogleUtilities/UserDefaults (~> 8.1)
- nanopb (~> 3.30910.0)
- PromisesSwift (~> 2.1)
- fmt (11.0.2) - fmt (11.0.2)
- glog (0.3.5) - glog (0.3.5)
- GoogleAppMeasurement/Core (12.8.0): - GoogleAppMeasurement/Core (12.8.0):
@@ -352,6 +380,9 @@ PODS:
- GoogleUtilities/Network (~> 8.1) - GoogleUtilities/Network (~> 8.1)
- "GoogleUtilities/NSData+zlib (~> 8.1)" - "GoogleUtilities/NSData+zlib (~> 8.1)"
- nanopb (~> 3.30910.0) - nanopb (~> 3.30910.0)
- GoogleDataTransport (10.1.0):
- nanopb (~> 3.30910.0)
- PromisesObjC (~> 2.4)
- GoogleSignIn (9.1.0): - GoogleSignIn (9.1.0):
- AppAuth (~> 2.0) - AppAuth (~> 2.0)
- AppCheckCore (~> 11.0) - AppCheckCore (~> 11.0)
@@ -402,8 +433,10 @@ PODS:
- nanopb/decode (3.30910.0) - nanopb/decode (3.30910.0)
- nanopb/encode (3.30910.0) - nanopb/encode (3.30910.0)
- PromisesObjC (2.4.0) - PromisesObjC (2.4.0)
- PurchasesHybridCommon (17.28.0): - PromisesSwift (2.4.0):
- RevenueCat (= 5.55.1) - PromisesObjC (= 2.4.0)
- PurchasesHybridCommon (17.30.1):
- RevenueCat (= 5.56.0)
- RCT-Folly (2024.10.14.00): - RCT-Folly (2024.10.14.00):
- boost - boost
- DoubleConversion - DoubleConversion
@@ -1705,6 +1738,29 @@ PODS:
- Yoga - Yoga
- react-native-safe-area-context (4.12.0): - react-native-safe-area-context (4.12.0):
- React-Core - React-Core
- react-native-skia (1.12.4):
- DoubleConversion
- glog
- hermes-engine
- RCT-Folly (= 2024.10.14.00)
- RCTRequired
- RCTTypeSafety
- React
- React-callinvoker
- 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-native-view-shot (4.0.3): - react-native-view-shot (4.0.3):
- DoubleConversion - DoubleConversion
- glog - glog
@@ -1998,10 +2054,10 @@ PODS:
- React-logger - React-logger
- React-perflogger - React-perflogger
- React-utils (= 0.76.9) - React-utils (= 0.76.9)
- RevenueCat (5.55.1) - RevenueCat (5.56.0)
- RNCAsyncStorage (1.23.1): - RNCAsyncStorage (1.23.1):
- React-Core - React-Core
- RNFBAnalytics (23.8.3): - RNFBAnalytics (23.8.5):
- DoubleConversion - DoubleConversion
- FirebaseAnalytics/Core (= 12.8.0) - FirebaseAnalytics/Core (= 12.8.0)
- FirebaseAnalytics/IdentitySupport (= 12.8.0) - FirebaseAnalytics/IdentitySupport (= 12.8.0)
@@ -2025,7 +2081,7 @@ PODS:
- ReactCommon/turbomodule/core - ReactCommon/turbomodule/core
- RNFBApp - RNFBApp
- Yoga - Yoga
- RNFBApp (23.8.3): - RNFBApp (23.8.5):
- DoubleConversion - DoubleConversion
- Firebase/CoreOnly (= 12.8.0) - Firebase/CoreOnly (= 12.8.0)
- glog - glog
@@ -2047,6 +2103,30 @@ PODS:
- ReactCommon/turbomodule/bridging - ReactCommon/turbomodule/bridging
- ReactCommon/turbomodule/core - ReactCommon/turbomodule/core
- Yoga - Yoga
- RNFBCrashlytics (23.8.5):
- DoubleConversion
- Firebase/Crashlytics (= 12.8.0)
- FirebaseCoreExtension
- 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
- RNFBApp
- Yoga
- RNGestureHandler (2.20.2): - RNGestureHandler (2.20.2):
- DoubleConversion - DoubleConversion
- glog - glog
@@ -2071,8 +2151,8 @@ PODS:
- RNGoogleSignin (16.1.1): - RNGoogleSignin (16.1.1):
- GoogleSignIn (~> 9.0) - GoogleSignIn (~> 9.0)
- React-Core - React-Core
- RNPurchases (9.7.1): - RNPurchases (9.7.5):
- PurchasesHybridCommon (= 17.28.0) - PurchasesHybridCommon (= 17.30.1)
- React-Core - React-Core
- RNReanimated (3.16.7): - RNReanimated (3.16.7):
- DoubleConversion - DoubleConversion
@@ -2183,29 +2263,6 @@ PODS:
- ReactCommon/turbomodule/bridging - ReactCommon/turbomodule/bridging
- ReactCommon/turbomodule/core - ReactCommon/turbomodule/core
- Yoga - Yoga
- RNSentry (6.10.0):
- DoubleConversion
- glog
- hermes-engine
- RCT-Folly (= 2024.10.14.00)
- RCTRequired
- RCTTypeSafety
- React-Core
- React-debug
- React-Fabric
- React-featureflags
- React-graphics
- React-hermes
- React-ImageManager
- React-NativeModulesApple
- React-RCTFabric
- React-rendererdebug
- React-utils
- ReactCodegen
- ReactCommon/turbomodule/bridging
- ReactCommon/turbomodule/core
- Sentry/HybridSDK (= 8.48.0)
- Yoga
- RNSVG (15.8.0): - RNSVG (15.8.0):
- React-Core - React-Core
- SDWebImage (5.19.7): - SDWebImage (5.19.7):
@@ -2216,8 +2273,9 @@ PODS:
- SDWebImage (~> 5.10) - SDWebImage (~> 5.10)
- SDWebImageSVGCoder (1.7.0): - SDWebImageSVGCoder (1.7.0):
- SDWebImage/Core (~> 5.6) - SDWebImage/Core (~> 5.6)
- Sentry/HybridSDK (8.48.0)
- SocketRocket (0.7.1) - SocketRocket (0.7.1)
- WidgetBridge (0.0.1):
- ExpoModulesCore
- Yoga (0.0.0) - Yoga (0.0.0)
DEPENDENCIES: DEPENDENCIES:
@@ -2254,6 +2312,8 @@ DEPENDENCIES:
- ExpoModulesCore (from `../../../node_modules/expo-modules-core`) - ExpoModulesCore (from `../../../node_modules/expo-modules-core`)
- ExpoSecureStore (from `../../../node_modules/expo-secure-store/ios`) - ExpoSecureStore (from `../../../node_modules/expo-secure-store/ios`)
- ExpoSharing (from `../../../node_modules/expo-sharing/ios`) - ExpoSharing (from `../../../node_modules/expo-sharing/ios`)
- "ExpoWidgets (from `../../../node_modules/@bittingz/expo-widgets/ios`)"
- "ExtensionStorage (from `../../../node_modules/@bacons/apple-targets/ios`)"
- EXUpdatesInterface (from `../../../node_modules/expo-updates-interface/ios`) - EXUpdatesInterface (from `../../../node_modules/expo-updates-interface/ios`)
- fast_float (from `../../../node_modules/react-native/third-party-podspecs/fast_float.podspec`) - fast_float (from `../../../node_modules/react-native/third-party-podspecs/fast_float.podspec`)
- FBLazyVector (from `../../../node_modules/react-native/Libraries/FBLazyVector`) - FBLazyVector (from `../../../node_modules/react-native/Libraries/FBLazyVector`)
@@ -2293,6 +2353,7 @@ DEPENDENCIES:
- React-microtasksnativemodule (from `../../../node_modules/react-native/ReactCommon/react/nativemodule/microtasks`) - React-microtasksnativemodule (from `../../../node_modules/react-native/ReactCommon/react/nativemodule/microtasks`)
- react-native-compressor (from `../../../node_modules/react-native-compressor`) - react-native-compressor (from `../../../node_modules/react-native-compressor`)
- react-native-safe-area-context (from `../../../node_modules/react-native-safe-area-context`) - react-native-safe-area-context (from `../../../node_modules/react-native-safe-area-context`)
- "react-native-skia (from `../node_modules/@shopify/react-native-skia`)"
- react-native-view-shot (from `../../../node_modules/react-native-view-shot`) - react-native-view-shot (from `../../../node_modules/react-native-view-shot`)
- React-nativeconfig (from `../../../node_modules/react-native/ReactCommon`) - React-nativeconfig (from `../../../node_modules/react-native/ReactCommon`)
- React-NativeModulesApple (from `../../../node_modules/react-native/ReactCommon/react/nativemodule/core/platform/ios`) - React-NativeModulesApple (from `../../../node_modules/react-native/ReactCommon/react/nativemodule/core/platform/ios`)
@@ -2322,15 +2383,16 @@ DEPENDENCIES:
- ReactCodegen (from `build/generated/ios`) - ReactCodegen (from `build/generated/ios`)
- ReactCommon/turbomodule/core (from `../../../node_modules/react-native/ReactCommon`) - ReactCommon/turbomodule/core (from `../../../node_modules/react-native/ReactCommon`)
- "RNCAsyncStorage (from `../../../node_modules/@react-native-async-storage/async-storage`)" - "RNCAsyncStorage (from `../../../node_modules/@react-native-async-storage/async-storage`)"
- "RNFBAnalytics (from `../../../node_modules/@react-native-firebase/analytics`)" - "RNFBAnalytics (from `../node_modules/@react-native-firebase/analytics`)"
- "RNFBApp (from `../../../node_modules/@react-native-firebase/app`)" - "RNFBApp (from `../node_modules/@react-native-firebase/app`)"
- "RNFBCrashlytics (from `../node_modules/@react-native-firebase/crashlytics`)"
- RNGestureHandler (from `../../../node_modules/react-native-gesture-handler`) - RNGestureHandler (from `../../../node_modules/react-native-gesture-handler`)
- "RNGoogleSignin (from `../../../node_modules/@react-native-google-signin/google-signin`)" - "RNGoogleSignin (from `../../../node_modules/@react-native-google-signin/google-signin`)"
- RNPurchases (from `../../../node_modules/react-native-purchases`) - RNPurchases (from `../../../node_modules/react-native-purchases`)
- RNReanimated (from `../../../node_modules/react-native-reanimated`) - RNReanimated (from `../../../node_modules/react-native-reanimated`)
- RNScreens (from `../../../node_modules/react-native-screens`) - RNScreens (from `../../../node_modules/react-native-screens`)
- "RNSentry (from `../../../node_modules/@sentry/react-native`)" - RNSVG (from `../../../node_modules/react-native-svg`)
- RNSVG (from `../node_modules/react-native-svg`) - WidgetBridge (from `../modules/widget-bridge/ios`)
- Yoga (from `../../../node_modules/react-native/ReactCommon/yoga`) - Yoga (from `../../../node_modules/react-native/ReactCommon/yoga`)
SPEC REPOS: SPEC REPOS:
@@ -2340,9 +2402,14 @@ SPEC REPOS:
- Firebase - Firebase
- FirebaseAnalytics - FirebaseAnalytics
- FirebaseCore - FirebaseCore
- FirebaseCoreExtension
- FirebaseCoreInternal - FirebaseCoreInternal
- FirebaseCrashlytics
- FirebaseInstallations - FirebaseInstallations
- FirebaseRemoteConfigInterop
- FirebaseSessions
- GoogleAppMeasurement - GoogleAppMeasurement
- GoogleDataTransport
- GoogleSignIn - GoogleSignIn
- GoogleUtilities - GoogleUtilities
- GTMAppAuth - GTMAppAuth
@@ -2351,12 +2418,12 @@ SPEC REPOS:
- libdav1d - libdav1d
- nanopb - nanopb
- PromisesObjC - PromisesObjC
- PromisesSwift
- PurchasesHybridCommon - PurchasesHybridCommon
- RevenueCat - RevenueCat
- SDWebImage - SDWebImage
- SDWebImageAVIFCoder - SDWebImageAVIFCoder
- SDWebImageSVGCoder - SDWebImageSVGCoder
- Sentry
- SocketRocket - SocketRocket
EXTERNAL SOURCES: EXTERNAL SOURCES:
@@ -2426,6 +2493,10 @@ EXTERNAL SOURCES:
:path: "../../../node_modules/expo-secure-store/ios" :path: "../../../node_modules/expo-secure-store/ios"
ExpoSharing: ExpoSharing:
:path: "../../../node_modules/expo-sharing/ios" :path: "../../../node_modules/expo-sharing/ios"
ExpoWidgets:
:path: "../../../node_modules/@bittingz/expo-widgets/ios"
ExtensionStorage:
:path: "../../../node_modules/@bacons/apple-targets/ios"
EXUpdatesInterface: EXUpdatesInterface:
:path: "../../../node_modules/expo-updates-interface/ios" :path: "../../../node_modules/expo-updates-interface/ios"
fast_float: fast_float:
@@ -2501,6 +2572,8 @@ EXTERNAL SOURCES:
:path: "../../../node_modules/react-native-compressor" :path: "../../../node_modules/react-native-compressor"
react-native-safe-area-context: react-native-safe-area-context:
:path: "../../../node_modules/react-native-safe-area-context" :path: "../../../node_modules/react-native-safe-area-context"
react-native-skia:
:path: "../node_modules/@shopify/react-native-skia"
react-native-view-shot: react-native-view-shot:
:path: "../../../node_modules/react-native-view-shot" :path: "../../../node_modules/react-native-view-shot"
React-nativeconfig: React-nativeconfig:
@@ -2560,9 +2633,11 @@ EXTERNAL SOURCES:
RNCAsyncStorage: RNCAsyncStorage:
:path: "../../../node_modules/@react-native-async-storage/async-storage" :path: "../../../node_modules/@react-native-async-storage/async-storage"
RNFBAnalytics: RNFBAnalytics:
:path: "../../../node_modules/@react-native-firebase/analytics" :path: "../node_modules/@react-native-firebase/analytics"
RNFBApp: RNFBApp:
:path: "../../../node_modules/@react-native-firebase/app" :path: "../node_modules/@react-native-firebase/app"
RNFBCrashlytics:
:path: "../node_modules/@react-native-firebase/crashlytics"
RNGestureHandler: RNGestureHandler:
:path: "../../../node_modules/react-native-gesture-handler" :path: "../../../node_modules/react-native-gesture-handler"
RNGoogleSignin: RNGoogleSignin:
@@ -2573,10 +2648,10 @@ EXTERNAL SOURCES:
:path: "../../../node_modules/react-native-reanimated" :path: "../../../node_modules/react-native-reanimated"
RNScreens: RNScreens:
:path: "../../../node_modules/react-native-screens" :path: "../../../node_modules/react-native-screens"
RNSentry:
:path: "../../../node_modules/@sentry/react-native"
RNSVG: RNSVG:
:path: "../node_modules/react-native-svg" :path: "../../../node_modules/react-native-svg"
WidgetBridge:
:path: "../modules/widget-bridge/ios"
Yoga: Yoga:
:path: "../../../node_modules/react-native/ReactCommon/yoga" :path: "../../../node_modules/react-native/ReactCommon/yoga"
@@ -2592,7 +2667,7 @@ SPEC CHECKSUMS:
EXJSONUtils: 01fc7492b66c234e395dcffdd5f53439c5c29c93 EXJSONUtils: 01fc7492b66c234e395dcffdd5f53439c5c29c93
EXManifests: a19d50504b8826546a4782770317bc83fffec87d EXManifests: a19d50504b8826546a4782770317bc83fffec87d
EXNotifications: 9d3d17e52c95f377750d4a2ae553a716313dd4aa EXNotifications: 9d3d17e52c95f377750d4a2ae553a716313dd4aa
Expo: 3889933926fade17d8607f449c57b87c4022433f Expo: 4bb70893882e6382b41d1e910d7226c6a1b85f0a
expo-dev-client: db44302cdbe0ec55b0ef1849c9a23a76dec6dbac expo-dev-client: db44302cdbe0ec55b0ef1849c9a23a76dec6dbac
expo-dev-launcher: c67c6a87f8a8ff54abda104abdc057ee4565a3f4 expo-dev-launcher: c67c6a87f8a8ff54abda104abdc057ee4565a3f4
expo-dev-menu: 1426660b67d2d594f70ec1d8a85bc26cafcf1bce expo-dev-menu: 1426660b67d2d594f70ec1d8a85bc26cafcf1bce
@@ -2615,18 +2690,25 @@ SPEC CHECKSUMS:
ExpoMediaLibrary: 2709fc2e4fcf87d28767714abcc603c4bec228c6 ExpoMediaLibrary: 2709fc2e4fcf87d28767714abcc603c4bec228c6
ExpoModulesCore: 2d8d1c8e238bd5e1448b06c284ac896b162fb856 ExpoModulesCore: 2d8d1c8e238bd5e1448b06c284ac896b162fb856
ExpoSecureStore: 9a3665d7161dd1031be386e278bffd623658f316 ExpoSecureStore: 9a3665d7161dd1031be386e278bffd623658f316
ExpoSharing: 0d983394ed4a80334bab5a0d5384f75710feb7e8 ExpoSharing: 849a5ce9985c22598c16ec027e32969be8062e8e
ExpoWidgets: 366fe61c681044e13b7cc344bdbd319ff5d54b28
ExtensionStorage: 5796de20bf181317c376d693fedb64c6781a33ba
EXUpdatesInterface: 7c977640bdd8b85833c19e3959ba46145c5719db EXUpdatesInterface: 7c977640bdd8b85833c19e3959ba46145c5719db
fast_float: 06eeec4fe712a76acc9376682e4808b05ce978b6 fast_float: 06eeec4fe712a76acc9376682e4808b05ce978b6
FBLazyVector: 7605ea4810e0e10ae4815292433c09bf4324ba45 FBLazyVector: 7605ea4810e0e10ae4815292433c09bf4324ba45
Firebase: 9a58fdbc9d8655ed7b79a19cf9690bb007d3d46d Firebase: 9a58fdbc9d8655ed7b79a19cf9690bb007d3d46d
FirebaseAnalytics: f20bbad8cb7f65d8a5eaefeb424ae8800a31bdfc FirebaseAnalytics: f20bbad8cb7f65d8a5eaefeb424ae8800a31bdfc
FirebaseCore: 0dbad74bda10b8fb9ca34ad8f375fb9dd3ebef7c FirebaseCore: 0dbad74bda10b8fb9ca34ad8f375fb9dd3ebef7c
FirebaseCoreExtension: 6605938d51f765d8b18bfcafd2085276a252bee2
FirebaseCoreInternal: fe5fa466aeb314787093a7dce9f0beeaad5a2a21 FirebaseCoreInternal: fe5fa466aeb314787093a7dce9f0beeaad5a2a21
FirebaseCrashlytics: fb31c6907e5b52aa252668394d3f1ab326df1511
FirebaseInstallations: 6a14ab3d694ebd9f839c48d330da5547e9ca9dc0 FirebaseInstallations: 6a14ab3d694ebd9f839c48d330da5547e9ca9dc0
FirebaseRemoteConfigInterop: 869ddca16614f979e5c931ece11fbb0b8729ed41
FirebaseSessions: d614ca154c63dbbc6c10d6c38259c2162c4e7c9b
fmt: 01b82d4ca6470831d1cc0852a1af644be019e8f6 fmt: 01b82d4ca6470831d1cc0852a1af644be019e8f6
glog: 08b301085f15bcbb6ff8632a8ebaf239aae04e6a glog: 08b301085f15bcbb6ff8632a8ebaf239aae04e6a
GoogleAppMeasurement: 72c9a682fec6290327ea5e3c4b829b247fcb2c17 GoogleAppMeasurement: 72c9a682fec6290327ea5e3c4b829b247fcb2c17
GoogleDataTransport: aae35b7ea0c09004c3797d53c8c41f66f219d6a7
GoogleSignIn: fcee2257188d5eda57a5e2b6a715550ffff9206d GoogleSignIn: fcee2257188d5eda57a5e2b6a715550ffff9206d
GoogleUtilities: 00c88b9a86066ef77f0da2fab05f65d7768ed8e1 GoogleUtilities: 00c88b9a86066ef77f0da2fab05f65d7768ed8e1
GTMAppAuth: 217a876b249c3c585a54fd6f73e6b58c4f5c4238 GTMAppAuth: 217a876b249c3c585a54fd6f73e6b58c4f5c4238
@@ -2636,7 +2718,8 @@ SPEC CHECKSUMS:
libdav1d: 23581a4d8ec811ff171ed5e2e05cd27bad64c39f libdav1d: 23581a4d8ec811ff171ed5e2e05cd27bad64c39f
nanopb: fad817b59e0457d11a5dfbde799381cd727c1275 nanopb: fad817b59e0457d11a5dfbde799381cd727c1275
PromisesObjC: f5707f49cb48b9636751c5b2e7d227e43fba9f47 PromisesObjC: f5707f49cb48b9636751c5b2e7d227e43fba9f47
PurchasesHybridCommon: c2f8f5fcd2e472c585fccd1ab2fe317a3dc1171e PromisesSwift: 9d77319bbe72ebf6d872900551f7eeba9bce2851
PurchasesHybridCommon: c0bc3edee10a8742e9a238247944b4be044ff177
RCT-Folly: ea9d9256ba7f9322ef911169a9f696e5857b9e17 RCT-Folly: ea9d9256ba7f9322ef911169a9f696e5857b9e17
RCTDeprecation: ebe712bb05077934b16c6bf25228bdec34b64f83 RCTDeprecation: ebe712bb05077934b16c6bf25228bdec34b64f83
RCTRequired: ca91e5dd26b64f577b528044c962baf171c6b716 RCTRequired: ca91e5dd26b64f577b528044c962baf171c6b716
@@ -2668,6 +2751,7 @@ SPEC CHECKSUMS:
React-microtasksnativemodule: 338bfa0a7d8cd1ed212b055f2143a6789252137a React-microtasksnativemodule: 338bfa0a7d8cd1ed212b055f2143a6789252137a
react-native-compressor: 40b3e2b942aebac3daaae9549b23a13510c67a6f react-native-compressor: 40b3e2b942aebac3daaae9549b23a13510c67a6f
react-native-safe-area-context: 8b8404e70b0cbf2a56428a17017c14c1dcc16448 react-native-safe-area-context: 8b8404e70b0cbf2a56428a17017c14c1dcc16448
react-native-skia: 0e98c0c8979795d3b812a45b3f6cde7e5d9bf267
react-native-view-shot: 4147b714c5316d7e17ed652d0b5a182c028f842e react-native-view-shot: 4147b714c5316d7e17ed652d0b5a182c028f842e
React-nativeconfig: 415626a63057638759bcc75e0a96e2e07771a479 React-nativeconfig: 415626a63057638759bcc75e0a96e2e07771a479
React-NativeModulesApple: d33b55553c6957ff94835574636838d78121a1c6 React-NativeModulesApple: d33b55553c6957ff94835574636838d78121a1c6
@@ -2696,24 +2780,24 @@ SPEC CHECKSUMS:
React-utils: 59c5bbbc0e72be22c9d6eceb40afadf9be872819 React-utils: 59c5bbbc0e72be22c9d6eceb40afadf9be872819
ReactCodegen: 52b654894b5771d0085b24d4b1b897ebeaf939dc ReactCodegen: 52b654894b5771d0085b24d4b1b897ebeaf939dc
ReactCommon: b2eb96a61b826ff327a773a74357b302cf6da678 ReactCommon: b2eb96a61b826ff327a773a74357b302cf6da678
RevenueCat: 347221fad5ab0ed5636546867deae2c32c64749f RevenueCat: 9e2a13bead55198a72272ff105a7265e9510cd11
RNCAsyncStorage: aa75595c1aefa18f868452091fa0c411a516ce11 RNCAsyncStorage: aa75595c1aefa18f868452091fa0c411a516ce11
RNFBAnalytics: d1c906bbb01b3a6f6b99d6eaa977f04b1ae5d279 RNFBAnalytics: 0213dc76b4bf1f83a687376be2ce4e8e395e270b
RNFBApp: fecce94543a693bcc73807a83ce5e9d70ff68725 RNFBApp: 02a70215550d9890516c3b381019f6b0f0543b7c
RNFBCrashlytics: 8ae23057fe4254c5804225f5c328cd05312cc3bf
RNGestureHandler: 2dd124adb171aab16629e9387cf1830957d242c2 RNGestureHandler: 2dd124adb171aab16629e9387cf1830957d242c2
RNGoogleSignin: 5782dd7dad559608a06f3c6b1d4142c395fb5308 RNGoogleSignin: 5782dd7dad559608a06f3c6b1d4142c395fb5308
RNPurchases: e265e2ed27349b567b91b89d8712aa8c9c57f5b0 RNPurchases: ae1f8f522674a7938dd26eba6c103c40d049dfee
RNReanimated: 38e033fd1790c817678bc8a075277cdc49d6817c RNReanimated: 38e033fd1790c817678bc8a075277cdc49d6817c
RNScreens: ad2d0cfb3f15c9ed56ffb43651366ec0dcd70c36 RNScreens: ad2d0cfb3f15c9ed56ffb43651366ec0dcd70c36
RNSentry: a8f0d86b88675719ce96cb31bd9258dbd793e535
RNSVG: 8542aa11770b27563714bbd8494a8436385fc85f RNSVG: 8542aa11770b27563714bbd8494a8436385fc85f
SDWebImage: 8a6b7b160b4d710e2a22b6900e25301075c34cb3 SDWebImage: 8a6b7b160b4d710e2a22b6900e25301075c34cb3
SDWebImageAVIFCoder: afe194a084e851f70228e4be35ef651df0fc5c57 SDWebImageAVIFCoder: afe194a084e851f70228e4be35ef651df0fc5c57
SDWebImageSVGCoder: 15a300a97ec1c8ac958f009c02220ac0402e936c SDWebImageSVGCoder: 15a300a97ec1c8ac958f009c02220ac0402e936c
Sentry: 1ca8405451040482877dcd344dfa3ef80b646631
SocketRocket: d4aabe649be1e368d1318fdf28a022d714d65748 SocketRocket: d4aabe649be1e368d1318fdf28a022d714d65748
WidgetBridge: 15f1466d7f79505a5e600e1400fcff1a40f807a4
Yoga: 1259c7a8cbaccf7b4c3ddf8ee36ca11be9dee407 Yoga: 1259c7a8cbaccf7b4c3ddf8ee36ca11be9dee407
PODFILE CHECKSUM: 085b90662c05f70d43685ff8bec1ea714a98abb3 PODFILE CHECKSUM: 3746f9f348a0d8fcc1584151ac3b5c42e0fa172e
COCOAPODS: 1.16.2 COCOAPODS: 1.16.2
+360 -137
View File
@@ -10,41 +10,112 @@
13B07FBC1A68108700A75B9A /* AppDelegate.mm in Sources */ = {isa = PBXBuildFile; fileRef = 13B07FB01A68108700A75B9A /* AppDelegate.mm */; }; 13B07FBC1A68108700A75B9A /* AppDelegate.mm in Sources */ = {isa = PBXBuildFile; fileRef = 13B07FB01A68108700A75B9A /* AppDelegate.mm */; };
13B07FBF1A68108700A75B9A /* Images.xcassets in Resources */ = {isa = PBXBuildFile; fileRef = 13B07FB51A68108700A75B9A /* Images.xcassets */; }; 13B07FBF1A68108700A75B9A /* Images.xcassets in Resources */ = {isa = PBXBuildFile; fileRef = 13B07FB51A68108700A75B9A /* Images.xcassets */; };
13B07FC11A68108700A75B9A /* main.m in Sources */ = {isa = PBXBuildFile; fileRef = 13B07FB71A68108700A75B9A /* main.m */; }; 13B07FC11A68108700A75B9A /* main.m in Sources */ = {isa = PBXBuildFile; fileRef = 13B07FB71A68108700A75B9A /* main.m */; };
2634B76381EF14A5455649E2 /* PrivacyInfo.xcprivacy in Resources */ = {isa = PBXBuildFile; fileRef = 56B77889699504CF056D3B82 /* PrivacyInfo.xcprivacy */; };
301A4F5B84BB9DA22D6434C2 /* Pods_Sauci.framework in Frameworks */ = {isa = PBXBuildFile; fileRef = 43233FEC4CFFD8C8AA8CD0C9 /* Pods_Sauci.framework */; };
3E461D99554A48A4959DE609 /* SplashScreen.storyboard in Resources */ = {isa = PBXBuildFile; fileRef = AA286B85B6C04FC6940260E9 /* SplashScreen.storyboard */; }; 3E461D99554A48A4959DE609 /* SplashScreen.storyboard in Resources */ = {isa = PBXBuildFile; fileRef = AA286B85B6C04FC6940260E9 /* SplashScreen.storyboard */; };
839B1D252C4B42B3AF4A6509 /* GoogleService-Info.plist in Resources */ = {isa = PBXBuildFile; fileRef = 93AA2882742645CC9B92CD37 /* GoogleService-Info.plist */; }; 5254BDADE70741A0B2504FB1 /* noop-file.swift in Sources */ = {isa = PBXBuildFile; fileRef = F744EAD4ABA54509966475D6 /* noop-file.swift */; };
7B71EEB2AF682FA7AFF556D5 /* Pods_Sauci.framework in Frameworks */ = {isa = PBXBuildFile; fileRef = DDDAD8C12C38D3BD0A76D345 /* Pods_Sauci.framework */; };
B18059E884C0ABDD17F3DC3D /* ExpoModulesProvider.swift in Sources */ = {isa = PBXBuildFile; fileRef = FAC715A2D49A985799AEE119 /* ExpoModulesProvider.swift */; }; B18059E884C0ABDD17F3DC3D /* ExpoModulesProvider.swift in Sources */ = {isa = PBXBuildFile; fileRef = FAC715A2D49A985799AEE119 /* ExpoModulesProvider.swift */; };
BB2F792D24A3F905000567C9 /* Expo.plist in Resources */ = {isa = PBXBuildFile; fileRef = BB2F792C24A3F905000567C9 /* Expo.plist */; }; BB2F792D24A3F905000567C9 /* Expo.plist in Resources */ = {isa = PBXBuildFile; fileRef = BB2F792C24A3F905000567C9 /* Expo.plist */; };
C09531978862477F9D6CB18B /* noop-file.swift in Sources */ = {isa = PBXBuildFile; fileRef = 68A46CE15AF447C78955EED5 /* noop-file.swift */; }; CEF2F278ABBC5FBFD470A0F7 /* PrivacyInfo.xcprivacy in Resources */ = {isa = PBXBuildFile; fileRef = D7B5C17768E3B74AC152531F /* PrivacyInfo.xcprivacy */; };
FDFABE1A1A3547B1B22DD2DD /* GoogleService-Info.plist in Resources */ = {isa = PBXBuildFile; fileRef = A9280FDF3E0D43AC90382DBC /* GoogleService-Info.plist */; };
XX12B55A1EDABF083C3B51XX /* WidgetKit.framework in Frameworks */ = {isa = PBXBuildFile; fileRef = XX470101BAEB8E0BBC8B0DXX /* WidgetKit.framework */; };
XX1A6800511A685AB346D2XX /* AppIntents.framework in Frameworks */ = {isa = PBXBuildFile; fileRef = XXA12889081EB894481655XX /* AppIntents.framework */; };
XX3C562DEF346B45594832XX /* ActivityKit.framework in Frameworks */ = {isa = PBXBuildFile; fileRef = XXCAF23C3FC41F82140FA1XX /* ActivityKit.framework */; };
XX6A44985675A04437B8C4XX /* SwiftUI.framework in Frameworks */ = {isa = PBXBuildFile; fileRef = XX4DFF38D47332D6BF0183XX /* SwiftUI.framework */; };
XXFA0BA4D1DD274640D183XX /* LiveDrawWidget.appex in Embed Foundation Extensions */ = {isa = PBXBuildFile; fileRef = XXAC1FC213FDAA75508994XX /* LiveDrawWidget.appex */; settings = {ATTRIBUTES = (RemoveHeadersOnCopy, ); }; };
/* End PBXBuildFile section */ /* End PBXBuildFile section */
/* Begin PBXContainerItemProxy section */
XXA7C7D5580082A497FD95XX /* PBXContainerItemProxy */ = {
isa = PBXContainerItemProxy;
containerPortal = 83CBB9F71A601CBA00E9B192 /* Project object */;
proxyType = 1;
remoteGlobalIDString = XX540FB4ABC75CE91A6036XX;
remoteInfo = LiveDrawWidget;
};
/* End PBXContainerItemProxy section */
/* Begin PBXCopyFilesBuildPhase section */
XX72A8CFBF0FBA13A79902XX /* Embed Foundation Extensions */ = {
isa = PBXCopyFilesBuildPhase;
buildActionMask = 2147483647;
dstPath = "";
dstSubfolderSpec = 13;
files = (
XXFA0BA4D1DD274640D183XX /* LiveDrawWidget.appex in Embed Foundation Extensions */,
);
name = "Embed Foundation Extensions";
runOnlyForDeploymentPostprocessing = 0;
};
/* End PBXCopyFilesBuildPhase section */
/* Begin PBXFileReference section */ /* Begin PBXFileReference section */
13B07F961A680F5B00A75B9A /* Sauci.app */ = {isa = PBXFileReference; explicitFileType = wrapper.application; includeInIndex = 0; path = Sauci.app; sourceTree = BUILT_PRODUCTS_DIR; }; 13B07F961A680F5B00A75B9A /* Sauci.app */ = {isa = PBXFileReference; explicitFileType = wrapper.application; fileEncoding = 4; includeInIndex = 0; path = Sauci.app; sourceTree = BUILT_PRODUCTS_DIR; };
13B07FAF1A68108700A75B9A /* AppDelegate.h */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.c.h; name = AppDelegate.h; path = Sauci/AppDelegate.h; sourceTree = "<group>"; }; 13B07FAF1A68108700A75B9A /* AppDelegate.h */ = {isa = PBXFileReference; fileEncoding = 4; includeInIndex = 0; lastKnownFileType = sourcecode.c.h; name = AppDelegate.h; path = Sauci/AppDelegate.h; sourceTree = "<group>"; };
13B07FB01A68108700A75B9A /* AppDelegate.mm */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.cpp.objcpp; name = AppDelegate.mm; path = Sauci/AppDelegate.mm; sourceTree = "<group>"; }; 13B07FB01A68108700A75B9A /* AppDelegate.mm */ = {isa = PBXFileReference; fileEncoding = 4; includeInIndex = 0; lastKnownFileType = sourcecode.cpp.objcpp; name = AppDelegate.mm; path = Sauci/AppDelegate.mm; sourceTree = "<group>"; };
13B07FB51A68108700A75B9A /* Images.xcassets */ = {isa = PBXFileReference; lastKnownFileType = folder.assetcatalog; name = Images.xcassets; path = Sauci/Images.xcassets; sourceTree = "<group>"; }; 13B07FB51A68108700A75B9A /* Images.xcassets */ = {isa = PBXFileReference; fileEncoding = 4; includeInIndex = 0; 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>"; }; 13B07FB61A68108700A75B9A /* Info.plist */ = {isa = PBXFileReference; fileEncoding = 4; includeInIndex = 0; 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>"; }; 13B07FB71A68108700A75B9A /* main.m */ = {isa = PBXFileReference; fileEncoding = 4; includeInIndex = 0; lastKnownFileType = sourcecode.c.objc; name = main.m; path = Sauci/main.m; sourceTree = "<group>"; };
3BD07AB1AFFA49C7B31B5B70 /* 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>"; }; 6C2E3173556A471DD304B334 /* Pods-Sauci.debug.xcconfig */ = {isa = PBXFileReference; fileEncoding = 4; includeInIndex = 1; lastKnownFileType = text.xcconfig; name = "Pods-Sauci.debug.xcconfig"; path = "Target Support Files/Pods-Sauci/Pods-Sauci.debug.xcconfig"; sourceTree = "<group>"; };
43233FEC4CFFD8C8AA8CD0C9 /* Pods_Sauci.framework */ = {isa = PBXFileReference; explicitFileType = wrapper.framework; includeInIndex = 0; path = Pods_Sauci.framework; sourceTree = BUILT_PRODUCTS_DIR; }; 7A4D352CD337FB3A3BF06240 /* Pods-Sauci.release.xcconfig */ = {isa = PBXFileReference; fileEncoding = 4; includeInIndex = 1; lastKnownFileType = text.xcconfig; name = "Pods-Sauci.release.xcconfig"; path = "Target Support Files/Pods-Sauci/Pods-Sauci.release.xcconfig"; sourceTree = "<group>"; };
56B77889699504CF056D3B82 /* PrivacyInfo.xcprivacy */ = {isa = PBXFileReference; includeInIndex = 1; name = PrivacyInfo.xcprivacy; path = Sauci/PrivacyInfo.xcprivacy; sourceTree = "<group>"; }; A9280FDF3E0D43AC90382DBC /* 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>"; };
68A46CE15AF447C78955EED5 /* 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>"; }; AA286B85B6C04FC6940260E9 /* SplashScreen.storyboard */ = {isa = PBXFileReference; fileEncoding = 4; includeInIndex = 0; lastKnownFileType = file.storyboard; name = SplashScreen.storyboard; path = Sauci/SplashScreen.storyboard; 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>"; }; BB2F792C24A3F905000567C9 /* Expo.plist */ = {isa = PBXFileReference; fileEncoding = 4; includeInIndex = 0; lastKnownFileType = text.plist.xml; path = Expo.plist; 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>"; }; D7B5C17768E3B74AC152531F /* PrivacyInfo.xcprivacy */ = {isa = PBXFileReference; fileEncoding = 4; includeInIndex = 1; lastKnownFileType = undefined; name = PrivacyInfo.xcprivacy; path = Sauci/PrivacyInfo.xcprivacy; sourceTree = "<group>"; };
93AA2882742645CC9B92CD37 /* 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>"; }; DA536CAC88B349A59DFC1F1E /* 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>"; };
AA286B85B6C04FC6940260E9 /* SplashScreen.storyboard */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = file.storyboard; name = SplashScreen.storyboard; path = Sauci/SplashScreen.storyboard; sourceTree = "<group>"; }; DDDAD8C12C38D3BD0A76D345 /* Pods_Sauci.framework */ = {isa = PBXFileReference; explicitFileType = wrapper.framework; fileEncoding = 4; includeInIndex = undefined; path = Pods_Sauci.framework; sourceTree = BUILT_PRODUCTS_DIR; };
BB2F792C24A3F905000567C9 /* Expo.plist */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = text.plist.xml; path = Expo.plist; sourceTree = "<group>"; }; ED297162215061F000B7C4FE /* JavaScriptCore.framework */ = {isa = PBXFileReference; fileEncoding = 4; includeInIndex = undefined; lastKnownFileType = wrapper.framework; name = JavaScriptCore.framework; path = System/Library/Frameworks/JavaScriptCore.framework; sourceTree = SDKROOT; };
ED297162215061F000B7C4FE /* JavaScriptCore.framework */ = {isa = PBXFileReference; lastKnownFileType = wrapper.framework; name = JavaScriptCore.framework; path = System/Library/Frameworks/JavaScriptCore.framework; sourceTree = SDKROOT; }; F744EAD4ABA54509966475D6 /* 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>"; };
FAC715A2D49A985799AEE119 /* ExpoModulesProvider.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; name = ExpoModulesProvider.swift; path = "Pods/Target Support Files/Pods-Sauci/ExpoModulesProvider.swift"; sourceTree = "<group>"; }; FAC715A2D49A985799AEE119 /* ExpoModulesProvider.swift */ = {isa = PBXFileReference; fileEncoding = 4; includeInIndex = 1; lastKnownFileType = sourcecode.swift; name = ExpoModulesProvider.swift; path = "Pods/Target Support Files/Pods-Sauci/ExpoModulesProvider.swift"; sourceTree = "<group>"; };
XX470101BAEB8E0BBC8B0DXX /* WidgetKit.framework */ = {isa = PBXFileReference; fileEncoding = 4; includeInIndex = undefined; lastKnownFileType = wrapper.framework; name = WidgetKit.framework; path = System/Library/Frameworks/WidgetKit.framework; sourceTree = SDKROOT; };
XX4DFF38D47332D6BF0183XX /* SwiftUI.framework */ = {isa = PBXFileReference; fileEncoding = 4; includeInIndex = undefined; lastKnownFileType = wrapper.framework; name = SwiftUI.framework; path = System/Library/Frameworks/SwiftUI.framework; sourceTree = SDKROOT; };
XXA12889081EB894481655XX /* AppIntents.framework */ = {isa = PBXFileReference; fileEncoding = 4; includeInIndex = undefined; lastKnownFileType = wrapper.framework; name = AppIntents.framework; path = System/Library/Frameworks/AppIntents.framework; sourceTree = SDKROOT; };
XXAC1FC213FDAA75508994XX /* LiveDrawWidget.appex */ = {isa = PBXFileReference; explicitFileType = "wrapper.app-extension"; fileEncoding = 4; includeInIndex = 0; path = LiveDrawWidget.appex; sourceTree = BUILT_PRODUCTS_DIR; };
XXCAF23C3FC41F82140FA1XX /* ActivityKit.framework */ = {isa = PBXFileReference; fileEncoding = 4; includeInIndex = undefined; lastKnownFileType = wrapper.framework; name = ActivityKit.framework; path = System/Library/Frameworks/ActivityKit.framework; sourceTree = SDKROOT; };
/* End PBXFileReference section */ /* End PBXFileReference section */
/* Begin PBXFileSystemSynchronizedBuildFileExceptionSet section */
XX020388E3BB9C764E774FXX /* Exceptions for "widget" folder in "LiveDrawWidget" target */ = {
isa = PBXFileSystemSynchronizedBuildFileExceptionSet;
membershipExceptions = (
Info.plist,
"expo-target.config.js",
);
target = XX540FB4ABC75CE91A6036XX /* LiveDrawWidget */;
};
/* End PBXFileSystemSynchronizedBuildFileExceptionSet section */
/* Begin PBXFileSystemSynchronizedRootGroup section */
XX683E29AD7AD8279E17B2XX /* widget */ = {
isa = PBXFileSystemSynchronizedRootGroup;
exceptions = (
XX020388E3BB9C764E774FXX /* Exceptions for "widget" folder in "LiveDrawWidget" target */,
);
explicitFileTypes = {
};
explicitFolders = (
);
path = widget;
sourceTree = "<group>";
};
/* End PBXFileSystemSynchronizedRootGroup section */
/* Begin PBXFrameworksBuildPhase section */ /* Begin PBXFrameworksBuildPhase section */
13B07F8C1A680F5B00A75B9A /* Frameworks */ = { 13B07F8C1A680F5B00A75B9A /* Frameworks */ = {
isa = PBXFrameworksBuildPhase; isa = PBXFrameworksBuildPhase;
buildActionMask = 2147483647; buildActionMask = 2147483647;
files = ( files = (
301A4F5B84BB9DA22D6434C2 /* Pods_Sauci.framework in Frameworks */, 7B71EEB2AF682FA7AFF556D5 /* Pods_Sauci.framework in Frameworks */,
);
runOnlyForDeploymentPostprocessing = 0;
};
XX074B8811C1CF8E8A3D74XX /* Frameworks */ = {
isa = PBXFrameworksBuildPhase;
buildActionMask = 2147483647;
files = (
XX12B55A1EDABF083C3B51XX /* WidgetKit.framework in Frameworks */,
XX6A44985675A04437B8C4XX /* SwiftUI.framework in Frameworks */,
XX3C562DEF346B45594832XX /* ActivityKit.framework in Frameworks */,
XX1A6800511A685AB346D2XX /* AppIntents.framework in Frameworks */,
); );
runOnlyForDeploymentPostprocessing = 0; runOnlyForDeploymentPostprocessing = 0;
}; };
@@ -61,10 +132,10 @@
13B07FB61A68108700A75B9A /* Info.plist */, 13B07FB61A68108700A75B9A /* Info.plist */,
13B07FB71A68108700A75B9A /* main.m */, 13B07FB71A68108700A75B9A /* main.m */,
AA286B85B6C04FC6940260E9 /* SplashScreen.storyboard */, AA286B85B6C04FC6940260E9 /* SplashScreen.storyboard */,
93AA2882742645CC9B92CD37 /* GoogleService-Info.plist */, A9280FDF3E0D43AC90382DBC /* GoogleService-Info.plist */,
68A46CE15AF447C78955EED5 /* noop-file.swift */, F744EAD4ABA54509966475D6 /* noop-file.swift */,
3BD07AB1AFFA49C7B31B5B70 /* Sauci-Bridging-Header.h */, DA536CAC88B349A59DFC1F1E /* Sauci-Bridging-Header.h */,
56B77889699504CF056D3B82 /* PrivacyInfo.xcprivacy */, D7B5C17768E3B74AC152531F /* PrivacyInfo.xcprivacy */,
); );
name = Sauci; name = Sauci;
sourceTree = "<group>"; sourceTree = "<group>";
@@ -73,7 +144,11 @@
isa = PBXGroup; isa = PBXGroup;
children = ( children = (
ED297162215061F000B7C4FE /* JavaScriptCore.framework */, ED297162215061F000B7C4FE /* JavaScriptCore.framework */,
43233FEC4CFFD8C8AA8CD0C9 /* Pods_Sauci.framework */, XX470101BAEB8E0BBC8B0DXX /* WidgetKit.framework */,
XX4DFF38D47332D6BF0183XX /* SwiftUI.framework */,
DDDAD8C12C38D3BD0A76D345 /* Pods_Sauci.framework */,
XXCAF23C3FC41F82140FA1XX /* ActivityKit.framework */,
XXA12889081EB894481655XX /* AppIntents.framework */,
); );
name = Frameworks; name = Frameworks;
sourceTree = "<group>"; sourceTree = "<group>";
@@ -88,6 +163,7 @@
83CBB9F61A601CBA00E9B192 = { 83CBB9F61A601CBA00E9B192 = {
isa = PBXGroup; isa = PBXGroup;
children = ( children = (
XX3E41C1850246162C0061XX /* expo:targets */,
13B07FAE1A68108700A75B9A /* Sauci */, 13B07FAE1A68108700A75B9A /* Sauci */,
832341AE1AAA6A7D00B99B32 /* Libraries */, 832341AE1AAA6A7D00B99B32 /* Libraries */,
83CBBA001A601CBA00E9B192 /* Products */, 83CBBA001A601CBA00E9B192 /* Products */,
@@ -104,6 +180,7 @@
isa = PBXGroup; isa = PBXGroup;
children = ( children = (
13B07F961A680F5B00A75B9A /* Sauci.app */, 13B07F961A680F5B00A75B9A /* Sauci.app */,
XXAC1FC213FDAA75508994XX /* LiveDrawWidget.appex */,
); );
name = Products; name = Products;
sourceTree = "<group>"; sourceTree = "<group>";
@@ -142,6 +219,15 @@
name = ExpoModulesProviders; name = ExpoModulesProviders;
sourceTree = "<group>"; sourceTree = "<group>";
}; };
XX3E41C1850246162C0061XX /* expo:targets */ = {
isa = PBXGroup;
children = (
XX683E29AD7AD8279E17B2XX /* widget */,
);
name = "expo:targets";
path = ../targets;
sourceTree = "<group>";
};
/* End PBXGroup section */ /* End PBXGroup section */
/* Begin PBXNativeTarget section */ /* Begin PBXNativeTarget section */
@@ -150,25 +236,47 @@
buildConfigurationList = 13B07F931A680F5B00A75B9A /* Build configuration list for PBXNativeTarget "Sauci" */; buildConfigurationList = 13B07F931A680F5B00A75B9A /* Build configuration list for PBXNativeTarget "Sauci" */;
buildPhases = ( buildPhases = (
08A4A3CD28434E44B6B9DE2E /* [CP] Check Pods Manifest.lock */, 08A4A3CD28434E44B6B9DE2E /* [CP] Check Pods Manifest.lock */,
B2B15A2D43DFA021A8882896 /* [Expo] Configure project */, DC3D179C6B62F16DA5F17C2F /* [Expo] Configure project */,
13B07F871A680F5B00A75B9A /* Sources */, 13B07F871A680F5B00A75B9A /* Sources */,
13B07F8C1A680F5B00A75B9A /* Frameworks */, 13B07F8C1A680F5B00A75B9A /* Frameworks */,
13B07F8E1A680F5B00A75B9A /* Resources */, 13B07F8E1A680F5B00A75B9A /* Resources */,
00DD1BFF1BD5951E006B06BC /* Bundle React Native code and images */, 00DD1BFF1BD5951E006B06BC /* Bundle React Native code and images */,
800E24972A6A228C8D4807E9 /* [CP] Copy Pods Resources */, 800E24972A6A228C8D4807E9 /* [CP] Copy Pods Resources */,
B0D9F1E2B09440679F2212B8 /* Upload Debug Symbols to Sentry */, XX72A8CFBF0FBA13A79902XX /* Embed Foundation Extensions */,
3944CF2ABF1FE1D2CEEDB515 /* [CP] Embed Pods Frameworks */, A1248897D24903117793599B /* [CP] Embed Pods Frameworks */,
C94C12D7632AB41D3D9E4FCA /* [CP-User] [RNFB] Core Configuration */, FB4B019D835055733ACDE109 /* [CP-User] [RNFB] Core Configuration */,
563ACE2651AC39438F3B399B /* [CP-User] [RNFB] Crashlytics Configuration */,
); );
buildRules = ( buildRules = (
); );
dependencies = ( dependencies = (
XX97289CEC7994AD5F4142XX /* PBXTargetDependency */,
); );
name = Sauci; name = Sauci;
productName = Sauci; productName = Sauci;
productReference = 13B07F961A680F5B00A75B9A /* Sauci.app */; productReference = 13B07F961A680F5B00A75B9A /* Sauci.app */;
productType = "com.apple.product-type.application"; productType = "com.apple.product-type.application";
}; };
XX540FB4ABC75CE91A6036XX /* LiveDrawWidget */ = {
isa = PBXNativeTarget;
buildConfigurationList = XX63FAB1BD532DA0D8AE18XX /* Build configuration list for PBXNativeTarget "LiveDrawWidget" */;
buildPhases = (
XX074B8811C1CF8E8A3D74XX /* Frameworks */,
XXE45443DC38DCFE1DB622XX /* Sources */,
XXFA5A8CCDD805DF7AEF9CXX /* Resources */,
);
buildRules = (
);
dependencies = (
);
fileSystemSynchronizedGroups = (
XX683E29AD7AD8279E17B2XX /* widget */,
);
name = LiveDrawWidget;
productName = LiveDrawWidget;
productReference = XXAC1FC213FDAA75508994XX /* LiveDrawWidget.appex */;
productType = "com.apple.product-type.app-extension";
};
/* End PBXNativeTarget section */ /* End PBXNativeTarget section */
/* Begin PBXProject section */ /* Begin PBXProject section */
@@ -179,7 +287,10 @@
TargetAttributes = { TargetAttributes = {
13B07F861A680F5B00A75B9A = { 13B07F861A680F5B00A75B9A = {
LastSwiftMigration = 1250; LastSwiftMigration = 1250;
DevelopmentTeam = "BXVPBATDZY"; };
XX540FB4ABC75CE91A6036XX = {
CreatedOnToolsVersion = 14.3;
DevelopmentTeam = BXVPBATDZY;
ProvisioningStyle = Automatic; ProvisioningStyle = Automatic;
}; };
}; };
@@ -198,6 +309,7 @@
projectRoot = ""; projectRoot = "";
targets = ( targets = (
13B07F861A680F5B00A75B9A /* Sauci */, 13B07F861A680F5B00A75B9A /* Sauci */,
XX540FB4ABC75CE91A6036XX /* LiveDrawWidget */,
); );
}; };
/* End PBXProject section */ /* End PBXProject section */
@@ -210,8 +322,15 @@
BB2F792D24A3F905000567C9 /* Expo.plist in Resources */, BB2F792D24A3F905000567C9 /* Expo.plist in Resources */,
13B07FBF1A68108700A75B9A /* Images.xcassets in Resources */, 13B07FBF1A68108700A75B9A /* Images.xcassets in Resources */,
3E461D99554A48A4959DE609 /* SplashScreen.storyboard in Resources */, 3E461D99554A48A4959DE609 /* SplashScreen.storyboard in Resources */,
839B1D252C4B42B3AF4A6509 /* GoogleService-Info.plist in Resources */, FDFABE1A1A3547B1B22DD2DD /* GoogleService-Info.plist in Resources */,
2634B76381EF14A5455649E2 /* PrivacyInfo.xcprivacy in Resources */, CEF2F278ABBC5FBFD470A0F7 /* PrivacyInfo.xcprivacy in Resources */,
);
runOnlyForDeploymentPostprocessing = 0;
};
XXFA5A8CCDD805DF7AEF9CXX /* Resources */ = {
isa = PBXResourcesBuildPhase;
buildActionMask = 2147483647;
files = (
); );
runOnlyForDeploymentPostprocessing = 0; runOnlyForDeploymentPostprocessing = 0;
}; };
@@ -224,14 +343,18 @@
buildActionMask = 2147483647; buildActionMask = 2147483647;
files = ( files = (
); );
inputFileListPaths = (
);
inputPaths = ( inputPaths = (
); );
name = "Bundle React Native code and images"; name = "Bundle React Native code and images";
outputFileListPaths = (
);
outputPaths = ( outputPaths = (
); );
runOnlyForDeploymentPostprocessing = 0; runOnlyForDeploymentPostprocessing = 0;
shellPath = /bin/sh; shellPath = /bin/sh;
shellScript = "if [[ -f \"$PODS_ROOT/../.xcode.env\" ]]; then\n source \"$PODS_ROOT/../.xcode.env\"\nfi\nif [[ -f \"$PODS_ROOT/../.xcode.env.local\" ]]; then\n source \"$PODS_ROOT/../.xcode.env.local\"\nfi\n\n# The project root by default is one level up from the ios directory\nexport PROJECT_ROOT=\"$PROJECT_DIR\"/..\n\nif [[ \"$CONFIGURATION\" = *Debug* ]]; then\n export SKIP_BUNDLING=1\nfi\nif [[ -z \"$ENTRY_FILE\" ]]; then\n # Set the entry JS file using the bundler's entry resolution.\n export ENTRY_FILE=\"$(\"$NODE_BINARY\" -e \"require('expo/scripts/resolveAppEntry')\" \"$PROJECT_ROOT\" ios absolute | tail -n 1)\"\nfi\n\nif [[ -z \"$CLI_PATH\" ]]; then\n # Use Expo CLI\n export CLI_PATH=\"$(\"$NODE_BINARY\" --print \"require.resolve('@expo/cli', { paths: [require.resolve('expo/package.json')] })\")\"\nfi\nif [[ -z \"$BUNDLE_COMMAND\" ]]; then\n # Default Expo CLI command for bundling\n export BUNDLE_COMMAND=\"export:embed\"\nfi\n\n# Source .xcode.env.updates if it exists to allow\n# SKIP_BUNDLING to be unset if needed\nif [[ -f \"$PODS_ROOT/../.xcode.env.updates\" ]]; then\n source \"$PODS_ROOT/../.xcode.env.updates\"\nfi\n# Source local changes to allow overrides\n# if needed\nif [[ -f \"$PODS_ROOT/../.xcode.env.local\" ]]; then\n source \"$PODS_ROOT/../.xcode.env.local\"\nfi\n\n/bin/sh `\"$NODE_BINARY\" --print \"require('path').dirname(require.resolve('@sentry/react-native/package.json')) + '/scripts/sentry-xcode.sh'\"` `\"$NODE_BINARY\" --print \"require('path').dirname(require.resolve('react-native/package.json')) + '/scripts/react-native-xcode.sh'\"`\n\n"; shellScript = "if [[ -f \"$PODS_ROOT/../.xcode.env\" ]]; then\n source \"$PODS_ROOT/../.xcode.env\"\nfi\nif [[ -f \"$PODS_ROOT/../.xcode.env.local\" ]]; then\n source \"$PODS_ROOT/../.xcode.env.local\"\nfi\n\n# The project root by default is one level up from the ios directory\nexport PROJECT_ROOT=\"$PROJECT_DIR\"/..\n\nif [[ \"$CONFIGURATION\" = *Debug* ]]; then\n export SKIP_BUNDLING=1\nfi\nif [[ -z \"$ENTRY_FILE\" ]]; then\n # Set the entry JS file using the bundler's entry resolution.\n export ENTRY_FILE=\"$(\"$NODE_BINARY\" -e \"require('expo/scripts/resolveAppEntry')\" \"$PROJECT_ROOT\" ios absolute | tail -n 1)\"\nfi\n\nif [[ -z \"$CLI_PATH\" ]]; then\n # Use Expo CLI\n export CLI_PATH=\"$(\"$NODE_BINARY\" --print \"require.resolve('@expo/cli', { paths: [require.resolve('expo/package.json')] })\")\"\nfi\nif [[ -z \"$BUNDLE_COMMAND\" ]]; then\n # Default Expo CLI command for bundling\n export BUNDLE_COMMAND=\"export:embed\"\nfi\n\n# Source .xcode.env.updates if it exists to allow\n# SKIP_BUNDLING to be unset if needed\nif [[ -f \"$PODS_ROOT/../.xcode.env.updates\" ]]; then\n source \"$PODS_ROOT/../.xcode.env.updates\"\nfi\n# Source local changes to allow overrides\n# if needed\nif [[ -f \"$PODS_ROOT/../.xcode.env.local\" ]]; then\n source \"$PODS_ROOT/../.xcode.env.local\"\nfi\n\n`\"$NODE_BINARY\" --print \"require('path').dirname(require.resolve('react-native/package.json')) + '/scripts/react-native-xcode.sh'\"`\n\n";
}; };
08A4A3CD28434E44B6B9DE2E /* [CP] Check Pods Manifest.lock */ = { 08A4A3CD28434E44B6B9DE2E /* [CP] Check Pods Manifest.lock */ = {
isa = PBXShellScriptBuildPhase; isa = PBXShellScriptBuildPhase;
@@ -255,7 +378,99 @@
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"; 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; showEnvVarsInLog = 0;
}; };
3944CF2ABF1FE1D2CEEDB515 /* [CP] Embed Pods Frameworks */ = { 563ACE2651AC39438F3B399B /* [CP-User] [RNFB] Crashlytics Configuration */ = {
isa = PBXShellScriptBuildPhase;
buildActionMask = 2147483647;
files = (
);
inputPaths = (
"${DWARF_DSYM_FOLDER_PATH}/${DWARF_DSYM_FILE_NAME}/Contents/Resources/DWARF/${TARGET_NAME}",
"$(BUILT_PRODUCTS_DIR)/$(INFOPLIST_PATH)",
);
name = "[CP-User] [RNFB] Crashlytics Configuration";
runOnlyForDeploymentPostprocessing = 0;
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#\nset -e\n\nif [[ ${PODS_ROOT} ]]; then\n echo \"info: Exec FirebaseCrashlytics Run from Pods\"\n \"${PODS_ROOT}/FirebaseCrashlytics/run\"\nelse\n echo \"info: Exec FirebaseCrashlytics Run from framework\"\n \"${PROJECT_DIR}/FirebaseCrashlytics.framework/run\"\nfi\n";
};
800E24972A6A228C8D4807E9 /* [CP] Copy Pods Resources */ = {
isa = PBXShellScriptBuildPhase;
buildActionMask = 2147483647;
files = (
);
inputPaths = (
"${PODS_ROOT}/Target Support Files/Pods-Sauci/Pods-Sauci-resources.sh",
"${PODS_CONFIGURATION_BUILD_DIR}/AppAuth/AppAuthCore_Privacy.bundle",
"${PODS_CONFIGURATION_BUILD_DIR}/EXApplication/ExpoApplication_privacy.bundle",
"${PODS_CONFIGURATION_BUILD_DIR}/EXConstants/EXConstants.bundle",
"${PODS_CONFIGURATION_BUILD_DIR}/EXConstants/ExpoConstants_privacy.bundle",
"${PODS_CONFIGURATION_BUILD_DIR}/EXNotifications/ExpoNotifications_privacy.bundle",
"${PODS_CONFIGURATION_BUILD_DIR}/ExpoFileSystem/ExpoFileSystem_privacy.bundle",
"${PODS_CONFIGURATION_BUILD_DIR}/ExpoMediaLibrary/ExpoMediaLibrary_privacy.bundle",
"${PODS_CONFIGURATION_BUILD_DIR}/FirebaseCore/FirebaseCore_Privacy.bundle",
"${PODS_CONFIGURATION_BUILD_DIR}/FirebaseCoreExtension/FirebaseCoreExtension_Privacy.bundle",
"${PODS_CONFIGURATION_BUILD_DIR}/FirebaseCoreInternal/FirebaseCoreInternal_Privacy.bundle",
"${PODS_CONFIGURATION_BUILD_DIR}/FirebaseCrashlytics/FirebaseCrashlytics_Privacy.bundle",
"${PODS_CONFIGURATION_BUILD_DIR}/FirebaseInstallations/FirebaseInstallations_Privacy.bundle",
"${PODS_CONFIGURATION_BUILD_DIR}/GTMAppAuth/GTMAppAuth_Privacy.bundle",
"${PODS_CONFIGURATION_BUILD_DIR}/GTMSessionFetcher/GTMSessionFetcher_Core_Privacy.bundle",
"${PODS_CONFIGURATION_BUILD_DIR}/GoogleDataTransport/GoogleDataTransport_Privacy.bundle",
"${PODS_CONFIGURATION_BUILD_DIR}/GoogleSignIn/GoogleSignIn.bundle",
"${PODS_CONFIGURATION_BUILD_DIR}/GoogleUtilities/GoogleUtilities_Privacy.bundle",
"${PODS_CONFIGURATION_BUILD_DIR}/PromisesObjC/FBLPromises_Privacy.bundle",
"${PODS_CONFIGURATION_BUILD_DIR}/PromisesSwift/Promises_Privacy.bundle",
"${PODS_CONFIGURATION_BUILD_DIR}/PurchasesHybridCommon/PurchasesHybridCommon.bundle",
"${PODS_CONFIGURATION_BUILD_DIR}/RCT-Folly/RCT-Folly_privacy.bundle",
"${PODS_CONFIGURATION_BUILD_DIR}/RNCAsyncStorage/RNCAsyncStorage_resources.bundle",
"${PODS_CONFIGURATION_BUILD_DIR}/React-Core/React-Core_privacy.bundle",
"${PODS_CONFIGURATION_BUILD_DIR}/React-cxxreact/React-cxxreact_privacy.bundle",
"${PODS_CONFIGURATION_BUILD_DIR}/RevenueCat/RevenueCat.bundle",
"${PODS_CONFIGURATION_BUILD_DIR}/SDWebImage/SDWebImage.bundle",
"${PODS_CONFIGURATION_BUILD_DIR}/boost/boost_privacy.bundle",
"${PODS_CONFIGURATION_BUILD_DIR}/expo-dev-launcher/EXDevLauncher.bundle",
"${PODS_CONFIGURATION_BUILD_DIR}/expo-dev-menu/EXDevMenu.bundle",
"${PODS_CONFIGURATION_BUILD_DIR}/glog/glog_privacy.bundle",
"${PODS_CONFIGURATION_BUILD_DIR}/nanopb/nanopb_Privacy.bundle",
);
name = "[CP] Copy Pods Resources";
outputPaths = (
"${TARGET_BUILD_DIR}/${UNLOCALIZED_RESOURCES_FOLDER_PATH}/AppAuthCore_Privacy.bundle",
"${TARGET_BUILD_DIR}/${UNLOCALIZED_RESOURCES_FOLDER_PATH}/ExpoApplication_privacy.bundle",
"${TARGET_BUILD_DIR}/${UNLOCALIZED_RESOURCES_FOLDER_PATH}/EXConstants.bundle",
"${TARGET_BUILD_DIR}/${UNLOCALIZED_RESOURCES_FOLDER_PATH}/ExpoConstants_privacy.bundle",
"${TARGET_BUILD_DIR}/${UNLOCALIZED_RESOURCES_FOLDER_PATH}/ExpoNotifications_privacy.bundle",
"${TARGET_BUILD_DIR}/${UNLOCALIZED_RESOURCES_FOLDER_PATH}/ExpoFileSystem_privacy.bundle",
"${TARGET_BUILD_DIR}/${UNLOCALIZED_RESOURCES_FOLDER_PATH}/ExpoMediaLibrary_privacy.bundle",
"${TARGET_BUILD_DIR}/${UNLOCALIZED_RESOURCES_FOLDER_PATH}/FirebaseCore_Privacy.bundle",
"${TARGET_BUILD_DIR}/${UNLOCALIZED_RESOURCES_FOLDER_PATH}/FirebaseCoreExtension_Privacy.bundle",
"${TARGET_BUILD_DIR}/${UNLOCALIZED_RESOURCES_FOLDER_PATH}/FirebaseCoreInternal_Privacy.bundle",
"${TARGET_BUILD_DIR}/${UNLOCALIZED_RESOURCES_FOLDER_PATH}/FirebaseCrashlytics_Privacy.bundle",
"${TARGET_BUILD_DIR}/${UNLOCALIZED_RESOURCES_FOLDER_PATH}/FirebaseInstallations_Privacy.bundle",
"${TARGET_BUILD_DIR}/${UNLOCALIZED_RESOURCES_FOLDER_PATH}/GTMAppAuth_Privacy.bundle",
"${TARGET_BUILD_DIR}/${UNLOCALIZED_RESOURCES_FOLDER_PATH}/GTMSessionFetcher_Core_Privacy.bundle",
"${TARGET_BUILD_DIR}/${UNLOCALIZED_RESOURCES_FOLDER_PATH}/GoogleDataTransport_Privacy.bundle",
"${TARGET_BUILD_DIR}/${UNLOCALIZED_RESOURCES_FOLDER_PATH}/GoogleSignIn.bundle",
"${TARGET_BUILD_DIR}/${UNLOCALIZED_RESOURCES_FOLDER_PATH}/GoogleUtilities_Privacy.bundle",
"${TARGET_BUILD_DIR}/${UNLOCALIZED_RESOURCES_FOLDER_PATH}/FBLPromises_Privacy.bundle",
"${TARGET_BUILD_DIR}/${UNLOCALIZED_RESOURCES_FOLDER_PATH}/Promises_Privacy.bundle",
"${TARGET_BUILD_DIR}/${UNLOCALIZED_RESOURCES_FOLDER_PATH}/PurchasesHybridCommon.bundle",
"${TARGET_BUILD_DIR}/${UNLOCALIZED_RESOURCES_FOLDER_PATH}/RCT-Folly_privacy.bundle",
"${TARGET_BUILD_DIR}/${UNLOCALIZED_RESOURCES_FOLDER_PATH}/RNCAsyncStorage_resources.bundle",
"${TARGET_BUILD_DIR}/${UNLOCALIZED_RESOURCES_FOLDER_PATH}/React-Core_privacy.bundle",
"${TARGET_BUILD_DIR}/${UNLOCALIZED_RESOURCES_FOLDER_PATH}/React-cxxreact_privacy.bundle",
"${TARGET_BUILD_DIR}/${UNLOCALIZED_RESOURCES_FOLDER_PATH}/RevenueCat.bundle",
"${TARGET_BUILD_DIR}/${UNLOCALIZED_RESOURCES_FOLDER_PATH}/SDWebImage.bundle",
"${TARGET_BUILD_DIR}/${UNLOCALIZED_RESOURCES_FOLDER_PATH}/boost_privacy.bundle",
"${TARGET_BUILD_DIR}/${UNLOCALIZED_RESOURCES_FOLDER_PATH}/EXDevLauncher.bundle",
"${TARGET_BUILD_DIR}/${UNLOCALIZED_RESOURCES_FOLDER_PATH}/EXDevMenu.bundle",
"${TARGET_BUILD_DIR}/${UNLOCALIZED_RESOURCES_FOLDER_PATH}/glog_privacy.bundle",
"${TARGET_BUILD_DIR}/${UNLOCALIZED_RESOURCES_FOLDER_PATH}/nanopb_Privacy.bundle",
);
runOnlyForDeploymentPostprocessing = 0;
shellPath = /bin/sh;
shellScript = "\"${PODS_ROOT}/Target Support Files/Pods-Sauci/Pods-Sauci-resources.sh\"\n";
showEnvVarsInLog = 0;
};
A1248897D24903117793599B /* [CP] Embed Pods Frameworks */ = {
isa = PBXShellScriptBuildPhase; isa = PBXShellScriptBuildPhase;
buildActionMask = 2147483647; buildActionMask = 2147483647;
files = ( files = (
@@ -273,93 +488,7 @@
shellScript = "\"${PODS_ROOT}/Target Support Files/Pods-Sauci/Pods-Sauci-frameworks.sh\"\n"; shellScript = "\"${PODS_ROOT}/Target Support Files/Pods-Sauci/Pods-Sauci-frameworks.sh\"\n";
showEnvVarsInLog = 0; showEnvVarsInLog = 0;
}; };
800E24972A6A228C8D4807E9 /* [CP] Copy Pods Resources */ = { DC3D179C6B62F16DA5F17C2F /* [Expo] Configure project */ = {
isa = PBXShellScriptBuildPhase;
buildActionMask = 2147483647;
files = (
);
inputPaths = (
"${PODS_ROOT}/Target Support Files/Pods-Sauci/Pods-Sauci-resources.sh",
"${PODS_CONFIGURATION_BUILD_DIR}/AppAuth/AppAuthCore_Privacy.bundle",
"${PODS_CONFIGURATION_BUILD_DIR}/EXApplication/ExpoApplication_privacy.bundle",
"${PODS_CONFIGURATION_BUILD_DIR}/EXConstants/EXConstants.bundle",
"${PODS_CONFIGURATION_BUILD_DIR}/EXConstants/ExpoConstants_privacy.bundle",
"${PODS_CONFIGURATION_BUILD_DIR}/EXNotifications/ExpoNotifications_privacy.bundle",
"${PODS_CONFIGURATION_BUILD_DIR}/ExpoFileSystem/ExpoFileSystem_privacy.bundle",
"${PODS_CONFIGURATION_BUILD_DIR}/ExpoMediaLibrary/ExpoMediaLibrary_privacy.bundle",
"${PODS_CONFIGURATION_BUILD_DIR}/FirebaseCore/FirebaseCore_Privacy.bundle",
"${PODS_CONFIGURATION_BUILD_DIR}/FirebaseCoreInternal/FirebaseCoreInternal_Privacy.bundle",
"${PODS_CONFIGURATION_BUILD_DIR}/FirebaseInstallations/FirebaseInstallations_Privacy.bundle",
"${PODS_CONFIGURATION_BUILD_DIR}/GTMAppAuth/GTMAppAuth_Privacy.bundle",
"${PODS_CONFIGURATION_BUILD_DIR}/GTMSessionFetcher/GTMSessionFetcher_Core_Privacy.bundle",
"${PODS_CONFIGURATION_BUILD_DIR}/GoogleSignIn/GoogleSignIn.bundle",
"${PODS_CONFIGURATION_BUILD_DIR}/GoogleUtilities/GoogleUtilities_Privacy.bundle",
"${PODS_CONFIGURATION_BUILD_DIR}/PromisesObjC/FBLPromises_Privacy.bundle",
"${PODS_CONFIGURATION_BUILD_DIR}/PurchasesHybridCommon/PurchasesHybridCommon.bundle",
"${PODS_CONFIGURATION_BUILD_DIR}/RCT-Folly/RCT-Folly_privacy.bundle",
"${PODS_CONFIGURATION_BUILD_DIR}/RNCAsyncStorage/RNCAsyncStorage_resources.bundle",
"${PODS_CONFIGURATION_BUILD_DIR}/React-Core/React-Core_privacy.bundle",
"${PODS_CONFIGURATION_BUILD_DIR}/React-cxxreact/React-cxxreact_privacy.bundle",
"${PODS_CONFIGURATION_BUILD_DIR}/RevenueCat/RevenueCat.bundle",
"${PODS_CONFIGURATION_BUILD_DIR}/SDWebImage/SDWebImage.bundle",
"${PODS_CONFIGURATION_BUILD_DIR}/Sentry/Sentry.bundle",
"${PODS_CONFIGURATION_BUILD_DIR}/boost/boost_privacy.bundle",
"${PODS_CONFIGURATION_BUILD_DIR}/expo-dev-launcher/EXDevLauncher.bundle",
"${PODS_CONFIGURATION_BUILD_DIR}/expo-dev-menu/EXDevMenu.bundle",
"${PODS_CONFIGURATION_BUILD_DIR}/glog/glog_privacy.bundle",
"${PODS_CONFIGURATION_BUILD_DIR}/nanopb/nanopb_Privacy.bundle",
);
name = "[CP] Copy Pods Resources";
outputPaths = (
"${TARGET_BUILD_DIR}/${UNLOCALIZED_RESOURCES_FOLDER_PATH}/AppAuthCore_Privacy.bundle",
"${TARGET_BUILD_DIR}/${UNLOCALIZED_RESOURCES_FOLDER_PATH}/ExpoApplication_privacy.bundle",
"${TARGET_BUILD_DIR}/${UNLOCALIZED_RESOURCES_FOLDER_PATH}/EXConstants.bundle",
"${TARGET_BUILD_DIR}/${UNLOCALIZED_RESOURCES_FOLDER_PATH}/ExpoConstants_privacy.bundle",
"${TARGET_BUILD_DIR}/${UNLOCALIZED_RESOURCES_FOLDER_PATH}/ExpoNotifications_privacy.bundle",
"${TARGET_BUILD_DIR}/${UNLOCALIZED_RESOURCES_FOLDER_PATH}/ExpoFileSystem_privacy.bundle",
"${TARGET_BUILD_DIR}/${UNLOCALIZED_RESOURCES_FOLDER_PATH}/ExpoMediaLibrary_privacy.bundle",
"${TARGET_BUILD_DIR}/${UNLOCALIZED_RESOURCES_FOLDER_PATH}/FirebaseCore_Privacy.bundle",
"${TARGET_BUILD_DIR}/${UNLOCALIZED_RESOURCES_FOLDER_PATH}/FirebaseCoreInternal_Privacy.bundle",
"${TARGET_BUILD_DIR}/${UNLOCALIZED_RESOURCES_FOLDER_PATH}/FirebaseInstallations_Privacy.bundle",
"${TARGET_BUILD_DIR}/${UNLOCALIZED_RESOURCES_FOLDER_PATH}/GTMAppAuth_Privacy.bundle",
"${TARGET_BUILD_DIR}/${UNLOCALIZED_RESOURCES_FOLDER_PATH}/GTMSessionFetcher_Core_Privacy.bundle",
"${TARGET_BUILD_DIR}/${UNLOCALIZED_RESOURCES_FOLDER_PATH}/GoogleSignIn.bundle",
"${TARGET_BUILD_DIR}/${UNLOCALIZED_RESOURCES_FOLDER_PATH}/GoogleUtilities_Privacy.bundle",
"${TARGET_BUILD_DIR}/${UNLOCALIZED_RESOURCES_FOLDER_PATH}/FBLPromises_Privacy.bundle",
"${TARGET_BUILD_DIR}/${UNLOCALIZED_RESOURCES_FOLDER_PATH}/PurchasesHybridCommon.bundle",
"${TARGET_BUILD_DIR}/${UNLOCALIZED_RESOURCES_FOLDER_PATH}/RCT-Folly_privacy.bundle",
"${TARGET_BUILD_DIR}/${UNLOCALIZED_RESOURCES_FOLDER_PATH}/RNCAsyncStorage_resources.bundle",
"${TARGET_BUILD_DIR}/${UNLOCALIZED_RESOURCES_FOLDER_PATH}/React-Core_privacy.bundle",
"${TARGET_BUILD_DIR}/${UNLOCALIZED_RESOURCES_FOLDER_PATH}/React-cxxreact_privacy.bundle",
"${TARGET_BUILD_DIR}/${UNLOCALIZED_RESOURCES_FOLDER_PATH}/RevenueCat.bundle",
"${TARGET_BUILD_DIR}/${UNLOCALIZED_RESOURCES_FOLDER_PATH}/SDWebImage.bundle",
"${TARGET_BUILD_DIR}/${UNLOCALIZED_RESOURCES_FOLDER_PATH}/Sentry.bundle",
"${TARGET_BUILD_DIR}/${UNLOCALIZED_RESOURCES_FOLDER_PATH}/boost_privacy.bundle",
"${TARGET_BUILD_DIR}/${UNLOCALIZED_RESOURCES_FOLDER_PATH}/EXDevLauncher.bundle",
"${TARGET_BUILD_DIR}/${UNLOCALIZED_RESOURCES_FOLDER_PATH}/EXDevMenu.bundle",
"${TARGET_BUILD_DIR}/${UNLOCALIZED_RESOURCES_FOLDER_PATH}/glog_privacy.bundle",
"${TARGET_BUILD_DIR}/${UNLOCALIZED_RESOURCES_FOLDER_PATH}/nanopb_Privacy.bundle",
);
runOnlyForDeploymentPostprocessing = 0;
shellPath = /bin/sh;
shellScript = "\"${PODS_ROOT}/Target Support Files/Pods-Sauci/Pods-Sauci-resources.sh\"\n";
showEnvVarsInLog = 0;
};
B0D9F1E2B09440679F2212B8 /* 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'\"`";
};
B2B15A2D43DFA021A8882896 /* [Expo] Configure project */ = {
isa = PBXShellScriptBuildPhase; isa = PBXShellScriptBuildPhase;
alwaysOutOfDate = 1; alwaysOutOfDate = 1;
buildActionMask = 2147483647; buildActionMask = 2147483647;
@@ -378,7 +507,7 @@
shellPath = /bin/sh; 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"; 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";
}; };
C94C12D7632AB41D3D9E4FCA /* [CP-User] [RNFB] Core Configuration */ = { FB4B019D835055733ACDE109 /* [CP-User] [RNFB] Core Configuration */ = {
isa = PBXShellScriptBuildPhase; isa = PBXShellScriptBuildPhase;
buildActionMask = 2147483647; buildActionMask = 2147483647;
files = ( files = (
@@ -401,12 +530,27 @@
13B07FBC1A68108700A75B9A /* AppDelegate.mm in Sources */, 13B07FBC1A68108700A75B9A /* AppDelegate.mm in Sources */,
13B07FC11A68108700A75B9A /* main.m in Sources */, 13B07FC11A68108700A75B9A /* main.m in Sources */,
B18059E884C0ABDD17F3DC3D /* ExpoModulesProvider.swift in Sources */, B18059E884C0ABDD17F3DC3D /* ExpoModulesProvider.swift in Sources */,
C09531978862477F9D6CB18B /* noop-file.swift in Sources */, 5254BDADE70741A0B2504FB1 /* noop-file.swift in Sources */,
);
runOnlyForDeploymentPostprocessing = 0;
};
XXE45443DC38DCFE1DB622XX /* Sources */ = {
isa = PBXSourcesBuildPhase;
buildActionMask = 2147483647;
files = (
); );
runOnlyForDeploymentPostprocessing = 0; runOnlyForDeploymentPostprocessing = 0;
}; };
/* End PBXSourcesBuildPhase section */ /* End PBXSourcesBuildPhase section */
/* Begin PBXTargetDependency section */
XX97289CEC7994AD5F4142XX /* PBXTargetDependency */ = {
isa = PBXTargetDependency;
target = XX540FB4ABC75CE91A6036XX /* LiveDrawWidget */;
targetProxy = XXA7C7D5580082A497FD95XX /* PBXContainerItemProxy */;
};
/* End PBXTargetDependency section */
/* Begin XCBuildConfiguration section */ /* Begin XCBuildConfiguration section */
13B07F941A680F5B00A75B9A /* Debug */ = { 13B07F941A680F5B00A75B9A /* Debug */ = {
isa = XCBuildConfiguration; isa = XCBuildConfiguration;
@@ -416,6 +560,7 @@
CLANG_ENABLE_MODULES = YES; CLANG_ENABLE_MODULES = YES;
CODE_SIGN_ENTITLEMENTS = Sauci/Sauci.entitlements; CODE_SIGN_ENTITLEMENTS = Sauci/Sauci.entitlements;
CURRENT_PROJECT_VERSION = 1; CURRENT_PROJECT_VERSION = 1;
DEVELOPMENT_TEAM = BXVPBATDZY;
ENABLE_BITCODE = NO; ENABLE_BITCODE = NO;
GCC_PREPROCESSOR_DEFINITIONS = ( GCC_PREPROCESSOR_DEFINITIONS = (
"$(inherited)", "$(inherited)",
@@ -424,7 +569,7 @@
INFOPLIST_FILE = Sauci/Info.plist; INFOPLIST_FILE = Sauci/Info.plist;
IPHONEOS_DEPLOYMENT_TARGET = 15.1; IPHONEOS_DEPLOYMENT_TARGET = 15.1;
LD_RUNPATH_SEARCH_PATHS = "$(inherited) @executable_path/Frameworks"; LD_RUNPATH_SEARCH_PATHS = "$(inherited) @executable_path/Frameworks";
MARKETING_VERSION = 1.0; MARKETING_VERSION = 1.0.5;
OTHER_LDFLAGS = ( OTHER_LDFLAGS = (
"$(inherited)", "$(inherited)",
"-ObjC", "-ObjC",
@@ -432,15 +577,12 @@
); );
OTHER_SWIFT_FLAGS = "$(inherited) -D EXPO_CONFIGURATION_DEBUG"; OTHER_SWIFT_FLAGS = "$(inherited) -D EXPO_CONFIGURATION_DEBUG";
PRODUCT_BUNDLE_IDENTIFIER = com.sauci.app; PRODUCT_BUNDLE_IDENTIFIER = com.sauci.app;
PRODUCT_NAME = "Sauci"; PRODUCT_NAME = Sauci;
SWIFT_OBJC_BRIDGING_HEADER = "Sauci/Sauci-Bridging-Header.h"; SWIFT_OBJC_BRIDGING_HEADER = "Sauci/Sauci-Bridging-Header.h";
SWIFT_OPTIMIZATION_LEVEL = "-Onone"; SWIFT_OPTIMIZATION_LEVEL = "-Onone";
SWIFT_VERSION = 5.0; SWIFT_VERSION = 5.0;
TARGETED_DEVICE_FAMILY = "1"; TARGETED_DEVICE_FAMILY = 1;
VERSIONING_SYSTEM = "apple-generic"; VERSIONING_SYSTEM = "apple-generic";
DEVELOPMENT_TEAM = "BXVPBATDZY";
CODE_SIGN_IDENTITY = "Apple Development";
CODE_SIGN_STYLE = Automatic;
}; };
name = Debug; name = Debug;
}; };
@@ -452,10 +594,11 @@
CLANG_ENABLE_MODULES = YES; CLANG_ENABLE_MODULES = YES;
CODE_SIGN_ENTITLEMENTS = Sauci/Sauci.entitlements; CODE_SIGN_ENTITLEMENTS = Sauci/Sauci.entitlements;
CURRENT_PROJECT_VERSION = 1; CURRENT_PROJECT_VERSION = 1;
DEVELOPMENT_TEAM = BXVPBATDZY;
INFOPLIST_FILE = Sauci/Info.plist; INFOPLIST_FILE = Sauci/Info.plist;
IPHONEOS_DEPLOYMENT_TARGET = 15.1; IPHONEOS_DEPLOYMENT_TARGET = 15.1;
LD_RUNPATH_SEARCH_PATHS = "$(inherited) @executable_path/Frameworks"; LD_RUNPATH_SEARCH_PATHS = "$(inherited) @executable_path/Frameworks";
MARKETING_VERSION = 1.0; MARKETING_VERSION = 1.0.5;
OTHER_LDFLAGS = ( OTHER_LDFLAGS = (
"$(inherited)", "$(inherited)",
"-ObjC", "-ObjC",
@@ -463,14 +606,11 @@
); );
OTHER_SWIFT_FLAGS = "$(inherited) -D EXPO_CONFIGURATION_RELEASE"; OTHER_SWIFT_FLAGS = "$(inherited) -D EXPO_CONFIGURATION_RELEASE";
PRODUCT_BUNDLE_IDENTIFIER = com.sauci.app; PRODUCT_BUNDLE_IDENTIFIER = com.sauci.app;
PRODUCT_NAME = "Sauci"; PRODUCT_NAME = Sauci;
SWIFT_OBJC_BRIDGING_HEADER = "Sauci/Sauci-Bridging-Header.h"; SWIFT_OBJC_BRIDGING_HEADER = "Sauci/Sauci-Bridging-Header.h";
SWIFT_VERSION = 5.0; SWIFT_VERSION = 5.0;
TARGETED_DEVICE_FAMILY = "1"; TARGETED_DEVICE_FAMILY = 1;
VERSIONING_SYSTEM = "apple-generic"; VERSIONING_SYSTEM = "apple-generic";
DEVELOPMENT_TEAM = "BXVPBATDZY";
CODE_SIGN_IDENTITY = "Apple Development";
CODE_SIGN_STYLE = Automatic;
}; };
name = Release; name = Release;
}; };
@@ -614,6 +754,80 @@
}; };
name = Release; name = Release;
}; };
XX1230635DC14DD95E28A2XX /* Release */ = {
isa = XCBuildConfiguration;
buildSettings = {
CLANG_ANALYZER_NONNULL = YES;
CLANG_ANALYZER_NUMBER_OBJECT_CONVERSION = YES_AGGRESSIVE;
CLANG_CXX_LANGUAGE_STANDARD = "gnu++20";
CLANG_ENABLE_OBJC_WEAK = YES;
CLANG_WARN_DOCUMENTATION_COMMENTS = YES;
CLANG_WARN_QUOTED_INCLUDE_IN_FRAMEWORK_HEADER = YES;
CLANG_WARN_UNGUARDED_AVAILABILITY = YES_AGGRESSIVE;
CODE_SIGN_ENTITLEMENTS = ../targets/widget/generated.entitlements;
CODE_SIGN_STYLE = Automatic;
COPY_PHASE_STRIP = NO;
CURRENT_PROJECT_VERSION = 41;
DEBUG_INFORMATION_FORMAT = "dwarf-with-dsym";
DEVELOPMENT_TEAM = BXVPBATDZY;
GCC_C_LANGUAGE_STANDARD = gnu11;
GENERATE_INFOPLIST_FILE = YES;
INFOPLIST_FILE = ../targets/widget/Info.plist;
INFOPLIST_KEY_CFBundleDisplayName = LiveDrawWidget;
INFOPLIST_KEY_NSHumanReadableCopyright = "";
IPHONEOS_DEPLOYMENT_TARGET = 16.0;
LD_RUNPATH_SEARCH_PATHS = "$(inherited) @executable_path/Frameworks @executable_path/../../Frameworks";
MARKETING_VERSION = 1.0.5;
MTL_FAST_MATH = YES;
OTHER_SWIFT_FLAGS = "$(inherited) -D EXPO_CONFIGURATION_RELEASE";
PRODUCT_BUNDLE_IDENTIFIER = com.sauci.app.LiveDrawWidget;
PRODUCT_NAME = "$(TARGET_NAME)";
SKIP_INSTALL = YES;
SWIFT_COMPILATION_MODE = wholemodule;
SWIFT_EMIT_LOC_STRINGS = YES;
SWIFT_OPTIMIZATION_LEVEL = "-O";
SWIFT_VERSION = 5.0;
TARGETED_DEVICE_FAMILY = "1,2";
};
name = Release;
};
XX710DB962925BCE8D59ACXX /* Debug */ = {
isa = XCBuildConfiguration;
buildSettings = {
CLANG_ANALYZER_NONNULL = YES;
CLANG_ANALYZER_NUMBER_OBJECT_CONVERSION = YES_AGGRESSIVE;
CLANG_CXX_LANGUAGE_STANDARD = "gnu++20";
CLANG_ENABLE_OBJC_WEAK = YES;
CLANG_WARN_DOCUMENTATION_COMMENTS = YES;
CLANG_WARN_QUOTED_INCLUDE_IN_FRAMEWORK_HEADER = YES;
CLANG_WARN_UNGUARDED_AVAILABILITY = YES_AGGRESSIVE;
CODE_SIGN_ENTITLEMENTS = ../targets/widget/generated.entitlements;
CODE_SIGN_STYLE = Automatic;
CURRENT_PROJECT_VERSION = 41;
DEBUG_INFORMATION_FORMAT = dwarf;
DEVELOPMENT_TEAM = BXVPBATDZY;
GCC_C_LANGUAGE_STANDARD = gnu11;
GENERATE_INFOPLIST_FILE = YES;
INFOPLIST_FILE = ../targets/widget/Info.plist;
INFOPLIST_KEY_CFBundleDisplayName = LiveDrawWidget;
INFOPLIST_KEY_NSHumanReadableCopyright = "";
IPHONEOS_DEPLOYMENT_TARGET = 16.0;
LD_RUNPATH_SEARCH_PATHS = "$(inherited) @executable_path/Frameworks @executable_path/../../Frameworks";
MARKETING_VERSION = 1.0.5;
MTL_ENABLE_DEBUG_INFO = INCLUDE_SOURCE;
MTL_FAST_MATH = YES;
OTHER_SWIFT_FLAGS = "$(inherited) -D EXPO_CONFIGURATION_DEBUG";
PRODUCT_BUNDLE_IDENTIFIER = com.sauci.app.LiveDrawWidget;
PRODUCT_NAME = "$(TARGET_NAME)";
SKIP_INSTALL = YES;
SWIFT_ACTIVE_COMPILATION_CONDITIONS = DEBUG;
SWIFT_EMIT_LOC_STRINGS = YES;
SWIFT_OPTIMIZATION_LEVEL = "-Onone";
SWIFT_VERSION = 5.0;
TARGETED_DEVICE_FAMILY = "1,2";
};
name = Debug;
};
/* End XCBuildConfiguration section */ /* End XCBuildConfiguration section */
/* Begin XCConfigurationList section */ /* Begin XCConfigurationList section */
@@ -635,6 +849,15 @@
defaultConfigurationIsVisible = 0; defaultConfigurationIsVisible = 0;
defaultConfigurationName = Release; defaultConfigurationName = Release;
}; };
XX63FAB1BD532DA0D8AE18XX /* Build configuration list for PBXNativeTarget "LiveDrawWidget" */ = {
isa = XCConfigurationList;
buildConfigurations = (
XX710DB962925BCE8D59ACXX /* Debug */,
XX1230635DC14DD95E28A2XX /* Release */,
);
defaultConfigurationIsVisible = 0;
defaultConfigurationName = Release;
};
/* End XCConfigurationList section */ /* End XCConfigurationList section */
}; };
rootObject = 83CBB9F71A601CBA00E9B192 /* Project object */; rootObject = 83CBB9F71A601CBA00E9B192 /* Project object */;
+2 -2
View File
@@ -21,7 +21,7 @@
<key>CFBundlePackageType</key> <key>CFBundlePackageType</key>
<string>$(PRODUCT_BUNDLE_PACKAGE_TYPE)</string> <string>$(PRODUCT_BUNDLE_PACKAGE_TYPE)</string>
<key>CFBundleShortVersionString</key> <key>CFBundleShortVersionString</key>
<string>1.0.4</string> <string>1.0.5</string>
<key>CFBundleSignature</key> <key>CFBundleSignature</key>
<string>????</string> <string>????</string>
<key>CFBundleURLTypes</key> <key>CFBundleURLTypes</key>
@@ -47,7 +47,7 @@
</dict> </dict>
</array> </array>
<key>CFBundleVersion</key> <key>CFBundleVersion</key>
<string>35</string> <string>42</string>
<key>GOOGLE_ANALYTICS_INAPURCHASE_COLLECTION_ENABLED</key> <key>GOOGLE_ANALYTICS_INAPURCHASE_COLLECTION_ENABLED</key>
<false/> <false/>
<key>LSMinimumSystemVersion</key> <key>LSMinimumSystemVersion</key>
+4
View File
@@ -8,5 +8,9 @@
<array> <array>
<string>Default</string> <string>Default</string>
</array> </array>
<key>com.apple.security.application-groups</key>
<array>
<string>group.com.sauci.app</string>
</array>
</dict> </dict>
</plist> </plist>
-4
View File
@@ -1,4 +0,0 @@
defaults.url=https://sentry.io/
defaults.org=molten-prints
defaults.project=sauci
# Using SENTRY_AUTH_TOKEN environment variable
@@ -0,0 +1,63 @@
apply plugin: 'com.android.library'
apply plugin: 'kotlin-android'
apply plugin: 'maven-publish'
group = 'expo.modules.widgetbridge'
version = '0.0.1'
buildscript {
def expoModulesCorePlugin = new File(project(":expo-modules-core").projectDir.absolutePath, "ExpoModulesCorePlugin.gradle")
if (expoModulesCorePlugin.exists()) {
apply from: expoModulesCorePlugin
applyKotlinExpoModulesCorePlugin()
}
}
afterEvaluate {
publishing {
publications {
release(MavenPublication) {
from components.release
}
}
repositories {
maven {
url = mavenLocal().url
}
}
}
}
android {
namespace "expo.modules.widgetbridge"
compileSdkVersion safeExtGet("compileSdkVersion", 34)
defaultConfig {
minSdkVersion safeExtGet("minSdkVersion", 23)
targetSdkVersion safeExtGet("targetSdkVersion", 34)
}
publishing {
singleVariant("release") {
withSourcesJar()
}
}
lintOptions {
abortOnError false
}
compileOptions {
sourceCompatibility JavaVersion.VERSION_17
targetCompatibility JavaVersion.VERSION_17
}
kotlinOptions {
jvmTarget = JavaVersion.VERSION_17.majorVersion
}
}
dependencies {
implementation project(':expo-modules-core')
implementation "org.jetbrains.kotlin:kotlin-stdlib-jdk7:${rootProject.ext.has('kotlinVersion') ? rootProject.ext.kotlinVersion : '1.9.25'}"
}
@@ -0,0 +1 @@
<manifest xmlns:android="http://schemas.android.com/apk/res/android" />
@@ -0,0 +1,48 @@
package expo.modules.widgetbridge
import android.appwidget.AppWidgetManager
import android.content.ComponentName
import android.content.Context
import android.content.Intent
import android.content.pm.PackageManager
import expo.modules.kotlin.modules.Module
import expo.modules.kotlin.modules.ModuleDefinition
class WidgetBridgeModule : Module() {
override fun definition() = ModuleDefinition {
Name("WidgetBridge")
Function("setWidgetData") { key: String, value: String ->
val ctx = requireNotNull(appContext.reactContext)
ctx.getSharedPreferences("${ctx.packageName}.widgetdata", Context.MODE_PRIVATE)
.edit()
.putString(key, value)
.apply()
}
Function("reloadWidgets") {
val ctx = requireNotNull(appContext.reactContext)
val intent = Intent(AppWidgetManager.ACTION_APPWIDGET_UPDATE)
val widgetManager = AppWidgetManager.getInstance(ctx)
val providers = ctx.packageManager.queryBroadcastReceivers(
Intent(AppWidgetManager.ACTION_APPWIDGET_UPDATE),
PackageManager.GET_META_DATA
)
for (provider in providers) {
if (provider.activityInfo.packageName == ctx.packageName) {
val component = ComponentName(
provider.activityInfo.packageName,
provider.activityInfo.name
)
val ids = widgetManager.getAppWidgetIds(component)
if (ids.isNotEmpty()) {
val updateIntent = Intent(AppWidgetManager.ACTION_APPWIDGET_UPDATE)
updateIntent.putExtra(AppWidgetManager.EXTRA_APPWIDGET_IDS, ids)
updateIntent.component = component
ctx.sendBroadcast(updateIntent)
}
}
}
}
}
}
@@ -0,0 +1,9 @@
{
"platforms": ["ios", "android"],
"ios": {
"modules": ["WidgetBridgeModule"]
},
"android": {
"modules": ["expo.modules.widgetbridge.WidgetBridgeModule"]
}
}
@@ -0,0 +1,11 @@
import { requireNativeModule } from "expo";
const WidgetBridge = requireNativeModule("WidgetBridge");
export function setWidgetData(key: string, value: string): void {
WidgetBridge.setWidgetData(key, value);
}
export function reloadWidgets(): void {
WidgetBridge.reloadWidgets();
}
@@ -0,0 +1,15 @@
Pod::Spec.new do |s|
s.name = 'WidgetBridge'
s.version = '0.0.1'
s.summary = 'Bridge for writing widget data to shared storage'
s.description = 'Expo module for writing to UserDefaults/SharedPreferences and reloading widgets'
s.author = ''
s.homepage = 'https://github.com/example'
s.platforms = { :ios => '15.1' }
s.source = { git: '' }
s.static_framework = true
s.dependency 'ExpoModulesCore'
s.source_files = '**/*.{h,m,mm,swift,hpp,cpp}'
end
@@ -0,0 +1,19 @@
import ExpoModulesCore
import WidgetKit
public class WidgetBridgeModule: Module {
public func definition() -> ModuleDefinition {
Name("WidgetBridge")
Function("setWidgetData") { (key: String, value: String) in
let suite = UserDefaults(suiteName: "group.com.sauci.app")
suite?.set(value, forKey: key)
}
Function("reloadWidgets") {
if #available(iOS 14.0, *) {
WidgetCenter.shared.reloadAllTimelines()
}
}
}
}
+6 -1
View File
@@ -2,7 +2,7 @@
"name": "@sauci/mobile", "name": "@sauci/mobile",
"version": "1.0.0", "version": "1.0.0",
"private": true, "private": true,
"main": "expo-router/entry", "main": "./index.js",
"scripts": { "scripts": {
"dev": "expo start", "dev": "expo start",
"android": "expo run:android", "android": "expo run:android",
@@ -19,9 +19,12 @@
"release:android:preview": "dotenv -e .env -- npx expo prebuild --platform android && dotenv -e .env -- eas build --platform android --profile preview --local" "release:android:preview": "dotenv -e .env -- npx expo prebuild --platform android && dotenv -e .env -- eas build --platform android --profile preview --local"
}, },
"dependencies": { "dependencies": {
"@bacons/apple-targets": "^0.1.6",
"@bittingz/expo-widgets": "^3.0.2",
"@expo/ngrok": "^4.1.0", "@expo/ngrok": "^4.1.0",
"@expo/vector-icons": "~14.0.4", "@expo/vector-icons": "~14.0.4",
"@react-native-async-storage/async-storage": "1.23.1", "@react-native-async-storage/async-storage": "1.23.1",
"@react-native-community/cli": "^20.1.1",
"@react-native-firebase/analytics": "^23.8.2", "@react-native-firebase/analytics": "^23.8.2",
"@react-native-firebase/app": "^23.8.2", "@react-native-firebase/app": "^23.8.2",
"@react-native-firebase/crashlytics": "^23.8.4", "@react-native-firebase/crashlytics": "^23.8.4",
@@ -29,6 +32,7 @@
"@react-navigation/bottom-tabs": "^7.0.0", "@react-navigation/bottom-tabs": "^7.0.0",
"@react-navigation/native": "^7.0.0", "@react-navigation/native": "^7.0.0",
"@react-navigation/native-stack": "^7.0.0", "@react-navigation/native-stack": "^7.0.0",
"@shopify/react-native-skia": "^1.12.4",
"@supabase/supabase-js": "^2.39.0", "@supabase/supabase-js": "^2.39.0",
"@tanstack/react-query": "^5.17.0", "@tanstack/react-query": "^5.17.0",
"babel-preset-expo": "~12.0.0", "babel-preset-expo": "~12.0.0",
@@ -59,6 +63,7 @@
"react": "18.3.1", "react": "18.3.1",
"react-dom": "18.3.1", "react-dom": "18.3.1",
"react-native": "0.76.9", "react-native": "0.76.9",
"react-native-android-widget": "^0.17.2",
"react-native-compressor": "^1.13.0", "react-native-compressor": "^1.13.0",
"react-native-gesture-handler": "~2.20.0", "react-native-gesture-handler": "~2.20.0",
"react-native-purchases": "^9.6.12", "react-native-purchases": "^9.6.12",
+36
View File
@@ -0,0 +1,36 @@
/**
* Expo config plugin that ensures hoisted native packages are moved back
* to apps/mobile/node_modules BEFORE any other plugins try to resolve them.
*
* This runs at plugin load time (synchronously), not as a deferred mod,
* so it fixes the issue before @react-native-firebase/app plugin resolution.
*/
const fs = require('fs');
const path = require('path');
const ROOT = path.resolve(__dirname, '..', '..', '..'); // apps/mobile/../../..
const MOBILE_NM = path.join(ROOT, 'apps', 'mobile', 'node_modules');
const ROOT_NM = path.join(ROOT, 'node_modules');
const PACKAGES_TO_FIX = [
'@react-native-firebase/app',
'@react-native-firebase/analytics',
'@react-native-firebase/crashlytics',
'@shopify/react-native-skia',
];
for (const pkg of PACKAGES_TO_FIX) {
const rootPkg = path.join(ROOT_NM, pkg);
const localPkg = path.join(MOBILE_NM, pkg);
if (!fs.existsSync(rootPkg)) continue;
if (fs.existsSync(localPkg)) continue;
const localParent = path.dirname(localPkg);
fs.mkdirSync(localParent, { recursive: true });
fs.renameSync(rootPkg, localPkg);
}
module.exports = function withFixHoisting(config) {
return config;
};
+173
View File
@@ -0,0 +1,173 @@
const { withDangerousMod } = require('expo/config-plugins');
const fs = require('fs');
const path = require('path');
/**
* Overwrites the react-native-android-widget generated files with a native
* implementation that reads directly from SharedPreferences instead of using
* a headless JS task (broken on RN 0.76 old-arch / PlatformConstants error).
*/
module.exports = function withNativeWidget(config) {
return withDangerousMod(config, [
'android',
(dangerousConfig) => {
const androidRoot = dangerousConfig.modRequest.platformProjectRoot;
const pkg = dangerousConfig.android && dangerousConfig.android.package
? dangerousConfig.android.package
: 'com.sauci.app';
const pkgPath = pkg.split('.').join('/');
// 1. Overwrite LiveDraw.java with native implementation
const widgetDir = path.join(androidRoot, 'app/src/main/java', pkgPath, 'widget');
fs.mkdirSync(widgetDir, { recursive: true });
fs.writeFileSync(
path.join(widgetDir, 'LiveDraw.java'),
LIVE_DRAW_JAVA.replace(/com\.sauci\.app/g, pkg)
);
// 2. Write widget layout
const layoutDir = path.join(androidRoot, 'app/src/main/res/layout');
fs.mkdirSync(layoutDir, { recursive: true });
fs.writeFileSync(path.join(layoutDir, 'widget_live_draw.xml'), WIDGET_LAYOUT);
// 3. Write placeholder drawable
const drawableDir = path.join(androidRoot, 'app/src/main/res/drawable');
fs.mkdirSync(drawableDir, { recursive: true });
fs.writeFileSync(path.join(drawableDir, 'widget_placeholder.xml'), PLACEHOLDER_DRAWABLE);
// 4. Write widget provider XML with our layout
const xmlDir = path.join(androidRoot, 'app/src/main/res/xml');
fs.mkdirSync(xmlDir, { recursive: true });
fs.writeFileSync(path.join(xmlDir, 'widgetprovider_livedraw.xml'), WIDGET_PROVIDER_XML);
return dangerousConfig;
},
]);
};
const LIVE_DRAW_JAVA = `package com.sauci.app.widget;
import android.appwidget.AppWidgetManager;
import android.appwidget.AppWidgetProvider;
import android.content.Context;
import android.content.SharedPreferences;
import android.graphics.Bitmap;
import android.graphics.BitmapFactory;
import android.graphics.Canvas;
import android.graphics.Paint;
import android.graphics.RectF;
import android.util.Base64;
import android.util.TypedValue;
import android.widget.RemoteViews;
import com.sauci.app.R;
public class LiveDraw extends AppWidgetProvider {
private static final String PREFS_NAME_SUFFIX = ".widgetdata";
private static final String KEY_IMAGE = "LiveDrawImage";
private static final int BG_COLOR = 0xFF1A1A2E;
private static final int CORNER_RADIUS_DP = 16;
@Override
public void onUpdate(Context context, AppWidgetManager appWidgetManager, int[] appWidgetIds) {
for (int widgetId : appWidgetIds) {
updateWidget(context, appWidgetManager, widgetId);
}
}
private void updateWidget(Context context, AppWidgetManager appWidgetManager, int widgetId) {
RemoteViews views = new RemoteViews(context.getPackageName(), R.layout.widget_live_draw);
String base64 = getStoredImage(context);
if (base64 != null && !base64.isEmpty()) {
try {
byte[] bytes = Base64.decode(base64, Base64.DEFAULT);
Bitmap decoded = BitmapFactory.decodeByteArray(bytes, 0, bytes.length);
if (decoded != null) {
Bitmap rounded = addRoundedBackground(context, decoded);
views.setImageViewBitmap(R.id.widget_image, rounded);
decoded.recycle();
}
} catch (Exception e) {
setPlaceholder(views);
}
} else {
setPlaceholder(views);
}
appWidgetManager.updateAppWidget(widgetId, views);
}
private String getStoredImage(Context context) {
SharedPreferences prefs = context.getSharedPreferences(
context.getPackageName() + PREFS_NAME_SUFFIX, Context.MODE_PRIVATE);
return prefs.getString(KEY_IMAGE, null);
}
private void setPlaceholder(RemoteViews views) {
views.setImageViewResource(R.id.widget_image, R.drawable.widget_placeholder);
}
private Bitmap addRoundedBackground(Context context, Bitmap drawing) {
float radius = TypedValue.applyDimension(
TypedValue.COMPLEX_UNIT_DIP, CORNER_RADIUS_DP,
context.getResources().getDisplayMetrics());
int w = drawing.getWidth();
int h = drawing.getHeight();
Bitmap output = Bitmap.createBitmap(w, h, Bitmap.Config.ARGB_8888);
Canvas canvas = new Canvas(output);
Paint bgPaint = new Paint(Paint.ANTI_ALIAS_FLAG);
bgPaint.setColor(BG_COLOR);
canvas.drawRoundRect(new RectF(0, 0, w, h), radius, radius, bgPaint);
canvas.drawBitmap(drawing, 0, 0, null);
return output;
}
}
`;
const WIDGET_LAYOUT = `<?xml version="1.0" encoding="utf-8"?>
<FrameLayout xmlns:android="http://schemas.android.com/apk/res/android"
android:layout_width="match_parent"
android:layout_height="match_parent"
android:background="#1A1A2E">
<ImageView
android:id="@+id/widget_image"
android:layout_width="match_parent"
android:layout_height="match_parent"
android:scaleType="centerInside"
android:contentDescription="@string/widget_livedraw_description" />
</FrameLayout>
`;
const WIDGET_PROVIDER_XML = `<?xml version="1.0" encoding="utf-8"?>
<appwidget-provider xmlns:android="http://schemas.android.com/apk/res/android"
android:minWidth="250dp"
android:minHeight="110dp"
android:targetCellWidth="4"
android:targetCellHeight="2"
android:resizeMode="none"
android:description="@string/widget_livedraw_description"
android:initialLayout="@layout/widget_live_draw"
android:previewImage="@drawable/livedraw_preview"
android:updatePeriodMillis="1800000"
android:widgetCategory="home_screen">
</appwidget-provider>
`;
const PLACEHOLDER_DRAWABLE = `<?xml version="1.0" encoding="utf-8"?>
<layer-list xmlns:android="http://schemas.android.com/apk/res/android">
<item>
<shape android:shape="rectangle">
<solid android:color="#1A1A2E" />
<corners android:radius="16dp" />
</shape>
</item>
</layer-list>
`;
+31
View File
@@ -0,0 +1,31 @@
const { withAndroidManifest } = require('expo/config-plugins');
/**
* Fixes react-native-android-widget setting android:exported="false" on widget receivers.
* Widget receivers MUST be exported so the launcher can send APPWIDGET_UPDATE broadcasts.
*
* Uses withAndroidManifest's dangerous modifier priority to run after all other plugins.
*/
module.exports = function withWidgetExported(config) {
return withAndroidManifest(config, (config) => {
const mainApplication =
config.modResults.manifest.application?.[0];
if (!mainApplication?.receiver) return config;
for (const receiver of mainApplication.receiver) {
const intentFilters = receiver['intent-filter'] ?? [];
const isWidget = intentFilters.some((filter) =>
filter.action?.some(
(a) =>
a.$?.['android:name'] ===
'android.appwidget.action.APPWIDGET_UPDATE'
)
);
if (isWidget) {
receiver.$['android:exported'] = 'true';
}
}
return config;
});
};
@@ -0,0 +1,118 @@
import React from 'react';
import { StyleSheet, View, Text, TouchableOpacity, Dimensions } from 'react-native';
import { Ionicons } from '@expo/vector-icons';
import { router } from 'expo-router';
import * as Haptics from 'expo-haptics';
import Animated, { FadeInDown } from 'react-native-reanimated';
import { colors, spacing, radius, typography, shadows, tileColors } from '../../theme';
import { useAuthStore } from '../../store';
const SCREEN_WIDTH = Dimensions.get('window').width;
const HORIZONTAL_PADDING = spacing.lg;
const TILE_HEIGHT = 100;
interface LiveDrawTileProps {
delay?: number;
}
export function LiveDrawTile({ delay = 0 }: LiveDrawTileProps) {
const { user } = useAuthStore();
const handlePress = () => {
Haptics.impactAsync(Haptics.ImpactFeedbackStyle.Light);
if (!user?.couple_id) return;
router.push({ pathname: '/(app)/live-draw', params: { coupleId: user.couple_id } } as any);
};
if (!user?.couple_id) return null;
return (
<Animated.View
entering={FadeInDown.delay(delay).duration(500).springify()}
style={styles.container}
>
<View style={styles.header}>
<Text style={styles.sectionTitle}>Activities</Text>
</View>
<TouchableOpacity
onPress={handlePress}
activeOpacity={0.85}
style={styles.tileWrapper}
>
<View style={[styles.tile, shadows.md]}>
<View style={styles.iconContainer}>
<Ionicons name="brush" size={24} color="rgba(255, 255, 255, 0.9)" />
</View>
<View style={styles.content}>
<Text style={styles.title}>Live Draw</Text>
<Text style={styles.subtitle}>Draw together with your partner in real-time</Text>
</View>
<Ionicons name="chevron-forward" size={20} color="rgba(255, 255, 255, 0.6)" />
<View style={styles.overlay} />
</View>
</TouchableOpacity>
</Animated.View>
);
}
const styles = StyleSheet.create({
container: {
marginBottom: spacing.lg,
},
header: {
paddingHorizontal: HORIZONTAL_PADDING,
marginBottom: spacing.md,
},
sectionTitle: {
...typography.callout,
color: colors.text,
fontWeight: '600',
},
tileWrapper: {
paddingHorizontal: HORIZONTAL_PADDING,
},
tile: {
flexDirection: 'row',
alignItems: 'center',
backgroundColor: tileColors.teal,
borderRadius: radius.lg,
padding: spacing.md,
height: TILE_HEIGHT,
overflow: 'hidden',
position: 'relative',
},
iconContainer: {
width: 48,
height: 48,
borderRadius: 24,
backgroundColor: 'rgba(255, 255, 255, 0.2)',
justifyContent: 'center',
alignItems: 'center',
marginRight: spacing.md,
},
content: {
flex: 1,
},
title: {
...typography.headline,
color: colors.text,
fontWeight: '700',
},
subtitle: {
...typography.caption1,
color: 'rgba(255, 255, 255, 0.8)',
marginTop: 2,
},
overlay: {
...StyleSheet.absoluteFillObject,
backgroundColor: 'rgba(0, 0, 0, 0.05)',
borderRadius: radius.lg,
pointerEvents: 'none',
},
});
@@ -2,3 +2,4 @@ export { ContentTile, TILE_WIDTH, TILE_HEIGHT } from './ContentTile';
export { ContentRow } from './ContentRow'; export { ContentRow } from './ContentRow';
export { CompactHeader } from './CompactHeader'; export { CompactHeader } from './CompactHeader';
export { CircularProgress } from './CircularProgress'; export { CircularProgress } from './CircularProgress';
export { LiveDrawTile } from './LiveDrawTile';
@@ -0,0 +1,273 @@
import React, { useState, useCallback, useRef } from 'react';
import {
StyleSheet,
View,
Text,
TouchableOpacity,
Alert,
useWindowDimensions,
} from 'react-native';
import { useLocalSearchParams, useRouter } from 'expo-router';
import { useSafeAreaInsets } from 'react-native-safe-area-context';
import { Ionicons } from '@expo/vector-icons';
import * as Haptics from 'expo-haptics';
import { DrawingCanvas } from './components/DrawingCanvas';
import type { DrawingCanvasHandle } from './components/DrawingCanvas';
import { DrawingToolbar } from './components/DrawingToolbar';
import { DrawingShareModal } from './components/DrawingShareModal';
import { useDrawingHistory } from './hooks/useDrawingHistory';
import { useDrawingSync } from './hooks/useDrawingSync';
import { useCanvasCapture } from './hooks/useCanvasCapture';
import { useAuthStore } from '../../store';
import { colors, spacing, typography } from '../../theme';
import { GradientBackground } from '../../components/ui';
import type { StrokeSegment, StrokePoint } from './types';
import {
BRUSH_DEFAULT_WIDTH,
PRESET_COLORS,
MAX_STROKES,
} from './constants';
export const LiveDrawScreen: React.FC = () => {
const { coupleId: coupleIdParam } = useLocalSearchParams<{ coupleId: string }>();
const router = useRouter();
const insets = useSafeAreaInsets();
const { user } = useAuthStore();
const coupleId = coupleIdParam || user?.couple_id;
const { width: screenWidth } = useWindowDimensions();
const [selectedColor, setSelectedColor] = useState(PRESET_COLORS[0]);
const [brushWidth, setBrushWidth] = useState(BRUSH_DEFAULT_WIDTH);
const [isEraser, setIsEraser] = useState(false);
const [showShareModal, setShowShareModal] = useState(false);
const [shareDrawingUri, setShareDrawingUri] = useState<string | null>(null);
const canvasRef = useRef<DrawingCanvasHandle>(null);
// Keep track of the current stroke id for sync broadcasts
const currentStrokeIdRef = useRef<string | null>(null);
const canvasWidth = screenWidth - spacing.md * 2;
const canvasHeight = canvasWidth * 1.2;
const {
strokes,
setStrokes,
addStroke,
updateStroke,
undo,
redo,
canUndo,
canRedo,
clearAll,
} = useDrawingHistory();
const { makeSnapshot, captureToFile } = useCanvasCapture();
const handleStrokeStartFromPartner = useCallback(
(stroke: StrokeSegment) => addStroke(stroke),
[addStroke]
);
const handleStrokeContinueFromPartner = useCallback(
(strokeId: string, points: StrokePoint[]) => updateStroke(strokeId, points),
[updateStroke]
);
const handleStrokeEndFromPartner = useCallback((_strokeId: string) => {}, []);
const handleClearFromPartner = useCallback(() => clearAll(), [clearAll]);
const handleUndoFromPartner = useCallback(
(strokeId: string) => {
setStrokes((prev) => prev.filter((s) => s.id !== strokeId));
},
[setStrokes]
);
const sync = useDrawingSync({
coupleId: coupleId!,
userId: user?.id,
onStrokeStart: handleStrokeStartFromPartner,
onStrokeContinue: handleStrokeContinueFromPartner,
onStrokeEnd: handleStrokeEndFromPartner,
onClearCanvas: handleClearFromPartner,
onUndo: handleUndoFromPartner,
onRedo: handleStrokeStartFromPartner,
onLoadStrokes: (loaded) => setStrokes(loaded),
});
const handleTouchStart = useCallback(
(point: StrokePoint) => {
if (!user?.id) return;
if (strokes.length >= MAX_STROKES) {
Alert.alert('Canvas Full', 'Save your drawing and start fresh.');
return;
}
const stroke: StrokeSegment = {
id: `${user.id}_${Date.now()}`,
userId: user.id,
points: [point],
color: selectedColor,
width: brushWidth,
timestamp: Date.now(),
isEraser,
};
currentStrokeIdRef.current = stroke.id;
canvasRef.current?.startStroke(stroke);
sync.broadcastStrokeStart(stroke);
},
[user?.id, selectedColor, brushWidth, isEraser, strokes.length, sync]
);
const handleTouchMove = useCallback(
(point: StrokePoint) => {
if (!currentStrokeIdRef.current) return;
canvasRef.current?.addPoint(point);
sync.broadcastStrokeContinue(currentStrokeIdRef.current, [point]);
},
[sync]
);
const handleTouchEnd = useCallback(() => {
if (!currentStrokeIdRef.current) return;
const finished = canvasRef.current?.endStroke() ?? null;
currentStrokeIdRef.current = null;
if (finished) {
addStroke(finished);
sync.broadcastStrokeEnd(finished.id);
sync.persistStrokes([...strokes, finished]);
}
}, [addStroke, strokes, sync]);
const handleUndo = useCallback(() => {
if (!user?.id) return;
const removed = undo(user.id);
if (removed) {
Haptics.impactAsync(Haptics.ImpactFeedbackStyle.Light);
sync.broadcastUndo(removed.id);
sync.persistStrokes(strokes.filter((s) => s.id !== removed.id));
}
}, [user?.id, undo, strokes, sync]);
const handleRedo = useCallback(() => {
if (!user?.id) return;
const restored = redo(user.id);
if (restored) {
Haptics.impactAsync(Haptics.ImpactFeedbackStyle.Light);
sync.broadcastRedo(restored);
sync.persistStrokes([...strokes, restored]);
}
}, [user?.id, redo, strokes, sync]);
const handleClear = useCallback(() => {
Alert.alert('Clear Canvas', 'This will erase the entire drawing.', [
{ text: 'Cancel', style: 'cancel' },
{
text: 'Clear',
style: 'destructive',
onPress: () => {
clearAll();
sync.broadcastClearCanvas();
sync.persistStrokes([]);
},
},
]);
}, [clearAll, sync]);
const handleShare = useCallback(async () => {
const uri = await captureToFile();
if (uri) {
setShareDrawingUri(uri);
setShowShareModal(true);
} else {
Alert.alert('Error', 'Failed to capture drawing');
}
}, [captureToFile]);
const handleToggleEraser = useCallback(() => {
setIsEraser((prev) => !prev);
Haptics.impactAsync(Haptics.ImpactFeedbackStyle.Light);
}, []);
return (
<GradientBackground>
<View style={[styles.header, { paddingTop: insets.top + spacing.sm }]}>
<TouchableOpacity onPress={() => router.back()} style={styles.backButton}>
<Ionicons name="chevron-back" size={24} color={colors.text} />
</TouchableOpacity>
<Text style={styles.title}>Live Draw</Text>
<View style={styles.backButton} />
</View>
<View style={styles.canvasContainer}>
<DrawingCanvas
ref={canvasRef}
strokes={strokes}
canvasWidth={canvasWidth}
canvasHeight={canvasHeight}
onTouchStart={handleTouchStart}
onTouchMove={handleTouchMove}
onTouchEnd={handleTouchEnd}
onCanvasReady={(snapshot) => {
makeSnapshot.current = snapshot;
}}
/>
</View>
<View style={[styles.toolbarContainer, { paddingBottom: insets.bottom + spacing.sm }]}>
<DrawingToolbar
selectedColor={selectedColor}
brushWidth={brushWidth}
isEraser={isEraser}
canUndo={canUndo(user?.id ?? '')}
canRedo={canRedo(user?.id ?? '')}
onColorChange={(c) => {
setSelectedColor(c);
setIsEraser(false);
}}
onBrushWidthChange={setBrushWidth}
onToggleEraser={handleToggleEraser}
onUndo={handleUndo}
onRedo={handleRedo}
onClear={handleClear}
onSave={handleShare}
/>
</View>
<DrawingShareModal
visible={showShareModal}
onClose={() => setShowShareModal(false)}
drawingUri={shareDrawingUri}
/>
</GradientBackground>
);
};
const styles = StyleSheet.create({
header: {
flexDirection: 'row',
alignItems: 'center',
justifyContent: 'space-between',
paddingHorizontal: spacing.md,
paddingBottom: spacing.sm,
},
backButton: {
width: 40,
height: 40,
borderRadius: 20,
justifyContent: 'center',
alignItems: 'center',
},
title: {
...typography.headline,
color: colors.text,
},
canvasContainer: {
flex: 1,
justifyContent: 'center',
alignItems: 'center',
paddingHorizontal: spacing.md,
},
toolbarContainer: {
backgroundColor: colors.backgroundLight,
borderTopWidth: 1,
borderTopColor: colors.border,
},
});
@@ -0,0 +1,224 @@
import React, { useMemo, useRef, useEffect, useCallback, useState, useImperativeHandle, forwardRef } from 'react';
import { StyleSheet, View } from 'react-native';
import {
Canvas,
Path,
Skia,
} from '@shopify/react-native-skia';
import type { SkImage } from '@shopify/react-native-skia';
import { Gesture, GestureDetector } from 'react-native-gesture-handler';
import { runOnJS } from 'react-native-reanimated';
import type { StrokeSegment, StrokePoint } from '../types';
import { CANVAS_BACKGROUND } from '../constants';
export interface DrawingCanvasHandle {
startStroke: (stroke: StrokeSegment) => void;
addPoint: (point: StrokePoint) => void;
endStroke: () => StrokeSegment | null;
}
interface DrawingCanvasProps {
strokes: StrokeSegment[];
canvasWidth: number;
canvasHeight: number;
onTouchStart: (point: StrokePoint) => void;
onTouchMove: (point: StrokePoint) => void;
onTouchEnd: () => void;
onCanvasReady?: (makeSnapshot: () => SkImage | null) => void;
}
function buildPath(points: StrokePoint[], width: number, height: number) {
const path = Skia.Path.Make();
if (points.length === 0) return path;
const first = points[0];
path.moveTo(first.x * width, first.y * height);
if (points.length === 1) {
path.lineTo(first.x * width + 0.1, first.y * height + 0.1);
return path;
}
// Quadratic bezier smoothing
for (let i = 1; i < points.length; i++) {
const prev = points[i - 1];
const curr = points[i];
const midX = ((prev.x + curr.x) / 2) * width;
const midY = ((prev.y + curr.y) / 2) * height;
if (i === 1) {
path.lineTo(midX, midY);
} else {
path.quadTo(prev.x * width, prev.y * height, midX, midY);
}
}
const last = points[points.length - 1];
path.lineTo(last.x * width, last.y * height);
return path;
}
const StrokePath = React.memo(function StrokePath({
stroke,
canvasWidth,
canvasHeight,
}: {
stroke: StrokeSegment;
canvasWidth: number;
canvasHeight: number;
}) {
const path = useMemo(
() => buildPath(stroke.points, canvasWidth, canvasHeight),
[stroke.points, canvasWidth, canvasHeight]
);
return (
<Path
path={path}
color={stroke.isEraser ? CANVAS_BACKGROUND : stroke.color}
style="stroke"
strokeWidth={stroke.width}
strokeCap="round"
strokeJoin="round"
/>
);
});
// Renders the in-progress stroke from a ref, re-rendered only via rafTick
const CurrentStrokePath = React.memo(function CurrentStrokePath({
strokeRef,
canvasWidth,
canvasHeight,
_tick,
}: {
strokeRef: React.RefObject<StrokeSegment | null>;
canvasWidth: number;
canvasHeight: number;
_tick: number; // forces re-render at RAF cadence
}) {
const stroke = strokeRef.current;
if (!stroke) return null;
const path = buildPath(stroke.points, canvasWidth, canvasHeight);
return (
<Path
path={path}
color={stroke.isEraser ? CANVAS_BACKGROUND : stroke.color}
style="stroke"
strokeWidth={stroke.width}
strokeCap="round"
strokeJoin="round"
/>
);
});
export const DrawingCanvas = forwardRef<DrawingCanvasHandle, DrawingCanvasProps>(
function DrawingCanvas(
{
strokes,
canvasWidth,
canvasHeight,
onTouchStart,
onTouchMove,
onTouchEnd,
onCanvasReady,
},
ref
) {
const canvasRef = useRef<any>(null);
const currentStrokeRef = useRef<StrokeSegment | null>(null);
const rafScheduled = useRef(false);
const [rafTick, setRafTick] = useState(0);
useEffect(() => {
if (onCanvasReady) {
onCanvasReady(() => {
return canvasRef.current?.makeImageSnapshot() ?? null;
});
}
}, [onCanvasReady]);
const scheduleRafUpdate = useCallback(() => {
if (rafScheduled.current) return;
rafScheduled.current = true;
requestAnimationFrame(() => {
rafScheduled.current = false;
setRafTick((t) => t + 1);
});
}, []);
useImperativeHandle(ref, () => ({
startStroke(stroke: StrokeSegment) {
currentStrokeRef.current = stroke;
scheduleRafUpdate();
},
addPoint(point: StrokePoint) {
const s = currentStrokeRef.current;
if (!s) return;
// Mutate in place — no React state involved
s.points.push(point);
scheduleRafUpdate();
},
endStroke(): StrokeSegment | null {
const s = currentStrokeRef.current;
currentStrokeRef.current = null;
scheduleRafUpdate();
return s;
},
}), [scheduleRafUpdate]);
const pan = Gesture.Pan()
.minDistance(0)
.onStart((e) => {
runOnJS(onTouchStart)({
x: e.x / canvasWidth,
y: e.y / canvasHeight,
});
})
.onUpdate((e) => {
runOnJS(onTouchMove)({
x: e.x / canvasWidth,
y: e.y / canvasHeight,
});
})
.onEnd(() => {
runOnJS(onTouchEnd)();
});
return (
<GestureDetector gesture={pan}>
<View style={[styles.container, { width: canvasWidth, height: canvasHeight }]}>
<Canvas ref={canvasRef} style={styles.canvas}>
{strokes.map((stroke) => (
<StrokePath
key={stroke.id}
stroke={stroke}
canvasWidth={canvasWidth}
canvasHeight={canvasHeight}
/>
))}
<CurrentStrokePath
strokeRef={currentStrokeRef}
canvasWidth={canvasWidth}
canvasHeight={canvasHeight}
_tick={rafTick}
/>
</Canvas>
</View>
</GestureDetector>
);
}
);
const styles = StyleSheet.create({
container: {
backgroundColor: CANVAS_BACKGROUND,
borderRadius: 12,
overflow: 'hidden',
},
canvas: {
flex: 1,
},
});
@@ -0,0 +1,401 @@
import React, { useRef, useEffect, useState } from 'react';
import {
View,
Text,
StyleSheet,
Modal,
TouchableOpacity,
Platform,
Linking,
Share,
Image,
useWindowDimensions,
} 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 DrawingShareModalProps {
visible: boolean;
onClose: () => void;
drawingUri: string | null;
}
export function DrawingShareModal({
visible,
onClose,
drawingUri,
}: DrawingShareModalProps) {
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 branded card when modal becomes visible and drawing is ready
useEffect(() => {
if (visible && drawingUri && shareViewRef.current) {
setIsCapturing(true);
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);
}
}, 300);
return () => clearTimeout(timer);
}
}, [visible, drawingUri]);
useEffect(() => {
if (!visible) {
setCapturedUri(null);
}
}, [visible]);
const handleInstagramShare = async () => {
if (!capturedUri) return;
try {
const instagramUrl = 'instagram://app';
const canOpenInstagram = await Linking.canOpenURL(instagramUrl);
if (canOpenInstagram && Platform.OS === 'ios') {
const instagramStoriesUrl = `instagram-stories://share?source_application=com.sauci.app`;
await Linking.openURL(instagramStoriesUrl);
} else {
await handleMoreShare();
}
} catch (error) {
console.error('Instagram share error:', error);
await handleMoreShare();
}
};
const handleMessagesShare = async () => {
if (!capturedUri) {
await Share.share({ message: 'Check out our drawing on sauci.app' });
return;
}
try {
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: 'Check out our drawing on sauci.app' });
}
};
const handleMoreShare = async () => {
if (!capturedUri) {
await Share.share({ message: 'Check out our drawing on sauci.app' });
return;
}
try {
const isAvailable = await Sharing.isAvailableAsync();
if (isAvailable) {
await Sharing.shareAsync(capturedUri, {
mimeType: 'image/png',
dialogTitle: 'Share drawing',
});
} else {
await Share.share({ message: 'Check out our drawing 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>
{/* Branded Card Preview */}
<View style={styles.previewContainer}>
<ViewShot
ref={shareViewRef}
options={{
format: 'png',
quality: 1,
result: 'tmpfile',
}}
>
<View style={[styles.shareableCard, { width: cardWidth }]}>
<LinearGradient
colors={gradients.primary as [string, string]}
style={[StyleSheet.absoluteFill, { borderRadius: radius.xl }]}
start={{ x: 0, y: 0 }}
end={{ x: 1, y: 1 }}
/>
{/* Drawing image */}
{drawingUri && (
<View style={styles.drawingContainer}>
<Image
source={{ uri: drawingUri }}
style={styles.drawingImage}
resizeMode="contain"
/>
</View>
)}
{/* Branding footer */}
<View style={styles.branding}>
<Image source={logo} style={styles.brandingLogo} />
<Text style={styles.brandingText}>sauci.app</Text>
</View>
</View>
</ViewShot>
</View>
{/* Share Actions */}
<View style={[styles.actionsContainer, { paddingBottom: insets.bottom + spacing.lg }]}>
<ShareActionButton
icon="logo-instagram"
label="Instagram"
buttonColors={['#833AB4', '#FD1D1D', '#F77737']}
onPress={handleInstagramShare}
disabled={isCapturing}
/>
<ShareActionButton
icon="chatbubble-ellipses"
label="Messages"
buttonColors={['#34C759', '#30D158']}
onPress={handleMessagesShare}
disabled={isCapturing}
/>
<ShareActionButton
icon="share-outline"
label="More"
buttonColors={[colors.glass.backgroundLight, colors.glass.backgroundLight]}
onPress={handleMoreShare}
disabled={isCapturing}
isOutline
/>
</View>
</Animated.View>
</View>
</Modal>
);
}
function ShareActionButton({
icon,
label,
buttonColors,
onPress,
disabled,
isOutline,
}: {
icon: string;
label: string;
buttonColors: 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>
);
}
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,
},
shareableCard: {
aspectRatio: 0.7,
borderRadius: radius.xl,
overflow: 'hidden',
padding: spacing.md,
justifyContent: 'space-between',
},
drawingContainer: {
flex: 1,
borderRadius: radius.lg,
overflow: 'hidden',
backgroundColor: '#1a1a2e',
},
drawingImage: {
flex: 1,
width: '100%',
},
branding: {
flexDirection: 'row',
alignItems: 'center',
justifyContent: 'center',
gap: spacing.sm,
paddingTop: spacing.sm,
},
brandingLogo: {
width: 24,
height: 24,
resizeMode: 'contain',
},
brandingText: {
...typography.subhead,
color: colors.text,
fontWeight: '600',
},
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',
},
});
@@ -0,0 +1,192 @@
import React from 'react';
import { StyleSheet, View, TouchableOpacity, Text } from 'react-native';
import { Ionicons } from '@expo/vector-icons';
import { colors, spacing, radius, typography } from '../../../theme';
import { PRESET_COLORS, BRUSH_MIN_WIDTH, BRUSH_MAX_WIDTH } from '../constants';
interface DrawingToolbarProps {
selectedColor: string;
brushWidth: number;
isEraser: boolean;
canUndo: boolean;
canRedo: boolean;
onColorChange: (color: string) => void;
onBrushWidthChange: (width: number) => void;
onToggleEraser: () => void;
onUndo: () => void;
onRedo: () => void;
onClear: () => void;
onSave: () => void;
}
export function DrawingToolbar({
selectedColor,
brushWidth,
isEraser,
canUndo,
canRedo,
onColorChange,
onBrushWidthChange,
onToggleEraser,
onUndo,
onRedo,
onClear,
onSave,
}: DrawingToolbarProps) {
const brushSizes = [BRUSH_MIN_WIDTH, 6, 12, BRUSH_MAX_WIDTH];
return (
<View style={styles.container}>
{/* Color palette */}
<View style={styles.row}>
{PRESET_COLORS.map((color) => (
<TouchableOpacity
key={color}
onPress={() => onColorChange(color)}
style={[
styles.colorDot,
{ backgroundColor: color },
selectedColor === color && !isEraser && styles.colorDotSelected,
]}
/>
))}
</View>
{/* Brush sizes */}
<View style={styles.row}>
{brushSizes.map((size) => (
<TouchableOpacity
key={size}
onPress={() => onBrushWidthChange(size)}
style={[styles.sizeButton, brushWidth === size && styles.sizeButtonSelected]}
>
<View
style={[
styles.sizeDot,
{
width: Math.max(size, 4),
height: Math.max(size, 4),
borderRadius: Math.max(size, 4) / 2,
backgroundColor: isEraser ? colors.textSecondary : selectedColor,
},
]}
/>
</TouchableOpacity>
))}
</View>
{/* Actions */}
<View style={styles.row}>
<TouchableOpacity
onPress={onToggleEraser}
style={[styles.actionButton, isEraser && styles.actionButtonActive]}
>
<Ionicons
name="backspace-outline"
size={20}
color={isEraser ? colors.background : colors.text}
/>
</TouchableOpacity>
<TouchableOpacity
onPress={onUndo}
disabled={!canUndo}
style={[styles.actionButton, !canUndo && styles.actionButtonDisabled]}
>
<Ionicons name="arrow-undo" size={20} color={canUndo ? colors.text : colors.textTertiary} />
</TouchableOpacity>
<TouchableOpacity
onPress={onRedo}
disabled={!canRedo}
style={[styles.actionButton, !canRedo && styles.actionButtonDisabled]}
>
<Ionicons name="arrow-redo" size={20} color={canRedo ? colors.text : colors.textTertiary} />
</TouchableOpacity>
<TouchableOpacity onPress={onClear} style={styles.actionButton}>
<Ionicons name="trash-outline" size={20} color={colors.error} />
</TouchableOpacity>
<TouchableOpacity onPress={onSave} style={styles.saveButton}>
<Ionicons name="share-outline" size={18} color={colors.text} />
<Text style={styles.saveText}>Share</Text>
</TouchableOpacity>
</View>
</View>
);
}
const styles = StyleSheet.create({
container: {
gap: spacing.md,
paddingHorizontal: spacing.md,
paddingVertical: spacing.md,
},
row: {
flexDirection: 'row',
alignItems: 'center',
justifyContent: 'center',
gap: spacing.sm,
},
colorDot: {
width: 28,
height: 28,
borderRadius: 14,
borderWidth: 2,
borderColor: 'transparent',
},
colorDotSelected: {
borderColor: colors.text,
borderWidth: 2,
},
sizeButton: {
width: 36,
height: 36,
borderRadius: 18,
justifyContent: 'center',
alignItems: 'center',
backgroundColor: colors.backgroundLight,
borderWidth: 1,
borderColor: colors.border,
},
sizeButtonSelected: {
borderColor: colors.primary,
backgroundColor: colors.primaryLight,
},
sizeDot: {
// Dynamic size set inline
},
actionButton: {
width: 40,
height: 40,
borderRadius: 20,
justifyContent: 'center',
alignItems: 'center',
backgroundColor: colors.backgroundLight,
borderWidth: 1,
borderColor: colors.border,
},
actionButtonActive: {
backgroundColor: colors.text,
borderColor: colors.text,
},
actionButtonDisabled: {
opacity: 0.5,
},
saveButton: {
flexDirection: 'row',
alignItems: 'center',
gap: spacing.xs,
backgroundColor: colors.primary,
paddingHorizontal: spacing.md,
paddingVertical: spacing.sm,
borderRadius: radius.full,
marginLeft: spacing.sm,
},
saveText: {
...typography.subhead,
color: colors.text,
fontWeight: '600',
},
});
@@ -0,0 +1,22 @@
export const BRUSH_MIN_WIDTH = 2;
export const BRUSH_MAX_WIDTH = 24;
export const BRUSH_DEFAULT_WIDTH = 4;
export const DEFAULT_HUE = 0; // Red
export const DEFAULT_COLOR = 'hsl(0, 80%, 60%)';
export const BROADCAST_BATCH_INTERVAL = 50; // ms
export const MAX_STROKES = 500;
export const CANVAS_BACKGROUND = '#1a1a2e';
export const PRESET_COLORS = [
'#e1306c', // Primary rose
'#9b59b6', // Secondary purple
'#14B8A6', // Teal
'#F97316', // Orange
'#2ECC71', // Green
'#3498db', // Blue
'#F39C12', // Yellow
'#ffffff', // White
];
@@ -0,0 +1,54 @@
import { useCallback, useRef } from 'react';
import type { SkImage } from '@shopify/react-native-skia';
import * as MediaLibrary from 'expo-media-library';
import * as FileSystem from 'expo-file-system';
interface UseCanvasCaptureReturn {
makeSnapshot: React.MutableRefObject<(() => SkImage | null) | null>;
saveToGallery: () => Promise<boolean>;
captureToFile: () => Promise<string | null>;
}
export function useCanvasCapture(): UseCanvasCaptureReturn {
const makeSnapshot = useRef<(() => SkImage | null) | null>(null);
const captureToFile = useCallback(async (): Promise<string | null> => {
if (!makeSnapshot.current) return null;
try {
const image = makeSnapshot.current();
if (!image) return null;
const base64 = image.encodeToBase64();
if (!base64) return null;
const fileUri = `${FileSystem.cacheDirectory}livedraw_${Date.now()}.png`;
await FileSystem.writeAsStringAsync(fileUri, base64, {
encoding: FileSystem.EncodingType.Base64,
});
return fileUri;
} catch (err) {
console.error('Canvas capture error:', err);
return null;
}
}, []);
const saveToGallery = useCallback(async (): Promise<boolean> => {
try {
const fileUri = await captureToFile();
if (!fileUri) return false;
const { status } = await MediaLibrary.requestPermissionsAsync();
if (status !== 'granted') return false;
await MediaLibrary.saveToLibraryAsync(fileUri);
return true;
} catch (err) {
console.error('Canvas capture error:', err);
return false;
}
}, [captureToFile]);
return { makeSnapshot, saveToGallery, captureToFile };
}
@@ -0,0 +1,97 @@
import { useState, useCallback, useRef } from 'react';
import type { StrokeSegment } from '../types';
interface UseDrawingHistoryReturn {
strokes: StrokeSegment[];
setStrokes: React.Dispatch<React.SetStateAction<StrokeSegment[]>>;
addStroke: (stroke: StrokeSegment) => void;
updateStroke: (strokeId: string, points: StrokeSegment['points']) => void;
undo: (userId: string) => StrokeSegment | undefined;
redo: (userId: string) => StrokeSegment | undefined;
canUndo: (userId: string) => boolean;
canRedo: (userId: string) => boolean;
clearAll: () => void;
}
export function useDrawingHistory(): UseDrawingHistoryReturn {
const [strokes, setStrokes] = useState<StrokeSegment[]>([]);
const redoStackRef = useRef<StrokeSegment[]>([]);
const addStroke = useCallback((stroke: StrokeSegment) => {
setStrokes((prev) => [...prev, stroke]);
// Clear redo stack when new stroke is added
redoStackRef.current = [];
}, []);
const updateStroke = useCallback(
(strokeId: string, points: StrokeSegment['points']) => {
setStrokes((prev) =>
prev.map((s) =>
s.id === strokeId ? { ...s, points: [...s.points, ...points] } : s
)
);
},
[]
);
const undo = useCallback(
(userId: string): StrokeSegment | undefined => {
let removed: StrokeSegment | undefined;
setStrokes((prev) => {
// Find last stroke by this user
const idx = [...prev].reverse().findIndex((s) => s.userId === userId);
if (idx === -1) return prev;
const actualIdx = prev.length - 1 - idx;
removed = prev[actualIdx];
redoStackRef.current.push(removed);
return [...prev.slice(0, actualIdx), ...prev.slice(actualIdx + 1)];
});
return removed;
},
[]
);
const redo = useCallback(
(userId: string): StrokeSegment | undefined => {
// Find last redo entry for this user
const idx = [...redoStackRef.current]
.reverse()
.findIndex((s) => s.userId === userId);
if (idx === -1) return undefined;
const actualIdx = redoStackRef.current.length - 1 - idx;
const stroke = redoStackRef.current[actualIdx];
redoStackRef.current.splice(actualIdx, 1);
setStrokes((prev) => [...prev, stroke]);
return stroke;
},
[]
);
const canUndo = useCallback(
(userId: string) => strokes.some((s) => s.userId === userId),
[strokes]
);
const canRedo = useCallback(
(userId: string) =>
redoStackRef.current.some((s) => s.userId === userId),
[]
);
const clearAll = useCallback(() => {
setStrokes([]);
redoStackRef.current = [];
}, []);
return {
strokes,
setStrokes,
addStroke,
updateStroke,
undo,
redo,
canUndo,
canRedo,
clearAll,
};
}
@@ -0,0 +1,228 @@
import { useEffect, useRef, useCallback } from 'react';
import { RealtimeChannel } from '@supabase/supabase-js';
import { supabase } from '../../../lib/supabase';
import type { StrokeSegment, StrokePoint, LiveDrawEvent } from '../types';
import { BROADCAST_BATCH_INTERVAL } from '../constants';
import { updateWidget } from '../utils/widgetBridge';
interface UseDrawingSyncConfig {
coupleId: string;
userId: string | undefined;
onStrokeStart: (stroke: StrokeSegment) => void;
onStrokeContinue: (strokeId: string, points: StrokePoint[]) => void;
onStrokeEnd: (strokeId: string) => void;
onClearCanvas: () => void;
onUndo: (strokeId: string) => void;
onRedo: (stroke: StrokeSegment) => void;
onLoadStrokes: (strokes: StrokeSegment[]) => void;
}
interface UseDrawingSyncReturn {
broadcastStrokeStart: (stroke: StrokeSegment) => void;
broadcastStrokeContinue: (strokeId: string, points: StrokePoint[]) => void;
broadcastStrokeEnd: (strokeId: string) => void;
broadcastClearCanvas: () => void;
broadcastUndo: (strokeId: string) => void;
broadcastRedo: (stroke: StrokeSegment) => void;
persistStrokes: (strokes: StrokeSegment[]) => Promise<void>;
}
export function useDrawingSync(config: UseDrawingSyncConfig): UseDrawingSyncReturn {
const {
coupleId,
userId,
onStrokeStart,
onStrokeContinue,
onStrokeEnd,
onClearCanvas,
onUndo,
onRedo,
onLoadStrokes,
} = config;
const channelRef = useRef<RealtimeChannel | null>(null);
const batchRef = useRef<{ strokeId: string; points: StrokePoint[] } | null>(null);
const batchTimerRef = useRef<NodeJS.Timeout | null>(null);
// Load existing strokes from DB on mount
useEffect(() => {
if (!coupleId) return;
// Clear any stale strokes from a previous couple before loading
onLoadStrokes([]);
const loadExisting = async () => {
const { data } = await supabase
.from('live_draw_sessions')
.select('strokes')
.eq('couple_id', coupleId)
.maybeSingle();
if (data?.strokes) {
onLoadStrokes(data.strokes as StrokeSegment[]);
}
};
loadExisting();
}, [coupleId]);
// Subscribe to broadcast channel
useEffect(() => {
if (!coupleId || !userId) return;
const channel = supabase.channel(`livedraw:${coupleId}`);
channelRef.current = channel;
channel.on('broadcast', { event: 'draw' }, (payload) => {
const event = payload.payload as LiveDrawEvent;
// Ignore own events
if ('stroke' in event && event.stroke?.userId === userId) return;
if ('userId' in event && event.userId === userId) return;
switch (event.type) {
case 'stroke_start':
onStrokeStart(event.stroke);
break;
case 'stroke_continue':
onStrokeContinue(event.strokeId, event.points);
break;
case 'stroke_end':
onStrokeEnd(event.strokeId);
break;
case 'clear_canvas':
onClearCanvas();
break;
case 'undo':
onUndo(event.strokeId);
break;
case 'redo':
// For redo, we'd need the full stroke - handled via DB sync
break;
}
});
channel.subscribe();
return () => {
channelRef.current = null;
supabase.removeChannel(channel);
if (batchTimerRef.current) {
clearInterval(batchTimerRef.current);
}
};
}, [coupleId, userId]);
const send = useCallback(
(event: LiveDrawEvent) => {
channelRef.current?.send({
type: 'broadcast',
event: 'draw',
payload: event,
});
},
[]
);
const broadcastStrokeStart = useCallback(
(stroke: StrokeSegment) => {
send({ type: 'stroke_start', stroke });
},
[send]
);
const broadcastStrokeContinue = useCallback(
(strokeId: string, points: StrokePoint[]) => {
// Batch points to reduce traffic
if (!batchRef.current || batchRef.current.strokeId !== strokeId) {
batchRef.current = { strokeId, points: [...points] };
} else {
batchRef.current.points.push(...points);
}
if (!batchTimerRef.current) {
batchTimerRef.current = setInterval(() => {
if (batchRef.current && batchRef.current.points.length > 0) {
send({
type: 'stroke_continue',
strokeId: batchRef.current.strokeId,
points: batchRef.current.points,
});
batchRef.current.points = [];
}
}, BROADCAST_BATCH_INTERVAL);
}
},
[send]
);
const broadcastStrokeEnd = useCallback(
(strokeId: string) => {
// Flush remaining batch
if (batchRef.current && batchRef.current.points.length > 0) {
send({
type: 'stroke_continue',
strokeId: batchRef.current.strokeId,
points: batchRef.current.points,
});
}
batchRef.current = null;
if (batchTimerRef.current) {
clearInterval(batchTimerRef.current);
batchTimerRef.current = null;
}
send({ type: 'stroke_end', strokeId });
},
[send]
);
const broadcastClearCanvas = useCallback(() => {
if (!userId) return;
send({ type: 'clear_canvas', userId });
}, [send, userId]);
const broadcastUndo = useCallback(
(strokeId: string) => {
if (!userId) return;
send({ type: 'undo', userId, strokeId });
},
[send, userId]
);
const broadcastRedo = useCallback(
(stroke: StrokeSegment) => {
// Send full stroke so partner can restore it
send({ type: 'stroke_start', stroke });
},
[send]
);
const persistStrokes = useCallback(
async (strokes: StrokeSegment[]) => {
await supabase
.from('live_draw_sessions')
.upsert(
{
couple_id: coupleId,
strokes: strokes as any,
updated_at: new Date().toISOString(),
},
{ onConflict: 'couple_id' }
);
// Update home screen widget with latest drawing
updateWidget(strokes);
},
[coupleId]
);
return {
broadcastStrokeStart,
broadcastStrokeContinue,
broadcastStrokeEnd,
broadcastClearCanvas,
broadcastUndo,
broadcastRedo,
persistStrokes,
};
}
@@ -0,0 +1 @@
export { LiveDrawScreen } from './LiveDrawScreen';
@@ -0,0 +1,24 @@
// Normalized coordinates (0-1) for cross-device compatibility
export interface StrokePoint {
x: number;
y: number;
}
export interface StrokeSegment {
id: string;
userId: string;
points: StrokePoint[];
color: string;
width: number;
timestamp: number;
isEraser: boolean;
}
// Broadcast events (real-time when both online)
export type LiveDrawEvent =
| { type: 'stroke_start'; stroke: StrokeSegment }
| { type: 'stroke_continue'; strokeId: string; points: StrokePoint[] }
| { type: 'stroke_end'; strokeId: string }
| { type: 'clear_canvas'; userId: string }
| { type: 'undo'; userId: string; strokeId: string }
| { type: 'redo'; userId: string; strokeId: string };
@@ -0,0 +1,22 @@
import { Platform } from 'react-native';
import { setWidgetData, reloadWidgets } from '../../../../modules/widget-bridge';
import type { StrokeSegment } from '../types';
import { renderStrokesToBase64 } from './widgetImageExport';
export async function updateWidget(strokes: StrokeSegment[]): Promise<void> {
try {
const base64 = renderStrokesToBase64(strokes);
if (!base64) return;
if (Platform.OS === 'ios') {
setWidgetData('LiveDrawImage', base64);
reloadWidgets();
} else if (Platform.OS === 'android') {
setWidgetData('LiveDrawImage', base64);
reloadWidgets();
}
} catch (e) {
// Widget update is non-critical; don't crash the app
console.warn('Failed to update widget:', e);
}
}
@@ -0,0 +1,71 @@
import { Skia, StrokeCap, StrokeJoin, PaintStyle } from '@shopify/react-native-skia';
import type { StrokeSegment, StrokePoint } from '../types';
import { CANVAS_BACKGROUND } from '../constants';
const WIDGET_WIDTH = 300;
const WIDGET_HEIGHT = 250;
function buildPath(points: StrokePoint[], width: number, height: number) {
const path = Skia.Path.Make();
if (points.length === 0) return path;
const first = points[0];
path.moveTo(first.x * width, first.y * height);
if (points.length === 1) {
path.lineTo(first.x * width + 0.1, first.y * height + 0.1);
return path;
}
for (let i = 1; i < points.length; i++) {
const prev = points[i - 1];
const curr = points[i];
const midX = ((prev.x + curr.x) / 2) * width;
const midY = ((prev.y + curr.y) / 2) * height;
if (i === 1) {
path.lineTo(midX, midY);
} else {
path.quadTo(prev.x * width, prev.y * height, midX, midY);
}
}
const last = points[points.length - 1];
path.lineTo(last.x * width, last.y * height);
return path;
}
export function renderStrokesToBase64(strokes: StrokeSegment[]): string | null {
const surface = Skia.Surface.Make(WIDGET_WIDTH, WIDGET_HEIGHT);
if (!surface) return null;
const canvas = surface.getCanvas();
// Draw background
const bgPaint = Skia.Paint();
bgPaint.setColor(Skia.Color(CANVAS_BACKGROUND));
canvas.drawRect(
{ x: 0, y: 0, width: WIDGET_WIDTH, height: WIDGET_HEIGHT },
bgPaint,
);
// Draw strokes
for (const stroke of strokes) {
const path = buildPath(stroke.points, WIDGET_WIDTH, WIDGET_HEIGHT);
const paint = Skia.Paint();
paint.setColor(
Skia.Color(stroke.isEraser ? CANVAS_BACKGROUND : stroke.color),
);
paint.setStyle(PaintStyle.Stroke);
paint.setStrokeWidth(stroke.width);
paint.setStrokeCap(StrokeCap.Round);
paint.setStrokeJoin(StrokeJoin.Round);
canvas.drawPath(path, paint);
}
surface.flush();
const image = surface.makeImageSnapshot();
const data = image.encodeToBase64();
return data;
}
@@ -0,0 +1,59 @@
import React from 'react';
import { FlexWidget, ImageWidget, TextWidget } from 'react-native-android-widget';
interface LiveDrawWidgetProps {
imageBase64?: string;
}
export function LiveDrawWidget({ imageBase64 }: LiveDrawWidgetProps) {
if (imageBase64) {
return (
<FlexWidget
style={{
width: 'match_parent',
height: 'match_parent',
backgroundColor: '#1a1a2e',
padding: 8,
borderRadius: 16,
}}
clickAction="OPEN_APP"
clickActionData={{ uri: 'app.sauci://live-draw' }}
>
<ImageWidget
image={`data:image/png;base64,${imageBase64}`}
imageWidth={284}
imageHeight={94}
style={{
width: 'match_parent',
height: 'match_parent',
borderRadius: 12,
}}
/>
</FlexWidget>
);
}
return (
<FlexWidget
style={{
width: 'match_parent',
height: 'match_parent',
backgroundColor: '#1a1a2e',
justifyContent: 'center',
alignItems: 'center',
borderRadius: 16,
}}
clickAction="OPEN_APP"
clickActionData={{ uri: 'app.sauci://live-draw' }}
>
<TextWidget
text="✏️"
style={{ fontSize: 32 }}
/>
<TextWidget
text="Draw together"
style={{ fontSize: 12, color: '#80FFFFFF', marginTop: 8 }}
/>
</FlexWidget>
);
}
@@ -0,0 +1,26 @@
import React from 'react';
import type { WidgetTaskHandlerProps } from 'react-native-android-widget';
import { LiveDrawWidget } from './LiveDrawWidget';
const WIDGET_NAME = 'LiveDraw';
export async function widgetTaskHandler(props: WidgetTaskHandlerProps) {
const widgetInfo = props.widgetInfo;
const widgetAction = props.widgetAction;
if (widgetInfo.widgetName !== WIDGET_NAME) return;
switch (widgetAction) {
case 'WIDGET_ADDED':
case 'WIDGET_UPDATE':
case 'WIDGET_RESIZED': {
// Read stored image from SharedPreferences via the widget bridge
// The image is stored by the app when strokes are persisted
props.renderWidget(<LiveDrawWidget />);
break;
}
case 'WIDGET_CLICK':
// Click handling is done via clickAction="OPEN_APP" in the widget
break;
}
}
@@ -0,0 +1,6 @@
{
"info": {
"version": 1,
"author": "xcode"
}
}
+15
View File
@@ -0,0 +1,15 @@
<?xml version="1.0" encoding="UTF-8"?>
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
<plist version="1.0">
<dict>
<key>NSExtension</key>
<dict>
<key>NSExtensionPointIdentifier</key>
<string>com.apple.widgetkit-extension</string>
</dict>
<key>CFBundleVersion</key>
<string>42</string>
<key>CFBundleShortVersionString</key>
<string>1.0.5</string>
</dict>
</plist>
@@ -0,0 +1,83 @@
import WidgetKit
import SwiftUI
struct LiveDrawEntry: TimelineEntry {
let date: Date
let image: UIImage?
}
struct LiveDrawProvider: TimelineProvider {
let suiteName = "group.com.sauci.app"
let dataKey = "LiveDrawImage"
func placeholder(in context: Context) -> LiveDrawEntry {
LiveDrawEntry(date: Date(), image: nil)
}
func getSnapshot(in context: Context, completion: @escaping (LiveDrawEntry) -> Void) {
completion(getEntry())
}
func getTimeline(in context: Context, completion: @escaping (Timeline<LiveDrawEntry>) -> Void) {
let entry = getEntry()
let nextUpdate = Calendar.current.date(byAdding: .minute, value: 30, to: Date())!
let timeline = Timeline(entries: [entry], policy: .after(nextUpdate))
completion(timeline)
}
private func getEntry() -> LiveDrawEntry {
guard let suite = UserDefaults(suiteName: suiteName),
let base64 = suite.string(forKey: dataKey),
let imageData = Data(base64Encoded: base64),
let image = UIImage(data: imageData)
else {
return LiveDrawEntry(date: Date(), image: nil)
}
return LiveDrawEntry(date: Date(), image: image)
}
}
struct LiveDrawWidgetEntryView: View {
var entry: LiveDrawProvider.Entry
var body: some View {
if let image = entry.image {
Image(uiImage: image)
.resizable()
.aspectRatio(contentMode: .fill)
.clipShape(RoundedRectangle(cornerRadius: 16))
} else {
ZStack {
Color(red: 26/255, green: 26/255, blue: 46/255)
VStack(spacing: 8) {
Image(systemName: "pencil.tip.crop.circle")
.font(.system(size: 32))
.foregroundColor(.white.opacity(0.5))
Text("Draw together")
.font(.caption)
.foregroundColor(.white.opacity(0.5))
}
}
}
}
}
struct LiveDrawWidget: Widget {
let kind: String = "LiveDrawWidget"
var body: some WidgetConfiguration {
StaticConfiguration(kind: kind, provider: LiveDrawProvider()) { entry in
if #available(iOS 17.0, *) {
LiveDrawWidgetEntryView(entry: entry)
.widgetURL(URL(string: "app.sauci://live-draw"))
.containerBackground(Color(red: 26/255, green: 26/255, blue: 46/255), for: .widget)
} else {
LiveDrawWidgetEntryView(entry: entry)
.widgetURL(URL(string: "app.sauci://live-draw"))
}
}
.configurationDisplayName("Live Draw")
.description("See your shared drawing with your partner.")
.supportedFamilies([.systemSmall, .systemMedium])
}
}
@@ -0,0 +1,12 @@
/** @type {import('@bacons/apple-targets').ConfigFunction} */
module.exports = (config) => ({
type: "widget",
name: "LiveDrawWidget",
deploymentTarget: "16.0",
entitlements: {
"com.apple.security.application-groups":
config.ios?.entitlements?.["com.apple.security.application-groups"] ?? [
"group.com.sauci.app",
],
},
});
@@ -0,0 +1,10 @@
<?xml version="1.0" encoding="UTF-8"?>
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
<plist version="1.0">
<dict>
<key>com.apple.security.application-groups</key>
<array>
<string>group.com.sauci.app</string>
</array>
</dict>
</plist>
+9
View File
@@ -0,0 +1,9 @@
import WidgetKit
import SwiftUI
@main
struct exportWidgets: WidgetBundle {
var body: some Widget {
LiveDrawWidget()
}
}
@@ -120,6 +120,12 @@ Deno.serve(async (req) => {
} }
} }
// Delete live draw sessions for this couple
await supabase
.from("live_draw_sessions")
.delete()
.eq("couple_id", coupleId);
// Clear couple_id from both partners' profiles first // Clear couple_id from both partners' profiles first
const { error: updateError } = await supabase const { error: updateError } = await supabase
.from("profiles") .from("profiles")
@@ -0,0 +1,174 @@
-- Store the project's own Supabase URL in app_config so cron jobs can
-- target the correct environment. This prevents non-prod cron jobs from
-- accidentally hitting production edge functions.
ALTER TABLE app_config
ADD COLUMN IF NOT EXISTS supabase_url TEXT;
-- Helper function for building edge function URLs from app_config
CREATE OR REPLACE FUNCTION get_supabase_edge_function_url(function_name TEXT)
RETURNS TEXT
LANGUAGE sql
STABLE
AS $$
SELECT (SELECT supabase_url FROM app_config LIMIT 1)
|| '/functions/v1/'
|| function_name;
$$;
-- For safety, abort if supabase_url is not set
DO $$
BEGIN
IF (SELECT supabase_url FROM app_config LIMIT 1) IS NULL THEN
RAISE EXCEPTION 'app_config.supabase_url must be set before running this migration. '
'Run: UPDATE app_config SET supabase_url = ''https://<project-ref>.supabase.co'';';
END IF;
END;
$$;
-- Safely unschedule all existing cron jobs (ignore if they don't exist)
DO $$
DECLARE
job_names TEXT[] := ARRAY[
'send-pack-change-notifications',
'send-partner-activity-notifications',
'process-scheduled-releases',
'check-streak-milestones',
'send-match-notification-digests',
'send-weekly-summary',
'send-unpaired-reminders',
'send-catchup-reminders'
];
j TEXT;
BEGIN
FOREACH j IN ARRAY job_names LOOP
BEGIN
PERFORM cron.unschedule(j);
EXCEPTION WHEN OTHERS THEN
RAISE NOTICE 'Job % not found, skipping unschedule', j;
END;
END LOOP;
END;
$$;
-- Re-register all cron jobs using dynamic URLs.
-- format() resolves at migration-time, baking the correct URL into the cron command.
SELECT cron.schedule(
'send-pack-change-notifications',
'*/5 * * * *',
format(
$cmd$
SELECT net.http_post(
url := %L,
headers := jsonb_build_object('Content-Type', 'application/json'),
body := '{}'::jsonb
)
$cmd$,
get_supabase_edge_function_url('send-pack-change-notification')
)
);
SELECT cron.schedule(
'send-partner-activity-notifications',
'*/5 * * * *',
format(
$cmd$
SELECT net.http_post(
url := %L,
headers := jsonb_build_object('Content-Type', 'application/json'),
body := '{}'::jsonb
)
$cmd$,
get_supabase_edge_function_url('send-partner-activity-notification')
)
);
SELECT cron.schedule(
'process-scheduled-releases',
'*/5 * * * *',
format(
$cmd$
SELECT net.http_post(
url := %L,
headers := jsonb_build_object('Content-Type', 'application/json'),
body := '{}'::jsonb
)
$cmd$,
get_supabase_edge_function_url('process-scheduled-releases')
)
);
SELECT cron.schedule(
'check-streak-milestones',
'5 0 * * *',
format(
$cmd$
SELECT net.http_post(
url := %L,
headers := jsonb_build_object('Content-Type', 'application/json'),
body := '{}'::jsonb
)
$cmd$,
get_supabase_edge_function_url('check-streak-milestones')
)
);
SELECT cron.schedule(
'send-match-notification-digests',
'* * * * *',
format(
$cmd$
SELECT net.http_post(
url := %L,
headers := jsonb_build_object('Content-Type', 'application/json'),
body := '{}'::jsonb
)
$cmd$,
get_supabase_edge_function_url('send-match-notification-digest')
)
);
SELECT cron.schedule(
'send-weekly-summary',
'0 10 * * 0',
format(
$cmd$
SELECT net.http_post(
url := %L,
headers := jsonb_build_object('Content-Type', 'application/json'),
body := '{}'::jsonb
)
$cmd$,
get_supabase_edge_function_url('send-weekly-summary')
)
);
SELECT cron.schedule(
'send-unpaired-reminders',
'0 18 * * *',
format(
$cmd$
SELECT net.http_post(
url := %L,
headers := jsonb_build_object('Content-Type', 'application/json'),
body := '{}'::jsonb
)
$cmd$,
get_supabase_edge_function_url('send-unpaired-reminder')
)
);
SELECT cron.schedule(
'send-catchup-reminders',
'0 17 * * *',
format(
$cmd$
SELECT net.http_post(
url := %L,
headers := jsonb_build_object('Content-Type', 'application/json'),
body := '{}'::jsonb
)
$cmd$,
get_supabase_edge_function_url('send-catchup-reminder')
)
);
@@ -0,0 +1,28 @@
-- Live Draw Sessions: persists canvas state for async collaborative drawing
-- One session per couple - partners draw on a shared canvas throughout the day
CREATE TABLE IF NOT EXISTS live_draw_sessions (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
couple_id UUID NOT NULL REFERENCES couples(id) ON DELETE CASCADE,
strokes JSONB NOT NULL DEFAULT '[]',
updated_at TIMESTAMPTZ NOT NULL DEFAULT now(),
created_at TIMESTAMPTZ NOT NULL DEFAULT now(),
UNIQUE(couple_id)
);
-- Enable RLS
ALTER TABLE live_draw_sessions ENABLE ROW LEVEL SECURITY;
-- Enable realtime for live updates
ALTER PUBLICATION supabase_realtime ADD TABLE live_draw_sessions;
-- RLS: Only couple members can read their drawing
CREATE POLICY "Users can view own couple drawings"
ON live_draw_sessions FOR SELECT
USING (couple_id = get_auth_user_couple_id());
-- RLS: Only couple members can insert/update/delete their drawing
CREATE POLICY "Users can manage own couple drawings"
ON live_draw_sessions FOR ALL
USING (couple_id = get_auth_user_couple_id());
CREATE INDEX IF NOT EXISTS idx_live_draw_sessions_couple_id ON live_draw_sessions(couple_id);
@@ -0,0 +1,4 @@
-- Allow super admins to read live draw sessions
CREATE POLICY "Admins can view all drawings"
ON live_draw_sessions FOR SELECT
USING (is_super_admin());
File renamed without changes.
-522
View File
@@ -1,522 +0,0 @@
# Frontend-Backend Communication Optimization Plan
This document outlines identified chattiness issues in the Sauci mobile app and provides a prioritized implementation plan for optimizations.
## Executive Summary
**Estimated Extra Traffic:** ~40-50% of current API calls are redundant or suboptimal
**Key Problem Areas:**
- Partner profile refreshed on every message send
- Match details fetched from DB instead of using cached store data
- Redundant polling alongside real-time subscriptions
- N+1 query patterns in match/response fetching
- Missing caching strategies across stores
---
## High Priority Tasks
### Task 1: Cache Partner Public Key with TTL
**Impact:** Eliminates 1 database query per message sent
**Current Behavior:**
- `useEncryptedSend.ts:84-85` calls `refreshPartner()` on every text message
- `useMediaUpload.ts:80-81` calls `refreshPartner()` on every media upload
- No caching - always hits database
**Files to Modify:**
- `apps/mobile/src/store/authStore.ts`
- `apps/mobile/src/hooks/useEncryptedSend.ts`
- `apps/mobile/src/features/chat/hooks/useMediaUpload.ts`
**Implementation:**
```typescript
// In authStore.ts - add to state
partnerLastFetched: number | null;
// In refreshPartner action
refreshPartner: async () => {
const now = Date.now();
const CACHE_TTL = 5 * 60 * 1000; // 5 minutes
const { partner, partnerLastFetched } = get();
// Return early if cache is fresh and partner has public key
if (partner?.public_key_jwk &&
partnerLastFetched &&
now - partnerLastFetched < CACHE_TTL) {
return;
}
// ... existing fetch logic
set({ partner: data, partnerLastFetched: now });
}
```
**In hooks - add force refresh option:**
```typescript
// Only force refresh if encryption fails due to key mismatch
const sendEncrypted = async (content: string, forceKeyRefresh = false) => {
if (forceKeyRefresh) {
await refreshPartner();
}
// ... rest of logic
}
```
**Acceptance Criteria:**
- [ ] Partner profile cached for 5 minutes
- [ ] Cache bypassed if `public_key_jwk` is null
- [ ] Force refresh available for encryption failures
- [ ] No regression in E2EE functionality
---
### Task 2: Use Store Data for Message Notifications
**Impact:** Eliminates 1 database query per incoming message
**Current Behavior:**
- `_layout.tsx:462-466` fetches match details from DB for every incoming message
- Match data already exists in `matchStore`
**Files to Modify:**
- `apps/mobile/app/(app)/_layout.tsx`
**Implementation:**
```typescript
// Replace DB query with store lookup
const handleNewMessage = async (newMessage: Message) => {
// Try to get match from store first
const { matches } = useMatchStore.getState();
const existingMatch = matches.find(m => m.id === newMessage.match_id);
if (existingMatch) {
// Use cached data
showNotificationToast(existingMatch.question.text, newMessage);
} else {
// Fallback: fetch from DB only if not in store (rare edge case)
const { data: match } = await supabase
.from("matches")
.select("id, question:questions(text)")
.eq("id", newMessage.match_id)
.single();
if (match) {
showNotificationToast(match.question.text, newMessage);
// Also refresh matches store since we're missing data
fetchMatches();
}
}
};
```
**Acceptance Criteria:**
- [ ] Message notifications use store data when available
- [ ] Fallback to DB query only when match not in store
- [ ] Store refreshed if match is missing (data consistency)
---
### Task 3: Remove Redundant Polling in Pairing Screen
**Impact:** Eliminates polling every 5 seconds when waiting for partner
**Current Behavior:**
- `pairing.tsx:39-45` polls `fetchCouple()` every 5 seconds
- `pairing.tsx:47-71` also subscribes to real-time profile updates
- Both doing the same job - redundant
**Files to Modify:**
- `apps/mobile/app/(app)/pairing.tsx`
**Implementation:**
```typescript
// DELETE the polling interval (lines 39-45)
// KEEP only the real-time subscription
useEffect(() => {
if (!couple || partner) return;
// Real-time subscription handles partner joining
const subscription = supabase
.channel(`couple-${couple.id}`)
.on(
"postgres_changes",
{
event: "UPDATE",
schema: "public",
table: "profiles",
filter: `couple_id=eq.${couple.id}`,
},
(payload) => {
if (payload.new.id !== user?.id) {
fetchCouple(); // Partner joined
}
}
)
.subscribe();
return () => {
subscription.unsubscribe();
};
}, [couple, partner, user?.id, fetchCouple]);
```
**Acceptance Criteria:**
- [ ] Polling removed
- [ ] Real-time subscription still detects partner joining
- [ ] No increase in time to detect partner
---
## Medium Priority Tasks
### Task 4: Optimize Match Responses Query (N+1 Fix)
**Impact:** Reduces 2 queries to 1, eliminates client-side filtering
**Current Behavior:**
- `matchStore.ts:23-29` fetches all matches
- `matchStore.ts:35-38` fetches ALL responses separately
- Client-side filtering to match responses to questions
**Files to Modify:**
- `apps/mobile/src/store/matchStore.ts`
**Implementation:**
```typescript
// Option A: Join responses in single query
const fetchMatches = async () => {
const { data: matches } = await supabase
.from("matches")
.select(`
*,
question:questions(*),
user_response:responses!inner(
id, answer, user_id
)
`)
.eq("couple_id", coupleId)
.in("responses.user_id", [userId, partnerId])
.order("created_at", { ascending: false });
// Process matches with embedded responses
const processedMatches = matches?.map(match => ({
...match,
userAnswer: match.user_response.find(r => r.user_id === userId)?.answer,
partnerAnswer: match.user_response.find(r => r.user_id === partnerId)?.answer,
}));
set({ matches: processedMatches });
};
```
**Alternative - Database View:**
```sql
-- Create view that joins match with responses
CREATE VIEW match_with_responses AS
SELECT
m.*,
q.text as question_text,
q.intensity,
ur.answer as user_answer,
pr.answer as partner_answer
FROM matches m
JOIN questions q ON m.question_id = q.id
LEFT JOIN responses ur ON m.question_id = ur.question_id AND ur.user_id = m.user1_id
LEFT JOIN responses pr ON m.question_id = pr.question_id AND pr.user_id = m.user2_id;
```
**Acceptance Criteria:**
- [ ] Single query fetches matches with responses
- [ ] No client-side filtering of responses
- [ ] Match list still displays correctly
---
### Task 5: Fix Mark All As Seen Trigger
**Impact:** Eliminates unnecessary DB updates on every render
**Current Behavior:**
- `matches.tsx:66-69` calls `markAllAsSeen()` whenever `matches.length` changes
- Triggers even when navigating back to screen with same matches
**Files to Modify:**
- `apps/mobile/app/(app)/matches.tsx`
**Implementation:**
```typescript
// Move markAllAsSeen to useFocusEffect, run once per focus
useFocusEffect(
useCallback(() => {
fetchMatches();
// Mark as seen after fetch completes
return () => {
// Optionally mark as seen on blur instead
};
}, [])
);
// Separate effect for marking seen - runs once after initial fetch
const [hasMarkedSeen, setHasMarkedSeen] = useState(false);
useEffect(() => {
if (matches.length > 0 && !hasMarkedSeen) {
markAllAsSeen();
setHasMarkedSeen(true);
}
}, [matches.length, hasMarkedSeen]);
// Reset on screen blur
useFocusEffect(
useCallback(() => {
return () => setHasMarkedSeen(false);
}, [])
);
```
**Acceptance Criteria:**
- [ ] `markAllAsSeen()` only called once per screen visit
- [ ] New matches still get marked as seen
- [ ] No regression in notification badge behavior
---
### Task 6: Batch Message Status Updates
**Impact:** Reduces 2 queries to 1 when opening chat
**Current Behavior:**
- `useMessageSubscription.ts:125-143` makes separate updates for:
1. Messages to mark as read
2. Messages to mark as delivered
**Files to Modify:**
- `apps/mobile/src/hooks/useMessageSubscription.ts`
**Implementation:**
```typescript
// Combine into single update operation
const markMessagesAsReadAndDelivered = async (messages: Message[]) => {
const now = new Date().toISOString();
const userId = useAuthStore.getState().user?.id;
// Find messages that need updating
const messagesToUpdate = messages.filter(
m => m.user_id !== userId && (!m.read_at || !m.delivered_at)
);
if (messagesToUpdate.length === 0) return;
const messageIds = messagesToUpdate.map(m => m.id);
// Single update query
await supabase
.from("messages")
.update({
delivered_at: now,
read_at: now
})
.in("id", messageIds)
.is("read_at", null); // Only update unread
};
```
**Acceptance Criteria:**
- [ ] Single query updates both delivered_at and read_at
- [ ] Read receipts still work correctly
- [ ] No regression in message status display
---
### Task 7: Debounce Answer Gap Check
**Impact:** Reduces RPC calls during rapid swiping
**Current Behavior:**
- `swipe.tsx:171` checks answer gap on mount
- `swipe.tsx:228` checks again after EVERY swipe response
**Files to Modify:**
- `apps/mobile/app/(app)/swipe.tsx`
**Implementation:**
```typescript
// Add debounced version
const debouncedCheckAnswerGap = useMemo(
() => debounce(checkAnswerGap, 2000), // 2 second debounce
[checkAnswerGap]
);
// On response submit
const handleResponse = async (answer: Answer) => {
await submitResponse(questionId, answer);
// Debounced check - won't fire if user swipes again within 2s
debouncedCheckAnswerGap();
};
// Cleanup on unmount
useEffect(() => {
return () => debouncedCheckAnswerGap.cancel();
}, []);
```
**Acceptance Criteria:**
- [ ] Answer gap only checked after swiping pauses
- [ ] Still checks on initial mount
- [ ] Gap modal still appears when threshold exceeded
---
## Low Priority Tasks
### Task 8: Add Stale Time to Packs Store
**Impact:** Prevents unnecessary refetches of static data
**Files to Modify:**
- `apps/mobile/src/store/packsStore.ts`
**Implementation:**
```typescript
// Add to store state
packsLastFetched: number | null;
// In fetchPacks
fetchPacks: async () => {
const STALE_TIME = 10 * 60 * 1000; // 10 minutes
const { packsLastFetched, packs } = get();
if (packs.length > 0 &&
packsLastFetched &&
Date.now() - packsLastFetched < STALE_TIME) {
return; // Use cached data
}
// ... existing fetch logic
set({ packs: data, packsLastFetched: Date.now() });
};
// Add force refresh for when user explicitly requests
fetchPacksForce: async () => {
set({ packsLastFetched: null });
return get().fetchPacks();
};
```
---
### Task 9: Persist Matches to AsyncStorage
**Impact:** Faster initial load, offline capability
**Files to Modify:**
- `apps/mobile/src/store/matchStore.ts`
**Implementation:**
```typescript
import AsyncStorage from "@react-native-async-storage/async-storage";
import { persist } from "zustand/middleware";
export const useMatchStore = create<MatchStore>()(
persist(
(set, get) => ({
// ... existing store
}),
{
name: "match-storage",
storage: createJSONStorage(() => AsyncStorage),
partialize: (state) => ({
matches: state.matches,
// Don't persist loading states
}),
}
)
);
```
---
### Task 10: Combine Couple + Partner Fetch
**Impact:** Reduces 2 queries to 1 in fetchCouple
**Current Behavior:**
- `authStore.ts:137-149` fetches couple, then partner separately
**Files to Modify:**
- `apps/mobile/src/store/authStore.ts`
**Implementation:**
```typescript
// Single query with join
const { data: coupleData } = await supabase
.from("couples")
.select(`
*,
profiles!inner(*)
`)
.eq("id", user.couple_id)
.single();
const partner = coupleData.profiles.find(p => p.id !== user.id);
set({ couple: coupleData, partner });
```
---
## Implementation Order
| Priority | Task | Effort | Impact |
|----------|------|--------|--------|
| 1 | Task 1: Cache partner key | Low | High |
| 2 | Task 2: Store-based notifications | Low | High |
| 3 | Task 3: Remove polling | Low | Medium |
| 4 | Task 5: Fix mark as seen | Low | Medium |
| 5 | Task 6: Batch message updates | Low | Low |
| 6 | Task 7: Debounce gap check | Low | Low |
| 7 | Task 4: N+1 fix | Medium | Medium |
| 8 | Task 8: Packs stale time | Low | Low |
| 9 | Task 10: Combine couple fetch | Low | Low |
| 10 | Task 9: Persist matches | Medium | Low |
---
## Testing Checklist
After implementing optimizations:
- [ ] E2EE messaging still works (send/receive encrypted messages)
- [ ] Partner joining couple is detected in real-time
- [ ] Match notifications display correctly
- [ ] New matches appear and are marked as seen
- [ ] Message read receipts work
- [ ] Answer gap modal appears when appropriate
- [ ] Packs load and display correctly
- [ ] No increase in error rates (monitor logs)
---
## Metrics to Track
Before/after comparison:
1. **API calls per session** - Target: 40% reduction
2. **Time to interactive on home screen** - Target: <500ms
3. **Message send latency** - Target: No increase
4. **Battery usage** - Target: 20% reduction in background activity
---
## Notes
- All caching should have invalidation strategies
- Consider adding React Query or TanStack Query for more sophisticated caching
- Real-time subscriptions are preferred over polling
- Database views can simplify complex queries but add maintenance overhead
File renamed without changes.
File renamed without changes.
-427
View File
@@ -1,427 +0,0 @@
# Plan: Remove E2EE and Switch to Server-Side Security
## Overview
This document outlines the plan to remove end-to-end encryption (E2EE) from Sauci's chat system and rely on Supabase's built-in security instead.
### Current State
- Messages are encrypted client-side using AES-256-GCM with triple-wrapped RSA keys
- Each message has sender, recipient, and admin wrapped keys
- Complex key management: generation, storage, rotation, recovery
- ~2500+ lines of encryption code across 21 files
- Edge functions handle key rotation and pending message re-encryption
### Target State
- Messages stored as plaintext in Supabase (protected by RLS policies)
- TLS encryption in transit (Supabase default)
- Encryption at rest (Supabase infrastructure)
- No client-side key management
- Simplified chat code
### Why Remove E2EE?
1. **Complexity** - Triple-wrapped keys, device recovery, key rotation adds significant maintenance burden
2. **Overkill** - For a couples intimacy app, server-side security is sufficient
3. **User Experience** - E2EE adds latency, recovery flows, and potential failure modes
4. **Development Velocity** - Simpler code = faster iteration
### Security Model After Removal
- **In Transit**: TLS 1.3 (Supabase default)
- **At Rest**: Supabase encrypts data at rest
- **Access Control**: RLS policies restrict access to couple's own messages
- **Admin Access**: Admins can read messages (same as current E2EE with admin key)
---
## Migration Strategy for Existing Messages
Existing v2 (encrypted) messages need handling. Options:
### Option A: Decrypt and Convert (Recommended)
Run a one-time migration to decrypt all v2 messages using admin key and store as v1 plaintext.
- Pros: Clean slate, no legacy code needed
- Cons: Requires admin private key, one-time effort
### Option B: Leave v2 Messages As-Is
Keep encrypted messages in database but stop creating new ones. Old messages become unreadable.
- Pros: Simpler migration
- Cons: Users lose access to old messages
### Option C: Hybrid Display
Keep decryption code for reading old v2 messages, but send new messages as v1.
- Pros: No data loss
- Cons: Maintains complexity, defeats purpose of removal
**Recommendation**: Option A - decrypt existing messages during migration window.
---
## Tasks
### Phase 1: Database Migration (Estimated: 2-3 hours)
#### Task 1.1: Create Migration to Decrypt Existing v2 Messages
Create an edge function that:
1. Fetches all messages with `version = 2`
2. Decrypts each using admin private key
3. Updates to `version = 1`, moves decrypted content to `content` column
4. Clears `encrypted_content`, `encryption_iv`, `keys_metadata`
**Files to create:**
- `apps/supabase/functions/migrate-e2ee-to-plaintext/index.ts`
#### Task 1.2: Run Migration
Execute the migration function against both non-prod and prod databases.
#### Task 1.3: Verify Migration
Query to confirm no v2 messages remain:
```sql
SELECT COUNT(*) FROM messages WHERE version = 2;
```
---
### Phase 2: Remove Client-Side Encryption Code (Estimated: 4-6 hours)
#### Task 2.1: Remove Encryption Library
Delete the entire encryption directory:
- `apps/mobile/src/lib/encryption/` (14 files)
**Files to delete:**
- `adminKeys.ts`
- `constants.ts`
- `crypto.ts`
- `index.ts`
- `keyManager.ts`
- `mediaDecryption.ts`
- `mediaEncryption.ts`
- `messageDecryption.ts`
- `messageEncryption.ts`
- `reencryptPendingMessages.ts`
- `repairStaleKey.ts`
- `triggerKeyRotation.ts`
- `types.ts`
#### Task 2.2: Remove Encryption Hooks
Delete the E2EE-specific hooks:
**Files to delete:**
- `apps/mobile/src/hooks/useDecryptedMessage.ts`
- `apps/mobile/src/hooks/useDecryptedMedia.ts`
- `apps/mobile/src/hooks/useEncryptedSend.ts`
#### Task 2.3: Simplify useEncryptionKeys Hook
Either delete entirely or convert to a no-op stub that returns `{ hasKeys: true }` for compatibility.
**File to modify/delete:**
- `apps/mobile/src/hooks/useEncryptionKeys.ts`
#### Task 2.4: Remove EncryptionKeyInitializer Component
Delete the component that initializes E2EE keys on app startup.
**File to delete:**
- `apps/mobile/src/components/EncryptionKeyInitializer.tsx`
#### Task 2.5: Remove from App Layout
Remove `EncryptionKeyInitializer` from the app layout.
**File to modify:**
- `apps/mobile/app/(app)/_layout.tsx`
---
### Phase 3: Simplify Chat Components (Estimated: 3-4 hours)
#### Task 3.1: Simplify ChatScreen.tsx
Remove:
- `useEncryptedSend` hook usage
- `encryptionKeys` from store
- `secureReady` checks
- Recovery UI (`encryptionKeys.isRecovering`)
- Encryption readiness blocking
Change message sending to use plaintext directly.
**File to modify:**
- `apps/mobile/src/features/chat/ChatScreen.tsx`
#### Task 3.2: Simplify MessageContent.tsx
Remove:
- `useDecryptedMessage` hook usage
- `useDecryptedMedia` hook usage
- Decryption loading states
- Decryption error states and retry buttons
Display `message.content` directly.
**File to modify:**
- `apps/mobile/src/features/chat/MessageContent.tsx`
#### Task 3.3: Simplify Media Upload
Remove encryption from media upload flow.
**File to modify:**
- `apps/mobile/src/hooks/useMediaUpload.ts`
Changes:
- Remove `encryptMediaFile` call
- Upload files directly without `.enc` extension
- Remove E2EE readiness checks
#### Task 3.4: Simplify InputBar (if applicable)
Remove any "security not ready" blocking logic.
**File to modify:**
- `apps/mobile/src/features/chat/InputBar.tsx`
---
### Phase 4: Clean Up State Management (Estimated: 1-2 hours)
#### Task 4.1: Remove encryptionKeys from Auth Store
Remove the entire `encryptionKeys` state object and related actions.
**File to modify:**
- `apps/mobile/src/store/authStore.ts`
Remove:
- `EncryptionKeyState` interface
- `encryptionKeys` state
- `setEncryptionKeys` action
- `beginEncryptionRecovery` / `endEncryptionRecovery` actions
- Reset logic in logout
---
### Phase 5: Remove Edge Functions (Estimated: 1 hour)
#### Task 5.1: Delete E2EE Edge Functions
Remove the edge functions that handle key rotation and re-encryption.
**Files to delete:**
- `apps/supabase/functions/handle-key-rotation/`
- `apps/supabase/functions/reencrypt-pending-messages/`
#### Task 5.2: Remove from Supabase Dashboard
If deployed, remove these functions from Supabase dashboard or let them remain (they won't be called).
---
### Phase 6: Database Cleanup (Optional) (Estimated: 1 hour)
#### Task 6.1: Remove E2EE Columns (Optional)
Create migration to drop unused columns. Can be deferred.
```sql
-- Optional cleanup migration
ALTER TABLE messages
DROP COLUMN IF EXISTS encrypted_content,
DROP COLUMN IF EXISTS encryption_iv,
DROP COLUMN IF EXISTS keys_metadata;
ALTER TABLE messages
DROP CONSTRAINT IF EXISTS messages_e2ee_fields_check;
ALTER TABLE profiles
DROP COLUMN IF EXISTS public_key_jwk;
DROP TABLE IF EXISTS master_keys;
```
**Note**: Can leave columns in place - they'll just be NULL for new messages.
#### Task 6.2: Update Message Version Default
Ensure new messages use version 1 (or remove version concept entirely).
---
### Phase 7: Cleanup and Testing (Estimated: 2-3 hours)
#### Task 7.1: Remove Unused Dependencies
Check if `react-native-quick-crypto` can be removed (may be needed for other crypto).
**File to modify:**
- `apps/mobile/package.json`
#### Task 7.2: Update CLAUDE.md
Remove E2EE documentation section.
**File to modify:**
- `CLAUDE.md`
#### Task 7.3: Delete E2EE Documentation
Remove the E2EE implementation docs.
**File to delete:**
- `docs/e2ee-implementation.md`
#### Task 7.4: Update iOS Permission Strings
Remove "encrypted" from iOS permission descriptions. These strings mention encryption:
**File to modify:**
- `apps/mobile/app.json`
**Current strings (lines 26-27):**
```json
"NSCameraUsageDescription": "Sauci uses your camera to take photos and videos that you can share privately with your partner in encrypted chat messages.",
"NSPhotoLibraryUsageDescription": "Sauci uses your photo library so you can select photos and videos to share privately with your partner in encrypted chat messages.",
```
**Updated strings:**
```json
"NSCameraUsageDescription": "Sauci uses your camera to take photos and videos that you can share privately with your partner.",
"NSPhotoLibraryUsageDescription": "Sauci uses your photo library so you can select photos and videos to share privately with your partner.",
```
After updating `app.json`, run `npx expo prebuild --clean` to regenerate `ios/Sauci/Info.plist`.
#### Task 7.5: Full Testing
Test all chat functionality:
- [ ] Send text message
- [ ] Receive text message
- [ ] Send image
- [ ] Receive image
- [ ] Send video
- [ ] Receive video
- [ ] View old messages (post-migration)
- [ ] New user signup and chat
- [ ] Device change / reinstall
---
## File Summary
### Files to Delete (19 files)
```text
apps/mobile/src/lib/encryption/adminKeys.ts
apps/mobile/src/lib/encryption/constants.ts
apps/mobile/src/lib/encryption/crypto.ts
apps/mobile/src/lib/encryption/index.ts
apps/mobile/src/lib/encryption/keyManager.ts
apps/mobile/src/lib/encryption/mediaDecryption.ts
apps/mobile/src/lib/encryption/mediaEncryption.ts
apps/mobile/src/lib/encryption/messageDecryption.ts
apps/mobile/src/lib/encryption/messageEncryption.ts
apps/mobile/src/lib/encryption/reencryptPendingMessages.ts
apps/mobile/src/lib/encryption/repairStaleKey.ts
apps/mobile/src/lib/encryption/triggerKeyRotation.ts
apps/mobile/src/lib/encryption/types.ts
apps/mobile/src/hooks/useDecryptedMessage.ts
apps/mobile/src/hooks/useDecryptedMedia.ts
apps/mobile/src/hooks/useEncryptedSend.ts
apps/mobile/src/hooks/useEncryptionKeys.ts
apps/mobile/src/components/EncryptionKeyInitializer.tsx
docs/e2ee-implementation.md
```
### Files to Modify (8 files)
```text
apps/mobile/src/features/chat/ChatScreen.tsx
apps/mobile/src/features/chat/MessageContent.tsx
apps/mobile/src/features/chat/InputBar.tsx
apps/mobile/src/hooks/useMediaUpload.ts
apps/mobile/src/store/authStore.ts
apps/mobile/app/(app)/_layout.tsx
apps/mobile/app.json
CLAUDE.md
```
### Edge Functions to Delete (2 functions)
```text
apps/supabase/functions/handle-key-rotation/
apps/supabase/functions/reencrypt-pending-messages/
```
---
## Estimated Total Effort
| Phase | Estimated Time |
| ------------------------------- | ---------------- |
| Phase 1: Database Migration | 2-3 hours |
| Phase 2: Remove Encryption Code | 4-6 hours |
| Phase 3: Simplify Chat Components | 3-4 hours |
| Phase 4: Clean Up State | 1-2 hours |
| Phase 5: Remove Edge Functions | 1 hour |
| Phase 6: Database Cleanup | 1 hour (optional)|
| Phase 7: Testing | 2-3 hours |
| **Total** | **14-20 hours** |
---
## Rollback Plan
If issues arise after deployment:
1. **Messages are preserved** - Content column still has plaintext for v1, migration preserves decrypted content
2. **Re-enable E2EE** - Git revert the client changes
3. **No data loss** - The migration only adds data (decrypts), doesn't delete
---
## Open Questions
1. **Timeline**: When to execute the migration? During maintenance window?
2. **Notification**: Should users be notified about the security model change?
3. **Privacy Policy**: Does the privacy policy mention E2EE? May need update.
4. **App Store**: Any App Store claims about E2EE that need updating?
File diff suppressed because it is too large. Load diff
-796
View File
@@ -1,796 +0,0 @@
# Missing Inverse Pairs - Review Document
This document lists all asymmetric questions that are missing their inverse pair.
For each question, the **inverse** would swap the `text` and `partner_text` fields,
allowing both partners to experience both roles.
## Summary
- **Total missing inverses:** 607
- **Categories affected:** 10
- **Packs affected:** 28
## How Inverse Pairs Work
For an asymmetric question like:
```
text: "Spank your partner"
partner_text: "Be spanked by your partner"
```
The inverse would be:
```
text: "Be spanked by your partner"
partner_text: "Spank your partner"
```
This ensures both partners get asked about both roles.
---
# Adventure & Travel (1 missing)
## ✨ Food & Culture (1 missing)
| # | Current Text | Current Partner Text | Intensity | Inverse to Create |
|---|--------------|---------------------|-----------|-------------------|
| 1 | Cook a traditional dish from your partner's heritage | Teach your partner to cook a dish from your heritage | 1 | text: "Teach your partner to cook a dish from your heritage" / partner: "Cook a traditional dish from your partner's heritage" |
# Bedroom Adventures (40 missing)
## 🔞 Intimate Games (3 missing)
| # | Current Text | Current Partner Text | Intensity | Inverse to Create |
|---|--------------|---------------------|-----------|-------------------|
| 1 | Take turns being the 'sex instructor' giving commands | Follow your partner's commands as they instruct | 4 | text: "Follow your partner's commands as they instruct" / partner: "Take turns being the 'sex instructor' giving commands" |
| 2 | Blindfold your partner and have them guess what you're doing | Be blindfolded and guess what your partner is doing | 4 | text: "Be blindfolded and guess what your partner is doing" / partner: "Blindfold your partner and have them guess what you're doing" |
| 3 | Use a timer and give your partner pleasure for exactly 5 minutes | Receive exactly 5 minutes of pleasure from your partner | 4 | text: "Receive exactly 5 minutes of pleasure from your partner" / partner: "Use a timer and give your partner pleasure for exactly 5 minutes" |
## 🔞 Position Exploration (10 missing)
| # | Current Text | Current Partner Text | Intensity | Inverse to Create |
|---|--------------|---------------------|-----------|-------------------|
| 1 | Have sex in missionary with a pillow under your partner's hips | Have sex in missionary with a pillow under your hips | 4 | text: "Have sex in missionary with a pillow under your hips" / partner: "Have sex in missionary with a pillow under your partner's hips" |
| 2 | Let your partner choose three sex positions for tonight | Choose three sex positions for tonight | 3 | text: "Choose three sex positions for tonight" / partner: "Let your partner choose three sex positions for tonight" |
| 3 | Have sex with your partner bent over furniture | Be bent over furniture while your partner has sex with you | 4 | text: "Be bent over furniture while your partner has sex with you" / partner: "Have sex with your partner bent over furniture" |
| 4 | Lift your partner while having sex | Be lifted by your partner while having sex | 4 | text: "Be lifted by your partner while having sex" / partner: "Lift your partner while having sex" |
| 5 | Have sex with your partner on top facing you | Ride your partner facing them | 5 | text: "Ride your partner facing them" / partner: "Have sex with your partner on top facing you" |
| 6 | Have sex with your partner's legs on your shoulders | Have sex with your legs on your partner's shoulders | 4 | text: "Have sex with your legs on your partner's shoulders" / partner: "Have sex with your partner's legs on your shoulders" |
| 7 | Have sex with your partner's ankles by their ears | Have sex with your ankles by your ears | 4 | text: "Have sex with your ankles by your ears" / partner: "Have sex with your partner's ankles by their ears" |
| 8 | Have your partner wrap their legs around you during sex | Wrap your legs around your partner during sex | 4 | text: "Wrap your legs around your partner during sex" / partner: "Have your partner wrap their legs around you during sex" |
| 9 | Perform oral while your partner is seated | Receive oral while seated | 3 | text: "Receive oral while seated" / partner: "Perform oral while your partner is seated" |
| 10 | Have sex in a chair with your partner in your lap | Have sex in your partner's lap while sitting in a chair | 4 | text: "Have sex in your partner's lap while sitting in a chair" / partner: "Have sex in a chair with your partner in your lap" |
## 🔞 Sensory Play (27 missing)
| # | Current Text | Current Partner Text | Intensity | Inverse to Create |
|---|--------------|---------------------|-----------|-------------------|
| 1 | Apply menthol or tingling lube to sensitive areas | Have tingling lube applied to sensitive areas | 4 | text: "Have tingling lube applied to sensitive areas" / partner: "Apply menthol or tingling lube to sensitive areas" |
| 2 | Use noise-canceling headphones and surprise your partner | Wear headphones and be surprised | 4 | text: "Wear headphones and be surprised" / partner: "Use noise-canceling headphones and surprise your partner" |
| 3 | Kiss your partner everywhere while they keep their eyes closed | Keep your eyes closed while being kissed everywhere | 4 | text: "Keep your eyes closed while being kissed everywhere" / partner: "Kiss your partner everywhere while they keep their eyes closed" |
| 4 | Trace words or patterns on your partner's back and have them guess | Guess what your partner is tracing on your back | 3 | text: "Guess what your partner is tracing on your back" / partner: "Trace words or patterns on your partner's back and have them guess" |
| 5 | Use a pinwheel or scratchy texture on your partner's skin | Feel a pinwheel or scratchy texture on your skin | 4 | text: "Feel a pinwheel or scratchy texture on your skin" / partner: "Use a pinwheel or scratchy texture on your partner's skin" |
| 6 | Blindfold your partner and use only your tongue | Be blindfolded while your partner uses only their tongue | 4 | text: "Be blindfolded while your partner uses only their tongue" / partner: "Blindfold your partner and use only your tongue" |
| 7 | Cover your partner's body in whipped cream and lick it off | Have whipped cream licked off your body | 4 | text: "Have whipped cream licked off your body" / partner: "Cover your partner's body in whipped cream and lick it off" |
| 8 | Use your hair to tickle and tease your partner | Be tickled and teased with your partner's hair | 3 | text: "Be tickled and teased with your partner's hair" / partner: "Use your hair to tickle and tease your partner" |
| 9 | Apply ice to your lips before kissing your partner everywhere | Be kissed everywhere with cold lips | 4 | text: "Be kissed everywhere with cold lips" / partner: "Apply ice to your lips before kissing your partner everywhere" |
| 10 | Use a vibrator on your partner while they're blindfolded | Experience a vibrator while blindfolded | 4 | text: "Experience a vibrator while blindfolded" / partner: "Use a vibrator on your partner while they're blindfolded" |
| 11 | Cover your partner's eyes and whisper dirty talk | Have your eyes covered while hearing dirty talk | 4 | text: "Have your eyes covered while hearing dirty talk" / partner: "Cover your partner's eyes and whisper dirty talk" |
| 12 | Use different textures on your partner's skin during foreplay | Experience different textures during foreplay | 4 | text: "Experience different textures during foreplay" / partner: "Use different textures on your partner's skin during foreplay" |
| 13 | Drip candle wax on your partner (massage candle) | Have massage candle wax dripped on you | 4 | text: "Have massage candle wax dripped on you" / partner: "Drip candle wax on your partner (massage candle)" |
| 14 | Use a blindfold and have your partner guess what toy you're using | Guess what toy is being used on you while blindfolded | 4 | text: "Guess what toy is being used on you while blindfolded" / partner: "Use a blindfold and have your partner guess what toy you're using" |
| 15 | Use silk scarves to restrain your partner lightly | Be lightly restrained with silk scarves | 5 | text: "Be lightly restrained with silk scarves" / partner: "Use silk scarves to restrain your partner lightly" |
| 16 | Blindfold your partner and feed them aphrodisiacs | Be blindfolded and fed aphrodisiacs | 3 | text: "Be blindfolded and fed aphrodisiacs" / partner: "Blindfold your partner and feed them aphrodisiacs" |
| 17 | Use a wartenberg wheel on your partner's skin | Have a wartenberg wheel used on your skin | 4 | text: "Have a wartenberg wheel used on your skin" / partner: "Use a wartenberg wheel on your partner's skin" |
| 18 | Use flavored lube and let your partner guess the flavor | Guess the flavor of lube your partner is using | 4 | text: "Guess the flavor of lube your partner is using" / partner: "Use flavored lube and let your partner guess the flavor" |
| 19 | Put earplugs in your partner and surprise them with touch | Wear earplugs and be surprised by touch | 4 | text: "Wear earplugs and be surprised by touch" / partner: "Put earplugs in your partner and surprise them with touch" |
| 20 | Use a feather to tease your partner's most sensitive areas | Be teased with a feather on your sensitive areas | 4 | text: "Be teased with a feather on your sensitive areas" / partner: "Use a feather to tease your partner's most sensitive areas" |
| 21 | Drip warm massage oil on your partner | Have warm oil dripped on your body | 4 | text: "Have warm oil dripped on your body" / partner: "Drip warm massage oil on your partner" |
| 22 | Use ice on your partner's body during foreplay | Have ice used on your body during foreplay | 4 | text: "Have ice used on your body during foreplay" / partner: "Use ice on your partner's body during foreplay" |
| 23 | Blindfold your partner during sex | Be blindfolded during sex | 5 | text: "Be blindfolded during sex" / partner: "Blindfold your partner during sex" |
| 24 | Use a fur mitt or soft glove during foreplay | Experience fur or soft glove during foreplay | 3 | text: "Experience fur or soft glove during foreplay" / partner: "Use a fur mitt or soft glove during foreplay" |
| 25 | Blindfold your partner in the shower and wash them sensually | Be blindfolded in the shower and washed sensually | 4 | text: "Be blindfolded in the shower and washed sensually" / partner: "Blindfold your partner in the shower and wash them sensually" |
| 26 | Tie your partner's hands loosely and tease them | Have your hands tied loosely and be teased | 5 | text: "Have your hands tied loosely and be teased" / partner: "Tie your partner's hands loosely and tease them" |
| 27 | Use your breath to create sensations on your partner's body | Feel your partner's breath creating sensations | 4 | text: "Feel your partner's breath creating sensations" / partner: "Use your breath to create sensations on your partner's body" |
# Fantasy Exploration (46 missing)
## 🔞 Dream Scenarios (4 missing)
| # | Current Text | Current Partner Text | Intensity | Inverse to Create |
|---|--------------|---------------------|-----------|-------------------|
| 1 | Share a fantasy about your partner surprising you sexually | Share a fantasy about surprising your partner sexually | 4 | text: "Share a fantasy about surprising your partner sexually" / partner: "Share a fantasy about your partner surprising you sexually" |
| 2 | Share your biggest sexual fantasy with your partner | Hear your partner's biggest sexual fantasy | 4 | text: "Hear your partner's biggest sexual fantasy" / partner: "Share your biggest sexual fantasy with your partner" |
| 3 | Share a fantasy about seducing your partner | Share a fantasy about being seduced | 3 | text: "Share a fantasy about being seduced" / partner: "Share a fantasy about seducing your partner" |
| 4 | Describe a scenario where you worship your partner's body | Describe a scenario where your body is worshipped | 4 | text: "Describe a scenario where your body is worshipped" / partner: "Describe a scenario where you worship your partner's body" |
## 🔞 Language Exploration (14 missing)
| # | Current Text | Current Partner Text | Intensity | Inverse to Create |
|---|--------------|---------------------|-----------|-------------------|
| 1 | Degrade your partner with dirty talk | Be sexually degraded by your partner's dirty talk | 5 | text: "Be sexually degraded by your partner's dirty talk" / partner: "Degrade your partner with dirty talk" |
| 2 | Use praise like 'good girl' or 'good boy' on your partner | Be praised as a 'good girl' or 'good boy' by your partner | 3 | text: "Be praised as a 'good girl' or 'good boy' by your partner" / partner: "Use praise like 'good girl' or 'good boy' on your partner" |
| 3 | Make your partner call you 'Daddy' | Let your partner make you call them 'Daddy' | 5 | text: "Let your partner make you call them 'Daddy'" / partner: "Make your partner call you 'Daddy'" |
| 4 | Dictate captions for your partner's nude poses | Pose for nudes while your partner dictates the captions | 4 | text: "Pose for nudes while your partner dictates the captions" / partner: "Dictate captions for your partner's nude poses" |
| 5 | Narrate graphic fantasies while edging your partner | Be edged while listening to your partner's graphic fantasies | 5 | text: "Be edged while listening to your partner's graphic fantasies" / partner: "Narrate graphic fantasies while edging your partner" |
| 6 | Make your partner beg for release | Beg your partner for permission to cum | 5 | text: "Beg your partner for permission to cum" / partner: "Make your partner beg for release" |
| 7 | Whisper vivid descriptions of your partner’s physical submission | Listen to your partner describe your body's total surrender | 4 | text: "Listen to your partner describe your body's total surrender" / partner: "Whisper vivid descriptions of your partner’s physical submission" |
| 8 | Whisper forbidden fantasies into your partner's ear | Listen to your partner's darkest desires | 3 | text: "Listen to your partner's darkest desires" / partner: "Whisper forbidden fantasies into your partner's ear" |
| 9 | Describe your partner's body in explicit detail | Have your body described in explicit detail by your partner | 3 | text: "Have your body described in explicit detail by your partner" / partner: "Describe your partner's body in explicit detail" |
| 10 | Correct your partner’s language using physical discipline | Receive physical correction for using the wrong words | 5 | text: "Receive physical correction for using the wrong words" / partner: "Correct your partner’s language using physical discipline" |
| 11 | Use a ball gag to silence your partner's protests | Experience being silenced and controlled with a ball gag | 5 | text: "Experience being silenced and controlled with a ball gag" / partner: "Use a ball gag to silence your partner's protests" |
| 12 | Narrate exactly how you will fuck your partner | Listen to your partner's detailed fucking plan | 4 | text: "Listen to your partner's detailed fucking plan" / partner: "Narrate exactly how you will fuck your partner" |
| 13 | Record a dirty voice memo of your explicit plans for your partner | Receive a dirty voice memo detailing your partner's explicit plans | 3 | text: "Receive a dirty voice memo detailing your partner's explicit plans" / partner: "Record a dirty voice memo of your explicit plans for your partner" |
| 14 | Describe your partner's taste in graphic detail | Listen to your partner describe your taste | 4 | text: "Listen to your partner describe your taste" / partner: "Describe your partner's taste in graphic detail" |
## 🔞 Role Play Ideas (3 missing)
| # | Current Text | Current Partner Text | Intensity | Inverse to Create |
|---|--------------|---------------------|-----------|-------------------|
| 1 | Act out being seduced by a mysterious stranger | Act out seducing someone as a mysterious stranger | 4 | text: "Act out seducing someone as a mysterious stranger" / partner: "Act out being seduced by a mysterious stranger" |
| 2 | Role play as a stripper giving a private dance | Role play as someone receiving a private dance | 4 | text: "Role play as someone receiving a private dance" / partner: "Role play as a stripper giving a private dance" |
| 3 | Role play as a doctor giving an examination | Role play as a patient getting an examination | 5 | text: "Role play as a patient getting an examination" / partner: "Role play as a doctor giving an examination" |
## 🔞 Secret Desires (25 missing)
| # | Current Text | Current Partner Text | Intensity | Inverse to Create |
|---|--------------|---------------------|-----------|-------------------|
| 1 | Tell your partner what you want them to do more often | Hear what your partner wants you to do more often | 2 | text: "Hear what your partner wants you to do more often" / partner: "Tell your partner what you want them to do more often" |
| 2 | Confess a desire you've been too shy to mention | Hear a desire your partner has been too shy to mention | 3 | text: "Hear a desire your partner has been too shy to mention" / partner: "Confess a desire you've been too shy to mention" |
| 3 | Share a sexual 'bucket list' experience you want to have | Hear a sexual 'bucket list' experience your partner wants | 3 | text: "Hear a sexual 'bucket list' experience your partner wants" / partner: "Share a sexual 'bucket list' experience you want to have" |
| 4 | Reveal what you secretly want to hear during sex | Learn what your partner wants to hear during sex | 3 | text: "Learn what your partner wants to hear during sex" / partner: "Reveal what you secretly want to hear during sex" |
| 5 | Share a fantasy involving voyeurism | Hear your partner's fantasy about being watched or watching | 3 | text: "Hear your partner's fantasy about being watched or watching" / partner: "Share a fantasy involving voyeurism" |
| 6 | Describe what you want to say or hear during dirty talk | Hear what your partner wants to say or hear during dirty talk | 3 | text: "Hear what your partner wants to say or hear during dirty talk" / partner: "Describe what you want to say or hear during dirty talk" |
| 7 | Describe a specific fantasy involving oral sex | Hear your partner's fantasy about giving or receiving oral | 3 | text: "Hear your partner's fantasy about giving or receiving oral" / partner: "Describe a specific fantasy involving oral sex" |
| 8 | Describe your desire for slow, gentle lovemaking | Hear your partner's desire for slow, gentle lovemaking | 2 | text: "Hear your partner's desire for slow, gentle lovemaking" / partner: "Describe your desire for slow, gentle lovemaking" |
| 9 | Share a fantasy involving exhibitionism | Hear your partner's fantasy about being seen | 3 | text: "Hear your partner's fantasy about being seen" / partner: "Share a fantasy involving exhibitionism" |
| 10 | Share what you fantasize about while masturbating | Hear what your partner fantasizes about while masturbating | 3 | text: "Hear what your partner fantasizes about while masturbating" / partner: "Share what you fantasize about while masturbating" |
| 11 | Describe your desire for rougher sex | Hear your partner's desire for rougher sex | 3 | text: "Hear your partner's desire for rougher sex" / partner: "Describe your desire for rougher sex" |
| 12 | Describe your hottest sexual fantasy involving your partner | Hear your partner's hottest sexual fantasy about you | 3 | text: "Hear your partner's hottest sexual fantasy about you" / partner: "Describe your hottest sexual fantasy involving your partner" |
| 13 | Share a desire about how you want to be touched | Learn how your partner wants to be touched | 4 | text: "Learn how your partner wants to be touched" / partner: "Share a desire about how you want to be touched" |
| 14 | Share a secret turn-on that might surprise your partner | Learn a secret turn-on from your partner | 4 | text: "Learn a secret turn-on from your partner" / partner: "Share a secret turn-on that might surprise your partner" |
| 15 | Tell your partner the one thing that would make sex perfect | Hear what would make sex perfect for your partner | 4 | text: "Hear what would make sex perfect for your partner" / partner: "Tell your partner the one thing that would make sex perfect" |
| 16 | Explain your desire to be the submissive one | Hear about your partner's desire to be submissive | 3 | text: "Hear about your partner's desire to be submissive" / partner: "Explain your desire to be the submissive one" |
| 17 | Describe the type of porn that turns you on | Hear about the porn that turns your partner on | 3 | text: "Hear about the porn that turns your partner on" / partner: "Describe the type of porn that turns you on" |
| 18 | Describe a taboo fantasy that excites you | Hear about a taboo fantasy that excites your partner | 3 | text: "Hear about a taboo fantasy that excites your partner" / partner: "Describe a taboo fantasy that excites you" |
| 19 | Reveal a kink you're curious about exploring | Hear about a kink your partner wants to explore | 3 | text: "Hear about a kink your partner wants to explore" / partner: "Reveal a kink you're curious about exploring" |
| 20 | Describe your desire to be worshipped in bed | Hear about your partner's desire to be worshipped | 3 | text: "Hear about your partner's desire to be worshipped" / partner: "Describe your desire to be worshipped in bed" |
| 21 | Reveal a location where you want to have sex | Hear where your partner secretly wants to have sex | 3 | text: "Hear where your partner secretly wants to have sex" / partner: "Reveal a location where you want to have sex" |
| 22 | Explain your desire to be the dominant one | Hear about your partner's desire to be dominant | 3 | text: "Hear about your partner's desire to be dominant" / partner: "Explain your desire to be the dominant one" |
| 23 | Share a desire to have sex multiple times in one day | Hear your partner's desire for multiple rounds of sex | 3 | text: "Hear your partner's desire for multiple rounds of sex" / partner: "Share a desire to have sex multiple times in one day" |
| 24 | Describe your ultimate sexual fantasy | Hear your partner's ultimate sexual fantasy in detail | 3 | text: "Hear your partner's ultimate sexual fantasy in detail" / partner: "Describe your ultimate sexual fantasy" |
| 25 | Describe a fantasy involving teasing and denial | Hear your partner's fantasy about teasing and denial | 3 | text: "Hear your partner's fantasy about teasing and denial" / partner: "Describe a fantasy involving teasing and denial" |
# Getting Started (5 missing)
## 🔞 Know Your Limits (5 missing)
| # | Current Text | Current Partner Text | Intensity | Inverse to Create |
|---|--------------|---------------------|-----------|-------------------|
| 1 | Wear a butt plug during sex | Have your partner wear a butt plug during sex | 4 | text: "Have your partner wear a butt plug during sex" / partner: "Wear a butt plug during sex" |
| 2 | Swallow when giving your partner oral | Have your partner swallow | 4 | text: "Have your partner swallow" / partner: "Swallow when giving your partner oral" |
| 3 | Deep throat your partner | Have your partner deep throat you | 4 | text: "Have your partner deep throat you" / partner: "Deep throat your partner" |
| 4 | Have your partner cum inside you | Cum inside your partner | 4 | text: "Cum inside your partner" / partner: "Have your partner cum inside you" |
| 5 | Have your partner cum on your body | Cum on your partner's body | 4 | text: "Cum on your partner's body" / partner: "Have your partner cum on your body" |
# Long Distance (106 missing)
## ✨ Missing You (30 missing)
| # | Current Text | Current Partner Text | Intensity | Inverse to Create |
|---|--------------|---------------------|-----------|-------------------|
| 1 | Send a photo of your hand and say you wish you were holding theirs | Receive a sweet reminder of how much your partner wants to hold you | 1 | text: "Receive a sweet reminder of how much your partner wants to hold you" / partner: "Send a photo of your hand and say you wish you were holding theirs" |
| 2 | Tell your partner you saw someone who reminded you of them | Hear how often your partner has you on their mind | 1 | text: "Hear how often your partner has you on their mind" / partner: "Tell your partner you saw someone who reminded you of them" |
| 3 | Tell your partner what you'd be doing right now if they were there | Hear what your partner wishes you were doing together | 2 | text: "Hear what your partner wishes you were doing together" / partner: "Tell your partner what you'd be doing right now if they were there" |
| 4 | Send a tipsy voice note telling them you miss them | Get a slightly drunk "I miss you" message | 2 | text: "Get a slightly drunk "I miss you" message" / partner: "Send a tipsy voice note telling them you miss them" |
| 5 | Send a photo of your bed and say it feels empty without them | Receive a sweet photo of your partner's bed and a message that you're missed | 2 | text: "Receive a sweet photo of your partner's bed and a message that you're missed" / partner: "Send a photo of your bed and say it feels empty without them" |
| 6 | Send a photo of an empty seat and say you saved it for them | Receive a photo of a seat saved just for you | 1 | text: "Receive a photo of a seat saved just for you" / partner: "Send a photo of an empty seat and say you saved it for them" |
| 7 | Send a lazy selfie and say you wish you were doing nothing together | Receive a lazy selfie and an invitation to do nothing together | 1 | text: "Receive a lazy selfie and an invitation to do nothing together" / partner: "Send a lazy selfie and say you wish you were doing nothing together" |
| 8 | Text your partner from an event that it would be better with them | Know your partner wishes you were there | 2 | text: "Know your partner wishes you were there" / partner: "Text your partner from an event that it would be better with them" |
| 9 | Tell your partner something small they do that you miss unexpectedly | Hear what little thing your partner misses about you | 2 | text: "Hear what little thing your partner misses about you" / partner: "Tell your partner something small they do that you miss unexpectedly" |
| 10 | FaceTime your partner just to see their face for a few minutes | Get an "I just needed to see you" call | 2 | text: "Get an "I just needed to see you" call" / partner: "FaceTime your partner just to see their face for a few minutes" |
| 11 | Tell your partner what you're counting down to about seeing them | Build excitement for the reunion | 2 | text: "Build excitement for the reunion" / partner: "Tell your partner what you're counting down to about seeing them" |
| 12 | Voice note your partner something you've been meaning to tell them | Hear something meaningful from your partner | 2 | text: "Hear something meaningful from your partner" / partner: "Voice note your partner something you've been meaning to tell them" |
| 13 | Send a voice note from bed saying you miss sleeping next to them | Hear a sleepy voice note about how much you are missed in bed | 2 | text: "Hear a sleepy voice note about how much you are missed in bed" / partner: "Send a voice note from bed saying you miss sleeping next to them" |
| 14 | Tell your partner one thing you'll do to them when you're alone | Hear exactly what your partner has planned for your next reunion | 3 | text: "Hear exactly what your partner has planned for your next reunion" / partner: "Tell your partner one thing you'll do to them when you're alone" |
| 15 | Send a mirror selfie when you are dressed up to go out | Receive a photo of your partner looking their best | 2 | text: "Receive a photo of your partner looking their best" / partner: "Send a mirror selfie when you are dressed up to go out" |
| 16 | Tell your partner what random thing reminded you of them today | Hear that random things trigger memories of you | 2 | text: "Hear that random things trigger memories of you" / partner: "Tell your partner what random thing reminded you of them today" |
| 17 | Text your partner that you caught yourself smiling at your phone because of them | Know you made your partner smile | 2 | text: "Know you made your partner smile" / partner: "Text your partner that you caught yourself smiling at your phone because of them" |
| 18 | Describe the exact way you want them to hold you when you reunite | Feel the anticipation build for their touch | 2 | text: "Feel the anticipation build for their touch" / partner: "Describe the exact way you want them to hold you when you reunite" |
| 19 | Send a sleepy morning selfie, messy hair and all | See your partner's unfiltered morning face | 2 | text: "See your partner's unfiltered morning face" / partner: "Send a sleepy morning selfie, messy hair and all" |
| 20 | Tell your partner what outfit of theirs you can't stop thinking about | Hear what look of yours drives them crazy | 2 | text: "Hear what look of yours drives them crazy" / partner: "Tell your partner what outfit of theirs you can't stop thinking about" |
| 21 | Text your partner at a party that you'd rather be home calling them | Feel chosen over a social event | 2 | text: "Feel chosen over a social event" / partner: "Text your partner at a party that you'd rather be home calling them" |
| 22 | Text your partner first thing in the morning that they were your first thought | Wake up knowing you're on their mind | 2 | text: "Wake up knowing you're on their mind" / partner: "Text your partner first thing in the morning that they were your first thought" |
| 23 | Send a photo of you wearing something of theirs | See your partner in your hoodie | 2 | text: "See your partner in your hoodie" / partner: "Send a photo of you wearing something of theirs" |
| 24 | Tell your partner about a dream you had about them | Hear you were in your partner's dreams | 2 | text: "Hear you were in your partner's dreams" / partner: "Tell your partner about a dream you had about them" |
| 25 | Send a getting-ready selfie and ask if they approve | Give your partner's outfit the seal of approval | 2 | text: "Give your partner's outfit the seal of approval" / partner: "Send a getting-ready selfie and ask if they approve" |
| 26 | Send a voice note when you're half asleep saying goodnight | Hear your partner's sleepy voice | 2 | text: "Hear your partner's sleepy voice" / partner: "Send a voice note when you're half asleep saying goodnight" |
| 27 | Tell your partner something specific about their body you've been thinking about | Hear what physical thing your partner misses | 2 | text: "Hear what physical thing your partner misses" / partner: "Tell your partner something specific about their body you've been thinking about" |
| 28 | Describe exactly how you'd greet them at the airport | Build anticipation for the reunion | 2 | text: "Build anticipation for the reunion" / partner: "Describe exactly how you'd greet them at the airport" |
| 29 | Text your partner during a boring meeting that you're thinking about them | Get a random "thinking of you" during work | 2 | text: "Get a random "thinking of you" during work" / partner: "Text your partner during a boring meeting that you're thinking about them" |
| 30 | Text your partner "wish you were here" from somewhere fun | Hear that your partner wishes you were by their side | 1 | text: "Hear that your partner wishes you were by their side" / partner: "Text your partner "wish you were here" from somewhere fun" |
## 🔞 Private Line (27 missing)
| # | Current Text | Current Partner Text | Intensity | Inverse to Create |
|---|--------------|---------------------|-----------|-------------------|
| 1 | Text your partner what you're wearing, then what you're taking off | Get a strip tease via text | 3 | text: "Get a strip tease via text" / partner: "Text your partner what you're wearing, then what you're taking off" |
| 2 | Send a post-climax photo to your partner | See your partner's glow in a post-climax photo | 4 | text: "See your partner's glow in a post-climax photo" / partner: "Send a post-climax photo to your partner" |
| 3 | Text your partner a play-by-play while you pleasure yourself | Read a play-by-play as your partner pleasures themselves | 3 | text: "Read a play-by-play as your partner pleasures themselves" / partner: "Text your partner a play-by-play while you pleasure yourself" |
| 4 | Voice note your partner while touching yourself - no words, just sounds | Hear your partner pleasure themselves | 4 | text: "Hear your partner pleasure themselves" / partner: "Voice note your partner while touching yourself - no words, just sounds" |
| 5 | Send a voice note of your release while saying their name | Hear your partner's release as they say your name | 4 | text: "Hear your partner's release as they say your name" / partner: "Send a voice note of your release while saying their name" |
| 6 | Share a secret fantasy with your partner in detail | Listen to your partner share a secret fantasy in detail | 2 | text: "Listen to your partner share a secret fantasy in detail" / partner: "Share a secret fantasy with your partner in detail" |
| 7 | Talk your partner through how to touch themselves over the phone | Let your partner guide your touch over the phone | 4 | text: "Let your partner guide your touch over the phone" / partner: "Talk your partner through how to touch themselves over the phone" |
| 8 | Send a photo from the shower thinking about your partner | See your partner wet and thinking of you | 3 | text: "See your partner wet and thinking of you" / partner: "Send a photo from the shower thinking about your partner" |
| 9 | Ask your partner for a specific photo you've been craving | Send your partner a specific photo they've been craving | 4 | text: "Send your partner a specific photo they've been craving" / partner: "Ask your partner for a specific photo you've been craving" |
| 10 | Send your partner a short video of your hand where you wish theirs was | Watch your partner touch themselves for you | 4 | text: "Watch your partner touch themselves for you" / partner: "Send your partner a short video of your hand where you wish theirs was" |
| 11 | Send your partner a video of you getting undressed | Watch your partner undress for you | 3 | text: "Watch your partner undress for you" / partner: "Send your partner a video of you getting undressed" |
| 12 | Send a photo in bed after finishing and tell them it should've been them | See your partner satisfied but wishing you were there | 4 | text: "See your partner satisfied but wishing you were there" / partner: "Send a photo in bed after finishing and tell them it should've been them" |
| 13 | Tell your partner step-by-step what you're going to do when you reunite | Hear your partner's detailed plan for your body | 3 | text: "Hear your partner's detailed plan for your body" / partner: "Tell your partner step-by-step what you're going to do when you reunite" |
| 14 | Video call and make your partner watch without touching until you say so | Be made to watch without touching yourself | 4 | text: "Be made to watch without touching yourself" / partner: "Video call and make your partner watch without touching until you say so" |
| 15 | Send a video of you in their favorite position, waiting | See your partner ready and waiting for you | 4 | text: "See your partner ready and waiting for you" / partner: "Send a video of you in their favorite position, waiting" |
| 16 | Record yourself saying exactly what you'd whisper in their ear in bed | Hear your partner's voice like they're right next to you | 3 | text: "Hear your partner's voice like they're right next to you" / partner: "Record yourself saying exactly what you'd whisper in their ear in bed" |
| 17 | Send your partner a video of you in the mirror, showing off for them | Watch your partner show off their body for you | 3 | text: "Watch your partner show off their body for you" / partner: "Send your partner a video of you in the mirror, showing off for them" |
| 18 | Describe what you're imagining while you touch yourself over text | Read your partner's thoughts while they get off to you | 4 | text: "Read your partner's thoughts while they get off to you" / partner: "Describe what you're imagining while you touch yourself over text" |
| 19 | Send a voice note telling your partner to touch themselves right now | Follow your partner's command from afar | 4 | text: "Follow your partner's command from afar" / partner: "Send a voice note telling your partner to touch themselves right now" |
| 20 | Watch the same video together over FaceTime and see who breaks first | Get turned on together from miles away | 4 | text: "Get turned on together from miles away" / partner: "Watch the same video together over FaceTime and see who breaks first" |
| 21 | Describe what position you want them in the moment you're alone together | Hear how your partner wants to have you | 3 | text: "Hear how your partner wants to have you" / partner: "Describe what position you want them in the moment you're alone together" |
| 22 | Sext your partner discreetly while you are both busy | Exchange discreet, sensual texts while you are both busy | 3 | text: "Exchange discreet, sensual texts while you are both busy" / partner: "Sext your partner discreetly while you are both busy" |
| 23 | Send a voice note describing what you'd do to your partner | Hear your partner describe what they want to do to you | 3 | text: "Hear your partner describe what they want to do to you" / partner: "Send a voice note describing what you'd do to your partner" |
| 24 | Send your partner a sensual morning text | Wake up to a sensual text from your partner | 2 | text: "Wake up to a sensual text from your partner" / partner: "Send your partner a sensual morning text" |
| 25 | Send a teasing text to build anticipation for a later call | Receive a teasing text that builds anticipation for a call | 2 | text: "Receive a teasing text that builds anticipation for a call" / partner: "Send a teasing text to build anticipation for a later call" |
| 26 | Send your partner a teasing, private photo | Receive a teasing, private photo from your partner | 3 | text: "Receive a teasing, private photo from your partner" / partner: "Send your partner a teasing, private photo" |
| 27 | Tell your partner exactly where you want their mouth when you see them | Hear where your partner wants to taste you | 2 | text: "Hear where your partner wants to taste you" / partner: "Tell your partner exactly where you want their mouth when you see them" |
## ✨ Staying Close (19 missing)
| # | Current Text | Current Partner Text | Intensity | Inverse to Create |
|---|--------------|---------------------|-----------|-------------------|
| 1 | Challenge your partner to an online board game | Be challenged to an online board game by your partner | 1 | text: "Be challenged to an online board game by your partner" / partner: "Challenge your partner to an online board game" |
| 2 | Send your partner a voice note on your commute | Get a voice note from your partner during their day | 1 | text: "Get a voice note from your partner during their day" / partner: "Send your partner a voice note on your commute" |
| 3 | Send a random photo of your current view | Receive a candid snapshot of your partner's day | 1 | text: "Receive a candid snapshot of your partner's day" / partner: "Send a random photo of your current view" |
| 4 | Send your partner a care package of their favorite snacks | Receive a snack-filled care package from your partner | 1 | text: "Receive a snack-filled care package from your partner" / partner: "Send your partner a care package of their favorite snacks" |
| 5 | Text your partner updates from a social event they'd hate | Live vicariously through your partner's obligations | 1 | text: "Live vicariously through your partner's obligations" / partner: "Text your partner updates from a social event they'd hate" |
| 6 | Make a shared note of restaurants to try when you're together | Build a list for your next visit | 1 | text: "Build a list for your next visit" / partner: "Make a shared note of restaurants to try when you're together" |
| 7 | Share your coffee order for your next visit | Learn your partner's go-to coffee order | 1 | text: "Learn your partner's go-to coffee order" / partner: "Share your coffee order for your next visit" |
| 8 | Send your partner a meme that only they would understand | Receive an inside joke from your partner | 1 | text: "Receive an inside joke from your partner" / partner: "Send your partner a meme that only they would understand" |
| 9 | Watch a live sports game or event together while texting reactions | React to something live with your partner | 1 | text: "React to something live with your partner" / partner: "Watch a live sports game or event together while texting reactions" |
| 10 | Do the same Wordle and compare results | Compete on a daily puzzle with your partner | 1 | text: "Compete on a daily puzzle with your partner" / partner: "Do the same Wordle and compare results" |
| 11 | Send your partner a song you heard that made you think of them | Get a track that reminded your partner of you | 1 | text: "Get a track that reminded your partner of you" / partner: "Send your partner a song you heard that made you think of them" |
| 12 | Screenshot something funny from your day and send it with no context | Get a random screenshot from your partner | 1 | text: "Get a random screenshot from your partner" / partner: "Screenshot something funny from your day and send it with no context" |
| 13 | Surprise your partner by ordering delivery to their house | Be surprised with a delivery meal from your partner | 1 | text: "Be surprised with a delivery meal from your partner" / partner: "Surprise your partner by ordering delivery to their house" |
| 14 | Share your location with your partner for safety | See your partner's location to know they're safe | 1 | text: "See your partner's location to know they're safe" / partner: "Share your location with your partner for safety" |
| 15 | Send a voice note venting about your day | Listen to your partner vent about their day | 1 | text: "Listen to your partner vent about their day" / partner: "Send a voice note venting about your day" |
| 16 | Video call your partner while you grocery shop | Go grocery shopping virtually with your partner | 1 | text: "Go grocery shopping virtually with your partner" / partner: "Video call your partner while you grocery shop" |
| 17 | Call your partner while you're on a walk just to chat | Take a walk with your partner over the phone | 1 | text: "Take a walk with your partner over the phone" / partner: "Call your partner while you're on a walk just to chat" |
| 18 | Send a photo of something that reminded you of an inside joke | Get a reference only you two would understand | 1 | text: "Get a reference only you two would understand" / partner: "Send a photo of something that reminded you of an inside joke" |
| 19 | Video call your partner during your lunch break | Enjoy a quick lunch date over video call | 1 | text: "Enjoy a quick lunch date over video call" / partner: "Video call your partner during your lunch break" |
## 🔞 Total Control (30 missing)
| # | Current Text | Current Partner Text | Intensity | Inverse to Create |
|---|--------------|---------------------|-----------|-------------------|
| 1 | Set rules your partner must follow until you see each other again | Follow your partner's rules until your next reunion | 5 | text: "Follow your partner's rules until your next reunion" / partner: "Set rules your partner must follow until you see each other again" |
| 2 | Make your partner edge for you on video every night for a week | Edge for your partner on camera every night until they let you finish | 5 | text: "Edge for your partner on camera every night until they let you finish" / partner: "Make your partner edge for you on video every night for a week" |
| 3 | Build a reunion list of everything you'll do when you're finally together | Add to the list of what your partner will do to you when you meet | 4 | text: "Add to the list of what your partner will do to you when you meet" / partner: "Build a reunion list of everything you'll do when you're finally together" |
| 4 | Control your partner's toy on maximum until they use your safe word | Take as much as you can until you have to tap out | 5 | text: "Take as much as you can until you have to tap out" / partner: "Control your partner's toy on maximum until they use your safe word" |
| 5 | Make your partner sleep with a plug in and send proof in the morning | Sleep with a plug and send your partner a photo when you wake up | 5 | text: "Sleep with a plug and send your partner a photo when you wake up" / partner: "Make your partner sleep with a plug in and send proof in the morning" |
| 6 | Have a session where your partner can't make a sound no matter what you do | Stay completely silent while your partner controls your toy | 5 | text: "Stay completely silent while your partner controls your toy" / partner: "Have a session where your partner can't make a sound no matter what you do" |
| 7 | Tell your partner exactly when they're allowed to finish - down to the minute | Wait until the exact moment your partner gives you permission | 5 | text: "Wait until the exact moment your partner gives you permission" / partner: "Tell your partner exactly when they're allowed to finish - down to the minute" |
| 8 | Make your partner keep a toy in while doing something mundane on video | Do a normal activity on camera while wearing a toy inside you | 4 | text: "Do a normal activity on camera while wearing a toy inside you" / partner: "Make your partner keep a toy in while doing something mundane on video" |
| 9 | Give your partner an assignment to edge a specific number of times before bed | Edge the exact number of times your partner told you to | 5 | text: "Edge the exact number of times your partner told you to" / partner: "Give your partner an assignment to edge a specific number of times before bed" |
| 10 | Control your partner's toy while they try to have a normal phone conversation with you | Try to talk normally while your partner controls your toy | 4 | text: "Try to talk normally while your partner controls your toy" / partner: "Control your partner's toy while they try to have a normal phone conversation with you" |
| 11 | Make your partner beg over text before you'll let them touch themselves | Beg your partner for permission to touch yourself | 5 | text: "Beg your partner for permission to touch yourself" / partner: "Make your partner beg over text before you'll let them touch themselves" |
| 12 | Set a time your partner must be ready and waiting on video | Be ready and waiting at the exact time your partner specified | 4 | text: "Be ready and waiting at the exact time your partner specified" / partner: "Set a time your partner must be ready and waiting on video" |
| 13 | Make your partner wear a remote toy during dinner with friends | Wear a toy at dinner while your partner teases you from afar | 5 | text: "Wear a toy at dinner while your partner teases you from afar" / partner: "Make your partner wear a remote toy during dinner with friends" |
| 14 | Have your partner send a video proving they followed your instructions | Send video evidence that you did what your partner asked | 5 | text: "Send video evidence that you did what your partner asked" / partner: "Have your partner send a video proving they followed your instructions" |
| 15 | Control your partner's toy and stop right before they finish - repeatedly | Be brought to the edge over and over without release | 5 | text: "Be brought to the edge over and over without release" / partner: "Control your partner's toy and stop right before they finish - repeatedly" |
| 16 | Make your partner use a toy on themselves while you watch and give commands | Use a toy on yourself while following your partner's instructions | 4 | text: "Use a toy on yourself while following your partner's instructions" / partner: "Make your partner use a toy on themselves while you watch and give commands" |
| 17 | Surprise your partner with a remote toy buzz during a work call | Try to keep composure while your partner surprises you mid-meeting | 4 | text: "Try to keep composure while your partner surprises you mid-meeting" / partner: "Surprise your partner with a remote toy buzz during a work call" |
| 18 | Make your partner edge themselves daily and report back to you | Edge yourself each day and send proof to your partner | 5 | text: "Edge yourself each day and send proof to your partner" / partner: "Make your partner edge themselves daily and report back to you" |
| 19 | Deny your partner for an entire day, building anticipation for the call | Go a full day without touching yourself before your partner lets you | 5 | text: "Go a full day without touching yourself before your partner lets you" / partner: "Deny your partner for an entire day, building anticipation for the call" |
| 20 | Make your partner describe every sensation while you control their toy | Narrate what you're feeling while your partner controls your toy | 4 | text: "Narrate what you're feeling while your partner controls your toy" / partner: "Make your partner describe every sensation while you control their toy" |
| 21 | Control all of your partner's pleasure for an entire video session | Give up all control and let your partner run the session | 5 | text: "Give up all control and let your partner run the session" / partner: "Control all of your partner's pleasure for an entire video session" |
| 22 | Tell your partner what to wear under their clothes for the day | Wear what your partner chose for you under your clothes | 4 | text: "Wear what your partner chose for you under your clothes" / partner: "Tell your partner what to wear under their clothes for the day" |
| 23 | Make your partner record themselves following your explicit instructions | Record yourself doing exactly what your partner tells you | 5 | text: "Record yourself doing exactly what your partner tells you" / partner: "Make your partner record themselves following your explicit instructions" |
| 24 | Count your partner's edges on video and decide when they've earned release | Count your edges out loud while your partner decides when you're done | 5 | text: "Count your edges out loud while your partner decides when you're done" / partner: "Count your partner's edges on video and decide when they've earned release" |
| 25 | Make your partner wear a plug they chose for you throughout the day | Wear a plug all day that your partner picked out | 5 | text: "Wear a plug all day that your partner picked out" / partner: "Make your partner wear a plug they chose for you throughout the day" |
| 26 | Give your partner a list of tasks to complete before your next video call | Complete tasks your partner assigns before you can call them | 5 | text: "Complete tasks your partner assigns before you can call them" / partner: "Give your partner a list of tasks to complete before your next video call" |
| 27 | Make your partner ask permission via text before they're allowed to orgasm | Ask your partner for permission before you're allowed to come | 5 | text: "Ask your partner for permission before you're allowed to come" / partner: "Make your partner ask permission via text before they're allowed to orgasm" |
| 28 | Edge your partner with a remote toy three times before letting them finish | Be edged remotely three times before your partner allows release | 5 | text: "Be edged remotely three times before your partner allows release" / partner: "Edge your partner with a remote toy three times before letting them finish" |
| 29 | Make your partner wear a remote toy while they run errands | Wear a toy in public while your partner teases you randomly | 4 | text: "Wear a toy in public while your partner teases you randomly" / partner: "Make your partner wear a remote toy while they run errands" |
| 30 | Control your partner's remote vibrator while on a video call | Wear a remote toy while your partner watches and controls it | 4 | text: "Wear a remote toy while your partner watches and controls it" / partner: "Control your partner's remote vibrator while on a video call" |
# Public Thrills (88 missing)
## 🔞 All Eyes On Us (28 missing)
| # | Current Text | Current Partner Text | Intensity | Inverse to Create |
|---|--------------|---------------------|-----------|-------------------|
| 1 | Have sex with your partner in the ocean or a lake | Have sex in the water with your partner | 5 | text: "Have sex in the water with your partner" / partner: "Have sex with your partner in the ocean or a lake" |
| 2 | Have sex on your apartment balcony | Get taken on a balcony in view of other buildings | 5 | text: "Get taken on a balcony in view of other buildings" / partner: "Have sex on your apartment balcony" |
| 3 | Have sex in a private room at a club or lounge | Sneak off to a back room and fuck at a venue | 4 | text: "Sneak off to a back room and fuck at a venue" / partner: "Have sex in a private room at a club or lounge" |
| 4 | Make your partner finish while other people are in the next room | Try not to make a sound while finishing with people nearby | 4 | text: "Try not to make a sound while finishing with people nearby" / partner: "Make your partner finish while other people are in the next room" |
| 5 | Make your partner beg for it quietly while in a public place | Beg your partner to fuck you while trying to stay quiet | 5 | text: "Beg your partner to fuck you while trying to stay quiet" / partner: "Make your partner beg for it quietly while in a public place" |
| 6 | Have sex at a beach at night when no one is around | Have sex on the sand under the stars | 5 | text: "Have sex on the sand under the stars" / partner: "Have sex at a beach at night when no one is around" |
| 7 | Have sex with your partner in front of a mirror in a semi-public place | Watch yourself have sex in a risky location | 5 | text: "Watch yourself have sex in a risky location" / partner: "Have sex with your partner in front of a mirror in a semi-public place" |
| 8 | Have sex with your partner in an Airbnb with thin walls | Have loud sex knowing the neighbors can hear | 4 | text: "Have loud sex knowing the neighbors can hear" / partner: "Have sex with your partner in an Airbnb with thin walls" |
| 9 | Have sex with your partner in a secluded part of a hiking trail | Have sex on a trail where a hiker could walk by | 5 | text: "Have sex on a trail where a hiker could walk by" / partner: "Have sex with your partner in a secluded part of a hiking trail" |
| 10 | Give your partner a handjob under a blanket on a flight | Get jerked off on a plane under a blanket | 5 | text: "Get jerked off on a plane under a blanket" / partner: "Give your partner a handjob under a blanket on a flight" |
| 11 | Have your partner go down on you in a parked car | Give road head while parked somewhere semi-private | 5 | text: "Give road head while parked somewhere semi-private" / partner: "Have your partner go down on you in a parked car" |
| 12 | Have your partner finish while sitting at a bar with a remote toy | Cum at a bar while your partner controls your toy | 5 | text: "Cum at a bar while your partner controls your toy" / partner: "Have your partner finish while sitting at a bar with a remote toy" |
| 13 | Have your partner wear a vibrator while meeting your friends | Wear a vibrator around your partner's friends while they control it | 5 | text: "Wear a vibrator around your partner's friends while they control it" / partner: "Have your partner wear a vibrator while meeting your friends" |
| 14 | Have sex in a hotel room with the curtains open at night | Get fucked with the curtains open for anyone to see | 5 | text: "Get fucked with the curtains open for anyone to see" / partner: "Have sex in a hotel room with the curtains open at night" |
| 15 | Have sex with your partner in a bathroom at a house party | Disappear at a party and come back looking suspicious | 4 | text: "Disappear at a party and come back looking suspicious" / partner: "Have sex with your partner in a bathroom at a house party" |
| 16 | Have sex with your partner in a parked car in a secluded spot | Have car sex somewhere you might get caught | 4 | text: "Have car sex somewhere you might get caught" / partner: "Have sex with your partner in a parked car in a secluded spot" |
| 17 | Make your partner moan loudly in a hotel knowing the walls are thin | Let the neighbors hear you while your partner makes you moan | 5 | text: "Let the neighbors hear you while your partner makes you moan" / partner: "Make your partner moan loudly in a hotel knowing the walls are thin" |
| 18 | Go down on your partner on a balcony at night | Receive oral on a balcony where someone could look up | 5 | text: "Receive oral on a balcony where someone could look up" / partner: "Go down on your partner on a balcony at night" |
| 19 | Make your partner finish in a semi-public bathroom | Get off in a public restroom with your partner | 4 | text: "Get off in a public restroom with your partner" / partner: "Make your partner finish in a semi-public bathroom" |
| 20 | Have sex with your partner in a hotel pool or hot tub late at night | Fool around in a hotel pool when no one else is around | 5 | text: "Fool around in a hotel pool when no one else is around" / partner: "Have sex with your partner in a hotel pool or hot tub late at night" |
| 21 | Bend your partner over in a dressing room | Get bent over in a fitting room by your partner | 5 | text: "Get bent over in a fitting room by your partner" / partner: "Bend your partner over in a dressing room" |
| 22 | Have sex with your partner against a window at night | Be taken against a window where someone could see | 5 | text: "Be taken against a window where someone could see" / partner: "Have sex with your partner against a window at night" |
| 23 | Get your partner off in the back row of an empty theater | Finish in a movie theater while your partner helps | 4 | text: "Finish in a movie theater while your partner helps" / partner: "Get your partner off in the back row of an empty theater" |
| 24 | Have your partner ride you in the driver's seat in a parking garage | Ride your partner in a parked car | 5 | text: "Ride your partner in a parked car" / partner: "Have your partner ride you in the driver's seat in a parking garage" |
| 25 | Have sex on a rooftop at night | Get fucked on a rooftop under the sky | 5 | text: "Get fucked on a rooftop under the sky" / partner: "Have sex on a rooftop at night" |
| 26 | Go down on your partner while they're on a phone call | Take a call while your partner goes down on you | 4 | text: "Take a call while your partner goes down on you" / partner: "Go down on your partner while they're on a phone call" |
| 27 | Have sex in a tent at a campsite where others might hear | Try to stay quiet while your partner fucks you in a tent | 5 | text: "Try to stay quiet while your partner fucks you in a tent" / partner: "Have sex in a tent at a campsite where others might hear" |
| 28 | Have sex in your childhood bedroom while visiting family | Fool around in your partner's family home | 4 | text: "Fool around in your partner's family home" / partner: "Have sex in your childhood bedroom while visiting family" |
## 🔞 Pushing Limits (30 missing)
| # | Current Text | Current Partner Text | Intensity | Inverse to Create |
|---|--------------|---------------------|-----------|-------------------|
| 1 | Unbutton one too many buttons on your shirt for your partner at an event | Catch your partner showing extra skin just for you | 3 | text: "Catch your partner showing extra skin just for you" / partner: "Unbutton one too many buttons on your shirt for your partner at an event" |
| 2 | Control your partner's remote toy while they order at a counter | Try to order food while your partner buzzes you | 4 | text: "Try to order food while your partner buzzes you" / partner: "Control your partner's remote toy while they order at a counter" |
| 3 | Have a quickie makeout in a hotel hallway | Make out with your partner before you even get to the room | 2 | text: "Make out with your partner before you even get to the room" / partner: "Have a quickie makeout in a hotel hallway" |
| 4 | Give your partner oral in a locked bathroom at a party | Receive oral while trying to stay quiet at an event | 5 | text: "Receive oral while trying to stay quiet at an event" / partner: "Give your partner oral in a locked bathroom at a party" |
| 5 | Make your partner wear a remote vibrator while out to dinner | Wear a vibrator at a restaurant while your partner controls it | 4 | text: "Wear a vibrator at a restaurant while your partner controls it" / partner: "Make your partner wear a remote vibrator while out to dinner" |
| 6 | Have your partner sit across from you at a bar while you describe what you'd do if you were alone | Listen to your partner describe exactly what's about to happen | 3 | text: "Listen to your partner describe exactly what's about to happen" / partner: "Have your partner sit across from you at a bar while you describe what you'd do if you were alone" |
| 7 | Press your partner against a car and kiss them before getting in | Get kissed against the car before you even leave | 3 | text: "Get kissed against the car before you even leave" / partner: "Press your partner against a car and kiss them before getting in" |
| 8 | Take your partner to a secluded spot at a park and make out | Find a hidden spot with your partner for some privacy | 3 | text: "Find a hidden spot with your partner for some privacy" / partner: "Take your partner to a secluded spot at a park and make out" |
| 9 | Kiss your partner's neck in the back of a dark venue | Feel your partner's mouth on your neck in public | 3 | text: "Feel your partner's mouth on your neck in public" / partner: "Kiss your partner's neck in the back of a dark venue" |
| 10 | Get your partner worked up in a booth at a dimly lit restaurant | Get teased in a booth where no one can see | 4 | text: "Get teased in a booth where no one can see" / partner: "Get your partner worked up in a booth at a dimly lit restaurant" |
| 11 | Make your partner sit on your lap at a bar and feel what they do to you | Sit on your partner's lap and feel their reaction | 3 | text: "Sit on your partner's lap and feel their reaction" / partner: "Make your partner sit on your lap at a bar and feel what they do to you" |
| 12 | Pull your partner into a coat closet at a party | Get pulled into a closet by your partner at an event | 4 | text: "Get pulled into a closet by your partner at an event" / partner: "Pull your partner into a coat closet at a party" |
| 13 | Touch your partner while you're both in a pool or hot tub with others nearby | Feel your partner's hands on you under the water | 4 | text: "Feel your partner's hands on you under the water" / partner: "Touch your partner while you're both in a pool or hot tub with others nearby" |
| 14 | Sneak off with your partner at a wedding for a private moment | Disappear with your partner during an event | 3 | text: "Disappear with your partner during an event" / partner: "Sneak off with your partner at a wedding for a private moment" |
| 15 | Whisper to your partner exactly what you want them to do to you once you leave | Hear in detail what's coming the moment you get home | 3 | text: "Hear in detail what's coming the moment you get home" / partner: "Whisper to your partner exactly what you want them to do to you once you leave" |
| 16 | Touch your partner through their clothes in a parked car | Get felt up in your car before going inside | 4 | text: "Get felt up in your car before going inside" / partner: "Touch your partner through their clothes in a parked car" |
| 17 | Touch your partner under the table at a dinner party until they squirm | Try not to react while your partner's hand is between your legs | 4 | text: "Try not to react while your partner's hand is between your legs" / partner: "Touch your partner under the table at a dinner party until they squirm" |
| 18 | Grind on your partner on a crowded dance floor | Feel your partner grind against you surrounded by people | 3 | text: "Feel your partner grind against you surrounded by people" / partner: "Grind on your partner on a crowded dance floor" |
| 19 | Tease your partner in the back of a cab until you get home | Get worked up in a cab knowing you can't do anything yet | 3 | text: "Get worked up in a cab knowing you can't do anything yet" / partner: "Tease your partner in the back of a cab until you get home" |
| 20 | Make out with your partner in a stairwell | Hook up with your partner in a quiet stairwell | 3 | text: "Hook up with your partner in a quiet stairwell" / partner: "Make out with your partner in a stairwell" |
| 21 | Touch your partner under a blanket at a friend's movie night | Get touched under a blanket while others are in the room | 4 | text: "Get touched under a blanket while others are in the room" / partner: "Touch your partner under a blanket at a friend's movie night" |
| 22 | Give your partner a hickey somewhere that will be hidden by clothes | Get marked by your partner in a semi-public place | 3 | text: "Get marked by your partner in a semi-public place" / partner: "Give your partner a hickey somewhere that will be hidden by clothes" |
| 23 | Slip your hand into your partner's pants briefly in a dark corner | Feel your partner's hand slide into your pants somewhere risky | 4 | text: "Feel your partner's hand slide into your pants somewhere risky" / partner: "Slip your hand into your partner's pants briefly in a dark corner" |
| 24 | Kiss your partner against a wall in an alley | Get pressed against a wall and kissed outside | 3 | text: "Get pressed against a wall and kissed outside" / partner: "Kiss your partner against a wall in an alley" |
| 25 | Touch your partner over their clothes at a house party when no one's looking | Get touched at a party while trying to act normal | 3 | text: "Get touched at a party while trying to act normal" / partner: "Touch your partner over their clothes at a house party when no one's looking" |
| 26 | Pull your partner into a dressing room for a heated makeout | Get dragged into a fitting room by your partner | 4 | text: "Get dragged into a fitting room by your partner" / partner: "Pull your partner into a dressing room for a heated makeout" |
| 27 | Get handsy with your partner in the back seat of an Uber | Fool around with your partner while someone else drives | 4 | text: "Fool around with your partner while someone else drives" / partner: "Get handsy with your partner in the back seat of an Uber" |
| 28 | Have your partner go commando on a date and tease them about it all night | Go without underwear while your partner reminds you constantly | 3 | text: "Go without underwear while your partner reminds you constantly" / partner: "Have your partner go commando on a date and tease them about it all night" |
| 29 | Touch your partner under their clothes in a dark movie theater | Feel your partner's hands under your clothes at the movies | 4 | text: "Feel your partner's hands under your clothes at the movies" / partner: "Touch your partner under their clothes in a dark movie theater" |
| 30 | Make out with your partner in a bar or club bathroom | Get pulled into a bathroom for a makeout session | 3 | text: "Get pulled into a bathroom for a makeout session" / partner: "Make out with your partner in a bar or club bathroom" |
## 🔞 Testing the Waters (30 missing)
| # | Current Text | Current Partner Text | Intensity | Inverse to Create |
|---|--------------|---------------------|-----------|-------------------|
| 1 | Graze your partner's back under their shirt while walking together | Feel your partner's fingers on your bare skin in public | 2 | text: "Feel your partner's fingers on your bare skin in public" / partner: "Graze your partner's back under their shirt while walking together" |
| 2 | Brush against your partner suggestively while passing them in a crowd | Feel your partner brush against you on purpose | 2 | text: "Feel your partner brush against you on purpose" / partner: "Brush against your partner suggestively while passing them in a crowd" |
| 3 | Leave your hand on your partner's inner thigh during a movie | Feel your partner's hand resting dangerously high during a film | 3 | text: "Feel your partner's hand resting dangerously high during a film" / partner: "Leave your hand on your partner's inner thigh during a movie" |
| 4 | Tell your partner exactly what you're going to do to them later while at a party | Hear your partner's plans for you while surrounded by people | 2 | text: "Hear your partner's plans for you while surrounded by people" / partner: "Tell your partner exactly what you're going to do to them later while at a party" |
| 5 | Make prolonged eye contact with your partner across a room until one of you breaks | Hold eye contact with your partner from across the room | 2 | text: "Hold eye contact with your partner from across the room" / partner: "Make prolonged eye contact with your partner across a room until one of you breaks" |
| 6 | Give your partner a look that tells them exactly what you're thinking | Catch your partner giving you that look in public | 2 | text: "Catch your partner giving you that look in public" / partner: "Give your partner a look that tells them exactly what you're thinking" |
| 7 | Whisper something sensual to your partner in a crowded place | Hear something sensual whispered in your ear in public | 2 | text: "Hear something sensual whispered in your ear in public" / partner: "Whisper something sensual to your partner in a crowded place" |
| 8 | Steal a long kiss with your partner in an elevator before the doors open | Make out in an elevator and stop just before it opens | 3 | text: "Make out in an elevator and stop just before it opens" / partner: "Steal a long kiss with your partner in an elevator before the doors open" |
| 9 | Run your fingers along your partner's neck while standing behind them | Feel your partner's touch on your neck in public | 2 | text: "Feel your partner's touch on your neck in public" / partner: "Run your fingers along your partner's neck while standing behind them" |
| 10 | Press against your partner from behind while waiting in line | Feel your partner pressed against you in a queue | 3 | text: "Feel your partner pressed against you in a queue" / partner: "Press against your partner from behind while waiting in line" |
| 11 | Kiss your partner's neck briefly while hugging them goodbye in front of others | Feel your partner's lips on your neck in a public goodbye | 2 | text: "Feel your partner's lips on your neck in a public goodbye" / partner: "Kiss your partner's neck briefly while hugging them goodbye in front of others" |
| 12 | Flirt with your partner like you just met them at a bar | Play along while your partner hits on you like a stranger | 2 | text: "Play along while your partner hits on you like a stranger" / partner: "Flirt with your partner like you just met them at a bar" |
| 13 | Bite your lip while looking at your partner across a table full of people | Catch your partner biting their lip at you in front of others | 2 | text: "Catch your partner biting their lip at you in front of others" / partner: "Bite your lip while looking at your partner across a table full of people" |
| 14 | Slip your hand into your partner's back pocket while walking | Feel your partner's hand slide into your pocket | 2 | text: "Feel your partner's hand slide into your pocket" / partner: "Slip your hand into your partner's back pocket while walking" |
| 15 | Tell your partner to meet you somewhere private in five minutes | Get a message to meet your partner somewhere right now | 3 | text: "Get a message to meet your partner somewhere right now" / partner: "Tell your partner to meet you somewhere private in five minutes" |
| 16 | Drag your nails lightly down your partner's arm while talking to friends | Feel your partner's nails on your skin while acting normal | 2 | text: "Feel your partner's nails on your skin while acting normal" / partner: "Drag your nails lightly down your partner's arm while talking to friends" |
| 17 | Lean in and smell your partner while complimenting them in public | Have your partner lean in close and breathe you in | 2 | text: "Have your partner lean in close and breathe you in" / partner: "Lean in and smell your partner while complimenting them in public" |
| 18 | Dance closely with your partner at a club like no one else exists | Get lost in your partner on the dance floor | 3 | text: "Get lost in your partner on the dance floor" / partner: "Dance closely with your partner at a club like no one else exists" |
| 19 | Pin your partner against a wall for a quick kiss when no one's watching | Get pinned and kissed somewhere semi-public | 3 | text: "Get pinned and kissed somewhere semi-public" / partner: "Pin your partner against a wall for a quick kiss when no one's watching" |
| 20 | Trace shapes on your partner's palm under the table | Feel your partner's fingers drawing on your hand secretly | 2 | text: "Feel your partner's fingers drawing on your hand secretly" / partner: "Trace shapes on your partner's palm under the table" |
| 21 | Whisper to your partner that you're skipping underwear today | Discover your partner is skipping underwear while you're out | 3 | text: "Discover your partner is skipping underwear while you're out" / partner: "Whisper to your partner that you're skipping underwear today" |
| 22 | Send your partner a suggestive photo while at an event | Receive a suggestive photo from your partner in public | 3 | text: "Receive a suggestive photo from your partner in public" / partner: "Send your partner a suggestive photo while at an event" |
| 23 | Give your partner's backside a playful squeeze | Have your partner playfully squeeze your backside | 2 | text: "Have your partner playfully squeeze your backside" / partner: "Give your partner's backside a playful squeeze" |
| 24 | Whisper a romantic fantasy to your partner at a gathering | Hear a romantic fantasy whispered to you in a crowd | 2 | text: "Hear a romantic fantasy whispered to you in a crowd" / partner: "Whisper a romantic fantasy to your partner at a gathering" |
| 25 | Play footsie with your partner under the table | Feel your partner's foot teasing yours under the table | 2 | text: "Feel your partner's foot teasing yours under the table" / partner: "Play footsie with your partner under the table" |
| 26 | Text your partner your desires while sitting across from them | Receive a suggestive text from your partner while looking at them | 2 | text: "Receive a suggestive text from your partner while looking at them" / partner: "Text your partner your desires while sitting across from them" |
| 27 | Kiss your partner somewhere unexpected while out running errands | Get kissed by your partner when you least expect it in public | 2 | text: "Get kissed by your partner when you least expect it in public" / partner: "Kiss your partner somewhere unexpected while out running errands" |
| 28 | Rest your hand on your partner's upper thigh at dinner | Feel your partner's hand creep up your thigh at a restaurant | 2 | text: "Feel your partner's hand creep up your thigh at a restaurant" / partner: "Rest your hand on your partner's upper thigh at dinner" |
| 29 | Hold your partner's hand under the table while making intense eye contact | Lock eyes with your partner while they hold your hand secretly | 2 | text: "Lock eyes with your partner while they hold your hand secretly" / partner: "Hold your partner's hand under the table while making intense eye contact" |
| 30 | Pull your partner into a quick hidden kiss in a store aisle | Get pulled into a secret kiss while shopping | 2 | text: "Get pulled into a secret kiss while shopping" / partner: "Pull your partner into a quick hidden kiss in a store aisle" |
# Romance & Sensuality (103 missing)
## 🔞 Date Night Sparks (7 missing)
| # | Current Text | Current Partner Text | Intensity | Inverse to Create |
|---|--------------|---------------------|-----------|-------------------|
| 1 | Give your partner a sensual massage that leads to more | Receive a sensual massage that leads to more | 3 | text: "Receive a sensual massage that leads to more" / partner: "Give your partner a sensual massage that leads to more" |
| 2 | Give your partner a lap dance | Receive a lap dance from your partner | 3 | text: "Receive a lap dance from your partner" / partner: "Give your partner a lap dance" |
| 3 | Go to dinner and whisper what you want to do later | Hear what your partner wants to do to you later | 3 | text: "Hear what your partner wants to do to you later" / partner: "Go to dinner and whisper what you want to do later" |
| 4 | Give your partner a foot massage that travels up their leg | Receive a foot massage that travels up your leg | 3 | text: "Receive a foot massage that travels up your leg" / partner: "Give your partner a foot massage that travels up their leg" |
| 5 | Wear something your partner picks out for date night | Pick out what your partner wears for date night | 2 | text: "Pick out what your partner wears for date night" / partner: "Wear something your partner picks out for date night" |
| 6 | Give your partner a striptease | Watch your partner do a striptease for you | 3 | text: "Watch your partner do a striptease for you" / partner: "Give your partner a striptease" |
| 7 | Plan a surprise date night ending with intimacy | Be surprised with a date night ending in intimacy | 3 | text: "Be surprised with a date night ending in intimacy" / partner: "Plan a surprise date night ending with intimacy" |
## 🔞 Love Notes (34 missing)
| # | Current Text | Current Partner Text | Intensity | Inverse to Create |
|---|--------------|---------------------|-----------|-------------------|
| 1 | Send a 'thinking about you' text describing exactly what you're thinking | Receive a detailed 'thinking about you' text | 3 | text: "Receive a detailed 'thinking about you' text" / partner: "Send a 'thinking about you' text describing exactly what you're thinking" |
| 2 | Write your partner a note about the first time you wanted them | Read about when your partner first wanted you | 2 | text: "Read about when your partner first wanted you" / partner: "Write your partner a note about the first time you wanted them" |
| 3 | Send a flirty emoji-only message and have them guess what it means | Decode a flirty emoji message from your partner | 2 | text: "Decode a flirty emoji message from your partner" / partner: "Send a flirty emoji-only message and have them guess what it means" |
| 4 | Create a scavenger hunt of notes leading to the bedroom | Follow a scavenger hunt of notes to the bedroom | 3 | text: "Follow a scavenger hunt of notes to the bedroom" / partner: "Create a scavenger hunt of notes leading to the bedroom" |
| 5 | Text your partner during work about the lingerie you're wearing | Receive a text about what your partner is wearing underneath | 3 | text: "Receive a text about what your partner is wearing underneath" / partner: "Text your partner during work about the lingerie you're wearing" |
| 6 | Write down what you want your partner to do to you and leave it on their pillow | Find instructions on your pillow from your partner | 4 | text: "Find instructions on your pillow from your partner" / partner: "Write down what you want your partner to do to you and leave it on their pillow" |
| 7 | Send your partner a playlist of songs that make you think of them sexually | Listen to a sexy playlist your partner made for you | 2 | text: "Listen to a sexy playlist your partner made for you" / partner: "Send your partner a playlist of songs that make you think of them sexually" |
| 8 | Leave a note in your partner's pocket or bag with tonight's plans | Find a surprise note about tonight's plans | 2 | text: "Find a surprise note about tonight's plans" / partner: "Leave a note in your partner's pocket or bag with tonight's plans" |
| 9 | Write a poem about your physical attraction to your partner | Receive a poem about your partner's attraction to you | 2 | text: "Receive a poem about your partner's attraction to you" / partner: "Write a poem about your physical attraction to your partner" |
| 10 | Send dirty talk messages throughout the day to build anticipation | Receive dirty talk messages all day | 4 | text: "Receive dirty talk messages all day" / partner: "Send dirty talk messages throughout the day to build anticipation" |
| 11 | Create a 'sexy coupon book' for your partner | Receive a sexy coupon book from your partner | 3 | text: "Receive a sexy coupon book from your partner" / partner: "Create a 'sexy coupon book' for your partner" |
| 12 | Write down a secret fantasy and give it to your partner | Receive and read your partner's secret fantasy | 3 | text: "Receive and read your partner's secret fantasy" / partner: "Write down a secret fantasy and give it to your partner" |
| 13 | Send your partner voice messages describing your desires | Receive voice messages describing your partner's desires | 3 | text: "Receive voice messages describing your partner's desires" / partner: "Send your partner voice messages describing your desires" |
| 14 | Write a list of places you want to have sex and share it | Receive a list of places your partner wants to have sex | 4 | text: "Receive a list of places your partner wants to have sex" / partner: "Write a list of places you want to have sex and share it" |
| 15 | Send your partner a countdown of sexy things you'll do when you see them | Receive a sexy countdown from your partner | 3 | text: "Receive a sexy countdown from your partner" / partner: "Send your partner a countdown of sexy things you'll do when you see them" |
| 16 | Leave a note on the bathroom mirror describing your favorite intimate moment | Find a note about your partner's favorite intimate moment | 2 | text: "Find a note about your partner's favorite intimate moment" / partner: "Leave a note on the bathroom mirror describing your favorite intimate moment" |
| 17 | Write a steamy story starring you and your partner | Read a steamy story your partner wrote about you both | 3 | text: "Read a steamy story your partner wrote about you both" / partner: "Write a steamy story starring you and your partner" |
| 18 | Send your partner a suggestive photo during the day | Receive a suggestive photo from your partner | 3 | text: "Receive a suggestive photo from your partner" / partner: "Send your partner a suggestive photo during the day" |
| 19 | Write down three things you love about your partner's body | Read three things your partner loves about your body | 2 | text: "Read three things your partner loves about your body" / partner: "Write down three things you love about your partner's body" |
| 20 | Text your partner a fantasy you want to try | Receive a fantasy text from your partner | 3 | text: "Receive a fantasy text from your partner" / partner: "Text your partner a fantasy you want to try" |
| 21 | Leave a flirty note hidden for your partner to find | Find a flirty hidden note from your partner | 2 | text: "Find a flirty hidden note from your partner" / partner: "Leave a flirty note hidden for your partner to find" |
| 22 | Send your partner a sexy text describing what you want to do tonight | Receive a sexy text about tonight | 3 | text: "Receive a sexy text about tonight" / partner: "Send your partner a sexy text describing what you want to do tonight" |
| 23 | Write your partner a love letter describing what turns you on about them | Receive a love letter about what turns your partner on | 2 | text: "Receive a love letter about what turns your partner on" / partner: "Write your partner a love letter describing what turns you on about them" |
| 24 | Write down a new position you want to try and slip it to your partner | Receive a suggestion for a new position to try | 4 | text: "Receive a suggestion for a new position to try" / partner: "Write down a new position you want to try and slip it to your partner" |
| 25 | Send your partner a teasing text and make them wait for the follow-up | Receive a teasing text with anticipation | 3 | text: "Receive a teasing text with anticipation" / partner: "Send your partner a teasing text and make them wait for the follow-up" |
| 26 | Write your partner a note about what you'd do if they walked in right now | Find a note about what would happen if you walked in | 3 | text: "Find a note about what would happen if you walked in" / partner: "Write your partner a note about what you'd do if they walked in right now" |
| 27 | Send a 'can't wait to see you' text with explicit details | Receive an explicit 'can't wait' text | 4 | text: "Receive an explicit 'can't wait' text" / partner: "Send a 'can't wait to see you' text with explicit details" |
| 28 | Write down your top 5 favorite things your partner does in bed | Read your partner's top 5 favorite things you do in bed | 3 | text: "Read your partner's top 5 favorite things you do in bed" / partner: "Write down your top 5 favorite things your partner does in bed" |
| 29 | Send your partner a gif or meme that expresses how much you want them | Receive a flirty gif from your partner | 2 | text: "Receive a flirty gif from your partner" / partner: "Send your partner a gif or meme that expresses how much you want them" |
| 30 | Write a note thanking your partner for an amazing sexual experience | Receive a thank-you note for an amazing experience | 2 | text: "Receive a thank-you note for an amazing experience" / partner: "Write a note thanking your partner for an amazing sexual experience" |
| 31 | Send your partner a 'remember when we...' text about a hot memory | Reminisce about a hot memory via text | 3 | text: "Reminisce about a hot memory via text" / partner: "Send your partner a 'remember when we...' text about a hot memory" |
| 32 | Leave a note with one word describing what you want tonight | Find a one-word hint about tonight | 3 | text: "Find a one-word hint about tonight" / partner: "Leave a note with one word describing what you want tonight" |
| 33 | Write a letter about how your partner makes you feel desired | Receive a letter about how you make your partner feel desired | 2 | text: "Receive a letter about how you make your partner feel desired" / partner: "Write a letter about how your partner makes you feel desired" |
| 34 | Send your partner a 'this made me think of you' message with something sexy | Receive a sexy 'this made me think of you' message | 2 | text: "Receive a sexy 'this made me think of you' message" / partner: "Send your partner a 'this made me think of you' message with something sexy" |
## ✨ Romantic Gestures (28 missing)
| # | Current Text | Current Partner Text | Intensity | Inverse to Create |
|---|--------------|---------------------|-----------|-------------------|
| 1 | Wash your partner in a warm bath | Be washed by your partner in a warm bath | 2 | text: "Be washed by your partner in a warm bath" / partner: "Wash your partner in a warm bath" |
| 2 | Greet your partner wearing only their favorite outfit or nothing | Be greeted by your partner wearing only your favorite outfit or nothing | 3 | text: "Be greeted by your partner wearing only your favorite outfit or nothing" / partner: "Greet your partner wearing only their favorite outfit or nothing" |
| 3 | Trace your fingers along your partner's body | Let your partner trace their fingers along your body | 2 | text: "Let your partner trace their fingers along your body" / partner: "Trace your fingers along your partner's body" |
| 4 | Surprise your partner with a passionate kiss | Be surprised with a passionate kiss | 2 | text: "Be surprised with a passionate kiss" / partner: "Surprise your partner with a passionate kiss" |
| 5 | Carry your partner to bed and worship their body | Be carried to bed and have your body worshipped | 3 | text: "Be carried to bed and have your body worshipped" / partner: "Carry your partner to bed and worship their body" |
| 6 | Slowly kiss from your partner's lips down to their collarbone | Be kissed from your lips down to your collarbone | 3 | text: "Be kissed from your lips down to your collarbone" / partner: "Slowly kiss from your partner's lips down to their collarbone" |
| 7 | Kiss your partner's forehead and tell them they're safe with you | Be kissed on the forehead and told you're safe | 2 | text: "Be kissed on the forehead and told you're safe" / partner: "Kiss your partner's forehead and tell them they're safe with you" |
| 8 | Pull your partner onto your lap and kiss them | Be pulled onto your partner's lap | 3 | text: "Be pulled onto your partner's lap" / partner: "Pull your partner onto your lap and kiss them" |
| 9 | Tell your partner you find them sexy while doing something mundane | Be told you're sexy during something mundane | 2 | text: "Be told you're sexy during something mundane" / partner: "Tell your partner you find them sexy while doing something mundane" |
| 10 | Cup your partner's face and kiss them deeply before saying goodbye | Be kissed deeply before your partner leaves | 2 | text: "Be kissed deeply before your partner leaves" / partner: "Cup your partner's face and kiss them deeply before saying goodbye" |
| 11 | Take your partner's hand and lead them to bed wordlessly | Be led to bed wordlessly | 3 | text: "Be led to bed wordlessly" / partner: "Take your partner's hand and lead them to bed wordlessly" |
| 12 | Hold your partner from behind and kiss their neck while they do something | Be held from behind and kissed on the neck | 3 | text: "Be held from behind and kissed on the neck" / partner: "Hold your partner from behind and kiss their neck while they do something" |
| 13 | Watch your partner get dressed and tell them how attractive they are | Be watched getting dressed and complimented | 2 | text: "Be watched getting dressed and complimented" / partner: "Watch your partner get dressed and tell them how attractive they are" |
| 14 | Give your partner a full body massage ending wherever they want | Receive a full body massage that ends wherever you want | 3 | text: "Receive a full body massage that ends wherever you want" / partner: "Give your partner a full body massage ending wherever they want" |
| 15 | Brush your partner's hair and tell them how beautiful they are | Have your partner brush your hair and compliment you | 2 | text: "Have your partner brush your hair and compliment you" / partner: "Brush your partner's hair and tell them how beautiful they are" |
| 16 | Slowly apply lotion to your partner's whole body | Have lotion applied slowly to your whole body | 3 | text: "Have lotion applied slowly to your whole body" / partner: "Slowly apply lotion to your partner's whole body" |
| 17 | Run a warm washcloth over your partner after intimacy | Be cared for after intimacy with a warm washcloth | 2 | text: "Be cared for after intimacy with a warm washcloth" / partner: "Run a warm washcloth over your partner after intimacy" |
| 18 | Seduce your partner in a dimly lit room | Be seduced in a dimly lit room | 3 | text: "Be seduced in a dimly lit room" / partner: "Seduce your partner in a dimly lit room" |
| 19 | Serve your partner breakfast in bed while naked | Be served breakfast in bed by your naked partner | 3 | text: "Be served breakfast in bed by your naked partner" / partner: "Serve your partner breakfast in bed while naked" |
| 20 | Lead your partner to the bedroom for a surprise | Be led to the bedroom for a surprise | 2 | text: "Be led to the bedroom for a surprise" / partner: "Lead your partner to the bedroom for a surprise" |
| 21 | Pull your partner close unexpectedly and tell them you want them | Be pulled close and told you're wanted | 3 | text: "Be pulled close and told you're wanted" / partner: "Pull your partner close unexpectedly and tell them you want them" |
| 22 | Give your partner a lingering kiss on the neck | Receive a lingering kiss on your neck | 3 | text: "Receive a lingering kiss on your neck" / partner: "Give your partner a lingering kiss on the neck" |
| 23 | Run your fingers through your partner's hair while they rest on you | Have your hair played with while resting on your partner | 2 | text: "Have your hair played with while resting on your partner" / partner: "Run your fingers through your partner's hair while they rest on you" |
| 24 | Hold your partner's face and tell them what you love about them before kissing | Have your partner hold your face and shower you with love | 2 | text: "Have your partner hold your face and shower you with love" / partner: "Hold your partner's face and tell them what you love about them before kissing" |
| 25 | Wake your partner up with kisses all over their body | Be woken up with kisses all over your body | 3 | text: "Be woken up with kisses all over your body" / partner: "Wake your partner up with kisses all over their body" |
| 26 | Kiss every part of your partner's body | Have every part of your body kissed | 3 | text: "Have every part of your body kissed" / partner: "Kiss every part of your partner's body" |
| 27 | Undress your partner slowly while maintaining eye contact | Be slowly undressed while looking into your partner's eyes | 3 | text: "Be slowly undressed while looking into your partner's eyes" / partner: "Undress your partner slowly while maintaining eye contact" |
| 28 | Whisper something sexy in your partner's ear in public | Have your partner whisper something sexy in your ear | 3 | text: "Have your partner whisper something sexy in your ear" / partner: "Whisper something sexy in your partner's ear in public" |
## 🔞 Sensual Touch (34 missing)
| # | Current Text | Current Partner Text | Intensity | Inverse to Create |
|---|--------------|---------------------|-----------|-------------------|
| 1 | Breathe warmly on your partner's neck before kissing it | Feel warm breath on your neck before being kissed | 3 | text: "Feel warm breath on your neck before being kissed" / partner: "Breathe warmly on your partner's neck before kissing it" |
| 2 | Use silk or satin fabric to touch your partner's skin | Be touched with silk or satin on your skin | 3 | text: "Be touched with silk or satin on your skin" / partner: "Use silk or satin fabric to touch your partner's skin" |
| 3 | Give your partner a foot massage with intention | Receive an intentional foot massage | 2 | text: "Receive an intentional foot massage" / partner: "Give your partner a foot massage with intention" |
| 4 | Lightly scratch your partner's back during intimacy | Have your back scratched during intimacy | 3 | text: "Have your back scratched during intimacy" / partner: "Lightly scratch your partner's back during intimacy" |
| 5 | Use your lips to explore your partner's body without using hands | Be explored with only your partner's lips | 3 | text: "Be explored with only your partner's lips" / partner: "Use your lips to explore your partner's body without using hands" |
| 6 | Trace your fingernails lightly down your partner's back | Have fingernails traced lightly down your back | 3 | text: "Have fingernails traced lightly down your back" / partner: "Trace your fingernails lightly down your partner's back" |
| 7 | Tease your partner everywhere except where they want it most | Be teased everywhere except where you want it most | 4 | text: "Be teased everywhere except where you want it most" / partner: "Tease your partner everywhere except where they want it most" |
| 8 | Massage your partner's inner thighs without going further | Have your inner thighs massaged without going further | 3 | text: "Have your inner thighs massaged without going further" / partner: "Massage your partner's inner thighs without going further" |
| 9 | Suck and lick your partner's fingers | Have your fingers sucked and licked | 3 | text: "Have your fingers sucked and licked" / partner: "Suck and lick your partner's fingers" |
| 10 | Touch your partner and have them rate the sensation | Let your partner touch you and rate the sensation | 3 | text: "Let your partner touch you and rate the sensation" / partner: "Touch your partner and have them rate the sensation" |
| 11 | Dry hump your partner slowly | Be dry humped slowly by your partner | 3 | text: "Be dry humped slowly by your partner" / partner: "Dry hump your partner slowly" |
| 12 | Kiss and nibble your partner's ears | Have your ears kissed and nibbled | 3 | text: "Have your ears kissed and nibbled" / partner: "Kiss and nibble your partner's ears" |
| 13 | Use warming or cooling massage oil on your partner | Experience warming or cooling massage oil | 3 | text: "Experience warming or cooling massage oil" / partner: "Use warming or cooling massage oil on your partner" |
| 14 | Give your partner a full body massage with warm oil | Receive a warm oil full body massage | 3 | text: "Receive a warm oil full body massage" / partner: "Give your partner a full body massage with warm oil" |
| 15 | Blindfold your partner and use different textures on their body | Be blindfolded and touched with different textures | 3 | text: "Be blindfolded and touched with different textures" / partner: "Blindfold your partner and use different textures on their body" |
| 16 | Run ice cubes along your partner's body | Have ice cubes run along your body | 3 | text: "Have ice cubes run along your body" / partner: "Run ice cubes along your partner's body" |
| 17 | Use light touch to give your partner shivers | Let your partner give you shivers with light touch | 3 | text: "Let your partner give you shivers with light touch" / partner: "Use light touch to give your partner shivers" |
| 18 | Explore your partner's body and discover new sensitive spots | Have your body explored for new sensitive spots | 3 | text: "Have your body explored for new sensitive spots" / partner: "Explore your partner's body and discover new sensitive spots" |
| 19 | Blindfold your partner and make them guess what you're touching them with | Guess what you're being touched with while blindfolded | 3 | text: "Guess what you're being touched with while blindfolded" / partner: "Blindfold your partner and make them guess what you're touching them with" |
| 20 | Use a feather to tease your partner all over | Be teased with a feather all over | 3 | text: "Be teased with a feather all over" / partner: "Use a feather to tease your partner all over" |
| 21 | Use your breath to stimulate your partner without touching | Be stimulated by your partner's breath without touch | 3 | text: "Be stimulated by your partner's breath without touch" / partner: "Use your breath to stimulate your partner without touching" |
| 22 | Caress your partner's face while looking into their eyes | Have your face caressed while looking into your partner's eyes | 2 | text: "Have your face caressed while looking into your partner's eyes" / partner: "Caress your partner's face while looking into their eyes" |
| 23 | Massage your partner's shoulders while they sit between your legs | Sit between your partner's legs while they massage you | 2 | text: "Sit between your partner's legs while they massage you" / partner: "Massage your partner's shoulders while they sit between your legs" |
| 24 | Use edible body paint on your partner | Have edible body paint used on you | 4 | text: "Have edible body paint used on you" / partner: "Use edible body paint on your partner" |
| 25 | Give your partner goosebumps by whispering and touching | Get goosebumps from whispers and touch | 3 | text: "Get goosebumps from whispers and touch" / partner: "Give your partner goosebumps by whispering and touching" |
| 26 | Alternate between firm and gentle touches on your partner's body | Experience alternating firm and gentle touches | 3 | text: "Experience alternating firm and gentle touches" / partner: "Alternate between firm and gentle touches on your partner's body" |
| 27 | Touch your partner through their clothes before undressing them | Be touched through your clothes before being undressed | 3 | text: "Be touched through your clothes before being undressed" / partner: "Touch your partner through their clothes before undressing them" |
| 28 | Run your hands through your partner's hair and grip gently | Have your hair played with and gripped gently | 3 | text: "Have your hair played with and gripped gently" / partner: "Run your hands through your partner's hair and grip gently" |
| 29 | Kiss the inside of your partner's wrist | Have the inside of your wrist kissed | 2 | text: "Have the inside of your wrist kissed" / partner: "Kiss the inside of your partner's wrist" |
| 30 | Use a massage candle on your partner's body | Have warm massage candle wax used on your body | 3 | text: "Have warm massage candle wax used on your body" / partner: "Use a massage candle on your partner's body" |
| 31 | Apply lotion to your partner slowly and sensually | Have lotion applied to you slowly | 3 | text: "Have lotion applied to you slowly" / partner: "Apply lotion to your partner slowly and sensually" |
| 32 | Use your tongue to trace patterns on your partner's skin | Have patterns traced on your skin with a tongue | 4 | text: "Have patterns traced on your skin with a tongue" / partner: "Use your tongue to trace patterns on your partner's skin" |
| 33 | Give your partner a scalp massage that turns sensual | Receive a scalp massage that turns sensual | 2 | text: "Receive a scalp massage that turns sensual" / partner: "Give your partner a scalp massage that turns sensual" |
| 34 | Hold your partner's hands above their head while kissing them | Have your hands held above your head while being kissed | 3 | text: "Have your hands held above your head while being kissed" / partner: "Hold your partner's hands above their head while kissing them" |
# Spicy Challenges (56 missing)
## 🔞 Dare to Play (22 missing)
| # | Current Text | Current Partner Text | Intensity | Inverse to Create |
|---|--------------|---------------------|-----------|-------------------|
| 1 | Perform a striptease for your partner | Watch your partner do a striptease | 3 | text: "Watch your partner do a striptease" / partner: "Perform a striptease for your partner" |
| 2 | Use only your mouth to pleasure your partner | Be pleasured using only your partner's mouth | 4 | text: "Be pleasured using only your partner's mouth" / partner: "Use only your mouth to pleasure your partner" |
| 3 | Make your partner orgasm twice in one session | Have your partner make you orgasm twice | 5 | text: "Have your partner make you orgasm twice" / partner: "Make your partner orgasm twice in one session" |
| 4 | Initiate sex at an unexpected time | Be surprised by your partner initiating sex | 4 | text: "Be surprised by your partner initiating sex" / partner: "Initiate sex at an unexpected time" |
| 5 | Make your partner beg for what they want | Let your partner make you beg for what you want | 4 | text: "Let your partner make you beg for what you want" / partner: "Make your partner beg for what they want" |
| 6 | Tell your partner your dirtiest thought | Hear your partner's dirtiest thought | 3 | text: "Hear your partner's dirtiest thought" / partner: "Tell your partner your dirtiest thought" |
| 7 | Send your partner a nude photo unexpectedly | Receive a nude photo from your partner | 4 | text: "Receive a nude photo from your partner" / partner: "Send your partner a nude photo unexpectedly " |
| 8 | Surprise your partner with spontaneous oral during a mundane activity | Be surprised with spontaneous oral | 4 | text: "Be surprised with spontaneous oral" / partner: "Surprise your partner with spontaneous oral during a mundane activity" |
| 9 | Blindfold your partner and pleasure them | Be blindfolded and pleasured | 4 | text: "Be blindfolded and pleasured" / partner: "Blindfold your partner and pleasure them" |
| 10 | Let your partner do whatever they want to you for 15 minutes | Do whatever you want to your partner for 15 minutes | 5 | text: "Do whatever you want to your partner for 15 minutes" / partner: "Let your partner do whatever they want to you for 15 minutes" |
| 11 | Describe exactly what you're going to do to your partner, then do it | Listen to what your partner will do, then experience it | 4 | text: "Listen to what your partner will do, then experience it" / partner: "Describe exactly what you're going to do to your partner, then do it" |
| 12 | Make your partner cum without using penetration | Have your partner make you cum without penetration | 4 | text: "Have your partner make you cum without penetration" / partner: "Make your partner cum without using penetration" |
| 13 | Wake your partner up with oral sex | Be woken up with oral sex | 4 | text: "Be woken up with oral sex" / partner: "Wake your partner up with oral sex" |
| 14 | Take control and tell your partner exactly what to do | Let your partner take full control | 4 | text: "Let your partner take full control" / partner: "Take control and tell your partner exactly what to do" |
| 15 | Give your partner a slow, teasing handjob or fingering session | Receive a slow, teasing handjob or fingering | 4 | text: "Receive a slow, teasing handjob or fingering" / partner: "Give your partner a slow, teasing handjob or fingering session" |
| 16 | Edge your partner three times before letting them finish | Be edged three times before finishing | 4 | text: "Be edged three times before finishing" / partner: "Edge your partner three times before letting them finish" |
| 17 | Go down on your partner for at least 10 minutes | Receive oral for at least 10 minutes | 4 | text: "Receive oral for at least 10 minutes" / partner: "Go down on your partner for at least 10 minutes" |
| 18 | Give your partner a lap dance in lingerie or underwear | Receive a lap dance | 3 | text: "Receive a lap dance" / partner: "Give your partner a lap dance in lingerie or underwear" |
| 19 | Masturbate in front of your partner | Watch your partner masturbate | 4 | text: "Watch your partner masturbate" / partner: "Masturbate in front of your partner" |
| 20 | Tease your partner with texts all day, then deliver | Be teased with texts then get the payoff | 4 | text: "Be teased with texts then get the payoff" / partner: "Tease your partner with texts all day, then deliver" |
| 21 | Make your partner come using only external stimulation | Be brought to orgasm without any penetration | 3 | text: "Be brought to orgasm without any penetration" / partner: "Make your partner come using only external stimulation" |
| 22 | Be brought to orgasm without any penetration | Make your partner cum using only external stimulation | 3 | text: "Make your partner cum using only external stimulation" / partner: "Be brought to orgasm without any penetration" |
## 🔞 Pleasure Experiments (1 missing)
| # | Current Text | Current Partner Text | Intensity | Inverse to Create |
|---|--------------|---------------------|-----------|-------------------|
| 1 | Slowly run an ice cube down your partner's body | Let your partner trace an ice cube down your body | 2 | text: "Let your partner trace an ice cube down your body" / partner: "Slowly run an ice cube down your partner's body" |
## 🔞 Tease & Tension (33 missing)
| # | Current Text | Current Partner Text | Intensity | Inverse to Create |
|---|--------------|---------------------|-----------|-------------------|
| 1 | Strip slowly in front of your partner but don't let them touch | Watch your partner strip without being able to touch | 3 | text: "Watch your partner strip without being able to touch" / partner: "Strip slowly in front of your partner but don't let them touch" |
| 2 | Whisper what you want to do later in explicit detail | Listen to explicit details of what's coming later | 3 | text: "Listen to explicit details of what's coming later" / partner: "Whisper what you want to do later in explicit detail" |
| 3 | Restrain your partner and take your time with them | Be restrained while your partner takes their time | 4 | text: "Be restrained while your partner takes their time" / partner: "Restrain your partner and take your time with them" |
| 4 | Use ice on your partner's body while they're restrained | Have ice used on you while restrained | 5 | text: "Have ice used on you while restrained" / partner: "Use ice on your partner's body while they're restrained" |
| 5 | Use a vibrator on your partner on the lowest setting | Be teased with a vibrator on the lowest setting | 4 | text: "Be teased with a vibrator on the lowest setting" / partner: "Use a vibrator on your partner on the lowest setting" |
| 6 | Blindfold your partner and make them guess what comes next | Be blindfolded and guess what's coming | 4 | text: "Be blindfolded and guess what's coming" / partner: "Blindfold your partner and make them guess what comes next" |
| 7 | Maintain eye contact while touching your partner slowly | Maintain eye contact while being touched slowly | 3 | text: "Maintain eye contact while being touched slowly" / partner: "Maintain eye contact while touching your partner slowly" |
| 8 | Make your partner beg before you touch them where they want | Let your partner make you beg before being touched | 4 | text: "Let your partner make you beg before being touched" / partner: "Make your partner beg before you touch them where they want" |
| 9 | Deny your partner an orgasm until they can't take it anymore | Be denied orgasm until you can't take it anymore | 5 | text: "Be denied orgasm until you can't take it anymore" / partner: "Deny your partner an orgasm until they can't take it anymore" |
| 10 | Give your partner a massage that 'accidentally' gets too close | Receive a massage that keeps getting close | 3 | text: "Receive a massage that keeps getting close" / partner: "Give your partner a massage that 'accidentally' gets too close" |
| 11 | Whisper dirty things in your partner's ear without touching them | Hear dirty things whispered without being touched | 3 | text: "Hear dirty things whispered without being touched" / partner: "Whisper dirty things in your partner's ear without touching them" |
| 12 | Bring your partner to the edge and stop - repeat three times | Be brought to the edge and stopped three times | 4 | text: "Be brought to the edge and stopped three times" / partner: "Bring your partner to the edge and stop - repeat three times" |
| 13 | Give your partner a lap dance without letting them touch | Receive a lap dance without being allowed to touch | 3 | text: "Receive a lap dance without being allowed to touch" / partner: "Give your partner a lap dance without letting them touch" |
| 14 | Send your partner flirty texts all day building anticipation | Receive flirty texts all day building anticipation | 2 | text: "Receive flirty texts all day building anticipation" / partner: "Send your partner flirty texts all day building anticipation" |
| 15 | Touch your partner everywhere except where they want most | Be touched everywhere except where you want most | 4 | text: "Be touched everywhere except where you want most" / partner: "Touch your partner everywhere except where they want most" |
| 16 | Use feathers or soft textures to tease all over | Be teased all over with feathers or soft textures | 3 | text: "Be teased all over with feathers or soft textures" / partner: "Use feathers or soft textures to tease all over" |
| 17 | Send a countdown of hours until you'll have sex, with teasers | Receive a countdown with teasing updates | 2 | text: "Receive a countdown with teasing updates" / partner: "Send a countdown of hours until you'll have sex, with teasers" |
| 18 | Undress your partner one piece at a time over an hour | Be undressed one piece at a time over an hour | 3 | text: "Be undressed one piece at a time over an hour" / partner: "Undress your partner one piece at a time over an hour" |
| 19 | Get your partner worked up then leave the room for 5 minutes | Be worked up then left waiting for 5 minutes | 4 | text: "Be worked up then left waiting for 5 minutes" / partner: "Get your partner worked up then leave the room for 5 minutes" |
| 20 | Tease your partner during an entire movie and only finish after | Be teased during a whole movie | 4 | text: "Be teased during a whole movie" / partner: "Tease your partner during an entire movie and only finish after" |
| 21 | Tell your partner exactly what you want but make them wait | Hear what's coming but have to wait | 3 | text: "Hear what's coming but have to wait" / partner: "Tell your partner exactly what you want but make them wait" |
| 22 | Touch yourself in front of your partner but don't let them join | Watch your partner but not be allowed to join | 4 | text: "Watch your partner but not be allowed to join" / partner: "Touch yourself in front of your partner but don't let them join" |
| 23 | Hover your body over your partner without making contact | Have your partner hover without making contact | 3 | text: "Have your partner hover without making contact" / partner: "Hover your body over your partner without making contact" |
| 24 | Kiss every part of your partner except their lips | Be kissed everywhere except your lips | 3 | text: "Be kissed everywhere except your lips" / partner: "Kiss every part of your partner except their lips" |
| 25 | Give oral for 30 seconds, stop, and make them wait | Receive 30 seconds of oral then wait | 4 | text: "Receive 30 seconds of oral then wait" / partner: "Give oral for 30 seconds, stop, and make them wait" |
| 26 | Describe everything you're going to do without doing it yet | Listen to everything your partner will do | 3 | text: "Listen to everything your partner will do" / partner: "Describe everything you're going to do without doing it yet" |
| 27 | Give your partner oral but stop right before they finish | Receive oral that stops right before you finish | 4 | text: "Receive oral that stops right before you finish" / partner: "Give your partner oral but stop right before they finish" |
| 28 | Touch your partner through their clothes for 10 minutes | Be touched through your clothes for 10 minutes | 3 | text: "Be touched through your clothes for 10 minutes" / partner: "Touch your partner through their clothes for 10 minutes" |
| 29 | Send your partner a suggestive photo during dinner | Receive a suggestive photo during dinner | 3 | text: "Receive a suggestive photo during dinner" / partner: "Send your partner a suggestive photo during dinner" |
| 30 | Kiss your partner's neck and stop when they want more | Have your neck kissed and then teased | 3 | text: "Have your neck kissed and then teased" / partner: "Kiss your partner's neck and stop when they want more" |
| 31 | Make your partner ask permission before you give them more | Ask permission before receiving more | 4 | text: "Ask permission before receiving more" / partner: "Make your partner ask permission before you give them more" |
| 32 | Use your mouth close to your partner's skin without touching | Feel your partner's mouth hover without touching | 3 | text: "Feel your partner's mouth hover without touching" / partner: "Use your mouth close to your partner's skin without touching" |
| 33 | Trace your fingers over your partner's body feather-light | Have feather-light touches traced on your body | 3 | text: "Have feather-light touches traced on your body" / partner: "Trace your fingers over your partner's body feather-light" |
# The Kink Lab (34 missing)
## 🔞 Forbidden Fantasy Dates (9 missing)
| # | Current Text | Current Partner Text | Intensity | Inverse to Create |
|---|--------------|---------------------|-----------|-------------------|
| 1 | Use nipple clamps on your partner | Let your partner use nipple clamps on you | 4 | text: "Let your partner use nipple clamps on you" / partner: "Use nipple clamps on your partner" |
| 2 | Let your partner use a clitoris clamp on you | Use a clitoris clamp on your partner | 5 | text: "Use a clitoris clamp on your partner" / partner: "Let your partner use a clitoris clamp on you" |
| 3 | Finger your partner while wearing black latex gloves | Be fingered by your partner wearing black latex gloves | 3 | text: "Be fingered by your partner wearing black latex gloves" / partner: "Finger your partner while wearing black latex gloves" |
| 4 | Edge your partner and deny their orgasm | Let your partner edge you and deny your orgasm | 5 | text: "Let your partner edge you and deny your orgasm" / partner: "Edge your partner and deny their orgasm" |
| 5 | Make your partner cum through domination | Let your partner dominate you until you cum | 5 | text: "Let your partner dominate you until you cum" / partner: "Make your partner cum through domination" |
| 6 | Blindfold your partner and plug their ears to take control | Be blindfolded with ears plugged while your partner controls you | 5 | text: "Be blindfolded with ears plugged while your partner controls you" / partner: "Blindfold your partner and plug their ears to take control" |
| 7 | Talk dirty about breeding your partner | Listen to your partner talk dirty about breeding you | 5 | text: "Listen to your partner talk dirty about breeding you" / partner: "Talk dirty about breeding your partner" |
| 8 | Let your partner take total control | Take total control of your partner | 3 | text: "Take total control of your partner" / partner: "Let your partner take total control" |
| 9 | Spank your partner firmly | Receive a firm spanking from your partner | 4 | text: "Receive a firm spanking from your partner" / partner: "Spank your partner firmly" |
## ✨ Kink Discovery (25 missing)
| # | Current Text | Current Partner Text | Intensity | Inverse to Create |
|---|--------------|---------------------|-----------|-------------------|
| 1 | Gag your partner during sex | Let your partner gag you during sex | 5 | text: "Let your partner gag you during sex" / partner: "Gag your partner during sex" |
| 2 | Hold your partner down during sex | Be held down by your partner during sex | 4 | text: "Be held down by your partner during sex" / partner: "Hold your partner down during sex" |
| 3 | Put a collar on your partner | Wear a collar for your partner | 4 | text: "Wear a collar for your partner" / partner: "Put a collar on your partner" |
| 4 | Blindfold your partner during sex | Be blindfolded by your partner during sex | 3 | text: "Be blindfolded by your partner during sex" / partner: "Blindfold your partner during sex" |
| 5 | Pull your partner's hair during sex | Let your partner pull your hair during sex | 4 | text: "Let your partner pull your hair during sex" / partner: "Pull your partner's hair during sex" |
| 6 | Make your partner ask permission to finish | Ask your partner for permission to finish | 4 | text: "Ask your partner for permission to finish" / partner: "Make your partner ask permission to finish" |
| 7 | Place your hands on your partner's throat | Let your partner's hands rest on your throat | 3 | text: "Let your partner's hands rest on your throat" / partner: "Place your hands on your partner's throat" |
| 8 | Worship your partner's body with kisses | Have your body worshipped with kisses | 2 | text: "Have your body worshipped with kisses" / partner: "Worship your partner's body with kisses" |
| 9 | Make your partner beg for it | Let your partner make you beg for it | 4 | text: "Let your partner make you beg for it" / partner: "Make your partner beg for it" |
| 10 | Use playful, naughty names for your partner | Be called playful, naughty names by your partner | 3 | text: "Be called playful, naughty names by your partner" / partner: "Use playful, naughty names for your partner" |
| 11 | Swallow your partner's release | Have your partner swallow your release | 3 | text: "Have your partner swallow your release" / partner: "Swallow your partner's release" |
| 12 | Finish on your partner's face | Let your partner finish on your face | 4 | text: "Let your partner finish on your face" / partner: "Finish on your partner's face" |
| 13 | Pull your partner's hair during sex | Have your hair pulled during sex | 4 | text: "Have your hair pulled during sex" / partner: "Pull your partner's hair during sex" |
| 14 | Put a collar on your partner | Wear a collar for your partner | 5 | text: "Wear a collar for your partner" / partner: "Put a collar on your partner" |
| 15 | Gag your partner during sex | Be gagged during sex | 5 | text: "Be gagged during sex" / partner: "Gag your partner during sex" |
| 16 | Blindfold your partner during sex | Be blindfolded during sex | 3 | text: "Be blindfolded during sex" / partner: "Blindfold your partner during sex" |
| 17 | Make your partner ask permission before they can cum | Ask permission before you're allowed to cum | 4 | text: "Ask permission before you're allowed to cum" / partner: "Make your partner ask permission before they can cum" |
| 18 | Choke your partner lightly during sex | Be choked lightly during sex | 5 | text: "Be choked lightly during sex" / partner: "Choke your partner lightly during sex" |
| 19 | Hold your partner down and fuck them | Be held down and fucked | 5 | text: "Be held down and fucked" / partner: "Hold your partner down and fuck them" |
| 20 | Worship your partner's body completely | Have your body worshipped and kissed everywhere | 3 | text: "Have your body worshipped and kissed everywhere" / partner: "Worship your partner's body completely" |
| 21 | Make your partner beg for it | Be made to beg for it | 4 | text: "Be made to beg for it" / partner: "Make your partner beg for it" |
| 22 | Call your partner a slut or whore during sex | Be called a slut or whore during sex | 5 | text: "Be called a slut or whore during sex" / partner: "Call your partner a slut or whore during sex" |
| 23 | Have your partner swallow | Swallow your partner's cum | 4 | text: "Swallow your partner's cum" / partner: "Have your partner swallow" |
| 24 | Fuck your partner's face | Have your face fucked | 5 | text: "Have your face fucked" / partner: "Fuck your partner's face" |
| 25 | Finish on your partner's face | Have your partner finish on your face | 5 | text: "Have your partner finish on your face" / partner: "Finish on your partner's face" |
# Toy Time (128 missing)
## 🔞 Leveling Up Together (45 missing)
| # | Current Text | Current Partner Text | Intensity | Inverse to Create |
|---|--------------|---------------------|-----------|-------------------|
| 1 | Attach a vibrator to your partner's inner thigh while you tease them elsewhere | Have a vibrator strapped to your thigh while your partner explores your body | 3 | text: "Have a vibrator strapped to your thigh while your partner explores your body" / partner: "Attach a vibrator to your partner's inner thigh while you tease them elsewhere" |
| 2 | Use a vibrating butt plug on your partner while giving them a slow handjob | Wear a vibrating plug while receiving a teasing handjob | 4 | text: "Wear a vibrating plug while receiving a teasing handjob" / partner: "Use a vibrating butt plug on your partner while giving them a slow handjob" |
| 3 | Blindfold your partner and make them guess which toy you're using | Guess the toy while blindfolded as your partner teases you | 3 | text: "Guess the toy while blindfolded as your partner teases you" / partner: "Blindfold your partner and make them guess which toy you're using" |
| 4 | Record your partner's reactions while you use a powerful toy on them | Let your partner film your pleasure as they use a toy on you | 4 | text: "Let your partner film your pleasure as they use a toy on you" / partner: "Record your partner's reactions while you use a powerful toy on them" |
| 5 | Tie your partner's wrists to the headboard and tease them with a vibrator | Be tied to the headboard and teased with a vibrator | 4 | text: "Be tied to the headboard and teased with a vibrator" / partner: "Tie your partner's wrists to the headboard and tease them with a vibrator" |
| 6 | Control the speed of your partner's toy while they try not to make noise | Use a toy while trying to stay quiet as your partner controls it | 4 | text: "Use a toy while trying to stay quiet as your partner controls it" / partner: "Control the speed of your partner's toy while they try not to make noise" |
| 7 | Wear a strap-on and let your partner control a remote vibrator in the base | Use a strap-on with a vibrator that your partner controls | 4 | text: "Use a strap-on with a vibrator that your partner controls" / partner: "Wear a strap-on and let your partner control a remote vibrator in the base" |
| 8 | Have your partner hold a vibrator on themselves while you penetrate them | Hold a vibrator on yourself while your partner penetrates you | 4 | text: "Hold a vibrator on yourself while your partner penetrates you" / partner: "Have your partner hold a vibrator on themselves while you penetrate them" |
| 9 | Use a textured sleeve on your partner while they watch porn together with you | Watch porn together while your partner uses a textured sleeve on you | 4 | text: "Watch porn together while your partner uses a textured sleeve on you" / partner: "Use a textured sleeve on your partner while they watch porn together with you" |
| 10 | Alternate between your mouth and a vibrator on your partner without warning | Be teased with unpredictable switches between oral and vibration | 4 | text: "Be teased with unpredictable switches between oral and vibration" / partner: "Alternate between your mouth and a vibrator on your partner without warning" |
| 11 | Perform a striptease and make your partner cum with a toy | Watch your partner strip before they make you cum with a toy | 4 | text: "Watch your partner strip before they make you cum with a toy" / partner: "Perform a striptease and make your partner cum with a toy" |
| 12 | Use a vibrating prostate massager on your partner during a handjob | Receive a handjob while wearing a vibrating prostate massager | 4 | text: "Receive a handjob while wearing a vibrating prostate massager" / partner: "Use a vibrating prostate massager on your partner during a handjob" |
| 13 | Perform oral while using nipple suction toys on your partner | Receive oral while your partner uses nipple suction toys on you | 4 | text: "Receive oral while your partner uses nipple suction toys on you" / partner: "Perform oral while using nipple suction toys on your partner" |
| 14 | Use a dildo on your partner to the beat of the music | Be penetrated by a dildo to the beat of the music | 4 | text: "Be penetrated by a dildo to the beat of the music" / partner: "Use a dildo on your partner to the beat of the music" |
| 15 | Use a warming lubricant and a glass dildo on your partner | Feel the contrast of warming lube with a cool glass toy inside you | 4 | text: "Feel the contrast of warming lube with a cool glass toy inside you" / partner: "Use a warming lubricant and a glass dildo on your partner" |
| 16 | Warm massage oil and a vibrating wand on your partner's entire body | Receive a full-body massage with warm oil and vibrations | 3 | text: "Receive a full-body massage with warm oil and vibrations" / partner: "Warm massage oil and a vibrating wand on your partner's entire body" |
| 17 | Insert anal beads into your partner slowly during foreplay | Have anal beads inserted slowly while your partner builds your arousal | 4 | text: "Have anal beads inserted slowly while your partner builds your arousal" / partner: "Insert anal beads into your partner slowly during foreplay" |
| 18 | Use a prostate or G-spot vibrator on your partner during oral | Receive oral while your partner stimulates your G-spot or P-spot | 4 | text: "Receive oral while your partner stimulates your G-spot or P-spot" / partner: "Use a prostate or G-spot vibrator on your partner during oral" |
| 19 | Blindfold your partner and tease them with ice and a warming vibrator | Be blindfolded and teased with ice and a warming vibrator | 4 | text: "Be blindfolded and teased with ice and a warming vibrator" / partner: "Blindfold your partner and tease them with ice and a warming vibrator" |
| 20 | Edge your partner with a wand, bringing them close twice before letting them finish | Be edged with a wand vibrator, brought close twice before release | 4 | text: "Be edged with a wand vibrator, brought close twice before release" / partner: "Edge your partner with a wand, bringing them close twice before letting them finish" |
| 21 | Control a remote vibrator inside your partner while out at dinner | Wear a remote-controlled vibrator while your partner teases you at dinner | 4 | text: "Wear a remote-controlled vibrator while your partner teases you at dinner" / partner: "Control a remote vibrator inside your partner while out at dinner" |
| 22 | Wear a vibrating cock ring while your partner rides you | Ride your partner while they wear a vibrating cock ring | 4 | text: "Ride your partner while they wear a vibrating cock ring" / partner: "Wear a vibrating cock ring while your partner rides you" |
| 23 | Have your partner wear vibrating panties while running errands together | Wear vibrating panties in public while your partner controls the intensity | 4 | text: "Wear vibrating panties in public while your partner controls the intensity" / partner: "Have your partner wear vibrating panties while running errands together" |
| 24 | Let your partner control a toy inside you during a video call with friends | Wear a hidden vibrator during a video call while your partner controls it | 4 | text: "Wear a hidden vibrator during a video call while your partner controls it" / partner: "Let your partner control a toy inside you during a video call with friends" |
| 25 | Drip massage candle wax on your partner while using a vibrator on them | Feel warm wax drip on your skin while your partner vibrates you | 4 | text: "Feel warm wax drip on your skin while your partner vibrates you" / partner: "Drip massage candle wax on your partner while using a vibrator on them" |
| 26 | Let your partner choose the toy and the setting while you lie back | Take full control of which toy and intensity to use on your partner | 4 | text: "Take full control of which toy and intensity to use on your partner" / partner: "Let your partner choose the toy and the setting while you lie back" |
| 27 | Take complete control of your partner's pleasure for an entire session | Surrender all control and let your partner decide everything | 5 | text: "Surrender all control and let your partner decide everything" / partner: "Take complete control of your partner's pleasure for an entire session" |
| 28 | Use a toy on your partner while lightly pulling their hair | Feel your hair pulled while your partner uses a toy on you | 5 | text: "Feel your hair pulled while your partner uses a toy on you" / partner: "Use a toy on your partner while lightly pulling their hair" |
| 29 | Blindfold your partner and make them hold completely still while you tease them | Stay perfectly still while blindfolded as your partner teases you with toys | 5 | text: "Stay perfectly still while blindfolded as your partner teases you with toys" / partner: "Blindfold your partner and make them hold completely still while you tease them" |
| 30 | Edge your partner until they're shaking, then finally let them finish | Be edged until you're trembling before your partner lets you come | 5 | text: "Be edged until you're trembling before your partner lets you come" / partner: "Edge your partner until they're shaking, then finally let them finish" |
| 31 | Use nipple clamps on your partner while penetrating them with a toy | Wear nipple clamps while your partner penetrates you with a toy | 5 | text: "Wear nipple clamps while your partner penetrates you with a toy" / partner: "Use nipple clamps on your partner while penetrating them with a toy" |
| 32 | Put a collar on your partner while pleasuring them with a vibrator | Wear a collar while your partner uses a vibrator on you | 5 | text: "Wear a collar while your partner uses a vibrator on you" / partner: "Put a collar on your partner while pleasuring them with a vibrator" |
| 33 | Restrain your partner's hands behind their back while using toys on them | Have your hands bound behind you while your partner pleasures you with toys | 5 | text: "Have your hands bound behind you while your partner pleasures you with toys" / partner: "Restrain your partner's hands behind their back while using toys on them" |
| 34 | Make your partner ask permission before they're allowed to orgasm | Ask your partner for permission before you're allowed to come | 5 | text: "Ask your partner for permission before you're allowed to come" / partner: "Make your partner ask permission before they're allowed to orgasm" |
| 35 | Blindfold and cuff your partner while using a wand on them | Be blindfolded and cuffed while your partner uses a wand on you | 5 | text: "Be blindfolded and cuffed while your partner uses a wand on you" / partner: "Blindfold and cuff your partner while using a wand on them" |
| 36 | Tie your partner's ankles to the bedposts and tease them with toys | Have your ankles tied while your partner teases you with toys | 5 | text: "Have your ankles tied while your partner teases you with toys" / partner: "Tie your partner's ankles to the bedposts and tease them with toys" |
| 37 | Edge your partner three times before finally letting them finish | Be edged three times before your partner allows you release | 4 | text: "Be edged three times before your partner allows you release" / partner: "Edge your partner three times before finally letting them finish" |
| 38 | Use a toy on your partner from behind while they grip the sheets | Grip the sheets while your partner uses a toy on you from behind | 4 | text: "Grip the sheets while your partner uses a toy on you from behind" / partner: "Use a toy on your partner from behind while they grip the sheets" |
| 39 | Use a thrusting toy on your partner while kissing their neck | Feel a thrusting toy while your partner kisses your neck | 4 | text: "Feel a thrusting toy while your partner kisses your neck" / partner: "Use a thrusting toy on your partner while kissing their neck" |
| 40 | Make your partner describe what they're feeling while you use a toy on them | Describe every sensation out loud while your partner toys you | 4 | text: "Describe every sensation out loud while your partner toys you" / partner: "Make your partner describe what they're feeling while you use a toy on them" |
| 41 | Use a suction toy on your partner while penetrating them | Feel suction stimulation while your partner penetrates you | 4 | text: "Feel suction stimulation while your partner penetrates you" / partner: "Use a suction toy on your partner while penetrating them" |
| 42 | Tease your partner with a wand through their underwear until they beg | Be teased through your underwear until you're begging for more | 4 | text: "Be teased through your underwear until you're begging for more" / partner: "Tease your partner with a wand through their underwear until they beg" |
| 43 | Use two different vibrators on your partner at the same time | Feel two vibrators stimulating different areas simultaneously | 4 | text: "Feel two vibrators stimulating different areas simultaneously" / partner: "Use two different vibrators on your partner at the same time" |
| 44 | Make your partner hold a vibrator on themselves while you watch | Hold a vibrator on yourself while your partner watches | 4 | text: "Hold a vibrator on yourself while your partner watches" / partner: "Make your partner hold a vibrator on themselves while you watch" |
| 45 | Use a vibrating plug on your partner while they go down on you | Wear a vibrating plug while giving your partner oral | 4 | text: "Wear a vibrating plug while giving your partner oral" / partner: "Use a vibrating plug on your partner while they go down on you" |
## 🔞 Starting Out (36 missing)
| # | Current Text | Current Partner Text | Intensity | Inverse to Create |
|---|--------------|---------------------|-----------|-------------------|
| 1 | Give your partner a backrub using a small vibrator | Receive a backrub with a small vibrator | 3 | text: "Receive a backrub with a small vibrator" / partner: "Give your partner a backrub using a small vibrator" |
| 2 | Tease your partner with a bullet vibrator over their clothes | Be teased with a bullet vibrator through your clothes | 3 | text: "Be teased with a bullet vibrator through your clothes" / partner: "Tease your partner with a bullet vibrator over their clothes" |
| 3 | Use a feather tickler on your blindfolded partner | Be blindfolded and teased with a feather tickler | 3 | text: "Be blindfolded and teased with a feather tickler" / partner: "Use a feather tickler on your blindfolded partner" |
| 4 | Have sex using a small dildo on your partner | Be penetrated with a small dildo during sex | 4 | text: "Be penetrated with a small dildo during sex" / partner: "Have sex using a small dildo on your partner" |
| 5 | Tease your partner with feathers and a heating massager | Be teased with feathers and a heating massager | 3 | text: "Be teased with feathers and a heating massager" / partner: "Tease your partner with feathers and a heating massager" |
| 6 | Press a wand vibrator against your partner during missionary | Have a wand vibrator pressed against you during missionary | 4 | text: "Have a wand vibrator pressed against you during missionary" / partner: "Press a wand vibrator against your partner during missionary" |
| 7 | Insert a small butt plug into your partner before you have sex | Have a small butt plug inserted before you have sex | 4 | text: "Have a small butt plug inserted before you have sex" / partner: "Insert a small butt plug into your partner before you have sex" |
| 8 | Wear a vibrating egg while you have sex | Have sex while your partner wears a vibrating egg | 4 | text: "Have sex while your partner wears a vibrating egg" / partner: "Wear a vibrating egg while you have sex" |
| 9 | Use a clitoral suction toy and a dildo on your partner simultaneously | Be penetrated while your partner uses a suction toy on your clit | 4 | text: "Be penetrated while your partner uses a suction toy on your clit" / partner: "Use a clitoral suction toy and a dildo on your partner simultaneously" |
| 10 | Use a textured sleeve on your partner's cock | Have a textured sleeve used on your cock | 4 | text: "Have a textured sleeve used on your cock" / partner: "Use a textured sleeve on your partner's cock" |
| 11 | Perform oral while using a small vibrator on your partner | Receive oral while your partner uses a small vibrator on you | 3 | text: "Receive oral while your partner uses a small vibrator on you" / partner: "Perform oral while using a small vibrator on your partner" |
| 12 | Blindfold your partner and penetrate them with a textured dildo | Be blindfolded and penetrated with a textured dildo | 4 | text: "Be blindfolded and penetrated with a textured dildo" / partner: "Blindfold your partner and penetrate them with a textured dildo" |
| 13 | Blindfold your partner and use a vibrating sleeve on their G-spot or P-spot | Be blindfolded while your partner stimulates your G-spot or P-spot with a vibrating sleeve | 4 | text: "Be blindfolded while your partner stimulates your G-spot or P-spot with a vibrating sleeve" / partner: "Blindfold your partner and use a vibrating sleeve on their G-spot or P-spot" |
| 14 | Blindfold your partner and tease their nipples with weighted clamps | Be blindfolded and have weighted clamps placed on your nipples | 5 | text: "Be blindfolded and have weighted clamps placed on your nipples" / partner: "Blindfold your partner and tease their nipples with weighted clamps" |
| 15 | Tie your partner's hands with a scarf and kiss their body | Have your hands tied with a scarf while your partner kisses you | 3 | text: "Have your hands tied with a scarf while your partner kisses you" / partner: "Tie your partner's hands with a scarf and kiss their body" |
| 16 | Record your partner using a toy on themselves | Let your partner record you using a toy on yourself | 5 | text: "Let your partner record you using a toy on yourself" / partner: "Record your partner using a toy on themselves" |
| 17 | Tease your partner with a toy under the covers in bed | Be teased with a toy under the blankets | 4 | text: "Be teased with a toy under the blankets" / partner: "Tease your partner with a toy under the covers in bed" |
| 18 | Shop for a new toy together online | Browse and pick out a new toy with your partner | 3 | text: "Browse and pick out a new toy with your partner" / partner: "Shop for a new toy together online" |
| 19 | Unbox a new toy together and explore how it feels | Discover a new toy together with your partner | 3 | text: "Discover a new toy together with your partner" / partner: "Unbox a new toy together and explore how it feels" |
| 20 | Use a warming massager on your partner's lower back | Feel warmth and vibration on your lower back | 3 | text: "Feel warmth and vibration on your lower back" / partner: "Use a warming massager on your partner's lower back" |
| 21 | Use a massage wand on your partner's back and shoulders | Receive a relaxing massage with a vibrating wand | 3 | text: "Receive a relaxing massage with a vibrating wand" / partner: "Use a massage wand on your partner's back and shoulders" |
| 22 | Trace a small vibrator slowly along your partner's arms and legs | Feel a vibrator traced gently across your limbs | 3 | text: "Feel a vibrator traced gently across your limbs" / partner: "Trace a small vibrator slowly along your partner's arms and legs" |
| 23 | Tease your partner's inner thighs with a bullet vibrator | Feel a bullet vibrator teasing your inner thighs | 3 | text: "Feel a bullet vibrator teasing your inner thighs" / partner: "Tease your partner's inner thighs with a bullet vibrator" |
| 24 | Use a vibrator on your partner while giving them a hand massage | Receive a hand massage while feeling vibrations elsewhere | 3 | text: "Receive a hand massage while feeling vibrations elsewhere" / partner: "Use a vibrator on your partner while giving them a hand massage" |
| 25 | Give your partner a foot massage using a small vibrator | Receive a foot massage with vibrating sensations | 3 | text: "Receive a foot massage with vibrating sensations" / partner: "Give your partner a foot massage using a small vibrator" |
| 26 | Hold a vibrator against your partner while cuddling on the couch | Feel vibrations while cuddling with your partner | 3 | text: "Feel vibrations while cuddling with your partner" / partner: "Hold a vibrator against your partner while cuddling on the couch" |
| 27 | Use a vibrating ring during foreplay | Feel the vibrating ring while making out with your partner | 3 | text: "Feel the vibrating ring while making out with your partner" / partner: "Use a vibrating ring during foreplay" |
| 28 | Tease your partner with a vibrator while they're still fully clothed | Feel vibrations through your clothes as your partner teases you | 3 | text: "Feel vibrations through your clothes as your partner teases you" / partner: "Tease your partner with a vibrator while they're still fully clothed" |
| 29 | Use a bullet vibrator on your partner while kissing them deeply | Be kissed while your partner uses a bullet vibrator on you | 3 | text: "Be kissed while your partner uses a bullet vibrator on you" / partner: "Use a bullet vibrator on your partner while kissing them deeply" |
| 30 | Let your partner choose which toy to use on you | Pick a toy from the collection to use on your partner | 3 | text: "Pick a toy from the collection to use on your partner" / partner: "Let your partner choose which toy to use on you" |
| 31 | Use a soft vibrator on your partner's neck and collarbone | Feel gentle vibrations on your neck and collarbone | 3 | text: "Feel gentle vibrations on your neck and collarbone" / partner: "Use a soft vibrator on your partner's neck and collarbone" |
| 32 | Use a small dildo on your partner while whispering what you want to do to them | Be penetrated with a toy while your partner whispers in your ear | 4 | text: "Be penetrated with a toy while your partner whispers in your ear" / partner: "Use a small dildo on your partner while whispering what you want to do to them" |
| 33 | Let your partner guide your hand while you hold a toy on them | Guide your partner's hand to show them where the toy feels best | 3 | text: "Guide your partner's hand to show them where the toy feels best" / partner: "Let your partner guide your hand while you hold a toy on them" |
| 34 | Let your partner use a toy on you while you touch yourself | Use a toy on your partner while they pleasure themselves | 4 | text: "Use a toy on your partner while they pleasure themselves" / partner: "Let your partner use a toy on you while you touch yourself" |
| 35 | Use a vibrator on your partner while they watch themselves in a mirror | Watch yourself in a mirror while your partner uses a toy on you | 4 | text: "Watch yourself in a mirror while your partner uses a toy on you" / partner: "Use a vibrator on your partner while they watch themselves in a mirror" |
| 36 | Use cooling lube with a small toy on your partner | Feel the cool sensation as your partner uses a toy with cooling lube | 4 | text: "Feel the cool sensation as your partner uses a toy with cooling lube" / partner: "Use cooling lube with a small toy on your partner" |
## ✨ The Deep End (47 missing)
| # | Current Text | Current Partner Text | Intensity | Inverse to Create |
|---|--------------|---------------------|-----------|-------------------|
| 1 | Lock your partner in a collar and leash, using toys on them while they kneel | Wear a collar and kneel while your partner uses toys on you | 5 | text: "Wear a collar and kneel while your partner uses toys on you" / partner: "Lock your partner in a collar and leash, using toys on them while they kneel" |
| 2 | Restrain your partner in a sex swing and use multiple toys on them | Be suspended in a swing while your partner pleasures you with toys | 5 | text: "Be suspended in a swing while your partner pleasures you with toys" / partner: "Restrain your partner in a sex swing and use multiple toys on them" |
| 3 | Use a toy and a vibrating plug on your partner | Be filled by a toy and a vibrating plug | 5 | text: "Be filled by a toy and a vibrating plug" / partner: "Use a toy and a vibrating plug on your partner" |
| 4 | Control a remote toy for your partner all day | Wear a remote toy for your partner to control all day | 4 | text: "Wear a remote toy for your partner to control all day" / partner: "Control a remote toy for your partner all day" |
| 5 | Suspend your partner's wrists above them and tease their body with a pinwheel and wand | Have your arms suspended while your partner torments you with sensation toys | 5 | text: "Have your arms suspended while your partner torments you with sensation toys" / partner: "Suspend your partner's wrists above them and tease their body with a pinwheel and wand" |
| 6 | Use two vibrating toys on your partner at once | Be filled by two vibrating toys at once | 5 | text: "Be filled by two vibrating toys at once" / partner: "Use two vibrating toys on your partner at once" |
| 7 | Restrain your partner's legs and use a suction toy | Be restrained while your partner uses a suction toy | 4 | text: "Be restrained while your partner uses a suction toy" / partner: "Restrain your partner's legs and use a suction toy" |
| 8 | Use a powerful wand on your partner during penetration | Feel a powerful wand while your partner penetrates you | 4 | text: "Feel a powerful wand while your partner penetrates you" / partner: "Use a powerful wand on your partner during penetration" |
| 9 | Slowly expand an inflatable toy inside your partner | Let your partner slowly expand an inflatable toy inside you | 5 | text: "Let your partner slowly expand an inflatable toy inside you" / partner: "Slowly expand an inflatable toy inside your partner" |
| 10 | Use sensation pads and a vibrator on your partner | Feel sensation pads and a vibrator on your thighs | 5 | text: "Feel sensation pads and a vibrator on your thighs" / partner: "Use sensation pads and a vibrator on your partner" |
| 11 | Use a rhythmic thrusting machine on your partner | Be pleasured by a thrusting machine while your partner controls it | 5 | text: "Be pleasured by a thrusting machine while your partner controls it" / partner: "Use a rhythmic thrusting machine on your partner" |
| 12 | Edge your partner five times before allowing them to peak | Let your partner edge you five times before you reach a peak | 4 | text: "Let your partner edge you five times before you reach a peak" / partner: "Edge your partner five times before allowing them to peak" |
| 13 | Use a TENS unit on your partner while simultaneously penetrating them with a toy | Feel electrical stimulation while your partner uses a toy inside you | 5 | text: "Feel electrical stimulation while your partner uses a toy inside you" / partner: "Use a TENS unit on your partner while simultaneously penetrating them with a toy" |
| 14 | Blindfold your partner and use a sensation wheel | Be blindfolded while your partner uses a sensation wheel | 5 | text: "Be blindfolded while your partner uses a sensation wheel" / partner: "Blindfold your partner and use a sensation wheel" |
| 15 | Make your partner ride a mounted dildo while you control a vibrator on them | Ride a dildo while your partner controls additional stimulation | 4 | text: "Ride a dildo while your partner controls additional stimulation" / partner: "Make your partner ride a mounted dildo while you control a vibrator on them" |
| 16 | Use a thrusting dildo on your partner while they're restrained and blindfolded | Be bound and blindfolded while a thrusting toy penetrates you | 5 | text: "Be bound and blindfolded while a thrusting toy penetrates you" / partner: "Use a thrusting dildo on your partner while they're restrained and blindfolded" |
| 17 | Bring your partner to a peak using only internal toys | Reach a peak using only internal stimulation | 4 | text: "Reach a peak using only internal stimulation" / partner: "Bring your partner to a peak using only internal toys" |
| 18 | Use a thrusting machine for anal play while using a wand on your partner | Experience a thrusting machine and wand stimulation simultaneously | 5 | text: "Experience a thrusting machine and wand stimulation simultaneously" / partner: "Use a thrusting machine for anal play while using a wand on your partner" |
| 19 | Tie your partner in a hogtie and edge them repeatedly | Be bound in a hogtie while your partner edges you | 5 | text: "Be bound in a hogtie while your partner edges you" / partner: "Tie your partner in a hogtie and edge them repeatedly" |
| 20 | Use a sensation wand on your partner's sensitive spots | Feel the tingle of a sensation wand on your body | 5 | text: "Feel the tingle of a sensation wand on your body" / partner: "Use a sensation wand on your partner's sensitive spots" |
| 21 | Attach weighted nipple clamps to your partner and use a powerful wand on them | Wear weighted clamps while your partner overwhelms you with a wand | 5 | text: "Wear weighted clamps while your partner overwhelms you with a wand" / partner: "Attach weighted nipple clamps to your partner and use a powerful wand on them" |
| 22 | Use an internal massager to tease your partner | Let your partner tease you with an internal massager | 4 | text: "Let your partner tease you with an internal massager" / partner: "Use an internal massager to tease your partner" |
| 23 | Restrain your partner and use a vibrator on them | Be restrained while your partner uses a vibrator on you | 5 | text: "Be restrained while your partner uses a vibrator on you" / partner: "Restrain your partner and use a vibrator on them" |
| 24 | Tease your partner while they wear a locked toy | Be teased by your partner while wearing a locked toy | 5 | text: "Be teased by your partner while wearing a locked toy" / partner: "Tease your partner while they wear a locked toy" |
| 25 | Restrain your partner with a spreader bar and tease them with a wand | Be restrained with a spreader bar while your partner teases you | 5 | text: "Be restrained with a spreader bar while your partner teases you" / partner: "Restrain your partner with a spreader bar and tease them with a wand" |
| 26 | Use a large plug and a powerful wand on your partner until they have multiple orgasms | Be filled and vibrated until you orgasm again and again | 5 | text: "Be filled and vibrated until you orgasm again and again" / partner: "Use a large plug and a powerful wand on your partner until they have multiple orgasms" |
| 27 | Make your partner count their edges out loud while you control the toy | Count each edge out loud while your partner decides when you've had enough | 5 | text: "Count each edge out loud while your partner decides when you've had enough" / partner: "Make your partner count their edges out loud while you control the toy" |
| 28 | Put your partner in predicament bondage where struggling increases toy intensity | Be bound so that every movement makes the vibrations stronger | 5 | text: "Be bound so that every movement makes the vibrations stronger" / partner: "Put your partner in predicament bondage where struggling increases toy intensity" |
| 29 | Restrain your partner in a strict position and methodically overstimulate every erogenous zone with toys | Be bound strictly while your partner systematically overwhelms every sensitive part of your body | 5 | text: "Be bound strictly while your partner systematically overwhelms every sensitive part of your body" / partner: "Restrain your partner in a strict position and methodically overstimulate every erogenous zone with toys" |
| 30 | Use a hollow plug on your partner so you can penetrate them while they're already filled | Wear a hollow plug while your partner penetrates you through it | 5 | text: "Wear a hollow plug while your partner penetrates you through it" / partner: "Use a hollow plug on your partner so you can penetrate them while they're already filled" |
| 31 | Edge your partner for an hour using various toys before allowing release | Be edged with different toys for an extended session before finally coming | 5 | text: "Be edged with different toys for an extended session before finally coming" / partner: "Edge your partner for an hour using various toys before allowing release" |
| 32 | Use a large toy on your partner with plenty of lube | Be filled by a large toy with plenty of lube | 4 | text: "Be filled by a large toy with plenty of lube" / partner: "Use a large toy on your partner with plenty of lube" |
| 33 | Use a rotating and thrusting toy on your partner while restraining their hips | Have your hips pinned while a toy rotates and thrusts inside you | 5 | text: "Have your hips pinned while a toy rotates and thrusts inside you" / partner: "Use a rotating and thrusting toy on your partner while restraining their hips" |
| 34 | Bring your partner to a peak while they are gagged | Let your partner bring you to a peak while gagged | 5 | text: "Let your partner bring you to a peak while gagged" / partner: "Bring your partner to a peak while they are gagged" |
| 35 | Use clover clamps on your partner that tighten when pulled while you penetrate them with a toy | Feel clamps tighten with each thrust as your partner toys you | 5 | text: "Feel clamps tighten with each thrust as your partner toys you" / partner: "Use clover clamps on your partner that tighten when pulled while you penetrate them with a toy" |
| 36 | Connect nipple clamps with a chain to your partner's collar while using a toy on them | Wear connected clamps and collar while your partner pleasures you with a toy | 5 | text: "Wear connected clamps and collar while your partner pleasures you with a toy" / partner: "Connect nipple clamps with a chain to your partner's collar while using a toy on them" |
| 37 | Use a large textured dildo on your partner while they wear a ball gag | Be gagged and stretched by a large textured toy | 5 | text: "Be gagged and stretched by a large textured toy" / partner: "Use a large textured dildo on your partner while they wear a ball gag" |
| 38 | Use a thrusting toy on your partner while spanking them | Be penetrated with a thrusting toy while your partner spanks you | 5 | text: "Be penetrated with a thrusting toy while your partner spanks you" / partner: "Use a thrusting toy on your partner while spanking them" |
| 39 | Use a pump on your partner to increase sensitivity before using other toys | Have your sensitivity heightened by a pump before your partner uses toys on you | 5 | text: "Have your sensitivity heightened by a pump before your partner uses toys on you" / partner: "Use a pump on your partner to increase sensitivity before using other toys" |
| 40 | Tie your partner face down and use a thrusting toy on them from behind | Be bound face down while a toy thrusts into you | 5 | text: "Be bound face down while a toy thrusts into you" / partner: "Tie your partner face down and use a thrusting toy on them from behind" |
| 41 | Bind your partner completely immobile and use toys on them until you decide they're done | Be completely restrained while your partner controls all stimulation | 5 | text: "Be completely restrained while your partner controls all stimulation" / partner: "Bind your partner completely immobile and use toys on them until you decide they're done" |
| 42 | Strap vibrators to multiple parts of your partner's body and turn them all on at once | Have vibrators attached to several erogenous zones buzzing simultaneously | 5 | text: "Have vibrators attached to several erogenous zones buzzing simultaneously" / partner: "Strap vibrators to multiple parts of your partner's body and turn them all on at once" |
| 43 | Strap a wand to your partner and leave it on medium while you do other things to them | Have a wand strapped to you as constant stimulation while your partner plays | 5 | text: "Have a wand strapped to you as constant stimulation while your partner plays" / partner: "Strap a wand to your partner and leave it on medium while you do other things to them" |
| 44 | Use electro-stimulation on your partner while they edge themselves with a toy | Shock yourself lightly while edging with a toy at your partner's command | 5 | text: "Shock yourself lightly while edging with a toy at your partner's command" / partner: "Use electro-stimulation on your partner while they edge themselves with a toy" |
| 45 | Make your partner edge themselves while you watch and give instructions | Edge yourself under your partner's commands while they watch | 5 | text: "Edge yourself under your partner's commands while they watch" / partner: "Make your partner edge themselves while you watch and give instructions" |
| 46 | Blindfold your partner and make them guess how many toys are inside them | Guess how many toys your partner has put inside you while blindfolded | 4 | text: "Guess how many toys your partner has put inside you while blindfolded" / partner: "Blindfold your partner and make them guess how many toys are inside them" |
| 47 | Tease your partner right to the edge before stopping | Be teased to the edge before your partner stops | 5 | text: "Be teased to the edge before your partner stops" / partner: "Tease your partner right to the edge before stopping" |
-256
View File
@@ -1,256 +0,0 @@
# New Question Types Draft - Non-Explicit Packs
New questions using **audio**, **photo**, and **who_likely** types for all non-explicit question packs. Questions match the tone, intensity range, and theme of each pack.
**Question types:**
- **audio** - User records a voice response (max 60s)
- **photo** - User uploads/captures a photo
- **who_likely** - "Who is more likely to..." (each partner picks who)
None of these types use partner_text.
---
## 1. Staying Close (Long Distance, intensity 1)
| # | Type | Text | Intensity |
|---|------|------|-----------|
| 1 | audio | Record a voice note saying good morning to your partner | 1 |
| 2 | audio | Record yourself humming or singing a song that reminds you of your partner | 1 |
| 3 | audio | Leave a voice note describing the best part of your day | 1 |
| 4 | audio | Record a bedtime voice note for your partner to fall asleep to | 1 |
| 5 | photo | Send a photo of your current view right now | 1 |
| 6 | photo | Share a photo of something that made you smile today | 1 |
| 7 | photo | Take a photo of your pillow and empty bed | 1 |
| 8 | photo | Send a photo of your morning coffee or tea | 1 |
| 9 | who_likely | Who is more likely to call the other first? | 1 |
| 10 | who_likely | Who is more likely to fall asleep on a video call? | 1 |
| 11 | who_likely | Who is more likely to send a random "I miss you" text? | 1 |
| 12 | who_likely | Who is more likely to plan a surprise visit? | 1 |
| 13 | who_likely | Who is more likely to cry at the airport? | 1 |
---
## 2. Finding Your Vibe (Social Life, intensity 1)
| # | Type | Text | Intensity |
|---|------|------|-----------|
| 1 | who_likely | Who is more likely to be the last one on the dance floor? | 1 |
| 2 | who_likely | Who is more likely to make friends with a stranger at a party? | 1 |
| 3 | who_likely | Who is more likely to suggest leaving early? | 1 |
| 4 | who_likely | Who is more likely to be the designated driver? | 1 |
| 5 | who_likely | Who is more likely to start a karaoke session? | 1 |
| 6 | who_likely | Who is more likely to cancel plans to stay in? | 1 |
| 7 | audio | Describe your perfect night out in a voice note | 1 |
| 8 | photo | Share a photo of your go-to going-out outfit | 1 |
---
## 3. Weekend Warriors (Adventure & Travel, intensity 1-2)
| # | Type | Text | Intensity |
|---|------|------|-----------|
| 1 | who_likely | Who is more likely to suggest an unplanned road trip? | 1 |
| 2 | who_likely | Who is more likely to get competitive at mini golf? | 1 |
| 3 | who_likely | Who is more likely to try a new restaurant without checking reviews? | 1 |
| 4 | who_likely | Who is more likely to spend the entire weekend on the couch? | 1 |
| 5 | who_likely | Who is more likely to drag the other to a flea market? | 1 |
| 6 | photo | Share a photo of a place nearby you've been wanting to visit together | 1 |
| 7 | photo | Take a photo of your ideal weekend morning setup | 1 |
| 8 | audio | Describe a weekend adventure you'd love to go on together | 1 |
| 9 | audio | Tell your partner about your favorite weekend memory together | 1 |
---
## 4. Outdoor Explorers (Adventure & Travel, intensity 1)
| # | Type | Text | Intensity |
|---|------|------|-----------|
| 1 | who_likely | Who is more likely to suggest a spontaneous hike? | 1 |
| 2 | who_likely | Who is more likely to get lost on a trail? | 1 |
| 3 | who_likely | Who is more likely to complain about the weather? | 1 |
| 4 | who_likely | Who is more likely to forget the sunscreen? | 1 |
| 5 | who_likely | Who would survive longer in the wilderness? | 1 |
| 6 | photo | Share a photo of your favorite outdoor spot | 1 |
| 7 | photo | Take a photo of the view from your window right now | 1 |
| 8 | audio | Describe your dream outdoor adventure in a voice note | 1 |
| 9 | audio | Tell your partner about a time in nature that felt magical | 1 |
---
## 5. Romantic Gestures (Romance & Sensuality, intensity 2-4)
| # | Type | Text | Intensity |
|---|------|------|-----------|
| 1 | audio | Whisper something sweet to your partner | 2 |
| 2 | audio | Record yourself saying what you love most about your partner | 2 |
| 3 | audio | Tell your partner about a moment with them you'll never forget | 2 |
| 4 | audio | In your most romantic voice, tell your partner what they mean to you | 3 |
| 5 | audio | Describe the first time you knew you were falling for your partner | 2 |
| 6 | photo | Set up a romantic scene at home and share a photo | 2 |
| 7 | photo | Write a short love note by hand and send a photo of it | 2 |
| 8 | photo | Share a photo from a moment together that makes your heart race | 2 |
| 9 | who_likely | Who is more likely to plan a surprise romantic gesture? | 2 |
| 10 | who_likely | Who is more likely to initiate a slow dance in the kitchen? | 2 |
| 11 | who_likely | Who is more likely to write a love letter? | 2 |
| 12 | who_likely | Who is more likely to pull the other in for a random kiss? | 2 |
---
## 6. Sensual Massage (Sensual Discovery, intensity 2-3)
| # | Type | Text | Intensity |
|---|------|------|-----------|
| 1 | who_likely | Who is more likely to ask for a massage after a long day? | 2 |
| 2 | who_likely | Who is more likely to fall asleep during a massage? | 2 |
| 3 | who_likely | Who gives the better back massage? | 2 |
| 4 | audio | Describe how you would give your partner the perfect massage | 3 |
| 5 | audio | Tell your partner which part of your body holds the most tension | 2 |
---
## 7. Missing You (Long Distance, intensity 1-3)
| # | Type | Text | Intensity |
|---|------|------|-----------|
| 1 | audio | Record a whispered "I miss you" voice note | 2 |
| 2 | audio | Describe what you'd do in the first five minutes of seeing your partner again | 2 |
| 3 | audio | Read your partner a short goodnight message in your sleepiest voice | 1 |
| 4 | audio | Tell your partner the thing you miss most about being together | 1 |
| 5 | photo | Take a selfie right now, no filter, no prep | 1 |
| 6 | photo | Share a photo of something you bought that reminded you of your partner | 1 |
| 7 | photo | Send a photo of what you're wearing right now | 2 |
| 8 | photo | Share a photo of your favorite thing your partner left at your place | 1 |
| 9 | who_likely | Who is more likely to send a 3am "thinking of you" text? | 1 |
| 10 | who_likely | Who is more likely to rewatch old photos and videos of you two? | 1 |
| 11 | who_likely | Who is more likely to wear the other person's hoodie? | 1 |
| 12 | who_likely | Who is more likely to get emotional hearing a song that reminds them of the other? | 1 |
---
## 8. Everyday Moments (Quality Time, intensity 1)
| # | Type | Text | Intensity |
|---|------|------|-----------|
| 1 | photo | Share a photo of your morning coffee or breakfast | 1 |
| 2 | photo | Take a photo of your workspace or desk setup | 1 |
| 3 | photo | Share a photo of something small that made your day better | 1 |
| 4 | audio | Describe your favorite daily ritual you share together | 1 |
| 5 | audio | Tell your partner what you were just thinking about | 1 |
| 6 | who_likely | Who is more likely to wake up first? | 1 |
| 7 | who_likely | Who is more likely to cook dinner on a weeknight? | 1 |
| 8 | who_likely | Who is more likely to fall asleep on the couch? | 1 |
| 9 | who_likely | Who is more likely to suggest ordering takeout? | 1 |
| 10 | who_likely | Who is more likely to do the dishes without being asked? | 1 |
---
## 9. Relationship Essentials (Quality Time, intensity 1)
| # | Type | Text | Intensity |
|---|------|------|-----------|
| 1 | audio | Tell your partner one thing you appreciate about them that you don't say enough | 1 |
| 2 | audio | Describe in your own words what makes your relationship work | 1 |
| 3 | audio | Tell your partner about a time they made you feel truly safe | 1 |
| 4 | who_likely | Who is more likely to apologize first after an argument? | 1 |
| 5 | who_likely | Who is more likely to bring up something that's bothering them? | 1 |
| 6 | who_likely | Who is more likely to remember an anniversary or special date? | 1 |
| 7 | who_likely | Who is more likely to compromise during a disagreement? | 1 |
| 8 | who_likely | Who is more likely to say "I love you" first each day? | 1 |
| 9 | photo | Share a photo of something that reminds you of your relationship | 1 |
---
## 10. Hosting (Social Life, intensity 1)
| # | Type | Text | Intensity |
|---|------|------|-----------|
| 1 | who_likely | Who is more likely to overdo it on the snacks and decorations? | 1 |
| 2 | who_likely | Who is more likely to still be cleaning five minutes before guests arrive? | 1 |
| 3 | who_likely | Who is the better DJ at a house party? | 1 |
| 4 | who_likely | Who is more likely to invite too many people? | 1 |
| 5 | photo | Share a photo of your dream hosting setup or table setting | 1 |
| 6 | audio | Describe your dream dinner party - who's invited and what's on the menu? | 1 |
| 7 | audio | What's your signature dish you'd make for guests? Describe it | 1 |
---
## 11. Body Mapping (Sensual Discovery, intensity 2-3)
| # | Type | Text | Intensity |
|---|------|------|-----------|
| 1 | who_likely | Who is more ticklish? | 2 |
| 2 | who_likely | Who has more sensitive ears? | 2 |
| 3 | who_likely | Who reacts more to having their neck kissed? | 2 |
| 4 | who_likely | Who is more likely to discover a new sensitive spot on the other? | 2 |
| 5 | audio | Describe where you most love being touched by your partner | 3 |
| 6 | audio | Tell your partner the kind of touch that instantly relaxes you | 2 |
---
## 12. Date Night Ideas (Quality Time, intensity 1-2)
| # | Type | Text | Intensity |
|---|------|------|-----------|
| 1 | photo | Share a photo of a restaurant or cafe you'd love to take your partner to | 1 |
| 2 | photo | Take a photo of your date night outfit | 1 |
| 3 | photo | Share a photo of a dish you'd love to cook together | 1 |
| 4 | audio | Pitch your dream date night from start to finish | 1 |
| 5 | audio | Describe the best date you've ever been on together | 1 |
| 6 | who_likely | Who is more likely to pick the restaurant? | 1 |
| 7 | who_likely | Who is more likely to try something completely new on a date? | 1 |
| 8 | who_likely | Who is more likely to plan a surprise date? | 1 |
| 9 | who_likely | Who is more likely to lose track of time on a date because you're having too much fun? | 1 |
---
## 13. First Impressions (Roleplay, intensity 2-3)
| # | Type | Text | Intensity |
|---|------|------|-----------|
| 1 | audio | Introduce yourself to your partner as if you're meeting for the first time | 2 |
| 2 | audio | Deliver your best pick-up line to your partner | 2 |
| 3 | audio | In character, leave a voicemail asking your partner on a first date | 2 |
| 4 | audio | Describe what you'd wear to impress your partner if you were meeting for the first time | 2 |
| 5 | who_likely | Who is more likely to approach the other if you were strangers? | 2 |
| 6 | who_likely | Who is more likely to break character and laugh? | 2 |
| 7 | who_likely | Who is more likely to use a cheesy pick-up line? | 2 |
| 8 | who_likely | Who would be more nervous on a real first date? | 2 |
| 9 | photo | Snap a photo of the outfit you'd wear to make a killer first impression | 2 |
---
## 14. Food & Culture (Adventure & Travel, intensity 1)
| # | Type | Text | Intensity |
|---|------|------|-----------|
| 1 | photo | Share a photo of a dish you've been craving lately | 1 |
| 2 | photo | Take a photo of something in your kitchen that tells a story | 1 |
| 3 | audio | Describe a meal from your childhood that means a lot to you | 1 |
| 4 | audio | Tell your partner about a cuisine you've always wanted to try together | 1 |
| 5 | who_likely | Who is the pickier eater? | 1 |
| 6 | who_likely | Who is more likely to try an unusual local delicacy while traveling? | 1 |
| 7 | who_likely | Who is more likely to send back a dish at a restaurant? | 1 |
| 8 | who_likely | Who is the better cook? | 1 |
---
## Summary
| Pack | Audio | Photo | Who Likely | Total New |
|------|-------|-------|------------|-----------|
| Staying Close | 4 | 4 | 5 | **13** |
| Finding Your Vibe | 1 | 1 | 6 | **8** |
| Weekend Warriors | 2 | 2 | 5 | **9** |
| Outdoor Explorers | 2 | 2 | 5 | **9** |
| Romantic Gestures | 5 | 3 | 4 | **12** |
| Sensual Massage | 2 | 0 | 3 | **5** |
| Missing You | 4 | 4 | 4 | **12** |
| Everyday Moments | 2 | 3 | 5 | **10** |
| Relationship Essentials | 3 | 1 | 5 | **9** |
| Hosting | 2 | 1 | 4 | **7** |
| Body Mapping | 2 | 0 | 4 | **6** |
| Date Night Ideas | 2 | 3 | 4 | **9** |
| First Impressions | 4 | 1 | 4 | **9** |
| Food & Culture | 2 | 2 | 4 | **8** |
| **Totals** | **37** | **27** | **62** | **126** |
+2988 -1882
View File
File diff suppressed because it is too large. Load diff
+4 -1
View File
@@ -8,6 +8,7 @@
"packages/*" "packages/*"
], ],
"scripts": { "scripts": {
"postinstall": "node scripts/fix-hoisting.js",
"dev": "turbo dev", "dev": "turbo dev",
"build": "turbo build", "build": "turbo build",
"lint": "turbo lint", "lint": "turbo lint",
@@ -16,6 +17,7 @@
"typecheck": "turbo typecheck" "typecheck": "turbo typecheck"
}, },
"devDependencies": { "devDependencies": {
"patch-package": "^8.0.1",
"pg": "^8.17.2", "pg": "^8.17.2",
"turbo": "^2.3.3", "turbo": "^2.3.3",
"typescript": "^5.3.3" "typescript": "^5.3.3"
@@ -26,7 +28,8 @@
}, },
"overrides": { "overrides": {
"rollup": "4.54.0", "rollup": "4.54.0",
"react-native": "0.76.9" "react-native": "0.76.9",
"expo": "~52.0.0"
}, },
"optionalDependencies": { "optionalDependencies": {
"@rollup/rollup-linux-arm64-musl": "4.54.0", "@rollup/rollup-linux-arm64-musl": "4.54.0",
+42
View File
@@ -0,0 +1,42 @@
/**
* Fixes npm workspace hoisting for React Native native modules.
*
* npm workspaces hoists all deps to the root node_modules by default.
* Native modules like @react-native-firebase/* and @shopify/react-native-skia
* break when hoisted because:
* - Firebase podspecs use relative paths to sibling packages (../app/package.json)
* - Skia native bindings must resolve from the app's node_modules
*
* This script moves hoisted native packages back into apps/mobile/node_modules
* so CocoaPods autolinking and Metro resolve them correctly.
*/
const fs = require('fs');
const path = require('path');
const ROOT = path.resolve(__dirname, '..');
const MOBILE_NM = path.join(ROOT, 'apps', 'mobile', 'node_modules');
const ROOT_NM = path.join(ROOT, 'node_modules');
// Packages that must live in apps/mobile/node_modules, not root
const PACKAGES_TO_FIX = [
'@react-native-firebase/app',
'@react-native-firebase/analytics',
'@react-native-firebase/crashlytics',
'@shopify/react-native-skia',
];
for (const pkg of PACKAGES_TO_FIX) {
const rootPkg = path.join(ROOT_NM, pkg);
const localPkg = path.join(MOBILE_NM, pkg);
if (!fs.existsSync(rootPkg)) continue;
if (fs.existsSync(localPkg)) continue; // already local
// Ensure parent scope dir exists (e.g. @react-native-firebase)
const localParent = path.dirname(localPkg);
fs.mkdirSync(localParent, { recursive: true });
// Move from root to local
fs.renameSync(rootPkg, localPkg);
console.log(`fix-hoisting: moved ${pkg} → apps/mobile/node_modules/`);
}