feat: add wallpaper editor with live shader preview

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.
This commit is contained in:
Mathis Pruvot
2026-05-28 11:49:52 +00:00
parent c3073b3f70
commit 0e1716738e

308
app/editor/[id].tsx Normal file
View File

@@ -0,0 +1,308 @@
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,
},
});