Fix iOS crash, storage, and printing issues; setup apk build

This commit is contained in:
2026-05-30 23:09:36 +02:00
parent eee684a3c9
commit 54666b077a
19 changed files with 1821 additions and 149 deletions
+310 -9
View File
@@ -10,9 +10,23 @@ import {
Alert,
} from 'react-native';
import Animated, { FadeIn, FadeOut } from 'react-native-reanimated';
import { FontAwesomeIcon } from '@fortawesome/react-native-fontawesome';
import {
faUserGear,
faArrowLeft,
faPrint,
faCameraRetro,
faLock,
faBorderAll,
faCalendarDay,
faImages,
faFloppyDisk,
faChampagneGlasses,
} from '@fortawesome/free-solid-svg-icons';
import { THEME } from '../styles/theme';
import KioskMode from '../../modules/kiosk-mode';
import { AppSettings, saveSettings } from '../services/settings';
import { FRAMES } from '../data/frames';
interface AdminScreenProps {
currentSettings: AppSettings;
@@ -20,6 +34,30 @@ interface AdminScreenProps {
onClose: () => void;
}
type FrameMode = 'off' | 'always' | 'available';
type DateOverlayMode = 'off' | 'date' | 'datetime';
type DatePosition = 'bottom-right' | 'bottom-left' | 'top-right' | 'top-left' | 'bottom-center';
const FRAME_MODE_OPTIONS: { value: FrameMode; label: string }[] = [
{ value: 'off', label: 'Aus' },
{ value: 'always', label: 'Immer an' },
{ value: 'available', label: 'Verfügbar' },
];
const DATE_OVERLAY_OPTIONS: { value: DateOverlayMode; label: string }[] = [
{ value: 'off', label: 'Aus' },
{ value: 'date', label: 'Nur Datum' },
{ value: 'datetime', label: 'Datum + Uhrzeit' },
];
const DATE_POSITION_OPTIONS: { value: DatePosition; label: string }[] = [
{ value: 'bottom-right', label: 'Unten rechts' },
{ value: 'bottom-left', label: 'Unten links' },
{ value: 'bottom-center', label: 'Unten Mitte' },
{ value: 'top-right', label: 'Oben rechts' },
{ value: 'top-left', label: 'Oben links' },
];
export default function AdminScreen({ currentSettings, onSave, onClose }: AdminScreenProps) {
const [countdown, setCountdown] = useState<string>(String(currentSettings.countdownDuration));
const [printerIp, setPrinterIp] = useState<string>(currentSettings.printerIp);
@@ -28,6 +66,16 @@ export default function AdminScreen({ currentSettings, onSave, onClose }: AdminS
const [kioskActive, setKioskActive] = useState<boolean>(false);
const [isDeviceOwner, setIsDeviceOwner] = useState<boolean>(false);
// New settings state
const [frameMode, setFrameMode] = useState<FrameMode>(currentSettings.frameMode);
const [selectedFrameId, setSelectedFrameId] = useState<string | null>(currentSettings.selectedFrameId);
const [dateOverlay, setDateOverlay] = useState<DateOverlayMode>(currentSettings.dateOverlay);
const [dateOverlayPosition, setDateOverlayPosition] = useState<DatePosition>(currentSettings.dateOverlayPosition);
const [eventText, setEventText] = useState<string>(currentSettings.eventText);
const [burstCount, setBurstCount] = useState<string>(String(currentSettings.burstCount));
const [burstInterval, setBurstInterval] = useState<string>(String(currentSettings.burstIntervalSec));
const [welcomeText, setWelcomeText] = useState<string>(currentSettings.welcomeText);
// Load Kiosk and Device Owner status on mount
useEffect(() => {
const checkKioskStatus = () => {
@@ -99,11 +147,36 @@ export default function AdminScreen({ currentSettings, onSave, onClose }: AdminS
return;
}
const burst = parseInt(burstCount, 10);
if (isNaN(burst) || burst < 1 || burst > 5) {
Alert.alert('Ungültige Eingabe', 'Die Serienbildanzahl muss zwischen 1 und 5 liegen.');
return;
}
const interval = parseInt(burstInterval, 10);
if (isNaN(interval) || interval < 3 || interval > 30) {
Alert.alert('Ungültige Eingabe', 'Der Abstand zwischen Serienbildern muss zwischen 3 und 30 Sekunden liegen.');
return;
}
if (frameMode === 'always' && selectedFrameId === null && FRAMES.length > 0) {
Alert.alert('Hinweis', 'Bitte wähle einen Rahmen aus, wenn der Modus "Immer an" aktiviert ist.');
return;
}
const updated: AppSettings = {
countdownDuration: duration,
printerIp: ipTrimmed,
adminPassword: password.trim(),
kioskModeEnabled: kioskActive,
frameMode,
selectedFrameId: frameMode === 'always' ? selectedFrameId : null,
dateOverlay,
dateOverlayPosition,
eventText: eventText.trim(),
burstCount: burst,
burstIntervalSec: interval,
welcomeText: welcomeText.trim() || 'Willkommen zu unserer Feier!',
};
try {
@@ -115,19 +188,47 @@ export default function AdminScreen({ currentSettings, onSave, onClose }: AdminS
}
};
// Renders a 3-way toggle segment control
const renderSegment = <T extends string>(
options: { value: T; label: string }[],
current: T,
onChange: (v: T) => void,
) => (
<View style={styles.segmentRow}>
{options.map((opt) => (
<TouchableOpacity
key={opt.value}
style={[styles.segmentBtn, current === opt.value && styles.segmentBtnActive]}
onPress={() => onChange(opt.value)}
>
<Text style={[styles.segmentText, current === opt.value && styles.segmentTextActive]}>
{opt.label}
</Text>
</TouchableOpacity>
))}
</View>
);
return (
<Animated.View entering={FadeIn.duration(400)} exiting={FadeOut.duration(300)} style={styles.container}>
<View style={styles.headerRow}>
<Text style={styles.title}>SCHNAPPIX EINSTELLUNGEN</Text>
<View style={styles.headerLeft}>
<FontAwesomeIcon icon={faUserGear} size={22} color={THEME.colors.primary} />
<Text style={styles.title}>EINSTELLUNGEN</Text>
</View>
<TouchableOpacity style={styles.closeBtn} onPress={onClose}>
<FontAwesomeIcon icon={faArrowLeft} size={14} color={THEME.colors.text} />
<Text style={styles.closeBtnText}>Zurück zur Fotobox</Text>
</TouchableOpacity>
</View>
<ScrollView contentContainerStyle={styles.scrollContent}>
{/* Printer Configurations */}
{/* ─── Printer Configuration ─── */}
<View style={styles.card}>
<Text style={styles.cardTitle}>Drucker-Konfiguration</Text>
<View style={styles.cardHeader}>
<FontAwesomeIcon icon={faPrint} size={16} color={THEME.colors.accent} />
<Text style={styles.cardTitle}>Drucker-Konfiguration</Text>
</View>
<Text style={styles.cardDesc}>
Konfiguriere die lokale IP-Adresse deines über WLAN verbundenen Canon CP1300 Druckers.
</Text>
@@ -144,9 +245,12 @@ export default function AdminScreen({ currentSettings, onSave, onClose }: AdminS
</View>
</View>
{/* Booth Behavior */}
{/* ─── Booth Behavior ─── */}
<View style={styles.card}>
<Text style={styles.cardTitle}>Fotobox-Verhalten</Text>
<View style={styles.cardHeader}>
<FontAwesomeIcon icon={faCameraRetro} size={16} color={THEME.colors.accent} />
<Text style={styles.cardTitle}>Fotobox-Verhalten</Text>
</View>
<View style={styles.inputGroup}>
<Text style={styles.label}>Countdown-Dauer (Sekunden)</Text>
<TextInput
@@ -170,11 +274,129 @@ export default function AdminScreen({ currentSettings, onSave, onClose }: AdminS
keyboardType="number-pad"
/>
</View>
<View style={styles.inputGroup}>
<Text style={styles.label}>Begrüßungstext</Text>
<TextInput
style={styles.input}
placeholder="Willkommen zu unserer Feier!"
placeholderTextColor={THEME.colors.textMuted}
value={welcomeText}
onChangeText={setWelcomeText}
/>
</View>
<View style={styles.inputGroup}>
<Text style={styles.label}>Event-Name (für Datum-Sticker)</Text>
<TextInput
style={styles.input}
placeholder="z.B. Jannik's Party"
placeholderTextColor={THEME.colors.textMuted}
value={eventText}
onChangeText={setEventText}
/>
</View>
</View>
{/* Security & Kiosk Mode */}
{/* ─── Continuous Shooting ─── */}
<View style={styles.card}>
<Text style={styles.cardTitle}>Kiosk-Modus & Sicherheit</Text>
<View style={styles.cardHeader}>
<FontAwesomeIcon icon={faImages} size={16} color={THEME.colors.accent} />
<Text style={styles.cardTitle}>Serienbild / Burst-Modus</Text>
</View>
<Text style={styles.cardDesc}>
Mehrere Fotos automatisch hintereinander aufnehmen. Bei Anzahl = 1 wird nur ein einzelnes Foto aufgenommen.
</Text>
<View style={styles.inputGroup}>
<Text style={styles.label}>Anzahl Fotos pro Durchgang (15)</Text>
<TextInput
style={styles.input}
placeholder="Standard: 1"
placeholderTextColor={THEME.colors.textMuted}
value={burstCount}
onChangeText={setBurstCount}
keyboardType="number-pad"
/>
</View>
<View style={styles.inputGroup}>
<Text style={styles.label}>Abstand zwischen Fotos (Sekunden, 330)</Text>
<TextInput
style={styles.input}
placeholder="Standard: 5"
placeholderTextColor={THEME.colors.textMuted}
value={burstInterval}
onChangeText={setBurstInterval}
keyboardType="number-pad"
/>
</View>
</View>
{/* ─── Photo Frames ─── */}
<View style={styles.card}>
<View style={styles.cardHeader}>
<FontAwesomeIcon icon={faBorderAll} size={16} color={THEME.colors.accent} />
<Text style={styles.cardTitle}>Fotorahmen</Text>
</View>
<Text style={styles.cardDesc}>
Rahmen über die Fotos legen. "Aus" deaktiviert Rahmen, "Immer an" erzwingt einen gewählten Rahmen, "Verfügbar" lässt Gäste einen Rahmen auswählen.
</Text>
{renderSegment(FRAME_MODE_OPTIONS, frameMode, setFrameMode)}
{frameMode === 'always' && FRAMES.length > 0 && (
<View style={styles.inputGroup}>
<Text style={styles.label}>Ausgewählter Rahmen</Text>
<View style={styles.frameChipRow}>
{FRAMES.map((frame) => (
<TouchableOpacity
key={frame.id}
style={[
styles.frameChip,
selectedFrameId === frame.id && styles.frameChipActive,
]}
onPress={() => setSelectedFrameId(frame.id)}
>
<Text style={[
styles.frameChipText,
selectedFrameId === frame.id && styles.frameChipTextActive,
]}>
{frame.name}
</Text>
</TouchableOpacity>
))}
</View>
</View>
)}
{frameMode === 'always' && FRAMES.length === 0 && (
<Text style={styles.warningText}>
Noch keine Rahmen hinterlegt. Lege PNG-Dateien in assets/frames/ ab.
</Text>
)}
</View>
{/* ─── Date/Time Overlay ─── */}
<View style={styles.card}>
<View style={styles.cardHeader}>
<FontAwesomeIcon icon={faCalendarDay} size={16} color={THEME.colors.accent} />
<Text style={styles.cardTitle}>Datum/Uhrzeit-Einblendung</Text>
</View>
<Text style={styles.cardDesc}>
Datum automatisch auf jedes Foto einblenden. Unabhängig davon ist der Datum-Sticker in der Bearbeitung immer verfügbar.
</Text>
{renderSegment(DATE_OVERLAY_OPTIONS, dateOverlay, setDateOverlay)}
{dateOverlay !== 'off' && (
<View style={styles.inputGroup}>
<Text style={styles.label}>Position der Einblendung</Text>
{renderSegment(DATE_POSITION_OPTIONS, dateOverlayPosition, setDateOverlayPosition)}
</View>
)}
</View>
{/* ─── Kiosk Mode ─── */}
<View style={styles.card}>
<View style={styles.cardHeader}>
<FontAwesomeIcon icon={faLock} size={16} color={THEME.colors.accent} />
<Text style={styles.cardTitle}>Kiosk-Modus & Sicherheit</Text>
</View>
<Text style={styles.cardDesc}>
Sperre den Bildschirm, um zu verhindern, dass Gäste die App schließen oder auf die Systemeinstellungen zugreifen.
</Text>
@@ -210,7 +432,9 @@ export default function AdminScreen({ currentSettings, onSave, onClose }: AdminS
</View>
</View>
{/* ─── Save Button ─── */}
<TouchableOpacity style={styles.saveBtn} onPress={handleSave}>
<FontAwesomeIcon icon={faFloppyDisk} size={18} color={THEME.colors.text} style={{ marginRight: 10 }} />
<Text style={styles.saveBtnText}>EINSTELLUNGEN SPEICHERN</Text>
</TouchableOpacity>
</ScrollView>
@@ -233,6 +457,11 @@ const styles = StyleSheet.create({
borderColor: THEME.colors.border,
backgroundColor: THEME.colors.surface,
},
headerLeft: {
flexDirection: 'row',
alignItems: 'center',
gap: 12,
},
title: {
fontSize: 22,
fontWeight: '900',
@@ -240,6 +469,9 @@ const styles = StyleSheet.create({
letterSpacing: 2,
},
closeBtn: {
flexDirection: 'row',
alignItems: 'center',
gap: 8,
backgroundColor: THEME.colors.surfaceSecondary,
borderWidth: 1,
borderColor: THEME.colors.border,
@@ -266,11 +498,16 @@ const styles = StyleSheet.create({
padding: THEME.spacing.lg,
marginBottom: THEME.spacing.lg,
},
cardHeader: {
flexDirection: 'row',
alignItems: 'center',
gap: 10,
marginBottom: THEME.spacing.xs,
},
cardTitle: {
fontSize: 18,
fontWeight: 'bold',
color: THEME.colors.text,
marginBottom: THEME.spacing.xs,
},
cardDesc: {
fontSize: 14,
@@ -295,6 +532,64 @@ const styles = StyleSheet.create({
paddingHorizontal: THEME.spacing.md,
fontSize: 16,
},
segmentRow: {
flexDirection: 'row',
backgroundColor: THEME.colors.surfaceSecondary,
borderRadius: THEME.borderRadius.sm,
borderWidth: 1,
borderColor: THEME.colors.border,
marginBottom: THEME.spacing.md,
overflow: 'hidden',
},
segmentBtn: {
flex: 1,
paddingVertical: 10,
alignItems: 'center',
justifyContent: 'center',
},
segmentBtnActive: {
backgroundColor: THEME.colors.primary,
},
segmentText: {
fontSize: 13,
fontWeight: '600',
color: THEME.colors.textMuted,
},
segmentTextActive: {
color: THEME.colors.text,
},
frameChipRow: {
flexDirection: 'row',
flexWrap: 'wrap',
gap: 8,
marginTop: THEME.spacing.xs,
},
frameChip: {
paddingVertical: 6,
paddingHorizontal: 14,
borderRadius: THEME.borderRadius.round,
borderWidth: 1,
borderColor: THEME.colors.border,
backgroundColor: THEME.colors.surfaceSecondary,
},
frameChipActive: {
borderColor: THEME.colors.primary,
backgroundColor: 'rgba(255, 43, 214, 0.15)',
},
frameChipText: {
fontSize: 13,
color: THEME.colors.textMuted,
fontWeight: '600',
},
frameChipTextActive: {
color: THEME.colors.primary,
},
warningText: {
fontSize: 12,
color: THEME.colors.error,
fontStyle: 'italic',
marginTop: THEME.spacing.xs,
},
statusRow: {
flexDirection: 'row',
marginBottom: THEME.spacing.xs,
@@ -315,7 +610,7 @@ const styles = StyleSheet.create({
color: THEME.colors.textMuted,
},
statusWarning: {
color: THEME.colors.accent,
color: THEME.colors.error,
},
kioskWarningText: {
fontSize: 12,
@@ -341,12 +636,18 @@ const styles = StyleSheet.create({
width: '100%',
maxWidth: 680,
height: 52,
flexDirection: 'row',
backgroundColor: THEME.colors.primary,
borderRadius: THEME.borderRadius.md,
justifyContent: 'center',
alignItems: 'center',
marginTop: THEME.spacing.sm,
marginBottom: THEME.spacing.xl,
shadowColor: THEME.colors.primary,
shadowOffset: { width: 0, height: 4 },
shadowOpacity: 0.4,
shadowRadius: 12,
elevation: 8,
},
saveBtnText: {
color: THEME.colors.text,
+239 -61
View File
@@ -1,4 +1,4 @@
import React, { useState, useEffect, useRef } from 'react';
import React, { useState, useEffect, useRef, useCallback } from 'react';
import {
StyleSheet,
Text,
@@ -11,7 +11,23 @@ import {
} from 'react-native';
import { CameraView, useCameraPermissions } from 'expo-camera';
import { File, Paths } from 'expo-file-system';
import Animated, { useSharedValue, useAnimatedStyle, withSpring, withTiming, FadeIn, FadeOut } from 'react-native-reanimated';
import Animated, {
useSharedValue,
useAnimatedStyle,
withSpring,
withTiming,
FadeIn,
FadeOut,
withSequence,
Easing,
runOnJS,
} from 'react-native-reanimated';
import { FontAwesomeIcon } from '@fortawesome/react-native-fontawesome';
import {
faUserGear,
faCameraRetro,
faXmark,
} from '@fortawesome/free-solid-svg-icons';
import { THEME } from '../styles/theme';
import { UsbCameraView, UsbCameraRef } from '../../modules/usb-camera';
import { saveToGallery } from '../services/storage';
@@ -19,21 +35,29 @@ import { saveToGallery } from '../services/storage';
interface CameraScreenProps {
countdownDuration: number;
onPhotoCaptured: (uri: string) => void;
onBurstComplete: (uris: string[]) => void;
onCancel: () => void;
isIdle?: boolean;
onStartBooth?: () => void;
onNavigateToAdmin?: () => void;
adminPassword?: string;
welcomeText?: string;
burstCount?: number;
burstIntervalSec?: number;
}
export default function CameraScreen({
countdownDuration,
onPhotoCaptured,
onBurstComplete,
onCancel,
isIdle = false,
onStartBooth,
onNavigateToAdmin,
adminPassword = '1234',
welcomeText = 'Willkommen zu unserer Feier!',
burstCount = 1,
burstIntervalSec = 5,
}: CameraScreenProps) {
const [permission, requestPermission] = useCameraPermissions();
const [isUsbConnected, setIsUsbConnected] = useState<boolean>(false);
@@ -41,6 +65,15 @@ export default function CameraScreen({
const [isCapturing, setIsCapturing] = useState<boolean>(false);
const [hasStarted, setHasStarted] = useState<boolean>(false);
// Burst mode state
const [burstIndex, setBurstIndex] = useState<number>(0);
const [burstUris, setBurstUris] = useState<string[]>([]);
const [showBurstIndicator, setShowBurstIndicator] = useState<string>('');
const [isBurstWaiting, setIsBurstWaiting] = useState<boolean>(false);
// Flash effect
const flashOpacity = useSharedValue(0);
// Admin Modal States
const [passwordModalVisible, setPasswordModalVisible] = useState(false);
const [enteredPassword, setEnteredPassword] = useState('');
@@ -48,6 +81,7 @@ export default function CameraScreen({
const usbCameraRef = useRef<UsbCameraRef>(null);
const expoCameraRef = useRef<any>(null);
const burstTimerRef = useRef<any>(null);
// Reanimated countdown pulse animation
const scale = useSharedValue(1);
@@ -65,6 +99,18 @@ export default function CameraScreen({
transform: [{ scale: scale.value }],
}));
const flashAnimatedStyle = useAnimatedStyle(() => ({
opacity: flashOpacity.value,
}));
// Trigger white flash effect
const triggerFlash = useCallback(() => {
flashOpacity.value = withSequence(
withTiming(1, { duration: 80, easing: Easing.out(Easing.quad) }),
withTiming(0, { duration: 400, easing: Easing.in(Easing.quad) })
);
}, []);
// 1. Request built-in camera permission on mount (just in case we fallback)
useEffect(() => {
if (!permission || !permission.granted) {
@@ -96,19 +142,35 @@ export default function CameraScreen({
};
}, []);
// Cleanup burst timer on unmount
useEffect(() => {
return () => {
if (burstTimerRef.current) clearTimeout(burstTimerRef.current);
};
}, []);
// 3. Start the countdown on screen load (only when NOT idle)
useEffect(() => {
if (isIdle) {
setHasStarted(false);
setBurstIndex(0);
setBurstUris([]);
return;
}
startCountdown();
return () => {};
}, [countdownDuration, isIdle]);
const startCountdown = () => {
let timer: any;
let count = countdownDuration;
setCountdown(count);
setHasStarted(true);
setIsCapturing(false);
setShowBurstIndicator('');
const runTimer = () => {
if (count > 1) {
@@ -120,16 +182,15 @@ export default function CameraScreen({
setIsCapturing(true);
timer = setTimeout(() => {
capture();
}, 800); // give the user a split second to smile
}, 800);
}
};
timer = setTimeout(runTimer, 1000);
return () => {
if (timer) clearTimeout(timer);
};
}, [countdownDuration, isIdle]);
// Store timer for cleanup
burstTimerRef.current = timer;
};
// 4. Capture photo function
const capture = async () => {
@@ -138,20 +199,15 @@ export default function CameraScreen({
const tempUri = tempFile.uri;
try {
triggerFlash();
let capturedUri: string;
if (Platform.OS === 'android' && isUsbConnected && usbCameraRef.current) {
// USB Camera Capture
console.log('Capturing from USB Camera...');
const path = await usbCameraRef.current.takePicture(tempUri);
// Auto-save individual photo directly to gallery
try {
await saveToGallery(path);
console.log('Auto-saved USB capture to gallery');
} catch (e) {
console.error('Failed to auto-save USB capture:', e);
}
onPhotoCaptured(path);
capturedUri = path;
} else {
// Fallback Camera Capture
console.log('Capturing from built-in camera fallback...');
@@ -160,20 +216,47 @@ export default function CameraScreen({
quality: 0.95,
skipProcessing: false,
});
// Auto-save individual photo directly to gallery
try {
await saveToGallery(photo.uri);
console.log('Auto-saved fallback capture to gallery');
} catch (e) {
console.error('Failed to auto-save fallback capture:', e);
}
onPhotoCaptured(photo.uri);
capturedUri = photo.uri;
} else {
throw new Error('Kamera-Referenz ist nicht verfügbar.');
}
}
// Auto-save individual photo to gallery
try {
await saveToGallery(capturedUri);
console.log('Auto-saved capture to gallery');
} catch (e) {
console.error('Failed to auto-save capture:', e);
}
// Handle burst mode
const effectiveBurst = burstCount || 1;
const newIndex = burstIndex + 1;
const newUris = [...burstUris, capturedUri];
if (effectiveBurst > 1 && newIndex < effectiveBurst) {
// More burst photos to take
setBurstIndex(newIndex);
setBurstUris(newUris);
setShowBurstIndicator(`Foto ${newIndex} von ${effectiveBurst}`);
setIsCapturing(false);
setIsBurstWaiting(true);
// Wait burstIntervalSec then start next countdown
burstTimerRef.current = setTimeout(() => {
setIsBurstWaiting(false);
startCountdown();
}, burstIntervalSec * 1000);
} else if (effectiveBurst > 1) {
// Final burst photo
setBurstIndex(0);
setBurstUris([]);
onBurstComplete(newUris);
} else {
// Single shot mode
onPhotoCaptured(capturedUri);
}
} catch (error: any) {
console.error('Capture failed:', error);
alert('Fehler beim Aufnehmen des Fotos: ' + error.message);
@@ -190,7 +273,9 @@ export default function CameraScreen({
const handlePasswordSubmit = () => {
if (enteredPassword === adminPassword) {
setPasswordModalVisible(false);
onNavigateToAdmin?.();
setTimeout(() => {
onNavigateToAdmin?.();
}, 350); // Delay navigation on iOS to avoid unmounting a view with an open modal
} else {
setErrorText('Falsches Passwort');
setEnteredPassword('');
@@ -231,16 +316,17 @@ export default function CameraScreen({
onPress={handleSettingsTap}
activeOpacity={0.7}
>
<Text style={styles.settingsIcon}></Text>
<FontAwesomeIcon icon={faUserGear} size={22} color={THEME.colors.accent} />
</TouchableOpacity>
<View style={styles.welcomeBox}>
<Text style={styles.logo}>SCHNAPPIX</Text>
<Text style={styles.welcomeTitle}>Willkommen zu unserer Feier!</Text>
<Text style={styles.welcomeTitle}>{welcomeText}</Text>
<View style={styles.divider} />
<View style={styles.startButton}>
<FontAwesomeIcon icon={faCameraRetro} size={20} color={THEME.colors.text} style={{ marginRight: 10 }} />
<Text style={styles.startButtonText}>ZUM STARTEN ÜBERALL TIPPEN</Text>
</View>
@@ -294,7 +380,7 @@ export default function CameraScreen({
);
}
// Capture Mode overlays (Countdown and Cancel button)
// Capture Mode overlays (Countdown, Burst indicator, and Cancel button)
return (
<View style={styles.captureOverlayContainer} pointerEvents="box-none">
{/* Source indicator */}
@@ -304,8 +390,28 @@ export default function CameraScreen({
</Text>
</View>
{/* Burst progress indicator */}
{(burstCount || 1) > 1 && (
<View style={styles.burstBadge}>
<FontAwesomeIcon icon={faCameraRetro} size={12} color={THEME.colors.accent} />
<Text style={styles.burstBadgeText}>
Foto {burstIndex + 1} / {burstCount}
</Text>
</View>
)}
{/* Burst waiting indicator */}
{isBurstWaiting && showBurstIndicator !== '' && (
<View style={styles.countdownOverlay} pointerEvents="none">
<View style={styles.burstWaitingBox}>
<Text style={styles.burstWaitingText}>{showBurstIndicator}</Text>
<Text style={styles.burstWaitingSub}>Nächstes Foto in {burstIntervalSec}s</Text>
</View>
</View>
)}
{/* Countdown overlay */}
{hasStarted && (
{hasStarted && !isBurstWaiting && (
<View style={styles.countdownOverlay} pointerEvents="none">
<Animated.View style={[styles.countdownBox, animatedStyle]}>
<Text style={styles.countdownText}>{countdown}</Text>
@@ -314,8 +420,9 @@ export default function CameraScreen({
)}
{/* Cancel Button */}
{!isCapturing && (
{!isCapturing && !isBurstWaiting && (
<TouchableOpacity style={styles.backButton} onPress={onCancel}>
<FontAwesomeIcon icon={faXmark} size={16} color={THEME.colors.text} style={{ marginRight: 6 }} />
<Text style={styles.backButtonText}>ABBRECHEN</Text>
</TouchableOpacity>
)}
@@ -340,6 +447,12 @@ export default function CameraScreen({
{/* Render UI controls/overlays on top */}
{renderContent()}
{/* White Flash Overlay */}
<Animated.View
style={[styles.flashOverlay, flashAnimatedStyle]}
pointerEvents="none"
/>
</Animated.View>
);
}
@@ -360,11 +473,20 @@ const styles = StyleSheet.create({
width: '100%',
height: '100%',
},
flashOverlay: {
position: 'absolute',
top: 0,
left: 0,
right: 0,
bottom: 0,
backgroundColor: '#FFFFFF',
zIndex: 9999,
},
idleOverlayContainer: {
flex: 1,
justifyContent: 'center',
alignItems: 'center',
backgroundColor: 'rgba(0, 0, 0, 0.15)',
backgroundColor: 'rgba(5, 5, 16, 0.25)',
},
captureOverlayContainer: {
flex: 1,
@@ -378,29 +500,25 @@ const styles = StyleSheet.create({
width: 50,
height: 50,
borderRadius: THEME.borderRadius.round,
backgroundColor: 'rgba(30, 30, 35, 0.75)',
backgroundColor: 'rgba(13, 13, 26, 0.8)',
borderWidth: 1,
borderColor: 'rgba(255, 255, 255, 0.1)',
borderColor: 'rgba(255, 43, 214, 0.25)',
justifyContent: 'center',
alignItems: 'center',
zIndex: 999,
},
settingsIcon: {
fontSize: 24,
color: THEME.colors.text,
},
welcomeBox: {
alignItems: 'center',
padding: THEME.spacing.xl,
borderRadius: THEME.borderRadius.lg,
backgroundColor: 'rgba(18, 18, 20, 0.8)',
backgroundColor: 'rgba(5, 5, 16, 0.85)',
borderWidth: 1,
borderColor: 'rgba(255, 255, 255, 0.15)',
borderColor: 'rgba(255, 43, 214, 0.2)',
width: '60%',
shadowColor: '#000',
shadowOffset: { width: 0, height: 10 },
shadowColor: THEME.colors.primary,
shadowOffset: { width: 0, height: 0 },
shadowOpacity: 0.3,
shadowRadius: 20,
shadowRadius: 30,
elevation: 10,
},
logo: {
@@ -409,9 +527,9 @@ const styles = StyleSheet.create({
color: THEME.colors.primary,
letterSpacing: 8,
marginBottom: THEME.spacing.sm,
textShadowColor: 'rgba(0, 0, 0, 0.5)',
textShadowOffset: { width: 0, height: 2 },
textShadowRadius: 4,
textShadowColor: THEME.colors.neonGlow,
textShadowOffset: { width: 0, height: 0 },
textShadowRadius: 20,
},
welcomeTitle: {
fontSize: 22,
@@ -426,13 +544,24 @@ const styles = StyleSheet.create({
backgroundColor: THEME.colors.accent,
marginBottom: THEME.spacing.xl,
borderRadius: THEME.borderRadius.sm,
shadowColor: THEME.colors.accent,
shadowOffset: { width: 0, height: 0 },
shadowOpacity: 0.8,
shadowRadius: 8,
},
startButton: {
flexDirection: 'row',
alignItems: 'center',
paddingVertical: THEME.spacing.md,
paddingHorizontal: THEME.spacing.xl,
backgroundColor: THEME.colors.primary,
borderRadius: THEME.borderRadius.round,
marginBottom: THEME.spacing.xl,
shadowColor: THEME.colors.primary,
shadowOffset: { width: 0, height: 4 },
shadowOpacity: 0.5,
shadowRadius: 12,
elevation: 8,
},
startButtonText: {
color: THEME.colors.text,
@@ -448,18 +577,62 @@ const styles = StyleSheet.create({
hudContainer: {
position: 'absolute',
bottom: 20,
backgroundColor: 'rgba(0, 0, 0, 0.7)',
backgroundColor: 'rgba(5, 5, 16, 0.8)',
paddingVertical: THEME.spacing.xs,
paddingHorizontal: THEME.spacing.md,
borderRadius: THEME.borderRadius.sm,
borderWidth: 0.5,
borderColor: 'rgba(255, 255, 255, 0.1)',
borderColor: 'rgba(255, 43, 214, 0.15)',
},
cameraSourceIndicator: {
color: THEME.colors.text,
fontSize: 14,
fontWeight: 'bold',
},
burstBadge: {
position: 'absolute',
top: 24,
right: 24,
flexDirection: 'row',
alignItems: 'center',
gap: 6,
backgroundColor: 'rgba(5, 5, 16, 0.85)',
paddingVertical: 6,
paddingHorizontal: 14,
borderRadius: THEME.borderRadius.round,
borderWidth: 1,
borderColor: THEME.colors.accent,
},
burstBadgeText: {
color: THEME.colors.accent,
fontSize: 14,
fontWeight: 'bold',
},
burstWaitingBox: {
backgroundColor: 'rgba(5, 5, 16, 0.9)',
paddingVertical: THEME.spacing.xl,
paddingHorizontal: THEME.spacing.xxl,
borderRadius: THEME.borderRadius.lg,
borderWidth: 1,
borderColor: THEME.colors.accent,
alignItems: 'center',
shadowColor: THEME.colors.accent,
shadowOffset: { width: 0, height: 0 },
shadowOpacity: 0.4,
shadowRadius: 20,
elevation: 8,
},
burstWaitingText: {
fontSize: 28,
fontWeight: '900',
color: THEME.colors.accent,
textAlign: 'center',
},
burstWaitingSub: {
fontSize: 16,
color: THEME.colors.textMuted,
marginTop: THEME.spacing.sm,
},
countdownOverlay: {
position: 'absolute',
top: 0,
@@ -470,19 +643,19 @@ const styles = StyleSheet.create({
alignItems: 'center',
},
countdownBox: {
backgroundColor: 'rgba(20, 20, 25, 0.85)',
backgroundColor: 'rgba(5, 5, 16, 0.88)',
paddingVertical: THEME.spacing.xl,
paddingHorizontal: THEME.spacing.xxl,
borderRadius: THEME.borderRadius.lg,
borderWidth: 1,
borderColor: 'rgba(255, 255, 255, 0.15)',
borderColor: 'rgba(255, 43, 214, 0.25)',
minWidth: 180,
alignItems: 'center',
justifyContent: 'center',
shadowColor: '#000',
shadowOffset: { width: 0, height: 10 },
shadowColor: THEME.colors.primary,
shadowOffset: { width: 0, height: 0 },
shadowOpacity: 0.4,
shadowRadius: 15,
shadowRadius: 20,
elevation: 8,
},
countdownText: {
@@ -490,17 +663,22 @@ const styles = StyleSheet.create({
fontWeight: '900',
color: THEME.colors.text,
textAlign: 'center',
textShadowColor: THEME.colors.neonGlow,
textShadowOffset: { width: 0, height: 0 },
textShadowRadius: 15,
},
backButton: {
position: 'absolute',
top: 24,
left: 24,
backgroundColor: 'rgba(30, 30, 35, 0.75)',
flexDirection: 'row',
alignItems: 'center',
backgroundColor: 'rgba(13, 13, 26, 0.8)',
paddingVertical: THEME.spacing.sm,
paddingHorizontal: THEME.spacing.lg,
borderRadius: THEME.borderRadius.round,
borderWidth: 1,
borderColor: 'rgba(255, 255, 255, 0.1)',
borderColor: 'rgba(255, 43, 214, 0.2)',
},
backButtonText: {
color: THEME.colors.text,
@@ -546,10 +724,10 @@ const styles = StyleSheet.create({
borderWidth: 1,
borderColor: THEME.colors.border,
alignItems: 'center',
shadowColor: '#000',
shadowOffset: { width: 0, height: 10 },
shadowOpacity: 0.4,
shadowRadius: 15,
shadowColor: THEME.colors.primary,
shadowOffset: { width: 0, height: 0 },
shadowOpacity: 0.3,
shadowRadius: 20,
elevation: 10,
},
modalTitle: {
+324 -51
View File
@@ -1,18 +1,32 @@
import React, { useState, useRef } from 'react';
import React, { useState, useRef, useCallback } from 'react';
import {
StyleSheet,
Text,
View,
Image,
TouchableOpacity,
ScrollView,
ActivityIndicator,
} from 'react-native';
import ViewShot from 'react-native-view-shot';
import Animated, { FadeIn, FadeOut } from 'react-native-reanimated';
import { FontAwesomeIcon } from '@fortawesome/react-native-fontawesome';
import {
faPrint,
faDownload,
faPlus,
faRotateLeft,
faRightFromBracket,
faFaceGrinStars,
faBorderAll,
} from '@fortawesome/free-solid-svg-icons';
import { THEME } from '../styles/theme';
import { printImageLocal } from '../services/printer';
import { saveToGallery } from '../services/storage';
import DraggableSticker, { StickerData } from '../components/DraggableSticker';
import StickerTray from '../components/StickerTray';
import FrameOverlay from '../components/FrameOverlay';
import FramePicker from '../components/FramePicker';
import { FRAMES } from '../data/frames';
interface PreviewScreenProps {
photoUris: string[];
@@ -20,6 +34,14 @@ interface PreviewScreenProps {
onRetakeLast: () => void;
onAddAnother: () => void;
onReset: () => void;
// Frame settings
frameMode: 'off' | 'always' | 'available';
initialFrameId: string | null;
// Date overlay settings
dateOverlay: 'off' | 'date' | 'datetime';
dateOverlayPosition: string;
// Event text
eventText: string;
}
type CollageLayout = 'single' | 'strip' | 'grid' | 'duo';
@@ -30,44 +52,147 @@ export default function PreviewScreen({
onRetakeLast,
onAddAnother,
onReset,
frameMode,
initialFrameId,
dateOverlay,
dateOverlayPosition,
eventText,
}: PreviewScreenProps) {
const [layout, setLayout] = useState<CollageLayout>('single');
const [isProcessing, setIsProcessing] = useState<boolean>(false);
const [statusMessage, setStatusMessage] = useState<string>('');
// Sticker state
const [stickers, setStickers] = useState<StickerData[]>([]);
const [selectedStickerId, setSelectedStickerId] = useState<string | null>(null);
const [showStickerTray, setShowStickerTray] = useState<boolean>(false);
// Frame state
const [selectedFrameId, setSelectedFrameId] = useState<string | null>(
frameMode === 'always' ? initialFrameId : null
);
const viewShotRef = useRef<any>(null);
const currentPhoto = photoUris[photoUris.length - 1];
// Get the active frame asset
const activeFrame = selectedFrameId
? FRAMES.find((f) => f.id === selectedFrameId)
: null;
// Format date/time for overlays and stickers
const formatDate = useCallback((mode: 'date' | 'datetime'): string => {
const now = new Date();
const date = now.toLocaleDateString('de-DE', {
day: '2-digit',
month: '2-digit',
year: 'numeric',
});
if (mode === 'datetime') {
const time = now.toLocaleTimeString('de-DE', {
hour: '2-digit',
minute: '2-digit',
});
return `${date} ${time}`;
}
return date;
}, []);
// Get date overlay position styles
const getDatePositionStyle = () => {
switch (dateOverlayPosition) {
case 'bottom-left':
return { bottom: 12, left: 12 };
case 'top-right':
return { top: 12, right: 12 };
case 'top-left':
return { top: 12, left: 12 };
case 'bottom-center':
return { bottom: 12, left: 0, right: 0, alignItems: 'center' as const };
case 'bottom-right':
default:
return { bottom: 12, right: 12 };
}
};
// ── Sticker handlers ──
const addSticker = useCallback((type: 'emoji' | 'text', content: string) => {
const newSticker: StickerData = {
id: `sticker_${Date.now()}_${Math.random().toString(36).substr(2, 5)}`,
type,
content,
x: 100 + Math.random() * 40 - 20,
y: 100 + Math.random() * 40 - 20,
scale: 1,
rotation: 0,
};
setStickers((prev) => [...prev, newSticker]);
setSelectedStickerId(newSticker.id);
}, []);
const handleAddEmoji = useCallback((emoji: string) => {
addSticker('emoji', emoji);
}, [addSticker]);
const handleAddDateSticker = useCallback((format: 'date' | 'datetime') => {
addSticker('text', formatDate(format));
}, [addSticker, formatDate]);
const handleAddEventSticker = useCallback(() => {
const date = formatDate('date');
addSticker('text', `${eventText}${date}`);
}, [addSticker, formatDate, eventText]);
const handleStickerSelect = useCallback((id: string) => {
setSelectedStickerId((prev) => (prev === id ? null : id));
}, []);
const handleStickerDelete = useCallback((id: string) => {
setStickers((prev) => prev.filter((s) => s.id !== id));
setSelectedStickerId(null);
}, []);
const handleStickerUpdate = useCallback((id: string, updates: Partial<StickerData>) => {
setStickers((prev) =>
prev.map((s) => (s.id === id ? { ...s, ...updates } : s))
);
}, []);
// Deselect stickers when tapping the photo area
const handleCanvasPress = useCallback(() => {
setSelectedStickerId(null);
}, []);
// ── Capture the final image ──
const captureComposite = async (): Promise<string> => {
if (viewShotRef.current) {
return await viewShotRef.current.capture();
}
return currentPhoto;
};
// Triggers the view capture and the print job
const handlePrint = async () => {
setIsProcessing(true);
setStatusMessage('Dein Foto wird vorbereitet...');
try {
let printUri = currentPhoto;
// If we are printing a collage layout, capture the ViewShot container
if (layout !== 'single') {
setStatusMessage('Collage wird generiert...');
const capturedUri = await viewShotRef.current.capture();
printUri = capturedUri;
}
setStatusMessage('Bild wird generiert...');
const printUri = await captureComposite();
setStatusMessage('Druckauftrag wird an Drucker gesendet...');
// 1. Silent Print via IPP
await printImageLocal(printUri, {
ipAddress: printerIp,
jobName: 'Schnappix Fotobox',
});
setStatusMessage('Wird in Galerie gespeichert...');
// 2. Save permanently to DCIM/Schnappix
await saveToGallery(printUri);
setStatusMessage('Druck erfolgreich! Viel Spaß! 🎉');
setTimeout(() => {
setIsProcessing(false);
onReset(); // Go back to Home Screen
onReset();
}, 2000);
} catch (error: any) {
console.error('Printing failed:', error);
@@ -81,12 +206,7 @@ export default function PreviewScreen({
setIsProcessing(true);
setStatusMessage('Bild wird generiert...');
try {
let saveUri = currentPhoto;
if (layout !== 'single') {
const capturedUri = await viewShotRef.current.capture();
saveUri = capturedUri;
}
const saveUri = await captureComposite();
setStatusMessage('Wird in Galerie gespeichert...');
await saveToGallery(saveUri);
@@ -103,18 +223,17 @@ export default function PreviewScreen({
}
};
// Auto-save collage and exit
// Auto-save and exit
const handleExit = async () => {
// If layout is single, it has already been auto-saved to gallery right when captured
if (layout === 'single') {
if (stickers.length === 0 && layout === 'single' && !activeFrame && dateOverlay === 'off') {
onReset();
return;
}
setIsProcessing(true);
setStatusMessage('Collage wird gespeichert...');
setStatusMessage('Wird gespeichert...');
try {
const capturedUri = await viewShotRef.current.capture();
const capturedUri = await captureComposite();
await saveToGallery(capturedUri);
setStatusMessage('Erfolgreich gespeichert!');
setTimeout(() => {
@@ -122,13 +241,13 @@ export default function PreviewScreen({
onReset();
}, 1000);
} catch (error) {
console.error('Auto-saving collage on exit failed:', error);
console.error('Auto-saving on exit failed:', error);
setIsProcessing(false);
onReset();
}
};
// Render the selected collage inside the capture container
// ── Render the collage ──
const renderCollageView = () => {
switch (layout) {
case 'strip':
@@ -183,20 +302,54 @@ export default function PreviewScreen({
return (
<Animated.View entering={FadeIn.duration(400)} exiting={FadeOut.duration(300)} style={styles.container}>
{/* Left panel: Preview Canvas */}
<View style={styles.previewPanel}>
{layout === 'single' ? (
renderCollageView()
) : (
// ViewShot wraps the collage to capture it at exactly 3:2 ratio (1200x1800 px) for the printer
<ViewShot
ref={viewShotRef}
options={{ format: 'jpg', quality: 0.95 }}
style={styles.viewShotContainer}
>
{renderCollageView()}
</ViewShot>
<TouchableOpacity
style={styles.previewPanel}
activeOpacity={1}
onPress={handleCanvasPress}
>
<ViewShot
ref={viewShotRef}
options={{ format: 'jpg', quality: 0.95 }}
style={styles.viewShotContainer}
>
{renderCollageView()}
{/* Frame Overlay */}
{activeFrame && <FrameOverlay frameAsset={activeFrame.asset} />}
{/* Date/Time Overlay */}
{dateOverlay !== 'off' && (
<View style={[styles.dateOverlay, getDatePositionStyle()]} pointerEvents="none">
<Text style={styles.dateOverlayText}>
{formatDate(dateOverlay)}
</Text>
</View>
)}
{/* Stickers */}
{stickers.map((sticker) => (
<DraggableSticker
key={sticker.id}
sticker={sticker}
isSelected={selectedStickerId === sticker.id}
onSelect={handleStickerSelect}
onDelete={handleStickerDelete}
onUpdate={handleStickerUpdate}
/>
))}
</ViewShot>
{/* Sticker Tray (outside ViewShot — not captured) */}
{showStickerTray && (
<StickerTray
onAddEmoji={handleAddEmoji}
onAddDateSticker={handleAddDateSticker}
onAddEventSticker={handleAddEventSticker}
onClose={() => setShowStickerTray(false)}
eventText={eventText}
/>
)}
</View>
</TouchableOpacity>
{/* Right panel: Controls */}
<View style={styles.controlPanel}>
@@ -252,28 +405,79 @@ export default function PreviewScreen({
Aufgenommene Fotos: {photoUris.length} / 4
</Text>
{/* Editing Tools Row */}
<View style={styles.editToolsRow}>
<TouchableOpacity
style={[styles.editToolBtn, showStickerTray && styles.editToolBtnActive]}
onPress={() => setShowStickerTray(!showStickerTray)}
>
<FontAwesomeIcon
icon={faFaceGrinStars}
size={16}
color={showStickerTray ? THEME.colors.text : THEME.colors.accent}
/>
<Text style={[styles.editToolText, showStickerTray && styles.editToolTextActive]}>
Sticker
</Text>
</TouchableOpacity>
{frameMode === 'available' && (
<TouchableOpacity
style={[styles.editToolBtn, selectedFrameId !== null && styles.editToolBtnActive]}
onPress={() => {
if (selectedFrameId !== null) {
setSelectedFrameId(null);
}
// FramePicker is shown below
}}
>
<FontAwesomeIcon
icon={faBorderAll}
size={16}
color={selectedFrameId !== null ? THEME.colors.text : THEME.colors.accent}
/>
<Text style={[styles.editToolText, selectedFrameId !== null && styles.editToolTextActive]}>
Rahmen
</Text>
</TouchableOpacity>
)}
</View>
{/* Frame Picker (when mode = available) */}
{frameMode === 'available' && (
<FramePicker
selectedFrameId={selectedFrameId}
onSelectFrame={setSelectedFrameId}
/>
)}
{/* Action Buttons */}
<View style={styles.actions}>
<TouchableOpacity style={[styles.actionBtn, styles.printBtn]} onPress={handlePrint}>
<Text style={styles.actionBtnText}>JETZT DRUCKEN 🖨</Text>
<FontAwesomeIcon icon={faPrint} size={18} color={THEME.colors.text} style={{ marginRight: 8 }} />
<Text style={styles.actionBtnText}>JETZT DRUCKEN</Text>
</TouchableOpacity>
<TouchableOpacity style={[styles.actionBtn, styles.saveBtn]} onPress={handleSaveOnly}>
<Text style={styles.actionBtnText}>Nur auf Tablet speichern 💾</Text>
<FontAwesomeIcon icon={faDownload} size={16} color={THEME.colors.accent} style={{ marginRight: 8 }} />
<Text style={[styles.actionBtnText, { color: THEME.colors.accent }]}>Nur auf Tablet speichern</Text>
</TouchableOpacity>
{photoUris.length < 4 && (
<TouchableOpacity style={[styles.actionBtn, styles.addBtn]} onPress={onAddAnother}>
<Text style={styles.actionBtnText}>+ Weiteres Foto aufnehmen</Text>
<FontAwesomeIcon icon={faPlus} size={16} color={THEME.colors.text} style={{ marginRight: 8 }} />
<Text style={styles.actionBtnText}>Weiteres Foto aufnehmen</Text>
</TouchableOpacity>
)}
<View style={styles.rowActions}>
<TouchableOpacity style={[styles.smallBtn, styles.retakeBtn]} onPress={onRetakeLast}>
<Text style={styles.smallBtnText}>Letztes wiederholen</Text>
<FontAwesomeIcon icon={faRotateLeft} size={14} color={THEME.colors.error} style={{ marginRight: 6 }} />
<Text style={[styles.smallBtnText, { color: THEME.colors.error }]}>Wiederholen</Text>
</TouchableOpacity>
<TouchableOpacity style={[styles.smallBtn, styles.resetBtn]} onPress={handleExit}>
<FontAwesomeIcon icon={faRightFromBracket} size={14} color={THEME.colors.textMuted} style={{ marginRight: 6 }} />
<Text style={styles.smallBtnText}>Beenden</Text>
</TouchableOpacity>
</View>
@@ -304,7 +508,7 @@ const styles = StyleSheet.create({
justifyContent: 'center',
alignItems: 'center',
padding: THEME.spacing.md,
backgroundColor: '#050507',
backgroundColor: '#030308',
},
viewShotContainer: {
width: 320,
@@ -327,6 +531,9 @@ const styles = StyleSheet.create({
textAlign: 'center',
marginBottom: THEME.spacing.md,
letterSpacing: 2,
textShadowColor: THEME.colors.neonGlow,
textShadowOffset: { width: 0, height: 0 },
textShadowRadius: 10,
},
layoutSelector: {
flexDirection: 'row',
@@ -346,6 +553,11 @@ const styles = StyleSheet.create({
layoutBtnActive: {
backgroundColor: THEME.colors.primary,
borderColor: THEME.colors.primary,
shadowColor: THEME.colors.primary,
shadowOffset: { width: 0, height: 2 },
shadowOpacity: 0.4,
shadowRadius: 8,
elevation: 4,
},
layoutBtnDisabled: {
opacity: 0.3,
@@ -359,13 +571,43 @@ const styles = StyleSheet.create({
fontSize: 14,
color: THEME.colors.textMuted,
textAlign: 'center',
marginBottom: THEME.spacing.lg,
marginBottom: THEME.spacing.sm,
},
editToolsRow: {
flexDirection: 'row',
justifyContent: 'center',
gap: 10,
marginBottom: THEME.spacing.md,
},
editToolBtn: {
flexDirection: 'row',
alignItems: 'center',
gap: 6,
paddingVertical: 8,
paddingHorizontal: 14,
borderRadius: THEME.borderRadius.round,
borderWidth: 1,
borderColor: THEME.colors.border,
backgroundColor: THEME.colors.surfaceSecondary,
},
editToolBtnActive: {
backgroundColor: THEME.colors.primary,
borderColor: THEME.colors.primary,
},
editToolText: {
fontSize: 13,
fontWeight: '600',
color: THEME.colors.accent,
},
editToolTextActive: {
color: THEME.colors.text,
},
actions: {
width: '100%',
},
actionBtn: {
width: '100%',
flexDirection: 'row',
paddingVertical: THEME.spacing.md,
borderRadius: THEME.borderRadius.md,
alignItems: 'center',
@@ -374,6 +616,11 @@ const styles = StyleSheet.create({
},
printBtn: {
backgroundColor: THEME.colors.primary,
shadowColor: THEME.colors.primary,
shadowOffset: { width: 0, height: 4 },
shadowOpacity: 0.4,
shadowRadius: 12,
elevation: 8,
},
saveBtn: {
backgroundColor: 'transparent',
@@ -381,7 +628,7 @@ const styles = StyleSheet.create({
borderColor: THEME.colors.border,
},
addBtn: {
backgroundColor: THEME.colors.accent,
backgroundColor: THEME.colors.accentDark,
},
actionBtnText: {
color: THEME.colors.text,
@@ -396,9 +643,11 @@ const styles = StyleSheet.create({
},
smallBtn: {
flex: 0.48,
flexDirection: 'row',
paddingVertical: THEME.spacing.sm,
borderRadius: THEME.borderRadius.sm,
alignItems: 'center',
justifyContent: 'center',
borderWidth: 1,
},
retakeBtn: {
@@ -410,19 +659,20 @@ const styles = StyleSheet.create({
backgroundColor: 'transparent',
},
smallBtnText: {
color: THEME.colors.text,
color: THEME.colors.textMuted,
fontSize: 14,
fontWeight: '600',
},
// ── Photo Canvases ──
singleCanvas: {
width: 320,
height: 480,
backgroundColor: '#fff',
padding: 10,
shadowColor: '#000',
shadowOffset: { width: 0, height: 4 },
shadowColor: THEME.colors.primary,
shadowOffset: { width: 0, height: 0 },
shadowOpacity: 0.3,
shadowRadius: 10,
shadowRadius: 15,
elevation: 5,
},
singleImage: {
@@ -496,6 +746,24 @@ const styles = StyleSheet.create({
resizeMode: 'cover',
borderRadius: 2,
},
// ── Date Overlay ──
dateOverlay: {
position: 'absolute',
zIndex: 60,
},
dateOverlayText: {
fontSize: 11,
fontWeight: 'bold',
color: '#FFFFFF',
textShadowColor: 'rgba(0, 0, 0, 0.8)',
textShadowOffset: { width: 1, height: 1 },
textShadowRadius: 3,
backgroundColor: 'rgba(0, 0, 0, 0.35)',
paddingVertical: 2,
paddingHorizontal: 6,
borderRadius: 3,
},
// ── Loading ──
loadingOverlay: {
position: 'absolute',
top: 0,
@@ -514,6 +782,11 @@ const styles = StyleSheet.create({
alignItems: 'center',
borderWidth: 1,
borderColor: THEME.colors.border,
shadowColor: THEME.colors.primary,
shadowOffset: { width: 0, height: 0 },
shadowOpacity: 0.3,
shadowRadius: 20,
elevation: 10,
},
loadingText: {
color: THEME.colors.text,