feat: v1.1 — highlight-weighted metering, settings modal, photo viewer, shutter sound control, cleanup
This commit is contained in:
@@ -0,0 +1,219 @@
|
||||
import React, { useEffect, useRef, useState } from 'react';
|
||||
import { View, Text, Modal, Image, 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';
|
||||
|
||||
// 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">
|
||||
<Image
|
||||
source={{ uri: p.uri }}
|
||||
style={{ width, height: '100%' }}
|
||||
className=""
|
||||
resizeMode="contain"
|
||||
/>
|
||||
</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>
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user