76cf4c6d7e
Bound the geotag fix with a 2 s deadline and prewarm it at launch, queue a shot pressed before the camera session binds and replay it, pass the options argument capturePhotoToFile requires, save through the native path so the media permission is not needed, and release the Skia surface and bitmap after every export. Store the JS bundle uncompressed and load the export pipeline on demand. Note in RELEASE_NOTES.md that none of this moved the tap threshold.
218 lines
7.7 KiB
TypeScript
218 lines
7.7 KiB
TypeScript
import React, { useEffect, useRef, useState } from 'react';
|
|
import { View, Text, Modal, TouchableOpacity, ScrollView, Share, Alert, useWindowDimensions } from 'react-native';
|
|
import * as Sharing from 'expo-sharing';
|
|
import { X, Share2, Download, Trash2 } from 'lucide-react-native';
|
|
import { PhotoEntry } from '../types';
|
|
import ZoomableImage from './ZoomableImage';
|
|
|
|
// Session photo viewer: swipe left/right across every photo taken/exported in
|
|
// this session. Top shows preset name + index/total; trash deletes the photo
|
|
// being viewed (confirm modal first); share/save as before. Opened from the
|
|
// thumbnail button (camera mode) or automatically after a library export —
|
|
// NOT after a camera capture (that only fills the thumbnail).
|
|
interface PhotoViewerModalProps {
|
|
visible: boolean;
|
|
photos: PhotoEntry[];
|
|
initialIndex: number;
|
|
onClose: () => void;
|
|
// Remove photo id from the session history (App also deletes cache + gallery copy).
|
|
onDelete: (id: string) => void;
|
|
// Save the (unsaved) photo id to the gallery; App stores the resulting assetId.
|
|
onSave: (id: string) => Promise<void>;
|
|
}
|
|
|
|
export default function PhotoViewerModal({
|
|
visible,
|
|
photos,
|
|
initialIndex,
|
|
onClose,
|
|
onDelete,
|
|
onSave,
|
|
}: PhotoViewerModalProps) {
|
|
const { width } = useWindowDimensions();
|
|
const [index, setIndex] = useState(Math.max(0, Math.min(initialIndex, photos.length - 1)));
|
|
const scrollRef = useRef<ScrollView>(null);
|
|
const [saving, setSaving] = useState(false);
|
|
const wasOpen = useRef(false);
|
|
|
|
// Opening the modal (or reopening on a different photo): jump to initialIndex.
|
|
// Shrinks of the list while open (deletion) must NOT re-scroll — the strip
|
|
// shifts left by one page and the same page slot keeps showing the next photo.
|
|
useEffect(() => {
|
|
if (!visible) {
|
|
wasOpen.current = false;
|
|
return;
|
|
}
|
|
if (!wasOpen.current) {
|
|
wasOpen.current = true;
|
|
if (photos.length > 0) {
|
|
const i = Math.max(0, Math.min(initialIndex, photos.length - 1));
|
|
setIndex(i);
|
|
requestAnimationFrame(() => {
|
|
scrollRef.current?.scrollTo({ x: i * width, animated: false });
|
|
});
|
|
}
|
|
}
|
|
}, [visible, initialIndex, width, photos.length]);
|
|
|
|
// Keep the page state in bounds when the list shrinks underneath us.
|
|
useEffect(() => {
|
|
if (visible && photos.length > 0 && index >= photos.length) {
|
|
const i = photos.length - 1;
|
|
setIndex(i);
|
|
scrollRef.current?.scrollTo({ x: i * width, animated: false });
|
|
}
|
|
}, [visible, index, photos.length, width]);
|
|
|
|
const photo = photos[index] || null;
|
|
|
|
const handleShare = async () => {
|
|
if (!photo) return;
|
|
try {
|
|
if (await Sharing.isAvailableAsync()) {
|
|
await Sharing.shareAsync(photo.uri, {
|
|
mimeType: 'image/jpeg',
|
|
dialogTitle: `Share with RecipesCam - Preset: ${photo.recipeName}`,
|
|
UTI: 'public.jpeg',
|
|
});
|
|
} else {
|
|
await Share.share({
|
|
url: photo.uri,
|
|
message: `Captured with RecipesCam - Preset: ${photo.recipeName}`,
|
|
});
|
|
}
|
|
} catch (error) {
|
|
console.error('Sharing failed:', error);
|
|
}
|
|
};
|
|
|
|
const handleDeletePress = () => {
|
|
if (!photo) return;
|
|
if (photo.savedToLibrary) {
|
|
// The gallery copy exists → Android itself shows its per-item delete
|
|
// confirmation, so no app modal in front of it.
|
|
onDelete(photo.id);
|
|
return;
|
|
}
|
|
// Never reached the gallery — no system prompt will happen, keep an app
|
|
// confirm as the only guard against an accidental trash tap.
|
|
Alert.alert(
|
|
'Delete Photo',
|
|
'Delete this photo? It was never saved to your gallery.',
|
|
[
|
|
{ text: 'Cancel', style: 'cancel' },
|
|
{ text: 'Delete', style: 'destructive', onPress: () => onDelete(photo.id) },
|
|
]
|
|
);
|
|
};
|
|
|
|
const handleDone = async () => {
|
|
if (!photo || photo.savedToLibrary || saving) {
|
|
onClose();
|
|
return;
|
|
}
|
|
setSaving(true);
|
|
try {
|
|
await onSave(photo.id);
|
|
onClose();
|
|
} catch (e) {
|
|
console.error('Save failed:', e);
|
|
} finally {
|
|
setSaving(false);
|
|
}
|
|
};
|
|
|
|
if (!visible) return null; // see PhotoViewerModal
|
|
|
|
return (
|
|
<Modal visible={visible} animationType="fade" transparent={false} onRequestClose={onClose}>
|
|
<View className="flex-1 bg-black">
|
|
{/* Top bar: preset + index/total (left), trash + close (right) */}
|
|
<View className="pt-12 pb-2 px-4 flex-row justify-between items-center">
|
|
<View className="flex-1 pr-2">
|
|
<Text className="text-zinc-500 font-mono text-xs">
|
|
{photo ? photo.recipeName.toUpperCase() : ''}
|
|
</Text>
|
|
<Text className="text-zinc-600 font-mono text-[10px] mt-0.5">
|
|
{photos.length > 0 ? `${index + 1} / ${photos.length}` : ''}
|
|
</Text>
|
|
</View>
|
|
<View className="flex-row items-center">
|
|
{photo && (
|
|
<TouchableOpacity
|
|
onPress={handleDeletePress}
|
|
className="w-9 h-9 rounded-full bg-zinc-900 flex items-center justify-center mr-2"
|
|
activeOpacity={0.7}
|
|
>
|
|
<Trash2 size={17} color="#ef4444" />
|
|
</TouchableOpacity>
|
|
)}
|
|
<TouchableOpacity
|
|
onPress={onClose}
|
|
className="w-9 h-9 rounded-full bg-zinc-900 flex items-center justify-center"
|
|
activeOpacity={0.7}
|
|
>
|
|
<X size={18} color="#ffffff" />
|
|
</TouchableOpacity>
|
|
</View>
|
|
</View>
|
|
|
|
{/* Swipeable photo strip */}
|
|
{photos.length > 0 ? (
|
|
<ScrollView
|
|
ref={scrollRef}
|
|
horizontal
|
|
pagingEnabled
|
|
showsHorizontalScrollIndicator={false}
|
|
onMomentumScrollEnd={(e) => {
|
|
const i = Math.round(e.nativeEvent.contentOffset.x / width);
|
|
setIndex(Math.max(0, Math.min(i, photos.length - 1)));
|
|
}}
|
|
>
|
|
{photos.map((p) => (
|
|
<View key={p.id} style={{ width }} className="justify-center items-center">
|
|
<ZoomableImage uri={p.uri} width={width} />
|
|
</View>
|
|
))}
|
|
</ScrollView>
|
|
) : (
|
|
<View className="flex-1 items-center justify-center">
|
|
<Text className="text-zinc-400 font-mono text-sm">NO PHOTOS</Text>
|
|
</View>
|
|
)}
|
|
|
|
{/* Bottom bar */}
|
|
<View className="pb-10 pt-3 px-4">
|
|
{photo && !photo.savedToLibrary && (
|
|
<Text className="text-amber-500 font-mono text-[10px] text-center mb-2">
|
|
NOT SAVED TO GALLERY - SAVE & DONE TO KEEP
|
|
</Text>
|
|
)}
|
|
<View className="flex-row justify-around items-center px-4">
|
|
<TouchableOpacity
|
|
onPress={handleShare}
|
|
disabled={!photo}
|
|
className="flex-row items-center bg-zinc-900 border border-zinc-800 px-6 py-3 rounded-full"
|
|
activeOpacity={0.7}
|
|
>
|
|
<Share2 size={16} color="#f59e0b" />
|
|
<Text className="text-white font-mono text-xs font-bold ml-2">SHARE</Text>
|
|
</TouchableOpacity>
|
|
<TouchableOpacity
|
|
onPress={handleDone}
|
|
disabled={!photo || saving}
|
|
className="flex-row items-center bg-amber-500 px-6 py-3 rounded-full"
|
|
activeOpacity={0.7}
|
|
>
|
|
<Download size={16} color="#000000" />
|
|
<Text className="text-black font-mono text-xs font-bold ml-2">
|
|
{saving ? 'SAVING...' : photo && !photo.savedToLibrary ? 'SAVE & DONE' : 'DONE'}
|
|
</Text>
|
|
</TouchableOpacity>
|
|
</View>
|
|
</View>
|
|
</View>
|
|
</Modal>
|
|
);
|
|
}
|