mirror of
https://github.com/jjbrunton/Sauci.git
synced 2026-11-01 18:19:43 +00:00
feat: add live draw feature and clean up docs
Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
This commit is contained in:
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;
|
||||
}
|
||||
}
|
||||
Reference in new issue
Block a user