Files
RecipesCam/App.tsx
T
3dtours 200be77903 feat: full-res screen sharpen on camera-captured exports
Lightroom-style 'Screen' output sharpen (unsharp mask, 3x3 convolution
via Skia ImageFilter.MakeMatrixConvolution, amount 0.5, kernel sums to
1) applied to final pixels before JPEG encode. Camera-capture branch
passes { dpi: 300, sharpen: true }; library-mode export untouched.
Export pixels were already full resolution - sharpen is a new final
GPU pass, no pixel-count change.
2026-09-07 16:48:16 +07:00

443 lines
14 KiB
TypeScript

import React, { useState, useEffect, useRef } from 'react';
import { View, StatusBar, Alert, ActivityIndicator, Text, AppState } from 'react-native';
import { SafeAreaView } from 'react-native-safe-area-context';
import { useCameraPermission, type Camera } from 'react-native-vision-camera';
import * as ImagePicker from 'expo-image-picker';
import * as FileSystem from 'expo-file-system/legacy';
import * as Haptics from 'expo-haptics';
import AsyncStorage from '@react-native-async-storage/async-storage';
import Header from './src/components/Header';
import Viewfinder from './src/components/Viewfinder';
import TabSelector, { TabId } from './src/components/TabSelector';
import AdjustmentPanel from './src/components/AdjustmentPanel';
import CameraControls from './src/components/CameraControls';
import PreviewModal from './src/components/PreviewModal';
import { Recipe, ColorAdjustments, FrameId, GPSInfo } from './src/types';
import { getAllRecipes, saveCustomRecipe, deleteCustomRecipe } from './src/utils/storageUtils';
import { getCurrentGPS, requestLocationPermissions } from './src/utils/locationUtils';
import { resolvePhotoGPS } from './src/utils/photoGps';
import { processAndExportPhoto } from './src/utils/exportEngine';
// @ts-ignore
import './global.css';
const SESSION_KEY = 'saved_session';
export default function App() {
const [mode, setMode] = useState<'camera' | 'library'>('camera');
const [recipes, setRecipes] = useState<Recipe[]>([]);
const [selectedRecipe, setSelectedRecipe] = useState<Recipe | null>(null);
// Custom temporary overrides for the sliders
const [adjustments, setAdjustments] = useState<ColorAdjustments>({
exposure: 0,
contrast: 0,
saturation: 0,
temperature: 5500,
tint: 0,
highlight: 0,
shadow: 0,
denoise: 0,
clarity: 0,
grain: 0,
colorChrome: 'none',
});
const [selectedFrame, setSelectedFrame] = useState<FrameId>('none');
const [useGeotag, setUseGeotag] = useState(true);
const [gpsInfo, setGpsInfo] = useState<GPSInfo | null>(null);
const [activeTab, setActiveTab] = useState<TabId>('recipes');
const [libraryImageUri, setLibraryImageUri] = useState<string | null>(null);
const [lastPhotoUri, setLastPhotoUri] = useState<string | null>(null);
const [lastSavedToLibrary, setLastSavedToLibrary] = useState(false);
const [previewVisible, setPreviewVisible] = useState(false);
const [isProcessing, setIsProcessing] = useState(false);
// Hardware Permissions (vision-camera)
const { hasPermission: cameraPermissionGranted, requestPermission: requestCameraPermission } = useCameraPermission();
const cameraRef = useRef<Camera>(null);
// vision-camera may return a raw absolute path on Android; surface a file:// URI.
const toUri = (path: string) => (/^(file|content|ph):\/\//.test(path) ? path : `file://${path}`);
useEffect(() => {
// B5: run a single sequenced init — restore the session first, then fall
// back to the first recipe only when no recipe was restored.
initSession();
// Do NOT auto-load device GPS here — it would override photo GPS on library mode
}, []);
// Save/restore session on app lifecycle changes
useEffect(() => {
const subscription = AppState.addEventListener('change', async (nextAppState) => {
if (nextAppState === 'background' || nextAppState === 'inactive') {
const session = {
mode,
libraryImageUri,
adjustments,
selectedFrame,
useGeotag,
selectedRecipeId: selectedRecipe?.id || null,
gpsInfo,
};
try {
await AsyncStorage.setItem(SESSION_KEY, JSON.stringify(session));
} catch (e) {
console.warn('Failed to save session:', e);
}
}
});
return () => subscription.remove();
}, [mode, libraryImageUri, adjustments, selectedFrame, useGeotag, selectedRecipe, gpsInfo]);
const initSession = async () => {
const list = await getAllRecipes();
setRecipes(list);
let session: any = null;
try {
const raw = await AsyncStorage.getItem(SESSION_KEY);
if (raw) session = JSON.parse(raw);
} catch (e) {
console.warn('Failed to read session:', e);
}
let restoredRecipe: Recipe | null = null;
if (session) {
// B12: the restored library image may no longer exist on disk
let imageUri: string | null = session.libraryImageUri || null;
if (imageUri) {
try {
const info = await FileSystem.getInfoAsync(imageUri);
if (!info.exists) imageUri = null;
} catch {
imageUri = null;
}
}
if (imageUri) {
setLibraryImageUri(imageUri);
setMode('library');
} else {
setMode(session.mode || 'camera');
}
if (session.adjustments) setAdjustments(session.adjustments);
if (session.selectedFrame) setSelectedFrame(session.selectedFrame);
if (session.useGeotag !== undefined) setUseGeotag(session.useGeotag);
if (session.gpsInfo) setGpsInfo(session.gpsInfo);
if (session.selectedRecipeId) {
restoredRecipe = list.find((r: Recipe) => r.id === session.selectedRecipeId) || null;
}
}
if (restoredRecipe) {
applyRecipe(restoredRecipe);
} else if (list.length > 0) {
applyRecipe(list[0]);
}
};
const loadGPS = async () => {
const info = await getCurrentGPS();
if (info) {
setGpsInfo({ ...info, source: 'device' });
}
};
const applyRecipe = (recipe: Recipe) => {
setSelectedRecipe(recipe);
setAdjustments(recipe.adjustments);
setSelectedFrame(recipe.frameId || 'none');
setUseGeotag(recipe.useGeotag);
};
const handleUpdateAdjustments = (updates: Partial<ColorAdjustments>) => {
// B8: never call a setState inside another setState's updater.
const next = { ...adjustments, ...updates };
setAdjustments(next);
if (selectedRecipe) {
setSelectedRecipe({
...selectedRecipe,
adjustments: next,
});
}
};
const handleUpdateFrame = (frameId: FrameId) => {
setSelectedFrame(frameId);
if (selectedRecipe) {
setSelectedRecipe({
...selectedRecipe,
frameId,
});
}
};
const handleToggleGeotag = async (enabled: boolean) => {
setUseGeotag(enabled);
if (selectedRecipe) {
setSelectedRecipe({
...selectedRecipe,
useGeotag: enabled,
});
}
if (enabled && !gpsInfo) {
const locationGranted = await requestLocationPermissions();
if (locationGranted) {
loadGPS();
} else {
Alert.alert('Permission Denied', 'Enable location services to use GPS Watermark.');
setUseGeotag(false);
}
}
};
const handleSaveRecipe = async (name: string) => {
if (!selectedRecipe) return;
const newRecipe = await saveCustomRecipe({
name,
baseFilter: selectedRecipe.baseFilter,
adjustments,
frameId: selectedFrame,
useGeotag,
});
const list = await getAllRecipes();
setRecipes(list);
applyRecipe(newRecipe);
Alert.alert('Recipe Saved', `"${name}" has been created.`);
};
const handleDeleteRecipe = async (id: string) => {
await deleteCustomRecipe(id);
const list = await getAllRecipes();
setRecipes(list);
if (selectedRecipe?.id === id && list.length > 0) {
applyRecipe(list[0]);
}
};
const handlePickImage = async () => {
if (isProcessing) return;
const result = await ImagePicker.launchImageLibraryAsync({
mediaTypes: ImagePicker.MediaTypeOptions.Images,
allowsEditing: false,
quality: 1,
exif: true,
});
if (!result.canceled && result.assets && result.assets.length > 0) {
const asset = result.assets[0];
setLibraryImageUri(asset.uri);
setMode('library');
// Clear old GPS — will be replaced if the photo (or device) has location data
setGpsInfo(null);
// Multi-tier GPS resolution: EXIF → MediaLibrary → device fallback
const { gps, permissionDenied } = await resolvePhotoGPS(asset, {
useGeotag,
allowDeviceFallback: useGeotag,
});
setGpsInfo(gps);
if (permissionDenied) {
console.warn('Media library read permission denied — photo GPS unavailable.');
}
Haptics.notificationAsync(Haptics.NotificationFeedbackType.Success);
}
};
const activeRecipe: Recipe =
selectedRecipe || {
id: 'temp',
name: 'Custom',
baseFilter: 'none',
adjustments,
frameId: selectedFrame,
useGeotag,
};
const handleCapture = async () => {
if (isProcessing) return;
// Camera mode (Phase 4, option A): real capture through vision-camera.
if (mode === 'camera') {
if (!cameraPermissionGranted) {
Alert.alert('Permission Needed', 'Grant camera permission to take photos.');
return;
}
if (!cameraRef.current) {
Alert.alert('Camera Not Ready', 'Please wait for the camera to start, then try again.');
return;
}
setIsProcessing(true);
Haptics.impactAsync(Haptics.ImpactFeedbackStyle.Medium);
try {
// A photo taken now is at the device's current spot — resolve device GPS
// fresh instead of reusing GPS left over from a previously loaded photo.
let captureGps = gpsInfo && gpsInfo.source === 'device' ? gpsInfo : null;
if (useGeotag && !captureGps) {
const deviceGps = await getCurrentGPS();
if (deviceGps) {
captureGps = { ...deviceGps, source: 'device' as const };
setGpsInfo(captureGps);
}
}
const photo = await cameraRef.current.takePhoto({});
const sourceUri = toUri(photo.path);
const result = await processAndExportPhoto(
sourceUri,
{ ...activeRecipe, adjustments, frameId: selectedFrame, useGeotag },
selectedFrame,
useGeotag,
captureGps,
{ dpi: 300, sharpen: true } // 300dpi header + full-res screen sharpen
);
if (result) {
setLastPhotoUri(result.uri);
setLastSavedToLibrary(result.savedToLibrary);
setPreviewVisible(true);
Haptics.notificationAsync(Haptics.NotificationFeedbackType.Success);
} else {
Alert.alert('Failed', 'Error occurred while processing the captured photo.');
}
} catch (error) {
console.error('Camera capture failed:', error);
Alert.alert('Capture Failed', 'Could not take the photo. See logs for details.');
} finally {
setIsProcessing(false);
}
return;
}
// Library mode capture/export: processes selected image with recipe, overlays, text
if (!libraryImageUri || !selectedRecipe) {
Alert.alert('Error', 'Please select an image from the library first.');
return;
}
setIsProcessing(true);
Haptics.impactAsync(Haptics.ImpactFeedbackStyle.Medium);
const result = await processAndExportPhoto(
libraryImageUri,
{
...selectedRecipe,
adjustments,
frameId: selectedFrame,
useGeotag,
},
selectedFrame,
useGeotag,
gpsInfo
);
setIsProcessing(false);
if (result) {
setLastPhotoUri(result.uri);
setLastSavedToLibrary(result.savedToLibrary);
setPreviewVisible(true);
Haptics.notificationAsync(Haptics.NotificationFeedbackType.Success);
} else {
Alert.alert('Failed', 'Error occurred while processing image.');
}
};
// Switching back to camera: drop GPS that belongs to a library photo, so the
// live view (and any capture) never stamps a stale location.
const handleSetMode = (next: 'camera' | 'library') => {
if (next === 'camera' && gpsInfo && gpsInfo.source !== 'device') {
setGpsInfo(null);
}
setMode(next);
};
return (
<SafeAreaView className="flex-1 bg-carbon">
<StatusBar barStyle="light-content" backgroundColor="#08080a" />
{/* Top Header */}
<Header mode={mode} setMode={handleSetMode} title="CamRecipe Pro" />
{/* Main Viewfinder Frame */}
<View className="flex-1 justify-center items-center px-4 py-2 bg-carbon">
<Viewfinder
mode={mode}
recipe={
selectedRecipe || {
id: 'temp',
name: 'Custom',
baseFilter: 'none',
adjustments,
frameId: selectedFrame,
useGeotag,
}
}
selectedFrame={selectedFrame}
useGeotag={useGeotag}
gpsInfo={gpsInfo}
libraryImageUri={libraryImageUri}
cameraPermissionGranted={cameraPermissionGranted}
onRequestCameraPermission={requestCameraPermission}
cameraRef={cameraRef}
/>
</View>
{/* Selector Tabs */}
<TabSelector activeTab={activeTab} setActiveTab={setActiveTab} />
{/* Adjustments Panel */}
<View style={{ height: 250 }} className="bg-carbon">
<AdjustmentPanel
activeTab={activeTab}
recipes={recipes}
currentRecipeId={selectedRecipe?.id || ''}
adjustments={adjustments}
selectedFrame={selectedFrame}
useGeotag={useGeotag}
onSelectRecipe={applyRecipe}
onUpdateAdjustments={handleUpdateAdjustments}
onUpdateFrame={handleUpdateFrame}
onToggleGeotag={handleToggleGeotag}
onSaveRecipe={handleSaveRecipe}
onDeleteRecipe={handleDeleteRecipe}
/>
</View>
{/* Bottom Camera Buttons */}
<CameraControls
lastPhotoUri={lastPhotoUri}
onCapture={handleCapture}
onPickImage={handlePickImage}
onOpenPreview={() => setPreviewVisible(true)}
isLibraryMode={mode === 'library'}
onSwitchToCamera={() => handleSetMode('camera')}
/>
{/* Preview Modal */}
<PreviewModal
visible={previewVisible}
onClose={() => setPreviewVisible(false)}
photoUri={lastPhotoUri}
recipeName={selectedRecipe?.name || 'Custom'}
savedToLibrary={lastSavedToLibrary}
/>
{/* Global Processing Loader Overlay */}
{isProcessing && (
<View className="absolute inset-0 bg-black/75 flex items-center justify-center space-y-4">
<ActivityIndicator size="large" color="#f59e0b" />
<Text className="text-amber-500 font-mono text-sm tracking-widest font-bold">
RENDERING PHOTO...
</Text>
</View>
)}
</SafeAreaView>
);
}