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

@@ -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;
}
}