first commit

This commit is contained in:
2026-07-16 12:22:02 +07:00
parent 04a2239def
commit adaab69b82
26 changed files with 3953 additions and 56 deletions
+384
View File
@@ -0,0 +1,384 @@
import React, { useState } from 'react';
import {
View,
Text,
TouchableOpacity,
ScrollView,
FlatList,
TextInput,
Modal,
} from 'react-native';
import Slider from '@react-native-community/slider';
import * as Haptics from 'expo-haptics';
import { Trash2, Plus, Check } from 'lucide-react-native';
import { Recipe, ColorAdjustments, FrameId } from '../types';
import { FRAMES } from '../utils/frameUtils';
interface AdjustmentPanelProps {
activeTab: 'recipes' | 'iq' | 'wb' | 'filters' | 'frame';
recipes: Recipe[];
currentRecipeId: string;
adjustments: ColorAdjustments;
selectedFrame: FrameId;
useGeotag: boolean;
onSelectRecipe: (recipe: Recipe) => void;
onUpdateAdjustments: (adjustments: Partial<ColorAdjustments>) => void;
onUpdateFrame: (frameId: FrameId) => void;
onToggleGeotag: (enabled: boolean) => void;
onSaveRecipe: (name: string) => void;
onDeleteRecipe: (id: string) => void;
}
export default function AdjustmentPanel({
activeTab,
recipes,
currentRecipeId,
adjustments,
selectedFrame,
useGeotag,
onSelectRecipe,
onUpdateAdjustments,
onUpdateFrame,
onToggleGeotag,
onSaveRecipe,
onDeleteRecipe,
}: AdjustmentPanelProps) {
const [modalVisible, setModalVisible] = useState(false);
const [newRecipeName, setNewRecipeName] = useState('');
const triggerHaptic = () => {
Haptics.impactAsync(Haptics.ImpactFeedbackStyle.Light);
};
const renderRecipeItem = React.useCallback(
({ item }: { item: Recipe }) => (
<RecipeItem
item={item}
isActive={item.id === currentRecipeId}
onSelect={onSelectRecipe}
onDelete={onDeleteRecipe}
triggerHaptic={triggerHaptic}
/>
),
[currentRecipeId, onSelectRecipe, onDeleteRecipe]
);
const handleSliderValueChange = () => {
// optional: light haptic while sliding, or only on completion.
// Usually Haptic on sliding is too heavy, so we don't trigger it on every frame,
// or we only trigger on sliding complete.
};
const handleSavePress = () => {
if (newRecipeName.trim()) {
onSaveRecipe(newRecipeName.trim());
setNewRecipeName('');
setModalVisible(false);
triggerHaptic();
}
};
const renderSlider = (
label: string,
value: number,
min: number,
max: number,
step: number,
onValueChange: (val: number) => void,
displayValueMapper?: (val: number) => string
) => {
const displayVal = displayValueMapper ? displayValueMapper(value) : value.toString();
return (
<View className="mb-4 px-4">
<View className="flex-row justify-between items-center mb-1">
<Text className="text-zinc-400 font-mono text-xs font-bold tracking-wider">{label.toUpperCase()}</Text>
<Text className="text-amber-500 font-mono text-xs font-semibold">{displayVal}</Text>
</View>
<Slider
value={value}
minimumValue={min}
maximumValue={max}
step={step}
onValueChange={onValueChange}
onSlidingComplete={triggerHaptic}
minimumTrackTintColor="#f59e0b"
maximumTrackTintColor="#27272a"
thumbTintColor="#f59e0b"
/>
</View>
);
};
switch (activeTab) {
case 'recipes':
return (
<View className="flex-1 bg-carbon">
<View className="flex-row justify-between items-center px-4 py-2 bg-titan border-b border-zinc-800">
<Text className="text-zinc-400 font-mono text-xs">SELECT OR CREATE PRESETS</Text>
<TouchableOpacity
onPress={() => setModalVisible(true)}
className="flex-row items-center bg-amber-500 px-3 py-1 rounded space-x-1"
activeOpacity={0.7}
>
<Plus size={14} color="#000000" />
<Text className="text-black font-mono text-xs font-bold">SAVE CURRENT</Text>
</TouchableOpacity>
</View>
<FlatList
data={recipes}
keyExtractor={(item) => item.id}
numColumns={2}
className="p-2"
columnWrapperStyle={{ justifyContent: 'space-between' }}
renderItem={renderRecipeItem}
/>
{/* Save Recipe Modal */}
<Modal
transparent
animationType="slide"
visible={modalVisible}
onRequestClose={() => setModalVisible(false)}
>
<View className="flex-1 justify-end bg-black/60">
<View className="bg-titan border-t border-zinc-800 rounded-t-3xl p-6 pb-8">
<Text className="text-white font-mono text-sm font-bold tracking-wider mb-4">
SAVE NEW RECIPE
</Text>
<TextInput
value={newRecipeName}
onChangeText={setNewRecipeName}
placeholder="Enter recipe name (e.g. Fuji Retro 🏮)"
placeholderTextColor="#71717a"
className="bg-carbon border border-zinc-800 rounded-lg p-3 text-white font-mono text-sm mb-4"
autoFocus
/>
<View className="flex-row space-x-3">
<TouchableOpacity
onPress={() => setModalVisible(false)}
className="flex-1 bg-zinc-800 py-3 rounded-lg items-center"
>
<Text className="text-zinc-300 font-mono text-xs font-semibold">CANCEL</Text>
</TouchableOpacity>
<TouchableOpacity
onPress={handleSavePress}
className="flex-1 bg-amber-500 py-3 rounded-lg items-center"
>
<Text className="text-black font-mono text-xs font-bold">SAVE RECIPE</Text>
</TouchableOpacity>
</View>
</View>
</View>
</Modal>
</View>
);
case 'iq':
return (
<ScrollView className="flex-1 bg-carbon py-3">
{renderSlider('Exposure', adjustments.exposure, -10, 10, 1, (val) =>
onUpdateAdjustments({ exposure: val })
)}
{renderSlider('Contrast', adjustments.contrast, -10, 10, 1, (val) =>
onUpdateAdjustments({ contrast: val })
)}
{renderSlider('Saturation', adjustments.saturation, -10, 10, 1, (val) =>
onUpdateAdjustments({ saturation: val })
)}
</ScrollView>
);
case 'wb':
return (
<ScrollView className="flex-1 bg-carbon py-3">
{renderSlider(
'Color Temp (Kelvin)',
adjustments.temperature,
2500,
10000,
100,
(val) => onUpdateAdjustments({ temperature: val }),
(val) => `${val}K`
)}
{renderSlider('Tint (Green-Magenta)', adjustments.tint, -10, 10, 1, (val) =>
onUpdateAdjustments({ tint: val })
)}
<View className="px-4 mb-4">
<Text className="text-zinc-400 font-mono text-xs font-bold tracking-wider mb-2">
COLOR CHROME EFFECT
</Text>
<View className="flex-row bg-titan p-0.5 rounded-lg border border-zinc-800">
{(['none', 'weak', 'strong'] as const).map((chrome) => {
const isActive = adjustments.colorChrome === chrome;
return (
<TouchableOpacity
key={chrome}
onPress={() => {
onUpdateAdjustments({ colorChrome: chrome });
triggerHaptic();
}}
className={`flex-1 py-1.5 rounded-md items-center ${
isActive ? 'bg-amber-500' : 'bg-transparent'
}`}
activeOpacity={0.7}
>
<Text
className={`font-mono text-xs font-bold ${
isActive ? 'text-black' : 'text-zinc-400'
}`}
>
{chrome.toUpperCase()}
</Text>
</TouchableOpacity>
);
})}
</View>
</View>
</ScrollView>
);
case 'filters':
return (
<ScrollView className="flex-1 bg-carbon py-3">
{renderSlider('Highlight Contrast', adjustments.highlight, -10, 10, 1, (val) =>
onUpdateAdjustments({ highlight: val })
)}
{renderSlider('Shadow Contrast', adjustments.shadow, -10, 10, 1, (val) =>
onUpdateAdjustments({ shadow: val })
)}
{renderSlider('Denoise (Blur)', adjustments.denoise, 0, 10, 1, (val) =>
onUpdateAdjustments({ denoise: val })
)}
{renderSlider('Clarity (Sharp/Soft)', adjustments.clarity, -10, 10, 1, (val) =>
onUpdateAdjustments({ clarity: val })
)}
{renderSlider('Monochrome Grain', adjustments.grain, 0, 10, 1, (val) =>
onUpdateAdjustments({ grain: val })
)}
</ScrollView>
);
case 'frame':
return (
<ScrollView className="flex-1 bg-carbon py-4">
<View className="px-4 mb-5">
<Text className="text-zinc-400 font-mono text-xs font-bold tracking-wider mb-3">
SELECT PNG FRAME OVERLAY
</Text>
<View className="flex-row flex-wrap">
{FRAMES.map((f: { id: FrameId; name: string }) => {
const isActive = selectedFrame === f.id;
return (
<TouchableOpacity
key={f.id}
onPress={() => {
onUpdateFrame(f.id);
triggerHaptic();
}}
className={`px-3 py-2 m-1 rounded border font-mono ${
isActive
? 'bg-amber-500/10 border-amber-500 text-amber-500'
: 'bg-titan border-zinc-800 text-zinc-400'
}`}
activeOpacity={0.7}
>
<Text
className={`font-mono text-xs font-bold ${
isActive ? 'text-amber-500' : 'text-zinc-400'
}`}
>
{f.name}
</Text>
</TouchableOpacity>
);
})}
</View>
</View>
<View className="px-4 flex-row items-center justify-between border-t border-zinc-800/50 pt-4">
<View>
<Text className="text-zinc-300 font-mono text-xs font-bold tracking-wider">
GPS GEOTAG WATERMARK
</Text>
<Text className="text-zinc-500 font-mono text-[10px] mt-0.5">
Stamps coordinates & place names onto photo
</Text>
</View>
<TouchableOpacity
onPress={() => {
onToggleGeotag(!useGeotag);
triggerHaptic();
}}
className={`w-12 h-6 rounded-full p-0.5 justify-center ${
useGeotag ? 'bg-amber-500 items-end' : 'bg-zinc-800 items-start'
}`}
activeOpacity={0.8}
>
<View className="w-5 h-5 rounded-full bg-black border border-zinc-950 flex justify-center items-center">
{useGeotag && <Check size={10} color="#f59e0b" />}
</View>
</TouchableOpacity>
</View>
</ScrollView>
);
default:
return null;
}
}
interface RecipeItemProps {
item: Recipe;
isActive: boolean;
onSelect: (recipe: Recipe) => void;
onDelete: (id: string) => void;
triggerHaptic: () => void;
}
const RecipeItem = React.memo(({ item, isActive, onSelect, onDelete, triggerHaptic }: RecipeItemProps) => {
return (
<TouchableOpacity
onPress={() => {
onSelect(item);
triggerHaptic();
}}
className={`flex-1 m-1.5 p-3 rounded-lg border ${
isActive
? 'bg-amber-500/10 border-amber-500'
: 'bg-titan border-zinc-800/80'
}`}
activeOpacity={0.7}
>
<View className="flex-row justify-between items-start">
<View className="flex-1 mr-1">
<Text
numberOfLines={1}
className={`font-mono text-xs font-bold ${
isActive ? 'text-amber-500' : 'text-zinc-200'
}`}
>
{item.name}
</Text>
<Text className="text-[10px] text-zinc-500 font-mono mt-0.5">
{item.baseFilter.toUpperCase()}
</Text>
</View>
{item.isCustom && (
<TouchableOpacity
onPress={(e) => {
e.stopPropagation();
onDelete(item.id);
triggerHaptic();
}}
className="p-1"
>
<Trash2 size={12} color="#ef4444" />
</TouchableOpacity>
)}
</View>
</TouchableOpacity>
);
});
+60
View File
@@ -0,0 +1,60 @@
import React from 'react';
import { View, Text, TouchableOpacity, Image } from 'react-native';
import { Image as ImageIcon, Eye } from 'lucide-react-native';
interface CameraControlsProps {
lastPhotoUri: string | null;
onCapture: () => void;
onPickImage: () => void;
onOpenPreview: () => void;
isLibraryMode: boolean;
}
export default function CameraControls({
lastPhotoUri,
onCapture,
onPickImage,
onOpenPreview,
isLibraryMode,
}: CameraControlsProps) {
return (
<View className="flex-row items-center justify-between px-8 py-6 bg-carbon border-t border-zinc-800/50">
{/* Pick Image Button */}
<TouchableOpacity
onPress={onPickImage}
className="w-12 h-12 rounded-full bg-titan border border-zinc-800 flex items-center justify-center"
activeOpacity={0.7}
>
<ImageIcon size={20} color="#f59e0b" />
</TouchableOpacity>
{/* Shutter Button */}
<TouchableOpacity
onPress={onCapture}
disabled={isLibraryMode}
className={`w-20 h-20 rounded-full border-4 border-zinc-800 flex items-center justify-center ${
isLibraryMode ? 'opacity-40' : 'opacity-100'
}`}
activeOpacity={0.8}
>
<View className="w-16 h-16 rounded-full bg-white border border-black flex items-center justify-center active:scale-95">
<View className="w-14 h-14 rounded-full bg-zinc-100/90 border border-zinc-200" />
</View>
</TouchableOpacity>
{/* Last Captured Image Preview Button */}
<TouchableOpacity
onPress={onOpenPreview}
disabled={!lastPhotoUri}
className="w-12 h-12 rounded-full bg-titan border border-zinc-800 flex items-center justify-center overflow-hidden"
activeOpacity={0.7}
>
{lastPhotoUri ? (
<Image source={{ uri: lastPhotoUri }} className="w-full h-full object-cover" />
) : (
<Eye size={20} color="#a1a1aa" />
)}
</TouchableOpacity>
</View>
);
}
+58
View File
@@ -0,0 +1,58 @@
import React from 'react';
import { View, Text, TouchableOpacity } from 'react-native';
import { Camera, Image as ImageIcon, Sliders } from 'lucide-react-native';
interface HeaderProps {
mode: 'camera' | 'library';
setMode: (mode: 'camera' | 'library') => void;
title: string;
}
export default function Header({ mode, setMode, title }: HeaderProps) {
return (
<View className="flex-row items-center justify-between px-4 py-3 bg-carbon border-b border-zinc-800/50">
<View className="flex-row items-center space-x-2">
<Sliders size={18} color="#f59e0b" />
<Text className="text-white font-mono text-sm tracking-wider font-bold">
{title.toUpperCase()}
</Text>
</View>
<View className="flex-row bg-titan rounded-lg p-0.5 border border-zinc-800">
<TouchableOpacity
onPress={() => setMode('camera')}
className={`flex-row items-center px-3 py-1.5 rounded-md space-x-1.5 ${
mode === 'camera' ? 'bg-amber-500' : 'bg-transparent'
}`}
activeOpacity={0.7}
>
<Camera size={14} color={mode === 'camera' ? '#000000' : '#a1a1aa'} />
<Text
className={`font-mono text-xs font-semibold ${
mode === 'camera' ? 'text-black' : 'text-zinc-400'
}`}
>
CAMERA
</Text>
</TouchableOpacity>
<TouchableOpacity
onPress={() => setMode('library')}
className={`flex-row items-center px-3 py-1.5 rounded-md space-x-1.5 ${
mode === 'library' ? 'bg-amber-500' : 'bg-transparent'
}`}
activeOpacity={0.7}
>
<ImageIcon size={14} color={mode === 'library' ? '#000000' : '#a1a1aa'} />
<Text
className={`font-mono text-xs font-semibold ${
mode === 'library' ? 'text-black' : 'text-zinc-400'
}`}
>
LIBRARY
</Text>
</TouchableOpacity>
</View>
</View>
);
}
+87
View File
@@ -0,0 +1,87 @@
import React from 'react';
import { View, Text, Modal, Image, TouchableOpacity, Share } from 'react-native';
import { X, Share2, Download } from 'lucide-react-native';
interface PreviewModalProps {
visible: boolean;
onClose: () => void;
photoUri: string | null;
recipeName: string;
}
export default function PreviewModal({
visible,
onClose,
photoUri,
recipeName,
}: PreviewModalProps) {
const handleShare = async () => {
if (!photoUri) return;
try {
await Share.share({
url: photoUri,
message: `Captured with CamRecipe Pro - Preset: ${recipeName}`,
});
} catch (error) {
console.error('Sharing failed:', error);
}
};
return (
<Modal
visible={visible}
animationType="fade"
transparent={false}
onRequestClose={onClose}
>
<View className="flex-1 bg-black justify-between py-6 px-4">
{/* Top bar */}
<View className="flex-row justify-between items-center px-2">
<Text className="text-zinc-500 font-mono text-xs">PRESET: {recipeName.toUpperCase()}</Text>
<TouchableOpacity
onPress={onClose}
className="w-8 h-8 rounded-full bg-zinc-900 flex items-center justify-center"
activeOpacity={0.7}
>
<X size={18} color="#ffffff" />
</TouchableOpacity>
</View>
{/* Image display */}
<View className="flex-1 justify-center items-center my-4">
{photoUri ? (
<Image
source={{ uri: photoUri }}
className="w-full aspect-[3/4] max-h-[75vh] rounded-lg border border-zinc-800"
resizeMode="contain"
/>
) : (
<Text className="text-zinc-400 font-mono text-sm">NO IMAGE CAPTURED</Text>
)}
</View>
{/* Bottom bar */}
<View className="flex-row justify-around items-center px-4">
<TouchableOpacity
onPress={handleShare}
disabled={!photoUri}
className="flex-row items-center space-x-2 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">SHARE</Text>
</TouchableOpacity>
<TouchableOpacity
onPress={onClose}
className="flex-row items-center space-x-2 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">DONE</Text>
</TouchableOpacity>
</View>
</View>
</Modal>
);
}
+54
View File
@@ -0,0 +1,54 @@
import React from 'react';
import { View, Text, TouchableOpacity, ScrollView } from 'react-native';
export type TabId = 'recipes' | 'iq' | 'wb' | 'filters' | 'frame';
interface TabSelectorProps {
activeTab: TabId;
setActiveTab: (tab: TabId) => void;
}
export default function TabSelector({ activeTab, setActiveTab }: TabSelectorProps) {
const tabs: { id: TabId; label: string }[] = [
{ id: 'recipes', label: 'RECIPES' },
{ id: 'iq', label: 'EXPOSURE' },
{ id: 'wb', label: 'WB & CHROME' },
{ id: 'filters', label: 'FILTERS' },
{ id: 'frame', label: 'FRAME & GPS' },
];
return (
<View className="bg-carbon border-b border-zinc-800">
<ScrollView
horizontal
showsHorizontalScrollIndicator={false}
className="px-2 py-2"
contentContainerStyle={{ alignItems: 'center' }}
>
{tabs.map((tab) => {
const isActive = activeTab === tab.id;
return (
<TouchableOpacity
key={tab.id}
onPress={() => setActiveTab(tab.id)}
className={`px-4 py-2 mx-1.5 rounded-full border ${
isActive
? 'bg-amber-500 border-amber-500'
: 'bg-titan border-zinc-800'
}`}
activeOpacity={0.7}
>
<Text
className={`font-mono text-xs tracking-widest font-bold ${
isActive ? 'text-black' : 'text-zinc-400'
}`}
>
{tab.label}
</Text>
</TouchableOpacity>
);
})}
</ScrollView>
</View>
);
}
+253
View File
@@ -0,0 +1,253 @@
import React from 'react';
import { View, Text, StyleSheet, Dimensions, ActivityIndicator, TouchableOpacity } from 'react-native';
import { CameraView } from 'expo-camera';
import {
Canvas,
Image as SkiaImage,
ColorMatrix,
useImage,
useFont,
Text as SkiaText,
rect,
Shader,
Skia,
Group,
Rect,
} from '@shopify/react-native-skia';
import { Recipe, GPSInfo, FrameId } from '../types';
import { getSkiaColorMatrix } from '../utils/colorUtils';
import { formatCoordinate } from '../utils/locationUtils';
interface ViewfinderProps {
mode: 'camera' | 'library';
recipe: Recipe;
selectedFrame: FrameId;
useGeotag: boolean;
gpsInfo: GPSInfo | null;
libraryImageUri: string | null;
cameraPermissionGranted: boolean;
onRequestCameraPermission: () => void;
}
const { width: screenWidth } = Dimensions.get('window');
const viewfinderWidth = screenWidth - 32; // padding sides
const viewfinderHeight = (viewfinderWidth * 4) / 3; // 3:4 aspect ratio
const noiseEffect = Skia.RuntimeEffect.Make(`
vec4 main(vec2 pos) {
float r = fract(sin(dot(pos.xy, vec2(12.9898, 78.233))) * 43758.5453);
return vec4(vec3(r), 1.0);
}
`);
export default function Viewfinder({
mode,
recipe,
selectedFrame,
useGeotag,
gpsInfo,
libraryImageUri,
cameraPermissionGranted,
onRequestCameraPermission,
}: ViewfinderProps) {
// Load library image if available
const skiaImage = useImage(libraryImageUri || '');
// Load Courier Prime font for the GPS watermark
const customFont = useFont(
require('../../assets/CourierPrime-Regular.ttf'),
Math.round(viewfinderWidth * 0.032) // ~12-14px scaled dynamically
);
const adjustments = recipe.adjustments;
const colorMatrix = getSkiaColorMatrix(
recipe.baseFilter,
adjustments.exposure,
adjustments.saturation,
adjustments.temperature,
adjustments.tint,
adjustments.colorChrome
);
const grainOpacity = adjustments.grain / 20;
const borderWidth = Math.min(viewfinderWidth, viewfinderHeight) * 0.05;
const sideBorder = Math.min(viewfinderWidth, viewfinderHeight) * 0.06;
const bottomBorder = viewfinderHeight * 0.18;
const barHeight = viewfinderHeight * 0.12;
const renderFrameOverlay = () => {
if (selectedFrame === 'none') return null;
if (selectedFrame === 'classic-white') {
return (
<Group color="white">
<Rect x={0} y={0} width={viewfinderWidth} height={borderWidth} />
<Rect x={0} y={viewfinderHeight - borderWidth} width={viewfinderWidth} height={borderWidth} />
<Rect x={0} y={0} width={borderWidth} height={viewfinderHeight} />
<Rect x={viewfinderWidth - borderWidth} y={0} width={borderWidth} height={viewfinderHeight} />
</Group>
);
}
if (selectedFrame === 'polaroid') {
return (
<Group>
<Rect x={0} y={0} width={viewfinderWidth} height={sideBorder} color="#faf9f6" />
<Rect x={0} y={0} width={sideBorder} height={viewfinderHeight} color="#faf9f6" />
<Rect x={viewfinderWidth - sideBorder} y={0} width={sideBorder} height={viewfinderHeight} color="#faf9f6" />
<Rect x={0} y={viewfinderHeight - bottomBorder} width={viewfinderWidth} height={bottomBorder} color="#faf9f6" />
<Rect
x={sideBorder - 1}
y={sideBorder - 1}
width={viewfinderWidth - 2 * sideBorder + 2}
height={viewfinderHeight - sideBorder - bottomBorder + 2}
color="#e5e5e5"
style="stroke"
strokeWidth={1}
/>
</Group>
);
}
if (selectedFrame === 'cinematic') {
return (
<Group color="black">
<Rect x={0} y={0} width={viewfinderWidth} height={barHeight} />
<Rect x={0} y={viewfinderHeight - barHeight} width={viewfinderWidth} height={barHeight} />
</Group>
);
}
return null;
};
const renderGPSWatermark = () => {
if (!useGeotag || !gpsInfo || !customFont) return null;
const latStr = formatCoordinate(gpsInfo.latitude, 'lat');
const lonStr = formatCoordinate(gpsInfo.longitude, 'lon');
const locationName = gpsInfo.locality || 'STREET VIEW';
const timestampStr = new Date(gpsInfo.timestamp).toLocaleDateString('vi-VN');
const yOffset = viewfinderHeight - (selectedFrame === 'polaroid' ? viewfinderHeight * 0.15 : 24);
const xOffset = selectedFrame === 'polaroid' ? viewfinderWidth * 0.08 : 16;
return (
<Group>
<SkiaText
x={xOffset}
y={yOffset - 32}
text={`📍 ${locationName}`}
font={customFont}
color="#f59e0b"
/>
<SkiaText
x={xOffset}
y={yOffset - 16}
text={`${latStr}, ${lonStr}`}
font={customFont}
color="#f59e0b"
/>
<SkiaText
x={xOffset}
y={yOffset}
text={`${timestampStr} | SS 1/125 f/2.8`}
font={customFont}
color="#f59e0b"
/>
</Group>
);
};
const renderLiveOverlay = () => {
return (
<Canvas style={StyleSheet.absoluteFill}>
{renderFrameOverlay()}
{renderGPSWatermark()}
</Canvas>
);
};
if (mode === 'camera') {
if (!cameraPermissionGranted) {
return (
<View
style={{ width: viewfinderWidth, height: viewfinderHeight }}
className="bg-zinc-950 border border-zinc-800 rounded-2xl flex items-center justify-center p-6"
>
<Text className="text-zinc-400 font-mono text-center mb-4">
Camera permission is required to use the viewfinder.
</Text>
<TouchableOpacity
onPress={onRequestCameraPermission}
className="bg-amber-500 px-6 py-3 rounded-full"
activeOpacity={0.7}
>
<Text className="text-black font-mono text-xs font-bold">GRANT PERMISSION</Text>
</TouchableOpacity>
</View>
);
}
return (
<View
style={{ width: viewfinderWidth, height: viewfinderHeight }}
className="relative overflow-hidden rounded-2xl border border-zinc-800 bg-black"
>
<CameraView style={StyleSheet.absoluteFill} facing="back" />
<View className="absolute inset-0 flex-row justify-between pointer-events-none opacity-20">
<View className="w-[1px] h-full bg-white ml-[33%]" />
<View className="w-[1px] h-full bg-white mr-[33%]" />
</View>
<View className="absolute inset-0 flex-col justify-between pointer-events-none opacity-20">
<View className="h-[1px] w-full bg-white mt-[33%]" />
<View className="h-[1px] w-full bg-white mb-[33%]" />
</View>
{renderLiveOverlay()}
</View>
);
}
return (
<View
style={{ width: viewfinderWidth, height: viewfinderHeight }}
className="relative overflow-hidden rounded-2xl border border-zinc-800 bg-zinc-950 justify-center items-center"
>
{libraryImageUri && skiaImage ? (
<Canvas style={StyleSheet.absoluteFill}>
<Group>
<ColorMatrix matrix={colorMatrix} />
<SkiaImage
image={skiaImage}
fit="cover"
rect={rect(0, 0, viewfinderWidth, viewfinderHeight)}
/>
</Group>
{grainOpacity > 0 && noiseEffect && (
<Group blendMode="overlay">
<Rect x={0} y={0} width={viewfinderWidth} height={viewfinderHeight} opacity={grainOpacity}>
<Shader source={noiseEffect} />
</Rect>
</Group>
)}
{renderFrameOverlay()}
{renderGPSWatermark()}
</Canvas>
) : (
<View className="items-center justify-center p-6">
<Text className="text-zinc-500 font-mono text-center text-xs">
{libraryImageUri ? 'LOADING IMAGE...' : 'NO IMAGE SELECTED'}
</Text>
<Text className="text-zinc-600 font-mono text-center text-[10px] mt-1">
Tap the gallery icon below to load a photo
</Text>
</View>
)}
</View>
);
}