feat: v1.1 — highlight-weighted metering, settings modal, photo viewer, shutter sound control, cleanup

This commit is contained in:
2026-09-09 09:41:55 +07:00
parent e9a9005ef2
commit 09dbc72cce
46 changed files with 1684 additions and 2491 deletions
+219
View File
@@ -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>
);
}