Files
RecipesCam/App.tsx
T

423 lines
14 KiB
TypeScript

import React, { useState, useEffect } from 'react';
import { View, StatusBar, Alert, ActivityIndicator, Text, AppState } from 'react-native';
import { SafeAreaView } from 'react-native-safe-area-context';
import { useCameraPermissions } from 'expo-camera';
import * as ImagePicker from 'expo-image-picker';
import * as MediaLibrary from 'expo-media-library';
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, reverseGeocode } from './src/utils/locationUtils';
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 [previewVisible, setPreviewVisible] = useState(false);
const [isProcessing, setIsProcessing] = useState(false);
// Hardware Permissions (expo-camera)
const [cameraPermission, requestCameraPermission] = useCameraPermissions();
useEffect(() => {
loadRecipes();
restoreSession();
// 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 restoreSession = async () => {
try {
const saved = await AsyncStorage.getItem(SESSION_KEY);
if (!saved) return;
const session = JSON.parse(saved);
if (session.libraryImageUri) {
setLibraryImageUri(session.libraryImageUri);
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) {
const allRecipes = await getAllRecipes();
const found = allRecipes.find((r: Recipe) => r.id === session.selectedRecipeId);
if (found) {
setSelectedRecipe(found);
}
}
} catch (e) {
console.warn('Failed to restore session:', e);
}
};
const loadRecipes = async () => {
const list = await getAllRecipes();
setRecipes(list);
if (list.length > 0) {
applyRecipe(list[0]);
}
};
const loadGPS = async () => {
const info = await getCurrentGPS();
if (info) {
setGpsInfo(info);
}
};
const applyRecipe = (recipe: Recipe) => {
setSelectedRecipe(recipe);
setAdjustments(recipe.adjustments);
setSelectedFrame(recipe.frameId || 'none');
setUseGeotag(recipe.useGeotag);
};
const handleUpdateAdjustments = (updates: Partial<ColorAdjustments>) => {
setAdjustments((prev) => {
const next = { ...prev, ...updates };
if (selectedRecipe) {
setSelectedRecipe({
...selectedRecipe,
adjustments: next,
});
}
return 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 () => {
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 photo has location data
setGpsInfo(null);
// Multi-tier GPS resolution: EXIF → MediaLibrary.getAssetInfoAsync
const resolvedGps = await resolvePhotoLocation(asset);
if (resolvedGps) {
setGpsInfo(resolvedGps);
} else if (useGeotag) {
// Tier 3: photo has no GPS — fall back to device location
const deviceGps = await getCurrentGPS();
if (deviceGps) {
setGpsInfo(deviceGps);
}
}
Haptics.notificationAsync(Haptics.NotificationFeedbackType.Success);
}
};
// Multi-tier GPS resolver: EXIF → MediaLibrary → Device GPS (fallback inside handlePickImage)
const resolvePhotoLocation = async (asset: any): Promise<GPSInfo | null> => {
// Tier 1: Extract GPS from ImagePicker EXIF
if (asset.exif && (asset.exif.GPSLatitude || asset.exif.GPSLongitude)) {
const lat = typeof asset.exif.GPSLatitude === 'number'
? asset.exif.GPSLatitude
: parseGPS(asset.exif.GPSLatitudeRef || '', asset.exif.GPSLatitude);
const lon = typeof asset.exif.GPSLongitude === 'number'
? asset.exif.GPSLongitude
: parseGPS(asset.exif.GPSLongitudeRef || '', asset.exif.GPSLongitude);
if (lat !== 0 || lon !== 0) {
return {
latitude: lat,
longitude: lon,
locality: undefined,
country: undefined,
timestamp: Date.now(),
};
}
}
// Tier 2: Use MediaLibrary.getAssetInfoAsync for richer metadata
// On Android, assetId is a content URI like "content://media/external/images/media/12345"
// but MediaLibrary expects just the numeric part "12345"
const rawId = asset.assetId || '';
const numericId = rawId.replace(/^.*\/media\//, '').replace(/\/.*$/, '');
if (numericId && /^\d+$/.test(numericId)) {
try {
const assetInfo = await MediaLibrary.getAssetInfoAsync(numericId);
if (assetInfo.location) {
const locality = await reverseGeocode(assetInfo.location.latitude, assetInfo.location.longitude) || undefined;
return {
latitude: assetInfo.location.latitude,
longitude: assetInfo.location.longitude,
locality,
country: undefined,
timestamp: Date.now(),
};
}
} catch (e) {
console.warn('MediaLibrary getAssetInfoAsync failed:', e);
}
}
return null;
};
// Helper to parse GPS coordinates from EXIF string format
const parseGPS = (ref: string, val: any): number => {
try {
if (Array.isArray(val)) {
const [degrees, minutes, seconds] = val.map(Number);
const decimal = degrees + minutes / 60 + seconds / 3600;
return (ref === 'S' || ref === 'W') ? -decimal : decimal;
}
if (typeof val === 'number') return val;
return 0;
} catch {
return 0;
}
};
const handleCapture = async () => {
if (mode === 'camera') {
// For simulator & simplicity, if camera view isn't fully mocked, we use a placeholder or trigger camera picture.
// In this setup, we simulate taking a photo or request library image if running in simulator.
// Let's create a beautiful simulation that pulls a mock asset or lets you select one if in simulator.
Alert.alert(
'Capture Mode',
'Camera capture requires native device hardware. For testing on emulators, please use the LIBRARY mode to load a photo and apply filters.',
[{ text: 'OK' }]
);
} else {
// 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 exportedUri = await processAndExportPhoto(
libraryImageUri,
{
...selectedRecipe,
adjustments,
frameId: selectedFrame,
useGeotag,
},
selectedFrame,
useGeotag,
gpsInfo
);
setIsProcessing(false);
if (exportedUri) {
setLastPhotoUri(exportedUri);
setPreviewVisible(true);
Haptics.notificationAsync(Haptics.NotificationFeedbackType.Success);
} else {
Alert.alert('Failed', 'Error occurred while processing image.');
}
}
};
return (
<SafeAreaView className="flex-1 bg-carbon">
<StatusBar barStyle="light-content" backgroundColor="#08080a" />
{/* Top Header */}
<Header mode={mode} setMode={setMode} 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={!!cameraPermission?.granted}
onRequestCameraPermission={requestCameraPermission as any}
/>
</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={() => setMode('camera')}
/>
{/* Preview Modal */}
<PreviewModal
visible={previewVisible}
onClose={() => setPreviewVisible(false)}
photoUri={lastPhotoUri}
recipeName={selectedRecipe?.name || 'Custom'}
/>
{/* 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>
);
}