feat: add live draw feature and clean up docs

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
This commit is contained in:
jjbruntonandClaude Opus 4.5 committed 2026-02-01 14:35:16 +00:00
1 parent 520d0a8444
commit 4c18afaeae
63 files changed
+6728 -12854

No files matched your search

+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 { supabase, supabaseConfig } from '@/config';
import { useRealtimeSubscription } from '@/hooks/useRealtimeSubscription';
@@ -37,7 +37,7 @@ import {
SelectTrigger,
SelectValue,
} 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 { 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" />;
}
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() {
const { userId } = useParams<{ userId: string }>();
const { hasPermission } = useAuth();
@@ -755,6 +895,7 @@ export function UserDetailPage() {
{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>}
<TabsTrigger value="packs" disabled={!profile.couple_id}>Enabled Packs ({enabledPacks.length})</TabsTrigger>
<TabsTrigger value="livedraw" disabled={!profile.couple_id}>Live Draw</TabsTrigger>
</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>}
{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>
)}
</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>
</div>
);
-120
View File
@@ -1,120 +0,0 @@
# Mobile data retrieval review: task list
This task list targets the **Home**, **Swipe**, and **Matches** flows in `apps/mobile`, focusing on correctness, performance, and realtime consistency.
## Overview (current state)
- Data fetching is split between Zustand stores (`useMatchStore`, `usePacksStore`) and screen-local Supabase RPC calls (notably `SwipeScreen`).
- Realtime updates are centralized in `apps/mobile/app/(app)/_layout.tsx` (matches/messages/profile/pack-settings subscriptions).
- Main risks today: missing query filters & error/loading states, redundant fetches, state races on param changes, and unread-count drift between realtime and the matches list.
## Scope
- Screens
- Home: `apps/mobile/app/(app)/index.tsx`
- Swipe: `apps/mobile/app/(app)/swipe.tsx`
- Matches: `apps/mobile/app/(app)/matches.tsx`
- Stores
- Matches: `apps/mobile/src/store/matchStore.ts`
- Packs: `apps/mobile/src/store/packsStore.ts`
- Messages: `apps/mobile/src/store/messageStore.ts`
- Realtime orchestration
- `apps/mobile/app/(app)/_layout.tsx`
## Priority 0 — correctness (must fix)
- [ ] **Home: fix packs count source-of-truth**
- Current: `enabledPacksCount = packs.length` (counts available packs).
- Decide + implement:
- If label means *enabled*: use `enabledPackIds.length`.
- If label means *available*: rename the variable + label.
- Acceptance: packs number matches user expectations and store state.
- [ ] **Swipe: normalize `packId` and reset state on param changes**
- Normalize `packId` from `useLocalSearchParams()` (`string | string[]`) before sending to RPC.
- Reset `currentIndex`, `isLoading`, and any derived UI state when `packId` changes.
- Acceptance: switching packs never shows stale index/loading/UI.
- [ ] **Swipe: add race protection for overlapping async fetches**
- Use a request-id guard (or abort pattern) so older `fetchQuestions()` results cannot overwrite newer results.
- Acceptance: rapid navigation/focus changes never clobber newer state.
- [ ] **Swipe: prevent stale “blocked” state on answer-gap errors**
- Ensure `checkAnswerGap()` clears/updates `isBlocked` + `gapInfo` deterministically even when RPC errors.
- Acceptance: blocked UI never “sticks” due to transient failures.
## Priority 1 — UX and data freshness
- [ ] **Home: ensure data freshness on revisit**
- Add `useFocusEffect` or a simple staleness TTL in stores.
- Acceptance: counts (matches/new/packs) refresh when returning to Home.
- [ ] **Home: align “Recent Matches” semantics**
- `useMatchStore.fetchMatches()` sorts unread first; Home slices top 3.
- Decide:
- True recent by `created_at`, or
- “Top matches” by unread-first.
- Acceptance: section name and behavior match.
- [ ] **Matches screen: wire up pull-to-refresh correctly**
- Add `isLoading`/`isRefreshing` + `error` to `useMatchStore`.
- Use those flags for `RefreshControl.refreshing`.
- Acceptance: pull-to-refresh shows spinner and recovers from errors.
## Priority 2 — performance and query efficiency
- [ ] **MatchStore: add explicit filtering and guard empty `in(...)` lists**
- Filter matches by `couple_id` (in addition to RLS) for clarity/perf.
- If no matches, avoid calling `.in('question_id', [])`.
- Acceptance: fewer noisy requests; no empty-list edge behavior.
- [ ] **MatchStore: reduce unread-count work**
- Current approach fetches one row per unread message and counts on client.
- Options:
- Add RPC/view that returns per-match unread counts.
- Or store a denormalized `unread_count` per match (if acceptable).
- Acceptance: fetching matches scales with large message histories.
- [ ] **Swipe: remove heavy dependency on `fetchPacks()`**
- Swipe often only needs `enabledPackIds`.
- Add lightweight `ensureEnabledPacksLoaded()` or call `fetchEnabledPacks()` directly.
- Acceptance: less data fetched when entering Swipe.
- [ ] **Swipe: replace random comparator sort**
- Replace `Array.sort()` comparator using `Math.random()` with a stable score/shuffle pass.
- Acceptance: consistent ordering logic; avoids comparator contract violations.
## Priority 3 — realtime correctness and consistency
- [ ] **Realtime messages: tighten subscription filter**
- Current `messages` subscription listens without a `filter`.
- Improve by filtering to the user’s couple scope (schema permitting) or alternative channel strategy.
- Acceptance: avoids noise; reduces risk of unintended cross-couple events.
- [ ] **Sync per-match unread counts with realtime events**
- Currently global unread count updates live; per-match counts update only on `fetchMatches()`.
- Options:
- Update `useMatchStore` unreadCount per match when realtime message arrives.
- Or trigger a targeted refresh when new partner message arrives.
- Acceptance: Matches list ordering/badges update as messages arrive.
## Optional: structural improvements (bigger refactors)
- [ ] Consolidate fetching into a query layer (e.g., TanStack Query) with caching, invalidation, retries, and focus refetch.
- [ ] Create a single “matches list” RPC that returns:
- match + question
- who-answered-first info (for two-part display)
- per-match unread counts
- new match flag
- [ ] Add lightweight integration tests around:
- `packId` parsing
- request race guards
- unread count updates
## Suggested implementation order
1. P0 correctness (Home packs count, Swipe param/reset/races, gap-state)
2. Matches loading/refresh UX
3. Query efficiency (couple filters, unread-count optimization)
4. Realtime tightening + unread sync
5. Optional refactors
-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
-303
View File
@@ -1,303 +0,0 @@
# Releasing Sauci Mobile App
This guide covers local release builds for Android and iOS.
## Prerequisites
- **Java JDK 17** - Required for Android Gradle builds
- **Android Studio** - For SDK and build tools (including Android SDK/NDK)
- **Xcode** - For iOS builds (macOS only)
- **EAS CLI** - `npm install -g eas-cli`
- **Expo account** - Run `eas login` to authenticate
---
## EAS Build Local (Recommended)
The `--local` flag runs the EAS build process on your machine instead of Expo's cloud servers. This automatically handles signing credentials and produces the same output as cloud builds.
### Quick Start
```bash
cd apps/mobile
# Android - produces .aab file (for Play Store)
eas build --platform android --profile production --local
# iOS - produces .ipa file (for App Store)
eas build --platform ios --profile production --local
```
Output files are placed in the current directory by default.
### Building an APK (for Testing)
The production profile creates an AAB (Android App Bundle), which can't be directly installed on devices. To build an APK for testing:
```bash
# Use the preview profile (configured for APK output)
eas build --platform android --profile preview --local
```
Or add a custom profile to `eas.json`:
```json
{
"build": {
"local-apk": {
"android": {
"buildType": "apk"
}
}
}
}
```
Then run:
```bash
eas build --platform android --profile local-apk --local
```
### Installing the APK
```bash
# Via ADB (device connected via USB)
adb install ./build-*.apk
# Or drag-and-drop onto Android Emulator
```
### Build Profiles
| Profile | Output | Use Case |
|---------|--------|----------|
| `production` | .aab | Play Store / App Store submission |
| `preview` | .apk | Internal testing, device installation |
| `development` | .apk | Dev client with hot reload |
### Environment Variables
Useful for debugging build issues:
| Variable | Purpose |
|----------|---------|
| `EAS_LOCAL_BUILD_SKIP_CLEANUP=1` | Keep build directory after completion |
| `EAS_LOCAL_BUILD_WORKINGDIR=/path` | Custom build directory (default: /tmp) |
| `EAS_LOCAL_BUILD_ARTIFACTS_DIR=/path` | Output directory for build artifacts |
Example with debugging enabled:
```bash
EAS_LOCAL_BUILD_SKIP_CLEANUP=1 \
EAS_LOCAL_BUILD_WORKINGDIR=~/eas-builds \
eas build --platform android --profile production --local
```
### Limitations of Local Builds
- **Single platform only** - Can't use `--platform all`
- **Software versions ignored** - `node`, `yarn`, `ndk`, `image` fields in eas.json are ignored
- **No caching** - Each build starts fresh
- **Secret env vars not available** - Set them in your local environment instead
- **macOS/Linux only** - Windows requires WSL (unsupported)
### Troubleshooting Local Builds
**Java version mismatch:**
```bash
# Check Java version (should be 17)
java -version
# On macOS, switch Java version
export JAVA_HOME=$(/usr/libexec/java_home -v 17)
```
**Gradle daemon issues:**
```bash
cd android && ./gradlew --stop && cd ..
```
**Clean build after dependency changes:**
```bash
# Remove native folders and rebuild
rm -rf android ios
npx expo prebuild --clean
eas build --platform android --profile production --local
```
**Out of memory:**
```bash
# Increase Gradle memory
echo "org.gradle.jvmargs=-Xmx4g" >> android/gradle.properties
```
---
## Manual Android Release Build
### Step 1: Get the Production Keystore
The production keystore is stored in EAS. Download it:
```bash
cd apps/mobile
eas credentials --platform android
```
Select:
1. `production`
2. `Keystore`
3. `Download Keystore`
Save the keystore file and note the **password** and **alias** shown.
### Step 2: Configure Signing
Ensure `android/gradle.properties` has the keystore configuration:
```properties
MYAPP_UPLOAD_STORE_FILE=sauci-release.keystore
MYAPP_UPLOAD_KEY_ALIAS=<alias from EAS>
MYAPP_UPLOAD_STORE_PASSWORD=<password from EAS>
MYAPP_UPLOAD_KEY_PASSWORD=<password from EAS>
```
**Important:** Never commit these credentials to git.
### Step 3: Prebuild (if needed)
If the `android/` folder doesn't exist or native dependencies changed:
```bash
npx expo prebuild --platform android --clean
```
### Step 4: Build the Release Bundle
```bash
cd android
./gradlew bundleRelease
```
The AAB file will be at:
```
android/app/build/outputs/bundle/release/app-release.aab
```
### Step 5: Upload to Play Store
1. Go to [Google Play Console](https://play.google.com/console)
2. Select **Sauci** app
3. Go to **Production** (or testing track)
4. **Create new release**
5. Upload the `.aab` file
6. Add release notes and submit
### Verifying Keystore Fingerprint
To verify you're using the correct keystore:
```bash
keytool -list -v -keystore android/sauci-release.keystore -storepass <password>
```
Expected SHA1: `C3:70:C5:8C:85:E5:B5:92:97:82:A9:B0:47:98:40:99:AC:C8:47:43`
---
## iOS Release Build
### Step 1: Prebuild (if needed)
```bash
npx expo prebuild --platform ios --clean
```
### Step 2: Install Pods
```bash
cd ios
pod install
cd ..
```
### Step 3: Build Archive in Xcode
1. Open `ios/Sauci.xcworkspace` in Xcode
2. Select **Any iOS Device (arm64)** as the build target
3. Go to **Product** → **Archive**
4. Wait for the build to complete
### Step 4: Upload to App Store
1. In Xcode Organizer (Window → Organizer), select the archive
2. Click **Distribute App**
3. Select **App Store Connect** → **Upload**
4. Follow the prompts to upload
### Step 5: Submit in App Store Connect
1. Go to [App Store Connect](https://appstoreconnect.apple.com)
2. Select **Sauci**
3. Create a new version or select the uploaded build
4. Fill in release notes and submit for review
---
## Version Management
Version is managed by EAS with `appVersionSource: "remote"` in `eas.json`.
To sync or bump versions:
```bash
# Check current version
eas build:version:get
# Set version manually
eas build:version:set --platform android --version 1.0.1
eas build:version:set --platform ios --version 1.0.1
```
For local builds, you may need to update `app.json` version manually:
```json
{
"expo": {
"version": "1.0.1"
}
}
```
And for Android, update `android/app/build.gradle`:
```gradle
versionCode 2
versionName "1.0.1"
```
---
## Troubleshooting
### Wrong Keystore Error
If Play Store rejects with "signed with wrong key":
1. Run `eas credentials --platform android`
2. Download the correct keystore
3. Verify fingerprint matches expected SHA1
4. Replace your local keystore file
### Build Failures After Dependency Changes
```bash
# Clean and rebuild
cd android && ./gradlew clean && cd ..
npx expo prebuild --platform android --clean
cd android && ./gradlew bundleRelease
```
### iOS Signing Issues
Ensure your Apple Developer certificates and provisioning profiles are up to date in Xcode:
- **Xcode** → **Settings** → **Accounts** → Download Manual Profiles
@@ -25,6 +25,7 @@
<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_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">
<intent-filter>
<action android:name="android.intent.action.MAIN"/>
@@ -39,5 +40,12 @@
<data android:scheme="exp+sauci"/>
</intent-filter>
</activity>
<receiver android:name=".widget.LiveDraw" android:exported="false" 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>
</manifest>
@@ -0,0 +1,6 @@
package com.sauci.app.widget;
import com.reactnativeandroidwidget.RNWidgetProvider;
public class LiveDraw extends RNWidgetProvider {
}
Binary file not shown.

After

Width:  |  Height:  |  Size: 688 KiB

@@ -2,4 +2,5 @@
<string name="app_name">Sauci</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="widget_livedraw_description" translatable="false">See your shared drawing with your partner.</string>
</resources>
@@ -0,0 +1,21 @@
<?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/rn_widget"
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
+28 -1
View File
@@ -16,6 +16,9 @@
"ios": {
"supportsTablet": false,
"bundleIdentifier": "com.sauci.app",
"entitlements": {
"com.apple.security.application-groups": ["group.com.sauci.app"]
},
"buildNumber": "35",
"usesAppleSignIn": true,
"googleServicesFile": "./GoogleService-Info.plist",
@@ -237,7 +240,31 @@
"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
}
]
}
]
],
"experiments": {
"typedRoutes": true
+8 -1
View File
@@ -76,8 +76,9 @@ export default function AppLayout() {
const isOnOnboarding = segmentStrings.includes("onboarding");
const isOnPairing = segmentStrings.includes("pairing");
const isOnChat = segmentStrings.includes("chat");
const isOnLiveDraw = segmentStrings.includes("live-draw");
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
useEffect(() => {
@@ -539,6 +540,12 @@ export default function AppLayout() {
href: null, // Hide from tab bar
}}
/>
<Tabs.Screen
name="live-draw"
options={{
href: null, // Hide from tab bar
}}
/>
</Tabs>
{/* Message Toast Notification */}
+4 -1
View File
@@ -7,7 +7,7 @@ import { BlurView } from 'expo-blur';
import { useAuthStore, usePacksStore, useSubscriptionStore } from '../../src/store';
import { GradientBackground } from '../../src/components/ui';
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 type { QuestionPack, Category } from '../../src/types';
@@ -114,6 +114,9 @@ export default function DiscoveryScreen() {
showsVerticalScrollIndicator={false}
>
<View style={[styles.innerContainer, isWideScreen && styles.innerContainerWide]}>
{/* Live Draw Activity Tile */}
<LiveDrawTile delay={100} />
{/* Content Rows by Category */}
{packsByCategory.map((group, index) => (
<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
# 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
+125 -47
View File
@@ -26,7 +26,7 @@ PODS:
- ExpoModulesCore
- EXNotifications (0.29.14):
- ExpoModulesCore
- Expo (52.0.48):
- Expo (52.0.49):
- ExpoModulesCore
- expo-dev-client (5.0.20):
- EXManifests
@@ -264,7 +264,7 @@ PODS:
- SDWebImageSVGCoder (~> 1.7.0)
- ExpoImagePicker (16.0.6):
- ExpoModulesCore
- ExpoKeepAwake (14.0.3):
- ExpoKeepAwake (15.0.8):
- ExpoModulesCore
- ExpoLinearGradient (14.0.2):
- ExpoModulesCore
@@ -300,7 +300,9 @@ PODS:
- Yoga
- ExpoSecureStore (14.0.1):
- ExpoModulesCore
- ExpoSharing (14.0.8):
- ExpoSharing (13.0.1):
- ExpoModulesCore
- ExpoWidgets (3.0.2):
- ExpoModulesCore
- EXUpdatesInterface (1.0.0):
- ExpoModulesCore
@@ -308,6 +310,9 @@ PODS:
- FBLazyVector (0.76.9)
- Firebase/CoreOnly (12.8.0):
- FirebaseCore (~> 12.8.0)
- Firebase/Crashlytics (12.8.0):
- Firebase/CoreOnly
- FirebaseCrashlytics (~> 12.8.0)
- FirebaseAnalytics/Core (12.8.0):
- FirebaseCore (~> 12.8.0)
- FirebaseInstallations (~> 12.8.0)
@@ -330,13 +335,34 @@ PODS:
- FirebaseCoreInternal (~> 12.8.0)
- GoogleUtilities/Environment (~> 8.1)
- GoogleUtilities/Logger (~> 8.1)
- FirebaseCoreExtension (12.8.0):
- FirebaseCore (~> 12.8.0)
- FirebaseCoreInternal (12.8.0):
- "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):
- FirebaseCore (~> 12.8.0)
- GoogleUtilities/Environment (~> 8.1)
- GoogleUtilities/UserDefaults (~> 8.1)
- 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)
- glog (0.3.5)
- GoogleAppMeasurement/Core (12.8.0):
@@ -352,6 +378,9 @@ PODS:
- GoogleUtilities/Network (~> 8.1)
- "GoogleUtilities/NSData+zlib (~> 8.1)"
- nanopb (~> 3.30910.0)
- GoogleDataTransport (10.1.0):
- nanopb (~> 3.30910.0)
- PromisesObjC (~> 2.4)
- GoogleSignIn (9.1.0):
- AppAuth (~> 2.0)
- AppCheckCore (~> 11.0)
@@ -402,6 +431,8 @@ PODS:
- nanopb/decode (3.30910.0)
- nanopb/encode (3.30910.0)
- PromisesObjC (2.4.0)
- PromisesSwift (2.4.0):
- PromisesObjC (= 2.4.0)
- PurchasesHybridCommon (17.28.0):
- RevenueCat (= 5.55.1)
- RCT-Folly (2024.10.14.00):
@@ -1705,6 +1736,29 @@ PODS:
- Yoga
- react-native-safe-area-context (4.12.0):
- 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):
- DoubleConversion
- glog
@@ -2025,7 +2079,7 @@ PODS:
- ReactCommon/turbomodule/core
- RNFBApp
- Yoga
- RNFBApp (23.8.3):
- RNFBApp (23.8.4):
- DoubleConversion
- Firebase/CoreOnly (= 12.8.0)
- glog
@@ -2047,6 +2101,30 @@ PODS:
- ReactCommon/turbomodule/bridging
- ReactCommon/turbomodule/core
- Yoga
- RNFBCrashlytics (23.8.4):
- 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):
- DoubleConversion
- glog
@@ -2183,29 +2261,6 @@ PODS:
- ReactCommon/turbomodule/bridging
- ReactCommon/turbomodule/core
- 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):
- React-Core
- SDWebImage (5.19.7):
@@ -2216,8 +2271,9 @@ PODS:
- SDWebImage (~> 5.10)
- SDWebImageSVGCoder (1.7.0):
- SDWebImage/Core (~> 5.6)
- Sentry/HybridSDK (8.48.0)
- SocketRocket (0.7.1)
- WidgetBridge (0.0.1):
- ExpoModulesCore
- Yoga (0.0.0)
DEPENDENCIES:
@@ -2230,7 +2286,7 @@ DEPENDENCIES:
- EXJSONUtils (from `../../../node_modules/expo-json-utils/ios`)
- EXManifests (from `../../../node_modules/expo-manifests/ios`)
- EXNotifications (from `../../../node_modules/expo-notifications/ios`)
- Expo (from `../../../node_modules/expo`)
- Expo (from `../node_modules/expo`)
- expo-dev-client (from `../../../node_modules/expo-dev-client/ios`)
- expo-dev-launcher (from `../../../node_modules/expo-dev-launcher`)
- expo-dev-menu (from `../../../node_modules/expo-dev-menu`)
@@ -2253,7 +2309,8 @@ DEPENDENCIES:
- ExpoMediaLibrary (from `../../../node_modules/expo-media-library/ios`)
- ExpoModulesCore (from `../../../node_modules/expo-modules-core`)
- ExpoSecureStore (from `../../../node_modules/expo-secure-store/ios`)
- ExpoSharing (from `../../../node_modules/expo-sharing/ios`)
- ExpoSharing (from `../node_modules/expo-sharing/ios`)
- "ExpoWidgets (from `../../../node_modules/@bittingz/expo-widgets/ios`)"
- EXUpdatesInterface (from `../../../node_modules/expo-updates-interface/ios`)
- fast_float (from `../../../node_modules/react-native/third-party-podspecs/fast_float.podspec`)
- FBLazyVector (from `../../../node_modules/react-native/Libraries/FBLazyVector`)
@@ -2293,6 +2350,7 @@ DEPENDENCIES:
- React-microtasksnativemodule (from `../../../node_modules/react-native/ReactCommon/react/nativemodule/microtasks`)
- react-native-compressor (from `../../../node_modules/react-native-compressor`)
- react-native-safe-area-context (from `../../../node_modules/react-native-safe-area-context`)
- "react-native-skia (from `../node_modules/@shopify/react-native-skia`)"
- react-native-view-shot (from `../../../node_modules/react-native-view-shot`)
- React-nativeconfig (from `../../../node_modules/react-native/ReactCommon`)
- React-NativeModulesApple (from `../../../node_modules/react-native/ReactCommon/react/nativemodule/core/platform/ios`)
@@ -2322,15 +2380,16 @@ DEPENDENCIES:
- ReactCodegen (from `build/generated/ios`)
- ReactCommon/turbomodule/core (from `../../../node_modules/react-native/ReactCommon`)
- "RNCAsyncStorage (from `../../../node_modules/@react-native-async-storage/async-storage`)"
- "RNFBAnalytics (from `../../../node_modules/@react-native-firebase/analytics`)"
- "RNFBApp (from `../../../node_modules/@react-native-firebase/app`)"
- "RNFBAnalytics (from `../node_modules/@react-native-firebase/analytics`)"
- "RNFBApp (from `../node_modules/@react-native-firebase/app`)"
- "RNFBCrashlytics (from `../node_modules/@react-native-firebase/crashlytics`)"
- RNGestureHandler (from `../../../node_modules/react-native-gesture-handler`)
- "RNGoogleSignin (from `../../../node_modules/@react-native-google-signin/google-signin`)"
- RNPurchases (from `../../../node_modules/react-native-purchases`)
- RNReanimated (from `../../../node_modules/react-native-reanimated`)
- RNScreens (from `../../../node_modules/react-native-screens`)
- "RNSentry (from `../../../node_modules/@sentry/react-native`)"
- RNSVG (from `../node_modules/react-native-svg`)
- WidgetBridge (from `../modules/widget-bridge/ios`)
- Yoga (from `../../../node_modules/react-native/ReactCommon/yoga`)
SPEC REPOS:
@@ -2340,9 +2399,14 @@ SPEC REPOS:
- Firebase
- FirebaseAnalytics
- FirebaseCore
- FirebaseCoreExtension
- FirebaseCoreInternal
- FirebaseCrashlytics
- FirebaseInstallations
- FirebaseRemoteConfigInterop
- FirebaseSessions
- GoogleAppMeasurement
- GoogleDataTransport
- GoogleSignIn
- GoogleUtilities
- GTMAppAuth
@@ -2351,12 +2415,12 @@ SPEC REPOS:
- libdav1d
- nanopb
- PromisesObjC
- PromisesSwift
- PurchasesHybridCommon
- RevenueCat
- SDWebImage
- SDWebImageAVIFCoder
- SDWebImageSVGCoder
- Sentry
- SocketRocket
EXTERNAL SOURCES:
@@ -2379,7 +2443,7 @@ EXTERNAL SOURCES:
EXNotifications:
:path: "../../../node_modules/expo-notifications/ios"
Expo:
:path: "../../../node_modules/expo"
:path: "../node_modules/expo"
expo-dev-client:
:path: "../../../node_modules/expo-dev-client/ios"
expo-dev-launcher:
@@ -2425,7 +2489,9 @@ EXTERNAL SOURCES:
ExpoSecureStore:
:path: "../../../node_modules/expo-secure-store/ios"
ExpoSharing:
:path: "../../../node_modules/expo-sharing/ios"
:path: "../node_modules/expo-sharing/ios"
ExpoWidgets:
:path: "../../../node_modules/@bittingz/expo-widgets/ios"
EXUpdatesInterface:
:path: "../../../node_modules/expo-updates-interface/ios"
fast_float:
@@ -2501,6 +2567,8 @@ EXTERNAL SOURCES:
:path: "../../../node_modules/react-native-compressor"
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:
:path: "../../../node_modules/react-native-view-shot"
React-nativeconfig:
@@ -2560,9 +2628,11 @@ EXTERNAL SOURCES:
RNCAsyncStorage:
:path: "../../../node_modules/@react-native-async-storage/async-storage"
RNFBAnalytics:
:path: "../../../node_modules/@react-native-firebase/analytics"
:path: "../node_modules/@react-native-firebase/analytics"
RNFBApp:
:path: "../../../node_modules/@react-native-firebase/app"
:path: "../node_modules/@react-native-firebase/app"
RNFBCrashlytics:
:path: "../node_modules/@react-native-firebase/crashlytics"
RNGestureHandler:
:path: "../../../node_modules/react-native-gesture-handler"
RNGoogleSignin:
@@ -2573,10 +2643,10 @@ EXTERNAL SOURCES:
:path: "../../../node_modules/react-native-reanimated"
RNScreens:
:path: "../../../node_modules/react-native-screens"
RNSentry:
:path: "../../../node_modules/@sentry/react-native"
RNSVG:
:path: "../node_modules/react-native-svg"
WidgetBridge:
:path: "../modules/widget-bridge/ios"
Yoga:
:path: "../../../node_modules/react-native/ReactCommon/yoga"
@@ -2592,7 +2662,7 @@ SPEC CHECKSUMS:
EXJSONUtils: 01fc7492b66c234e395dcffdd5f53439c5c29c93
EXManifests: a19d50504b8826546a4782770317bc83fffec87d
EXNotifications: 9d3d17e52c95f377750d4a2ae553a716313dd4aa
Expo: 3889933926fade17d8607f449c57b87c4022433f
Expo: 4bb70893882e6382b41d1e910d7226c6a1b85f0a
expo-dev-client: db44302cdbe0ec55b0ef1849c9a23a76dec6dbac
expo-dev-launcher: c67c6a87f8a8ff54abda104abdc057ee4565a3f4
expo-dev-menu: 1426660b67d2d594f70ec1d8a85bc26cafcf1bce
@@ -2608,25 +2678,31 @@ SPEC CHECKSUMS:
ExpoHead: 2a14db58219c0a506e4a409ea83f884eab23f555
ExpoImage: d840b256050f4428d2942bc2b6e9251f9e0d7021
ExpoImagePicker: 24e5ba8da111f74519b1e6dc556e0b438b2b8464
ExpoKeepAwake: b0171a73665bfcefcfcc311742a72a956e6aa680
ExpoKeepAwake: 55f75eca6499bb9e4231ebad6f3e9cb8f99c0296
ExpoLinearGradient: 35ebd83b16f80b3add053a2fd68cc328ed927f60
ExpoLinking: 8d12bee174ba0cdf31239706578e29e74a417402
ExpoLocalAuthentication: eb2be9c7bcdc68e9434d4be4bb5aa5cf7943e816
ExpoMediaLibrary: 2709fc2e4fcf87d28767714abcc603c4bec228c6
ExpoModulesCore: 2d8d1c8e238bd5e1448b06c284ac896b162fb856
ExpoSecureStore: 9a3665d7161dd1031be386e278bffd623658f316
ExpoSharing: 0d983394ed4a80334bab5a0d5384f75710feb7e8
ExpoSharing: 849a5ce9985c22598c16ec027e32969be8062e8e
ExpoWidgets: 366fe61c681044e13b7cc344bdbd319ff5d54b28
EXUpdatesInterface: 7c977640bdd8b85833c19e3959ba46145c5719db
fast_float: 06eeec4fe712a76acc9376682e4808b05ce978b6
FBLazyVector: 7605ea4810e0e10ae4815292433c09bf4324ba45
Firebase: 9a58fdbc9d8655ed7b79a19cf9690bb007d3d46d
FirebaseAnalytics: f20bbad8cb7f65d8a5eaefeb424ae8800a31bdfc
FirebaseCore: 0dbad74bda10b8fb9ca34ad8f375fb9dd3ebef7c
FirebaseCoreExtension: 6605938d51f765d8b18bfcafd2085276a252bee2
FirebaseCoreInternal: fe5fa466aeb314787093a7dce9f0beeaad5a2a21
FirebaseCrashlytics: fb31c6907e5b52aa252668394d3f1ab326df1511
FirebaseInstallations: 6a14ab3d694ebd9f839c48d330da5547e9ca9dc0
FirebaseRemoteConfigInterop: 869ddca16614f979e5c931ece11fbb0b8729ed41
FirebaseSessions: d614ca154c63dbbc6c10d6c38259c2162c4e7c9b
fmt: 01b82d4ca6470831d1cc0852a1af644be019e8f6
glog: 08b301085f15bcbb6ff8632a8ebaf239aae04e6a
GoogleAppMeasurement: 72c9a682fec6290327ea5e3c4b829b247fcb2c17
GoogleDataTransport: aae35b7ea0c09004c3797d53c8c41f66f219d6a7
GoogleSignIn: fcee2257188d5eda57a5e2b6a715550ffff9206d
GoogleUtilities: 00c88b9a86066ef77f0da2fab05f65d7768ed8e1
GTMAppAuth: 217a876b249c3c585a54fd6f73e6b58c4f5c4238
@@ -2636,6 +2712,7 @@ SPEC CHECKSUMS:
libdav1d: 23581a4d8ec811ff171ed5e2e05cd27bad64c39f
nanopb: fad817b59e0457d11a5dfbde799381cd727c1275
PromisesObjC: f5707f49cb48b9636751c5b2e7d227e43fba9f47
PromisesSwift: 9d77319bbe72ebf6d872900551f7eeba9bce2851
PurchasesHybridCommon: c2f8f5fcd2e472c585fccd1ab2fe317a3dc1171e
RCT-Folly: ea9d9256ba7f9322ef911169a9f696e5857b9e17
RCTDeprecation: ebe712bb05077934b16c6bf25228bdec34b64f83
@@ -2668,6 +2745,7 @@ SPEC CHECKSUMS:
React-microtasksnativemodule: 338bfa0a7d8cd1ed212b055f2143a6789252137a
react-native-compressor: 40b3e2b942aebac3daaae9549b23a13510c67a6f
react-native-safe-area-context: 8b8404e70b0cbf2a56428a17017c14c1dcc16448
react-native-skia: 0e98c0c8979795d3b812a45b3f6cde7e5d9bf267
react-native-view-shot: 4147b714c5316d7e17ed652d0b5a182c028f842e
React-nativeconfig: 415626a63057638759bcc75e0a96e2e07771a479
React-NativeModulesApple: d33b55553c6957ff94835574636838d78121a1c6
@@ -2699,21 +2777,21 @@ SPEC CHECKSUMS:
RevenueCat: 347221fad5ab0ed5636546867deae2c32c64749f
RNCAsyncStorage: aa75595c1aefa18f868452091fa0c411a516ce11
RNFBAnalytics: d1c906bbb01b3a6f6b99d6eaa977f04b1ae5d279
RNFBApp: fecce94543a693bcc73807a83ce5e9d70ff68725
RNFBApp: 5c07dbcf30f00d7ea5400c3faae4e88e000b7685
RNFBCrashlytics: bd6ae7c83468c749e986707c0e54365832b3d237
RNGestureHandler: 2dd124adb171aab16629e9387cf1830957d242c2
RNGoogleSignin: 5782dd7dad559608a06f3c6b1d4142c395fb5308
RNPurchases: e265e2ed27349b567b91b89d8712aa8c9c57f5b0
RNReanimated: 38e033fd1790c817678bc8a075277cdc49d6817c
RNScreens: ad2d0cfb3f15c9ed56ffb43651366ec0dcd70c36
RNSentry: a8f0d86b88675719ce96cb31bd9258dbd793e535
RNSVG: 8542aa11770b27563714bbd8494a8436385fc85f
SDWebImage: 8a6b7b160b4d710e2a22b6900e25301075c34cb3
SDWebImageAVIFCoder: afe194a084e851f70228e4be35ef651df0fc5c57
SDWebImageSVGCoder: 15a300a97ec1c8ac958f009c02220ac0402e936c
Sentry: 1ca8405451040482877dcd344dfa3ef80b646631
SocketRocket: d4aabe649be1e368d1318fdf28a022d714d65748
WidgetBridge: 15f1466d7f79505a5e600e1400fcff1a40f807a4
Yoga: 1259c7a8cbaccf7b4c3ddf8ee36ca11be9dee407
PODFILE CHECKSUM: 085b90662c05f70d43685ff8bec1ea714a98abb3
PODFILE CHECKSUM: 3746f9f348a0d8fcc1584151ac3b5c42e0fa172e
COCOAPODS: 1.16.2
+352 -137
View File
@@ -10,41 +10,106 @@
13B07FBC1A68108700A75B9A /* AppDelegate.mm in Sources */ = {isa = PBXBuildFile; fileRef = 13B07FB01A68108700A75B9A /* AppDelegate.mm */; };
13B07FBF1A68108700A75B9A /* Images.xcassets in Resources */ = {isa = PBXBuildFile; fileRef = 13B07FB51A68108700A75B9A /* Images.xcassets */; };
13B07FC11A68108700A75B9A /* main.m in Sources */ = {isa = PBXBuildFile; fileRef = 13B07FB71A68108700A75B9A /* main.m */; };
2634B76381EF14A5455649E2 /* PrivacyInfo.xcprivacy in Resources */ = {isa = PBXBuildFile; fileRef = 56B77889699504CF056D3B82 /* PrivacyInfo.xcprivacy */; };
301A4F5B84BB9DA22D6434C2 /* Pods_Sauci.framework in Frameworks */ = {isa = PBXBuildFile; fileRef = 43233FEC4CFFD8C8AA8CD0C9 /* Pods_Sauci.framework */; };
170EE6B9AAF2430B9AB5101D /* noop-file.swift in Sources */ = {isa = PBXBuildFile; fileRef = 6823268A39AC4F3D974A70BB /* noop-file.swift */; };
1963764A838740A9AB3B6EDB /* GoogleService-Info.plist in Resources */ = {isa = PBXBuildFile; fileRef = 943B28EF95A6439B9246862F /* GoogleService-Info.plist */; };
2BC8A1C04BCD698B29D2E6E8 /* PrivacyInfo.xcprivacy in Resources */ = {isa = PBXBuildFile; fileRef = 70C97E8BE29E641DB4CA72F3 /* PrivacyInfo.xcprivacy */; };
2C1F3D17C413D275BE33BC07 /* Pods_Sauci.framework in Frameworks */ = {isa = PBXBuildFile; fileRef = DCE00E1F3D8BD0B3956748EB /* Pods_Sauci.framework */; };
3E461D99554A48A4959DE609 /* SplashScreen.storyboard in Resources */ = {isa = PBXBuildFile; fileRef = AA286B85B6C04FC6940260E9 /* SplashScreen.storyboard */; };
839B1D252C4B42B3AF4A6509 /* GoogleService-Info.plist in Resources */ = {isa = PBXBuildFile; fileRef = 93AA2882742645CC9B92CD37 /* GoogleService-Info.plist */; };
B18059E884C0ABDD17F3DC3D /* ExpoModulesProvider.swift in Sources */ = {isa = PBXBuildFile; fileRef = FAC715A2D49A985799AEE119 /* ExpoModulesProvider.swift */; };
BB2F792D24A3F905000567C9 /* Expo.plist in Resources */ = {isa = PBXBuildFile; fileRef = BB2F792C24A3F905000567C9 /* Expo.plist */; };
C09531978862477F9D6CB18B /* noop-file.swift in Sources */ = {isa = PBXBuildFile; fileRef = 68A46CE15AF447C78955EED5 /* noop-file.swift */; };
XX12B55A1EDABF083C3B51XX /* WidgetKit.framework in Frameworks */ = {isa = PBXBuildFile; fileRef = XX470101BAEB8E0BBC8B0DXX /* WidgetKit.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 */
/* 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 */
13B07F961A680F5B00A75B9A /* Sauci.app */ = {isa = PBXFileReference; explicitFileType = wrapper.application; 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>"; };
13B07FB01A68108700A75B9A /* AppDelegate.mm */ = {isa = PBXFileReference; fileEncoding = 4; 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>"; };
13B07FB61A68108700A75B9A /* Info.plist */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = text.plist.xml; name = Info.plist; path = Sauci/Info.plist; sourceTree = "<group>"; };
13B07FB71A68108700A75B9A /* main.m */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.c.objc; name = main.m; path = Sauci/main.m; sourceTree = "<group>"; };
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>"; };
43233FEC4CFFD8C8AA8CD0C9 /* Pods_Sauci.framework */ = {isa = PBXFileReference; explicitFileType = wrapper.framework; includeInIndex = 0; path = Pods_Sauci.framework; sourceTree = BUILT_PRODUCTS_DIR; };
56B77889699504CF056D3B82 /* PrivacyInfo.xcprivacy */ = {isa = PBXFileReference; includeInIndex = 1; name = PrivacyInfo.xcprivacy; path = Sauci/PrivacyInfo.xcprivacy; 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>"; };
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>"; };
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>"; };
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>"; };
AA286B85B6C04FC6940260E9 /* SplashScreen.storyboard */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = file.storyboard; name = SplashScreen.storyboard; path = Sauci/SplashScreen.storyboard; sourceTree = "<group>"; };
BB2F792C24A3F905000567C9 /* Expo.plist */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = text.plist.xml; path = Expo.plist; sourceTree = "<group>"; };
ED297162215061F000B7C4FE /* JavaScriptCore.framework */ = {isa = PBXFileReference; lastKnownFileType = wrapper.framework; name = JavaScriptCore.framework; path = System/Library/Frameworks/JavaScriptCore.framework; sourceTree = SDKROOT; };
FAC715A2D49A985799AEE119 /* ExpoModulesProvider.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; name = ExpoModulesProvider.swift; path = "Pods/Target Support Files/Pods-Sauci/ExpoModulesProvider.swift"; sourceTree = "<group>"; };
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; includeInIndex = 0; lastKnownFileType = sourcecode.c.h; name = AppDelegate.h; path = Sauci/AppDelegate.h; 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; fileEncoding = 4; includeInIndex = 0; lastKnownFileType = folder.assetcatalog; name = Images.xcassets; path = Sauci/Images.xcassets; 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; includeInIndex = 0; lastKnownFileType = sourcecode.c.objc; name = main.m; path = Sauci/main.m; sourceTree = "<group>"; };
6823268A39AC4F3D974A70BB /* 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>"; };
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>"; };
70C97E8BE29E641DB4CA72F3 /* PrivacyInfo.xcprivacy */ = {isa = PBXFileReference; includeInIndex = 1; name = PrivacyInfo.xcprivacy; path = Sauci/PrivacyInfo.xcprivacy; sourceTree = "<group>"; };
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>"; };
943B28EF95A6439B9246862F /* 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>"; };
AA286B85B6C04FC6940260E9 /* SplashScreen.storyboard */ = {isa = PBXFileReference; fileEncoding = 4; includeInIndex = 0; lastKnownFileType = file.storyboard; name = SplashScreen.storyboard; path = Sauci/SplashScreen.storyboard; sourceTree = "<group>"; };
BB2F792C24A3F905000567C9 /* Expo.plist */ = {isa = PBXFileReference; fileEncoding = 4; includeInIndex = 0; lastKnownFileType = text.plist.xml; path = Expo.plist; sourceTree = "<group>"; };
D927CBDDA31C4219BE8F155B /* 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>"; };
DCE00E1F3D8BD0B3956748EB /* Pods_Sauci.framework */ = {isa = PBXFileReference; explicitFileType = wrapper.framework; includeInIndex = 0; path = Pods_Sauci.framework; sourceTree = BUILT_PRODUCTS_DIR; };
ED297162215061F000B7C4FE /* JavaScriptCore.framework */ = {isa = PBXFileReference; fileEncoding = 4; includeInIndex = undefined; lastKnownFileType = wrapper.framework; name = JavaScriptCore.framework; path = System/Library/Frameworks/JavaScriptCore.framework; sourceTree = SDKROOT; };
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; };
XXAC1FC213FDAA75508994XX /* LiveDrawWidget.appex */ = {isa = PBXFileReference; explicitFileType = "wrapper.app-extension"; fileEncoding = 4; includeInIndex = 0; path = LiveDrawWidget.appex; sourceTree = BUILT_PRODUCTS_DIR; };
/* 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 */
13B07F8C1A680F5B00A75B9A /* Frameworks */ = {
isa = PBXFrameworksBuildPhase;
buildActionMask = 2147483647;
files = (
301A4F5B84BB9DA22D6434C2 /* Pods_Sauci.framework in Frameworks */,
2C1F3D17C413D275BE33BC07 /* Pods_Sauci.framework in Frameworks */,
);
runOnlyForDeploymentPostprocessing = 0;
};
XX074B8811C1CF8E8A3D74XX /* Frameworks */ = {
isa = PBXFrameworksBuildPhase;
buildActionMask = 2147483647;
files = (
XX12B55A1EDABF083C3B51XX /* WidgetKit.framework in Frameworks */,
XX6A44985675A04437B8C4XX /* SwiftUI.framework in Frameworks */,
);
runOnlyForDeploymentPostprocessing = 0;
};
@@ -61,10 +126,10 @@
13B07FB61A68108700A75B9A /* Info.plist */,
13B07FB71A68108700A75B9A /* main.m */,
AA286B85B6C04FC6940260E9 /* SplashScreen.storyboard */,
93AA2882742645CC9B92CD37 /* GoogleService-Info.plist */,
68A46CE15AF447C78955EED5 /* noop-file.swift */,
3BD07AB1AFFA49C7B31B5B70 /* Sauci-Bridging-Header.h */,
56B77889699504CF056D3B82 /* PrivacyInfo.xcprivacy */,
943B28EF95A6439B9246862F /* GoogleService-Info.plist */,
6823268A39AC4F3D974A70BB /* noop-file.swift */,
D927CBDDA31C4219BE8F155B /* Sauci-Bridging-Header.h */,
70C97E8BE29E641DB4CA72F3 /* PrivacyInfo.xcprivacy */,
);
name = Sauci;
sourceTree = "<group>";
@@ -73,7 +138,9 @@
isa = PBXGroup;
children = (
ED297162215061F000B7C4FE /* JavaScriptCore.framework */,
43233FEC4CFFD8C8AA8CD0C9 /* Pods_Sauci.framework */,
XX470101BAEB8E0BBC8B0DXX /* WidgetKit.framework */,
XX4DFF38D47332D6BF0183XX /* SwiftUI.framework */,
DCE00E1F3D8BD0B3956748EB /* Pods_Sauci.framework */,
);
name = Frameworks;
sourceTree = "<group>";
@@ -88,6 +155,7 @@
83CBB9F61A601CBA00E9B192 = {
isa = PBXGroup;
children = (
XX3E41C1850246162C0061XX /* expo:targets */,
13B07FAE1A68108700A75B9A /* Sauci */,
832341AE1AAA6A7D00B99B32 /* Libraries */,
83CBBA001A601CBA00E9B192 /* Products */,
@@ -104,6 +172,7 @@
isa = PBXGroup;
children = (
13B07F961A680F5B00A75B9A /* Sauci.app */,
XXAC1FC213FDAA75508994XX /* LiveDrawWidget.appex */,
);
name = Products;
sourceTree = "<group>";
@@ -142,6 +211,15 @@
name = ExpoModulesProviders;
sourceTree = "<group>";
};
XX3E41C1850246162C0061XX /* expo:targets */ = {
isa = PBXGroup;
children = (
XX683E29AD7AD8279E17B2XX /* widget */,
);
name = "expo:targets";
path = ../targets;
sourceTree = "<group>";
};
/* End PBXGroup section */
/* Begin PBXNativeTarget section */
@@ -150,25 +228,47 @@
buildConfigurationList = 13B07F931A680F5B00A75B9A /* Build configuration list for PBXNativeTarget "Sauci" */;
buildPhases = (
08A4A3CD28434E44B6B9DE2E /* [CP] Check Pods Manifest.lock */,
B2B15A2D43DFA021A8882896 /* [Expo] Configure project */,
78092B71106F49D87690DCAF /* [Expo] Configure project */,
13B07F871A680F5B00A75B9A /* Sources */,
13B07F8C1A680F5B00A75B9A /* Frameworks */,
13B07F8E1A680F5B00A75B9A /* Resources */,
00DD1BFF1BD5951E006B06BC /* Bundle React Native code and images */,
800E24972A6A228C8D4807E9 /* [CP] Copy Pods Resources */,
B0D9F1E2B09440679F2212B8 /* Upload Debug Symbols to Sentry */,
3944CF2ABF1FE1D2CEEDB515 /* [CP] Embed Pods Frameworks */,
C94C12D7632AB41D3D9E4FCA /* [CP-User] [RNFB] Core Configuration */,
XX72A8CFBF0FBA13A79902XX /* Embed Foundation Extensions */,
40AF2F5BA5CA84AF70BDCE64 /* [CP] Embed Pods Frameworks */,
D2EE170024D385D3D7E9FF8F /* [CP-User] [RNFB] Core Configuration */,
CCC1C00E4CE250301404ADA6 /* [CP-User] [RNFB] Crashlytics Configuration */,
);
buildRules = (
);
dependencies = (
XX97289CEC7994AD5F4142XX /* PBXTargetDependency */,
);
name = Sauci;
productName = Sauci;
productReference = 13B07F961A680F5B00A75B9A /* Sauci.app */;
productType = "com.apple.product-type.application";
};
XX540FB4ABC75CE91A6036XX /* LiveDrawWidget */ = {
isa = PBXNativeTarget;
buildConfigurationList = XXBAA32A968BCCE457D462XX /* 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 */
/* Begin PBXProject section */
@@ -179,7 +279,10 @@
TargetAttributes = {
13B07F861A680F5B00A75B9A = {
LastSwiftMigration = 1250;
DevelopmentTeam = "BXVPBATDZY";
};
XX540FB4ABC75CE91A6036XX = {
CreatedOnToolsVersion = 14.3;
DevelopmentTeam = BXVPBATDZY;
ProvisioningStyle = Automatic;
};
};
@@ -198,6 +301,7 @@
projectRoot = "";
targets = (
13B07F861A680F5B00A75B9A /* Sauci */,
XX540FB4ABC75CE91A6036XX /* LiveDrawWidget */,
);
};
/* End PBXProject section */
@@ -210,8 +314,15 @@
BB2F792D24A3F905000567C9 /* Expo.plist in Resources */,
13B07FBF1A68108700A75B9A /* Images.xcassets in Resources */,
3E461D99554A48A4959DE609 /* SplashScreen.storyboard in Resources */,
839B1D252C4B42B3AF4A6509 /* GoogleService-Info.plist in Resources */,
2634B76381EF14A5455649E2 /* PrivacyInfo.xcprivacy in Resources */,
1963764A838740A9AB3B6EDB /* GoogleService-Info.plist in Resources */,
2BC8A1C04BCD698B29D2E6E8 /* PrivacyInfo.xcprivacy in Resources */,
);
runOnlyForDeploymentPostprocessing = 0;
};
XXFA5A8CCDD805DF7AEF9CXX /* Resources */ = {
isa = PBXResourcesBuildPhase;
buildActionMask = 2147483647;
files = (
);
runOnlyForDeploymentPostprocessing = 0;
};
@@ -224,14 +335,18 @@
buildActionMask = 2147483647;
files = (
);
inputFileListPaths = (
);
inputPaths = (
);
name = "Bundle React Native code and images";
outputFileListPaths = (
);
outputPaths = (
);
runOnlyForDeploymentPostprocessing = 0;
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 */ = {
isa = PBXShellScriptBuildPhase;
@@ -255,7 +370,7 @@
shellScript = "diff \"${PODS_PODFILE_DIR_PATH}/Podfile.lock\" \"${PODS_ROOT}/Manifest.lock\" > /dev/null\nif [ $? != 0 ] ; then\n # print error to STDERR\n echo \"error: The sandbox is not in sync with the Podfile.lock. Run 'pod install' or update your CocoaPods installation.\" >&2\n exit 1\nfi\n# This output is used by Xcode 'outputs' to avoid re-running this script phase.\necho \"SUCCESS\" > \"${SCRIPT_OUTPUT_FILE_0}\"\n";
showEnvVarsInLog = 0;
};
3944CF2ABF1FE1D2CEEDB515 /* [CP] Embed Pods Frameworks */ = {
40AF2F5BA5CA84AF70BDCE64 /* [CP] Embed Pods Frameworks */ = {
isa = PBXShellScriptBuildPhase;
buildActionMask = 2147483647;
files = (
@@ -273,93 +388,7 @@
shellScript = "\"${PODS_ROOT}/Target Support Files/Pods-Sauci/Pods-Sauci-frameworks.sh\"\n";
showEnvVarsInLog = 0;
};
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}/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 */ = {
78092B71106F49D87690DCAF /* [Expo] Configure project */ = {
isa = PBXShellScriptBuildPhase;
alwaysOutOfDate = 1;
buildActionMask = 2147483647;
@@ -378,7 +407,99 @@
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";
};
C94C12D7632AB41D3D9E4FCA /* [CP-User] [RNFB] Core Configuration */ = {
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;
};
CCC1C00E4CE250301404ADA6 /* [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";
};
D2EE170024D385D3D7E9FF8F /* [CP-User] [RNFB] Core Configuration */ = {
isa = PBXShellScriptBuildPhase;
buildActionMask = 2147483647;
files = (
@@ -401,12 +522,27 @@
13B07FBC1A68108700A75B9A /* AppDelegate.mm in Sources */,
13B07FC11A68108700A75B9A /* main.m in Sources */,
B18059E884C0ABDD17F3DC3D /* ExpoModulesProvider.swift in Sources */,
C09531978862477F9D6CB18B /* noop-file.swift in Sources */,
170EE6B9AAF2430B9AB5101D /* noop-file.swift in Sources */,
);
runOnlyForDeploymentPostprocessing = 0;
};
XXE45443DC38DCFE1DB622XX /* Sources */ = {
isa = PBXSourcesBuildPhase;
buildActionMask = 2147483647;
files = (
);
runOnlyForDeploymentPostprocessing = 0;
};
/* End PBXSourcesBuildPhase section */
/* Begin PBXTargetDependency section */
XX97289CEC7994AD5F4142XX /* PBXTargetDependency */ = {
isa = PBXTargetDependency;
target = XX540FB4ABC75CE91A6036XX /* LiveDrawWidget */;
targetProxy = XXA7C7D5580082A497FD95XX /* PBXContainerItemProxy */;
};
/* End PBXTargetDependency section */
/* Begin XCBuildConfiguration section */
13B07F941A680F5B00A75B9A /* Debug */ = {
isa = XCBuildConfiguration;
@@ -416,6 +552,7 @@
CLANG_ENABLE_MODULES = YES;
CODE_SIGN_ENTITLEMENTS = Sauci/Sauci.entitlements;
CURRENT_PROJECT_VERSION = 1;
DEVELOPMENT_TEAM = BXVPBATDZY;
ENABLE_BITCODE = NO;
GCC_PREPROCESSOR_DEFINITIONS = (
"$(inherited)",
@@ -424,7 +561,7 @@
INFOPLIST_FILE = Sauci/Info.plist;
IPHONEOS_DEPLOYMENT_TARGET = 15.1;
LD_RUNPATH_SEARCH_PATHS = "$(inherited) @executable_path/Frameworks";
MARKETING_VERSION = 1.0;
MARKETING_VERSION = 1.0.4;
OTHER_LDFLAGS = (
"$(inherited)",
"-ObjC",
@@ -432,15 +569,12 @@
);
OTHER_SWIFT_FLAGS = "$(inherited) -D EXPO_CONFIGURATION_DEBUG";
PRODUCT_BUNDLE_IDENTIFIER = com.sauci.app;
PRODUCT_NAME = "Sauci";
PRODUCT_NAME = Sauci;
SWIFT_OBJC_BRIDGING_HEADER = "Sauci/Sauci-Bridging-Header.h";
SWIFT_OPTIMIZATION_LEVEL = "-Onone";
SWIFT_VERSION = 5.0;
TARGETED_DEVICE_FAMILY = "1";
TARGETED_DEVICE_FAMILY = 1;
VERSIONING_SYSTEM = "apple-generic";
DEVELOPMENT_TEAM = "BXVPBATDZY";
CODE_SIGN_IDENTITY = "Apple Development";
CODE_SIGN_STYLE = Automatic;
};
name = Debug;
};
@@ -452,10 +586,11 @@
CLANG_ENABLE_MODULES = YES;
CODE_SIGN_ENTITLEMENTS = Sauci/Sauci.entitlements;
CURRENT_PROJECT_VERSION = 1;
DEVELOPMENT_TEAM = BXVPBATDZY;
INFOPLIST_FILE = Sauci/Info.plist;
IPHONEOS_DEPLOYMENT_TARGET = 15.1;
LD_RUNPATH_SEARCH_PATHS = "$(inherited) @executable_path/Frameworks";
MARKETING_VERSION = 1.0;
MARKETING_VERSION = 1.0.4;
OTHER_LDFLAGS = (
"$(inherited)",
"-ObjC",
@@ -463,14 +598,11 @@
);
OTHER_SWIFT_FLAGS = "$(inherited) -D EXPO_CONFIGURATION_RELEASE";
PRODUCT_BUNDLE_IDENTIFIER = com.sauci.app;
PRODUCT_NAME = "Sauci";
PRODUCT_NAME = Sauci;
SWIFT_OBJC_BRIDGING_HEADER = "Sauci/Sauci-Bridging-Header.h";
SWIFT_VERSION = 5.0;
TARGETED_DEVICE_FAMILY = "1";
TARGETED_DEVICE_FAMILY = 1;
VERSIONING_SYSTEM = "apple-generic";
DEVELOPMENT_TEAM = "BXVPBATDZY";
CODE_SIGN_IDENTITY = "Apple Development";
CODE_SIGN_STYLE = Automatic;
};
name = Release;
};
@@ -614,6 +746,80 @@
};
name = Release;
};
XX7511AEB23A8B63601AE1XX /* 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 = 35;
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.4;
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;
TARGETED_DEVICE_FAMILY = "1,2";
};
name = Debug;
};
XX917F28A41B713380F670XX /* 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 = 35;
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.4;
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;
TARGETED_DEVICE_FAMILY = "1,2";
};
name = Release;
};
/* End XCBuildConfiguration section */
/* Begin XCConfigurationList section */
@@ -635,6 +841,15 @@
defaultConfigurationIsVisible = 0;
defaultConfigurationName = Release;
};
XXBAA32A968BCCE457D462XX /* Build configuration list for PBXNativeTarget "LiveDrawWidget" */ = {
isa = XCConfigurationList;
buildConfigurations = (
XX7511AEB23A8B63601AE1XX /* Debug */,
XX917F28A41B713380F670XX /* Release */,
);
defaultConfigurationIsVisible = 0;
defaultConfigurationName = Release;
};
/* End XCConfigurationList section */
};
rootObject = 83CBB9F71A601CBA00E9B192 /* Project object */;
+4
View File
@@ -8,5 +8,9 @@
<array>
<string>Default</string>
</array>
<key>com.apple.security.application-groups</key>
<array>
<string>group.com.sauci.app</string>
</array>
</dict>
</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:${getKotlinVersion()}"
}
@@ -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",
"version": "1.0.0",
"private": true,
"main": "expo-router/entry",
"main": "./index.js",
"scripts": {
"dev": "expo start",
"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"
},
"dependencies": {
"@bacons/apple-targets": "^0.1.6",
"@bittingz/expo-widgets": "^3.0.2",
"@expo/ngrok": "^4.1.0",
"@expo/vector-icons": "~14.0.4",
"@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/app": "^23.8.2",
"@react-native-firebase/crashlytics": "^23.8.4",
@@ -29,6 +32,7 @@
"@react-navigation/bottom-tabs": "^7.0.0",
"@react-navigation/native": "^7.0.0",
"@react-navigation/native-stack": "^7.0.0",
"@shopify/react-native-skia": "^1.12.4",
"@supabase/supabase-js": "^2.39.0",
"@tanstack/react-query": "^5.17.0",
"babel-preset-expo": "~12.0.0",
@@ -59,6 +63,7 @@
"react": "18.3.1",
"react-dom": "18.3.1",
"react-native": "0.76.9",
"react-native-android-widget": "^0.20.1",
"react-native-compressor": "^1.13.0",
"react-native-gesture-handler": "~2.20.0",
"react-native-purchases": "^9.6.12",
-36
View File
@@ -1,36 +0,0 @@
# Sauci Mobile Release Notes
## v1.0.1 (Build 30) - January 2025
### New Features
- **Match Archiving** - Archive old matches to keep your active list focused
- **Daily Swipe Limits** - Pace your discovery with daily question limits
- **Intensity Preferences** - Set your comfort level for question intensity (1-5 scale)
- **Infinite Scroll** - Smoother browsing through Matches and Answers with infinite scroll
- **Chat Date Separators** - Easily see when conversations happened
- **Lifecycle Notifications** - Get reminded to check in with your partner
- **Message Reporting** - Report inappropriate messages for review
- **Account Deletion** - Full account deletion option in settings
- **Reset Progress** - Start fresh by resetting your question progress
### Improvements
- **Enhanced Swipe UI** - Smoother swiping experience with visual refinements
- **Improved Chat Layout** - Match card moved into message list for cleaner design
- **Keyboard Behavior** - Chat header collapses when keyboard opens for more screen space
- **Profile Settings** - New appearance and notification preferences
- **Premium Design** - Updated subscription card with gold styling
- **Version Display** - App version now visible in settings
### Bug Fixes
- Fixed subscription status incorrectly showing "Via partner's" after direct purchase
- Fixed Android navigation issues
- Various performance improvements and stability fixes
### Privacy
- Removed partner answered indicator for enhanced privacy
---
## v1.0.0 (Build 25) - January 8, 2025
Initial release.
@@ -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 { CompactHeader } from './CompactHeader';
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"
}
}
+11
View File
@@ -0,0 +1,11 @@
<?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>
</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
const { error: updateError } = await supabase
.from("profiles")
@@ -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);