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:
308
app/editor/[id].tsx
Normal file
308
app/editor/[id].tsx
Normal 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,
|
||||
},
|
||||
});
|
||||
Reference in New Issue
Block a user