423 lines
14 KiB
TypeScript
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>
|
|
);
|
|
}
|