feat: add navigation with tab bar and screen layouts
Expo Router file-based navigation with glass tab bar, 3 tabs (Créations, Explorer, Profil), Phosphor icons, modal picker, and fade transitions.
This commit is contained in:
44
app/(tabs)/_layout.tsx
Normal file
44
app/(tabs)/_layout.tsx
Normal file
@@ -0,0 +1,44 @@
|
||||
import { Tabs } from "expo-router";
|
||||
import { GlassTabBar } from "@/components/glass";
|
||||
import { House, Compass, UserCircle } from "phosphor-react-native";
|
||||
import type { ColorValue } from "react-native";
|
||||
|
||||
export default function TabLayout() {
|
||||
return (
|
||||
<Tabs
|
||||
tabBar={(props) => <GlassTabBar {...props} />}
|
||||
screenOptions={{
|
||||
headerShown: false,
|
||||
tabBarStyle: { display: "none" },
|
||||
}}
|
||||
>
|
||||
<Tabs.Screen
|
||||
name="index"
|
||||
options={{
|
||||
title: "Créations",
|
||||
tabBarIcon: ({ color, size }: { color: ColorValue; size: number }) => (
|
||||
<House size={size} color={color as string} weight="light" />
|
||||
),
|
||||
}}
|
||||
/>
|
||||
<Tabs.Screen
|
||||
name="explore"
|
||||
options={{
|
||||
title: "Explorer",
|
||||
tabBarIcon: ({ color, size }: { color: ColorValue; size: number }) => (
|
||||
<Compass size={size} color={color as string} weight="light" />
|
||||
),
|
||||
}}
|
||||
/>
|
||||
<Tabs.Screen
|
||||
name="profile"
|
||||
options={{
|
||||
title: "Profil",
|
||||
tabBarIcon: ({ color, size }: { color: ColorValue; size: number }) => (
|
||||
<UserCircle size={size} color={color as string} weight="light" />
|
||||
),
|
||||
}}
|
||||
/>
|
||||
</Tabs>
|
||||
);
|
||||
}
|
||||
60
app/(tabs)/explore.tsx
Normal file
60
app/(tabs)/explore.tsx
Normal file
@@ -0,0 +1,60 @@
|
||||
import { StyleSheet, Text, View } from "react-native";
|
||||
import { useSafeAreaInsets } from "react-native-safe-area-context";
|
||||
import Animated, { FadeInDown } from "react-native-reanimated";
|
||||
import { GlassCard } from "@/components/glass";
|
||||
import { colors, typography, spacing, layout } from "@/theme";
|
||||
|
||||
export default function ExploreScreen() {
|
||||
const insets = useSafeAreaInsets();
|
||||
|
||||
return (
|
||||
<View style={[styles.container, { paddingTop: insets.top + spacing.md }]}>
|
||||
<Animated.View entering={FadeInDown.duration(500)}>
|
||||
<GlassCard variant="subtle" radius="lg" padding={16}>
|
||||
<Text style={typography.title}>Explorer</Text>
|
||||
<Text style={[typography.caption, { marginTop: 2 }]}>
|
||||
Découvrez la communauté
|
||||
</Text>
|
||||
</GlassCard>
|
||||
</Animated.View>
|
||||
|
||||
<Animated.View
|
||||
entering={FadeInDown.delay(200).duration(600)}
|
||||
style={styles.placeholder}
|
||||
>
|
||||
<GlassCard variant="medium" radius="xl" padding={32}>
|
||||
<View style={styles.content}>
|
||||
<Text style={[typography.heading, { textAlign: "center" }]}>
|
||||
Bientôt disponible
|
||||
</Text>
|
||||
<Text
|
||||
style={[
|
||||
typography.bodySecondary,
|
||||
{ textAlign: "center", marginTop: 8 },
|
||||
]}
|
||||
>
|
||||
Partagez vos créations et découvrez{"\n"}
|
||||
celles de la communauté — V1.5
|
||||
</Text>
|
||||
</View>
|
||||
</GlassCard>
|
||||
</Animated.View>
|
||||
</View>
|
||||
);
|
||||
}
|
||||
|
||||
const styles = StyleSheet.create({
|
||||
container: {
|
||||
flex: 1,
|
||||
backgroundColor: colors.background,
|
||||
paddingHorizontal: layout.screenPadding,
|
||||
},
|
||||
placeholder: {
|
||||
flex: 1,
|
||||
justifyContent: "center",
|
||||
paddingHorizontal: spacing.md,
|
||||
},
|
||||
content: {
|
||||
alignItems: "center",
|
||||
},
|
||||
});
|
||||
232
app/(tabs)/index.tsx
Normal file
232
app/(tabs)/index.tsx
Normal file
@@ -0,0 +1,232 @@
|
||||
import { useCallback } from "react";
|
||||
import {
|
||||
Dimensions,
|
||||
FlatList,
|
||||
Image,
|
||||
Pressable,
|
||||
StyleSheet,
|
||||
Text,
|
||||
View,
|
||||
} from "react-native";
|
||||
import { useSafeAreaInsets } from "react-native-safe-area-context";
|
||||
import { useRouter } from "expo-router";
|
||||
import Animated, { FadeInDown } from "react-native-reanimated";
|
||||
import { Plus, ImageSquare } from "phosphor-react-native";
|
||||
import { GlassCard, GlassButton } from "@/components/glass";
|
||||
import { useWallpaperStore } from "@/stores/wallpaper.store";
|
||||
import { colors, typography, spacing, layout } from "@/theme";
|
||||
import { ANIMATION_META, type WallpaperConfig } from "@/types/wallpaper";
|
||||
|
||||
const { width: SCREEN_WIDTH } = Dimensions.get("window");
|
||||
const CARD_SIZE = (SCREEN_WIDTH - layout.screenPadding * 2 - spacing.md) / 2;
|
||||
|
||||
export default function HomeScreen() {
|
||||
const insets = useSafeAreaInsets();
|
||||
const router = useRouter();
|
||||
const savedWallpapers = useWallpaperStore((s) => s.savedWallpapers);
|
||||
|
||||
const handleNewWallpaper = useCallback(() => {
|
||||
router.push("/picker");
|
||||
}, [router]);
|
||||
|
||||
const handleOpenWallpaper = useCallback(
|
||||
(config: WallpaperConfig) => {
|
||||
const store = useWallpaperStore.getState();
|
||||
store.setSourceImage(config.sourceUri);
|
||||
store.setAnimation(config.animation);
|
||||
Object.entries(config.uniforms).forEach(([key, value]) => {
|
||||
store.setUniform(key, value);
|
||||
});
|
||||
router.push(`/editor/${config.id}`);
|
||||
},
|
||||
[router]
|
||||
);
|
||||
|
||||
return (
|
||||
<View style={[styles.container, { paddingTop: insets.top + spacing.md }]}>
|
||||
{/* Header */}
|
||||
<Animated.View
|
||||
entering={FadeInDown.duration(500)}
|
||||
style={styles.header}
|
||||
>
|
||||
<GlassCard variant="subtle" radius="lg" padding={16}>
|
||||
<View style={styles.headerRow}>
|
||||
<View>
|
||||
<Text style={typography.title}>Lively</Text>
|
||||
<Text style={[typography.caption, { marginTop: 2 }]}>
|
||||
Vos fonds d'écran animés
|
||||
</Text>
|
||||
</View>
|
||||
</View>
|
||||
</GlassCard>
|
||||
</Animated.View>
|
||||
|
||||
{/* Content */}
|
||||
{savedWallpapers.length === 0 ? (
|
||||
<EmptyState onPress={handleNewWallpaper} />
|
||||
) : (
|
||||
<FlatList
|
||||
data={savedWallpapers}
|
||||
numColumns={2}
|
||||
keyExtractor={(item) => item.id}
|
||||
contentContainerStyle={styles.grid}
|
||||
columnWrapperStyle={styles.row}
|
||||
showsVerticalScrollIndicator={false}
|
||||
ListHeaderComponent={
|
||||
<View style={styles.sectionHeader}>
|
||||
<Text style={typography.label}>Récentes</Text>
|
||||
<Text style={[typography.caption, { color: colors.text.secondary }]}>
|
||||
{savedWallpapers.length} création{savedWallpapers.length > 1 ? "s" : ""}
|
||||
</Text>
|
||||
</View>
|
||||
}
|
||||
renderItem={({ item, index }) => (
|
||||
<WallpaperCard
|
||||
config={item}
|
||||
index={index}
|
||||
onPress={() => handleOpenWallpaper(item)}
|
||||
/>
|
||||
)}
|
||||
/>
|
||||
)}
|
||||
|
||||
{/* Floating CTA */}
|
||||
{savedWallpapers.length > 0 && (
|
||||
<Animated.View
|
||||
entering={FadeInDown.delay(300).duration(400)}
|
||||
style={[styles.fabContainer, { bottom: layout.tabBarHeight + insets.bottom + 16 }]}
|
||||
>
|
||||
<GlassButton
|
||||
label="Nouveau"
|
||||
onPress={handleNewWallpaper}
|
||||
variant="primary"
|
||||
size="lg"
|
||||
pulse
|
||||
/>
|
||||
</Animated.View>
|
||||
)}
|
||||
</View>
|
||||
);
|
||||
}
|
||||
|
||||
function EmptyState({ onPress }: { onPress: () => void }) {
|
||||
return (
|
||||
<Animated.View
|
||||
entering={FadeInDown.delay(200).duration(600)}
|
||||
style={styles.emptyContainer}
|
||||
>
|
||||
<GlassCard variant="medium" radius="xl" padding={32}>
|
||||
<View style={styles.emptyContent}>
|
||||
<Text style={[typography.heading, { textAlign: "center" }]}>
|
||||
Donnez vie à vos photos
|
||||
</Text>
|
||||
<Text
|
||||
style={[
|
||||
typography.bodySecondary,
|
||||
{ textAlign: "center", marginTop: 8, marginBottom: 24 },
|
||||
]}
|
||||
>
|
||||
Choisissez une image et appliquez{"\n"}une animation en quelques taps
|
||||
</Text>
|
||||
<GlassButton
|
||||
label="Choisir une photo"
|
||||
onPress={onPress}
|
||||
variant="primary"
|
||||
size="lg"
|
||||
pulse
|
||||
/>
|
||||
</View>
|
||||
</GlassCard>
|
||||
</Animated.View>
|
||||
);
|
||||
}
|
||||
|
||||
function WallpaperCard({
|
||||
config,
|
||||
index,
|
||||
onPress,
|
||||
}: {
|
||||
config: WallpaperConfig;
|
||||
index: number;
|
||||
onPress: () => void;
|
||||
}) {
|
||||
const meta = ANIMATION_META[config.animation];
|
||||
|
||||
return (
|
||||
<Animated.View entering={FadeInDown.delay(index * 80).duration(400)}>
|
||||
<Pressable onPress={onPress}>
|
||||
<GlassCard variant="subtle" radius="lg" noPadding>
|
||||
<Image
|
||||
source={{ uri: config.sourceUri }}
|
||||
style={styles.cardImage}
|
||||
resizeMode="cover"
|
||||
/>
|
||||
<View style={styles.cardOverlay}>
|
||||
<Text style={styles.cardLabel}>{meta.label}</Text>
|
||||
</View>
|
||||
</GlassCard>
|
||||
</Pressable>
|
||||
</Animated.View>
|
||||
);
|
||||
}
|
||||
|
||||
const styles = StyleSheet.create({
|
||||
container: {
|
||||
flex: 1,
|
||||
backgroundColor: colors.background,
|
||||
paddingHorizontal: layout.screenPadding,
|
||||
},
|
||||
header: {
|
||||
marginBottom: spacing.lg,
|
||||
},
|
||||
headerRow: {
|
||||
flexDirection: "row",
|
||||
justifyContent: "space-between",
|
||||
alignItems: "center",
|
||||
},
|
||||
sectionHeader: {
|
||||
flexDirection: "row",
|
||||
justifyContent: "space-between",
|
||||
alignItems: "center",
|
||||
marginBottom: spacing.md,
|
||||
},
|
||||
grid: {
|
||||
paddingBottom: 160,
|
||||
},
|
||||
row: {
|
||||
gap: spacing.md,
|
||||
},
|
||||
emptyContainer: {
|
||||
flex: 1,
|
||||
justifyContent: "center",
|
||||
paddingHorizontal: spacing.md,
|
||||
},
|
||||
emptyContent: {
|
||||
alignItems: "center",
|
||||
},
|
||||
cardImage: {
|
||||
width: CARD_SIZE,
|
||||
height: CARD_SIZE * 1.4,
|
||||
borderRadius: 24,
|
||||
},
|
||||
cardOverlay: {
|
||||
position: "absolute",
|
||||
bottom: 0,
|
||||
left: 0,
|
||||
right: 0,
|
||||
padding: 12,
|
||||
borderBottomLeftRadius: 24,
|
||||
borderBottomRightRadius: 24,
|
||||
backgroundColor: "rgba(0, 0, 0, 0.4)",
|
||||
},
|
||||
cardLabel: {
|
||||
...typography.caption,
|
||||
color: colors.text.primary,
|
||||
fontWeight: "600",
|
||||
},
|
||||
fabContainer: {
|
||||
position: "absolute",
|
||||
left: layout.screenPadding,
|
||||
right: layout.screenPadding,
|
||||
},
|
||||
});
|
||||
242
app/(tabs)/profile.tsx
Normal file
242
app/(tabs)/profile.tsx
Normal file
@@ -0,0 +1,242 @@
|
||||
import { ScrollView, StyleSheet, Switch, Text, View } from "react-native";
|
||||
import { useSafeAreaInsets } from "react-native-safe-area-context";
|
||||
import Animated, { FadeInDown } from "react-native-reanimated";
|
||||
import { GlassCard } from "@/components/glass";
|
||||
import { useSettingsStore } from "@/stores/settings.store";
|
||||
import { useWallpaperStore } from "@/stores/wallpaper.store";
|
||||
import { colors, typography, spacing, layout } from "@/theme";
|
||||
|
||||
export default function ProfileScreen() {
|
||||
const insets = useSafeAreaInsets();
|
||||
const settings = useSettingsStore();
|
||||
const wallpaperCount = useWallpaperStore((s) => s.savedWallpapers.length);
|
||||
|
||||
return (
|
||||
<ScrollView
|
||||
style={[styles.container, { paddingTop: insets.top + spacing.md }]}
|
||||
contentContainerStyle={styles.content}
|
||||
showsVerticalScrollIndicator={false}
|
||||
>
|
||||
<Animated.View entering={FadeInDown.duration(500)}>
|
||||
<GlassCard variant="subtle" radius="lg" padding={16}>
|
||||
<Text style={typography.title}>Profil</Text>
|
||||
<Text style={[typography.caption, { marginTop: 2 }]}>
|
||||
Préférences & réglages
|
||||
</Text>
|
||||
</GlassCard>
|
||||
</Animated.View>
|
||||
|
||||
{/* Stats */}
|
||||
<Animated.View entering={FadeInDown.delay(100).duration(400)} style={styles.section}>
|
||||
<GlassCard variant="medium" radius="lg">
|
||||
<Text style={typography.label}>Statistiques</Text>
|
||||
<View style={styles.statsRow}>
|
||||
<View style={styles.stat}>
|
||||
<Text style={typography.title}>{wallpaperCount}</Text>
|
||||
<Text style={typography.caption}>Créations</Text>
|
||||
</View>
|
||||
<View style={styles.statDivider} />
|
||||
<View style={styles.stat}>
|
||||
<Text style={typography.title}>5</Text>
|
||||
<Text style={typography.caption}>Animations</Text>
|
||||
</View>
|
||||
</View>
|
||||
</GlassCard>
|
||||
</Animated.View>
|
||||
|
||||
{/* Settings */}
|
||||
<Animated.View entering={FadeInDown.delay(200).duration(400)} style={styles.section}>
|
||||
<GlassCard variant="medium" radius="lg">
|
||||
<Text style={[typography.label, { marginBottom: 16 }]}>Réglages</Text>
|
||||
|
||||
<SettingRow
|
||||
label="Retour haptique"
|
||||
description="Vibrations au toucher"
|
||||
value={settings.hapticFeedback}
|
||||
onToggle={settings.setHapticFeedback}
|
||||
/>
|
||||
|
||||
<SettingRow
|
||||
label="Depth map auto"
|
||||
description="Générer automatiquement (V1.5)"
|
||||
value={settings.autoDepthMap}
|
||||
onToggle={settings.setAutoDepthMap}
|
||||
/>
|
||||
</GlassCard>
|
||||
</Animated.View>
|
||||
|
||||
{/* Export Quality */}
|
||||
<Animated.View entering={FadeInDown.delay(300).duration(400)} style={styles.section}>
|
||||
<GlassCard variant="medium" radius="lg">
|
||||
<Text style={[typography.label, { marginBottom: 16 }]}>
|
||||
Qualité d'export
|
||||
</Text>
|
||||
{(["low", "medium", "high"] as const).map((q) => (
|
||||
<QualityOption
|
||||
key={q}
|
||||
label={q === "low" ? "Économique" : q === "medium" ? "Standard" : "Maximum"}
|
||||
description={
|
||||
q === "low"
|
||||
? "15 FPS, compression forte"
|
||||
: q === "medium"
|
||||
? "24 FPS, bon compromis"
|
||||
: "30 FPS, qualité maximale"
|
||||
}
|
||||
selected={settings.exportQuality === q}
|
||||
onPress={() => settings.setExportQuality(q)}
|
||||
/>
|
||||
))}
|
||||
</GlassCard>
|
||||
</Animated.View>
|
||||
|
||||
{/* About */}
|
||||
<Animated.View entering={FadeInDown.delay(400).duration(400)} style={styles.section}>
|
||||
<GlassCard variant="subtle" radius="lg">
|
||||
<Text style={typography.label}>À propos</Text>
|
||||
<Text style={[typography.bodySecondary, { marginTop: 8 }]}>
|
||||
Lively v1.0.0
|
||||
</Text>
|
||||
<Text style={[typography.caption, { marginTop: 4 }]}>
|
||||
Fonds d'écran animés depuis votre galerie
|
||||
</Text>
|
||||
</GlassCard>
|
||||
</Animated.View>
|
||||
|
||||
<View style={{ height: layout.tabBarHeight + 40 }} />
|
||||
</ScrollView>
|
||||
);
|
||||
}
|
||||
|
||||
function SettingRow({
|
||||
label,
|
||||
description,
|
||||
value,
|
||||
onToggle,
|
||||
}: {
|
||||
label: string;
|
||||
description: string;
|
||||
value: boolean;
|
||||
onToggle: (v: boolean) => void;
|
||||
}) {
|
||||
return (
|
||||
<View style={styles.settingRow}>
|
||||
<View style={styles.settingText}>
|
||||
<Text style={typography.body}>{label}</Text>
|
||||
<Text style={typography.caption}>{description}</Text>
|
||||
</View>
|
||||
<Switch
|
||||
value={value}
|
||||
onValueChange={onToggle}
|
||||
trackColor={{
|
||||
false: "rgba(255,255,255,0.1)",
|
||||
true: "rgba(255,255,255,0.3)",
|
||||
}}
|
||||
thumbColor={value ? colors.text.primary : colors.text.tertiary}
|
||||
/>
|
||||
</View>
|
||||
);
|
||||
}
|
||||
|
||||
function QualityOption({
|
||||
label,
|
||||
description,
|
||||
selected,
|
||||
onPress,
|
||||
}: {
|
||||
label: string;
|
||||
description: string;
|
||||
selected: boolean;
|
||||
onPress: () => void;
|
||||
}) {
|
||||
return (
|
||||
<View
|
||||
style={[styles.qualityRow, selected && styles.qualitySelected]}
|
||||
onTouchEnd={onPress}
|
||||
>
|
||||
<View style={styles.settingText}>
|
||||
<Text style={[typography.body, selected && { color: colors.text.primary }]}>
|
||||
{label}
|
||||
</Text>
|
||||
<Text style={typography.caption}>{description}</Text>
|
||||
</View>
|
||||
<View
|
||||
style={[
|
||||
styles.radio,
|
||||
selected && styles.radioSelected,
|
||||
]}
|
||||
>
|
||||
{selected && <View style={styles.radioDot} />}
|
||||
</View>
|
||||
</View>
|
||||
);
|
||||
}
|
||||
|
||||
const styles = StyleSheet.create({
|
||||
container: {
|
||||
flex: 1,
|
||||
backgroundColor: colors.background,
|
||||
paddingHorizontal: layout.screenPadding,
|
||||
},
|
||||
content: {
|
||||
paddingBottom: 40,
|
||||
},
|
||||
section: {
|
||||
marginTop: spacing.md,
|
||||
},
|
||||
statsRow: {
|
||||
flexDirection: "row",
|
||||
marginTop: 16,
|
||||
alignItems: "center",
|
||||
},
|
||||
stat: {
|
||||
flex: 1,
|
||||
alignItems: "center",
|
||||
},
|
||||
statDivider: {
|
||||
width: 1,
|
||||
height: 40,
|
||||
backgroundColor: colors.glass.border,
|
||||
},
|
||||
settingRow: {
|
||||
flexDirection: "row",
|
||||
justifyContent: "space-between",
|
||||
alignItems: "center",
|
||||
paddingVertical: 12,
|
||||
borderBottomWidth: 1,
|
||||
borderBottomColor: colors.glass.highlight,
|
||||
},
|
||||
settingText: {
|
||||
flex: 1,
|
||||
marginRight: 16,
|
||||
},
|
||||
qualityRow: {
|
||||
flexDirection: "row",
|
||||
justifyContent: "space-between",
|
||||
alignItems: "center",
|
||||
paddingVertical: 12,
|
||||
paddingHorizontal: 12,
|
||||
borderRadius: 12,
|
||||
marginBottom: 4,
|
||||
},
|
||||
qualitySelected: {
|
||||
backgroundColor: "rgba(255, 255, 255, 0.06)",
|
||||
},
|
||||
radio: {
|
||||
width: 20,
|
||||
height: 20,
|
||||
borderRadius: 10,
|
||||
borderWidth: 1.5,
|
||||
borderColor: colors.text.tertiary,
|
||||
alignItems: "center",
|
||||
justifyContent: "center",
|
||||
},
|
||||
radioSelected: {
|
||||
borderColor: colors.text.primary,
|
||||
},
|
||||
radioDot: {
|
||||
width: 10,
|
||||
height: 10,
|
||||
borderRadius: 5,
|
||||
backgroundColor: colors.text.primary,
|
||||
},
|
||||
});
|
||||
Reference in New Issue
Block a user