216 lines
7.6 KiB
TypeScript
216 lines
7.6 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 CamRecipe Pro - Preset: ${photo.recipeName}`,
|
|
UTI: 'public.jpeg',
|
|
});
|
|
} else {
|
|
await Share.share({
|
|
url: photo.uri,
|
|
message: `Captured with CamRecipe Pro - 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);
|
|
}
|
|
};
|
|
|
|
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>
|
|
);
|
|
}
|