Full-screen Skia canvas with real-time shader preview, glass bottom sheet controls (animation selector, intensity/ speed/direction sliders, particle preset picker), save and export actions with status feedback.
309 lines
8.9 KiB
TypeScript
309 lines
8.9 KiB
TypeScript
import { useCallback, useMemo } from "react";
|
|
import {
|
|
ActivityIndicator,
|
|
Dimensions,
|
|
Pressable,
|
|
ScrollView,
|
|
StyleSheet,
|
|
Text,
|
|
View,
|
|
} from "react-native";
|
|
import { useSafeAreaInsets } from "react-native-safe-area-context";
|
|
import { useRouter, useLocalSearchParams } from "expo-router";
|
|
import {
|
|
Canvas,
|
|
Image as SkImage,
|
|
Shader,
|
|
Fill,
|
|
useImage,
|
|
} from "@shopify/react-native-skia";
|
|
import Animated, { FadeIn, FadeInUp } from "react-native-reanimated";
|
|
import { ArrowLeft, Check, Export } from "phosphor-react-native";
|
|
import { GlassBottomSheet, GlassButton, GlassCard, GlassPill, GlassSlider } from "@/components/glass";
|
|
import { useWallpaperStore } from "@/stores/wallpaper.store";
|
|
import { useAnimation } from "@/hooks/useAnimation";
|
|
import { useGyroscope } from "@/hooks/useGyroscope";
|
|
import { useWallpaperExport } from "@/hooks/useWallpaperExport";
|
|
import { shaders } from "@/shaders";
|
|
import {
|
|
ANIMATION_META,
|
|
type AnimationType,
|
|
} from "@/types/wallpaper";
|
|
import { colors, typography, spacing, layout } from "@/theme";
|
|
|
|
const { width: SCREEN_WIDTH, height: SCREEN_HEIGHT } = Dimensions.get("window");
|
|
|
|
const AVAILABLE_ANIMATIONS: AnimationType[] = [
|
|
"ken-burns",
|
|
"color-shift",
|
|
"particles",
|
|
"vignette-pulse",
|
|
"glitch",
|
|
];
|
|
|
|
export default function EditorScreen() {
|
|
const insets = useSafeAreaInsets();
|
|
const router = useRouter();
|
|
const { id } = useLocalSearchParams<{ id: string }>();
|
|
|
|
const {
|
|
sourceUri,
|
|
animation,
|
|
uniforms,
|
|
particlePreset,
|
|
setAnimation,
|
|
setUniform,
|
|
setParticlePreset,
|
|
saveWallpaper,
|
|
} = useWallpaperStore();
|
|
|
|
const { time } = useAnimation(animation, uniforms);
|
|
const gyro = useGyroscope(animation === "depth-parallax");
|
|
const { status, exportAsWallpaper } = useWallpaperExport();
|
|
|
|
const image = useImage(sourceUri);
|
|
const shader = useMemo(() => shaders[animation], [animation]);
|
|
|
|
const handleApply = useCallback(async () => {
|
|
const config = saveWallpaper();
|
|
if (config) {
|
|
await exportAsWallpaper(config);
|
|
}
|
|
}, [saveWallpaper, exportAsWallpaper]);
|
|
|
|
const handleSaveOnly = useCallback(() => {
|
|
saveWallpaper();
|
|
router.back();
|
|
}, [saveWallpaper, router]);
|
|
|
|
// No image selected — redirect back
|
|
if (!sourceUri) {
|
|
return (
|
|
<View style={[styles.container, styles.centered]}>
|
|
<GlassCard variant="medium" radius="xl" padding={32}>
|
|
<Text style={[typography.heading, { textAlign: "center" }]}>
|
|
Aucune image sélectionnée
|
|
</Text>
|
|
<Text
|
|
style={[typography.bodySecondary, { textAlign: "center", marginTop: 8, marginBottom: 20 }]}
|
|
>
|
|
Retournez à la galerie pour choisir une photo
|
|
</Text>
|
|
<GlassButton
|
|
label="Retour"
|
|
onPress={() => router.back()}
|
|
variant="primary"
|
|
size="md"
|
|
/>
|
|
</GlassCard>
|
|
</View>
|
|
);
|
|
}
|
|
|
|
// Image loading
|
|
if (!image) {
|
|
return (
|
|
<View style={[styles.container, styles.centered]}>
|
|
<ActivityIndicator color={colors.text.secondary} size="large" />
|
|
<Text style={[typography.bodySecondary, { marginTop: 16 }]}>
|
|
Chargement de l'image...
|
|
</Text>
|
|
</View>
|
|
);
|
|
}
|
|
|
|
return (
|
|
<View style={styles.container}>
|
|
{/* Full-screen animated preview */}
|
|
<Canvas style={styles.canvas}>
|
|
{shader && (
|
|
<Fill>
|
|
<Shader
|
|
source={shader}
|
|
uniforms={{
|
|
resolution: [SCREEN_WIDTH, SCREEN_HEIGHT],
|
|
time: time.value,
|
|
intensity: uniforms.intensity,
|
|
speed: uniforms.speed,
|
|
direction: uniforms.direction,
|
|
particleType: particlePreset === "snow" ? 0 : particlePreset === "rain" ? 1 : 2,
|
|
gyroX: gyro.x.value,
|
|
gyroY: gyro.y.value,
|
|
}}
|
|
>
|
|
<SkImage
|
|
image={image}
|
|
x={0}
|
|
y={0}
|
|
width={SCREEN_WIDTH}
|
|
height={SCREEN_HEIGHT}
|
|
fit="cover"
|
|
/>
|
|
</Shader>
|
|
</Fill>
|
|
)}
|
|
</Canvas>
|
|
|
|
{/* Top bar overlay */}
|
|
<Animated.View
|
|
entering={FadeIn.delay(200).duration(300)}
|
|
style={[styles.topBar, { top: insets.top + 8 }]}
|
|
>
|
|
<Pressable onPress={() => router.back()} style={styles.iconButton}>
|
|
<ArrowLeft size={20} color={colors.text.primary} weight="light" />
|
|
</Pressable>
|
|
|
|
<Pressable onPress={handleSaveOnly} style={styles.iconButton}>
|
|
<Check size={20} color={colors.text.primary} weight="light" />
|
|
</Pressable>
|
|
</Animated.View>
|
|
|
|
{/* Bottom sheet with controls */}
|
|
<GlassBottomSheet initialState="peek">
|
|
<ScrollView showsVerticalScrollIndicator={false}>
|
|
{/* Animation selector */}
|
|
<Text style={[typography.label, { marginBottom: 12 }]}>Animation</Text>
|
|
<ScrollView
|
|
horizontal
|
|
showsHorizontalScrollIndicator={false}
|
|
style={styles.animationScroll}
|
|
>
|
|
{AVAILABLE_ANIMATIONS.map((type) => (
|
|
<GlassPill
|
|
key={type}
|
|
label={ANIMATION_META[type].label}
|
|
selected={animation === type}
|
|
onPress={() => setAnimation(type)}
|
|
/>
|
|
))}
|
|
</ScrollView>
|
|
|
|
{/* Particle preset selector (only for particles animation) */}
|
|
{animation === "particles" && (
|
|
<>
|
|
<Text style={[typography.label, { marginBottom: 12 }]}>Style</Text>
|
|
<ScrollView
|
|
horizontal
|
|
showsHorizontalScrollIndicator={false}
|
|
style={styles.animationScroll}
|
|
>
|
|
{(["snow", "rain", "bokeh"] as const).map((preset) => (
|
|
<GlassPill
|
|
key={preset}
|
|
label={preset === "snow" ? "Neige" : preset === "rain" ? "Pluie" : "Bokeh"}
|
|
selected={particlePreset === preset}
|
|
onPress={() => setParticlePreset(preset)}
|
|
/>
|
|
))}
|
|
</ScrollView>
|
|
</>
|
|
)}
|
|
|
|
{/* Uniform controls */}
|
|
<View style={styles.controls}>
|
|
<GlassSlider
|
|
label="Intensité"
|
|
value={uniforms.intensity}
|
|
min={0}
|
|
max={1}
|
|
onValueChange={(v) => setUniform("intensity", v)}
|
|
/>
|
|
<GlassSlider
|
|
label="Vitesse"
|
|
value={uniforms.speed}
|
|
min={0.05}
|
|
max={1}
|
|
onValueChange={(v) => setUniform("speed", v)}
|
|
/>
|
|
{(animation === "ken-burns" || animation === "glitch") && (
|
|
<GlassSlider
|
|
label="Direction"
|
|
value={uniforms.direction}
|
|
min={0}
|
|
max={6.28}
|
|
step={0.1}
|
|
onValueChange={(v) => setUniform("direction", v)}
|
|
formatValue={(v) => `${Math.round((v * 180) / Math.PI)}°`}
|
|
/>
|
|
)}
|
|
</View>
|
|
|
|
{/* Export button */}
|
|
<View style={styles.ctaContainer}>
|
|
<GlassButton
|
|
label={
|
|
status === "preparing"
|
|
? "Préparation..."
|
|
: status === "exporting"
|
|
? "Export en cours..."
|
|
: status === "success"
|
|
? "Exporté !"
|
|
: "Appliquer comme fond d'écran"
|
|
}
|
|
onPress={handleApply}
|
|
variant="primary"
|
|
size="lg"
|
|
loading={status === "preparing" || status === "exporting"}
|
|
disabled={status === "success"}
|
|
pulse={status === "idle"}
|
|
icon={
|
|
status === "idle" ? (
|
|
<Export size={18} color={colors.text.primary} weight="light" />
|
|
) : undefined
|
|
}
|
|
/>
|
|
</View>
|
|
|
|
<View style={{ height: 40 }} />
|
|
</ScrollView>
|
|
</GlassBottomSheet>
|
|
</View>
|
|
);
|
|
}
|
|
|
|
const styles = StyleSheet.create({
|
|
container: {
|
|
flex: 1,
|
|
backgroundColor: colors.background,
|
|
},
|
|
centered: {
|
|
justifyContent: "center",
|
|
alignItems: "center",
|
|
paddingHorizontal: layout.screenPadding,
|
|
},
|
|
canvas: {
|
|
position: "absolute",
|
|
top: 0,
|
|
left: 0,
|
|
width: SCREEN_WIDTH,
|
|
height: SCREEN_HEIGHT,
|
|
},
|
|
topBar: {
|
|
position: "absolute",
|
|
left: 16,
|
|
right: 16,
|
|
flexDirection: "row",
|
|
justifyContent: "space-between",
|
|
zIndex: 10,
|
|
},
|
|
iconButton: {
|
|
width: 40,
|
|
height: 40,
|
|
borderRadius: 20,
|
|
backgroundColor: "rgba(0, 0, 0, 0.4)",
|
|
borderWidth: 1,
|
|
borderColor: colors.glass.border,
|
|
alignItems: "center",
|
|
justifyContent: "center",
|
|
},
|
|
animationScroll: {
|
|
marginBottom: spacing.lg,
|
|
},
|
|
controls: {
|
|
marginBottom: spacing.lg,
|
|
},
|
|
ctaContainer: {
|
|
marginTop: spacing.sm,
|
|
},
|
|
});
|