feat: pouch

This commit is contained in:
2025-11-04 15:44:09 +01:00
parent 678d63c73f
commit 7d93be845b
+589 -60
View File
@@ -1,9 +1,19 @@
import { BlurView } from "expo-blur";
import { Image as ExpoImage } from "expo-image"; import { Image as ExpoImage } from "expo-image";
import React, { useCallback, useEffect, useState } from "react"; import React, { useCallback, useEffect, useMemo, useState } from "react";
import { ActivityIndicator, Text, View } from "react-native"; import {
ActivityIndicator,
Image,
Pressable,
StyleSheet,
Text,
TextInput,
View,
} from "react-native";
import useMinuit from "react-native-minuit/src/hooks/useMinuit.js"; import useMinuit from "react-native-minuit/src/hooks/useMinuit.js";
import { background, icons } from "../../assets"; import { background, icons } from "../../assets";
import alert from "../../components/Alert"; import alert from "../../components/Alert";
import AppCheckbox from "../../components/AppCheckbox";
import BorderGradientButton from "../../components/BorderGradientButton"; import BorderGradientButton from "../../components/BorderGradientButton";
import GradientButton from "../../components/GradientButton"; import GradientButton from "../../components/GradientButton";
import MusicLandHeader from "../../components/MusicLandHeader"; import MusicLandHeader from "../../components/MusicLandHeader";
@@ -15,27 +25,69 @@ import { Routes } from "../../navigation";
import { goBack, navigate } from "../../navigation/NavigationService"; import { goBack, navigate } from "../../navigation/NavigationService";
import { useUser } from "../../providers/UserDataProvider"; import { useUser } from "../../providers/UserDataProvider";
import { gutters, Palette, Style } from "../../styles"; import { gutters, Palette, Style } from "../../styles";
import { FONT_FAMILY } from "../../styles/Fonts";
import { getStageAction } from "../../utils/projectStages";
import CreateLyricsHeader from "../Writing/components/CreateLyricsHeader"; import CreateLyricsHeader from "../Writing/components/CreateLyricsHeader";
import CustomInput from "../Writing/components/CustomInput";
const COVER_STYLE_PRESETS = [
"Néo-Néon / Cyberpunk",
"Photographie Minimaliste & Éditoriale",
"Illustration & Collage Surréaliste",
"Anti-Design / Maximalisme (Tendance Actuelle)",
];
const PouchReady = () => { const PouchReady = () => {
const { selectedProjectId, selectedProject, updateProjectData } = useUser(); const { selectedProjectId, selectedProject, updateProjectData } = useUser();
const { setIsLoading } = useMinuit(); const { setIsLoading } = useMinuit();
const isGenerating = selectedProject?.coverStatus === "GENERATING"; const isGenerating = selectedProject?.coverStatus === "GENERATING";
const coverOptions = Array.isArray(selectedProject?.cover?.options) const coverOptions = useMemo(() => {
? selectedProject.cover.options if (!Array.isArray(selectedProject?.cover?.options)) {
: []; return [];
}
return selectedProject.cover.options.filter(Boolean);
}, [selectedProject?.cover?.options]);
const hasGeneratedOptions = coverOptions.length > 0; const hasGeneratedOptions = coverOptions.length > 0;
const selectedOptionId = selectedProject?.cover?.selectedOptionId || null;
const selectedOption = useMemo(() => {
if (!coverOptions.length) {
return null;
}
const found = coverOptions.find((option) => option?.id === selectedOptionId);
return found || coverOptions[0] || null;
}, [coverOptions, selectedOptionId]);
const coverBackgroundMessage = isWeb const coverBackgroundMessage = isWeb
? loaderMessages.pouchReadyGenerationWeb ? loaderMessages.pouchReadyGenerationWeb
: ""; : "";
const [coverStyle, setCoverStyle] = useState( const [styleMode, setStyleMode] = useState("preset");
selectedProject?.coverStyle || "" const [selectedPresetStyle, setSelectedPresetStyle] = useState(
COVER_STYLE_PRESETS[0]
); );
const [customStyle, setCustomStyle] = useState("");
const [isPresetDropdownOpen, setIsPresetDropdownOpen] = useState(false);
const [isSelecting, setIsSelecting] = useState(false);
useEffect(() => { useEffect(() => {
setCoverStyle(selectedProject?.coverStyle || ""); const projectStyle = (selectedProject?.coverStyle || "").trim();
const isPresetStyle = COVER_STYLE_PRESETS.includes(projectStyle);
if (!projectStyle) {
setStyleMode("preset");
setSelectedPresetStyle(COVER_STYLE_PRESETS[0]);
setCustomStyle("");
setIsPresetDropdownOpen(false);
return;
}
if (isPresetStyle) {
setStyleMode("preset");
setSelectedPresetStyle(projectStyle);
setCustomStyle("");
} else {
setStyleMode("custom");
setCustomStyle(projectStyle);
}
setIsPresetDropdownOpen(false);
}, [selectedProject?.coverStyle]); }, [selectedProject?.coverStyle]);
const generateCover = useCallback( const generateCover = useCallback(
@@ -75,7 +127,9 @@ const PouchReady = () => {
); );
const requestCoverGeneration = useCallback(() => { const requestCoverGeneration = useCallback(() => {
const trimmedStyle = (coverStyle || "").trim(); const selectedStyle =
styleMode === "preset" ? selectedPresetStyle : customStyle;
const trimmedStyle = (selectedStyle || "").trim();
if (!trimmedStyle) { if (!trimmedStyle) {
alert("Attention", "Merci de renseigner un style pour la pochette.", [ alert("Attention", "Merci de renseigner un style pour la pochette.", [
{ text: "OK" }, { text: "OK" },
@@ -92,17 +146,16 @@ const PouchReady = () => {
onPress: () => generateCover(trimmedStyle), onPress: () => generateCover(trimmedStyle),
}, },
]); ]);
}, [coverStyle, generateCover]); }, [customStyle, generateCover, selectedPresetStyle, styleMode]);
const validateCover = () => {
navigate(Routes.ValidateCover);
};
const coverPreviewUrl = const coverPreviewUrl =
selectedProject?.cover?.result || selectedProject?.cover?.result ||
selectedProject?.cover?.generatedBackground || selectedProject?.cover?.generatedBackground ||
null; null;
const shouldShowStylePanel =
!isGenerating && !hasGeneratedOptions && !coverPreviewUrl;
const displayOptions = hasGeneratedOptions const displayOptions = hasGeneratedOptions
? coverOptions ? coverOptions
: coverPreviewUrl : coverPreviewUrl
@@ -115,10 +168,120 @@ const PouchReady = () => {
] ]
: []; : [];
const handleSelectOption = useCallback(
async (option) => {
if (
!option ||
option?.id === selectedOptionId ||
isSelecting ||
!option?.id
) {
return;
}
const existingCover = selectedProject?.cover || {};
const finalUrl = option.finalUrl || option.generatedUrl || null;
setIsSelecting(true);
try {
await updateProjectData({
cover: {
...existingCover,
options: coverOptions,
selectedOptionId: option.id,
result: finalUrl,
generatedBackground: option.generatedUrl || option.finalUrl || null,
},
});
} catch (e) {
console.log("PouchReady: unable to select cover", e?.message);
} finally {
setIsSelecting(false);
}
},
[
coverOptions,
isSelecting,
selectedOptionId,
selectedProject?.cover,
updateProjectData,
]
);
const onValidatePicture = useCallback(async () => {
if (!selectedOption) {
return;
}
try {
await setIsLoading(true);
const existingCover = selectedProject?.cover || {};
const finalUrl =
selectedOption.finalUrl || selectedOption.generatedUrl || null;
const nextCoverData = {
...existingCover,
options: coverOptions,
selectedOptionId: selectedOption.id,
result: finalUrl,
generatedBackground:
selectedOption.generatedUrl || selectedOption.finalUrl || null,
};
await updateProjectData({
cover: nextCoverData,
coverUrl: finalUrl,
});
const projectForStage = {
...selectedProject,
cover: nextCoverData,
coverUrl: finalUrl,
};
const playbackStage = getStageAction("director", projectForStage);
await setIsLoading(false);
alert(
"Malik",
"Super ! Ta pochette est validée. Tu peux retourner à l'accueil ou continuer avec John pour produire ton playback.",
[
{
text: "Retour à l'accueil",
style: "cancel",
onPress: () => navigate(Routes.Home),
},
{
text: "Continuer avec John",
onPress: () => {
const targetRoute = playbackStage?.route || Routes.Playback;
const params =
playbackStage?.params || { project: projectForStage };
navigate(targetRoute, params);
},
},
]
);
} catch (e) {
console.log("PouchReady: unable to validate cover", e?.message);
} finally {
await setIsLoading(false);
}
}, [
coverOptions,
navigate,
selectedOption,
selectedProject,
setIsLoading,
updateProjectData,
]);
const primaryActionTitle = hasGeneratedOptions
? "Valider la pochette"
: isGenerating
? "Veuillez patienter..."
: "En attente de la génération";
const isPrimaryActionDisabled =
isGenerating ||
(hasGeneratedOptions ? !selectedOption || isSelecting : true);
return ( return (
<Page backgroundImg={background.studioBG2} headerType="NONE"> <Page backgroundImg={background.studioBG2} headerType="NONE">
<MusicLandHeader onPressBack={goBack} progress={72} /> <MusicLandHeader onPressBack={goBack} progress={72} />
<View style={{ flex: 1, marginTop: 16 }}> <View style={{ flex: 1, marginTop: 0 }}>
<CreateLyricsHeader <CreateLyricsHeader
title={ title={
selectedProject?.cover?.backgroundGenerated selectedProject?.cover?.backgroundGenerated
@@ -143,14 +306,55 @@ const PouchReady = () => {
const optionUri = const optionUri =
option?.finalUrl || option?.generatedUrl || ""; option?.finalUrl || option?.generatedUrl || "";
if (!optionUri) return null; if (!optionUri) return null;
if (hasGeneratedOptions) {
const isSelected = option?.id === selectedOption?.id;
const cardWidthStyle = isWeb
? styles.coverOptionCardWeb
: styles.coverOptionCardMobile;
return (
<Pressable
key={option?.id || index}
onPress={() => handleSelectOption(option)}
disabled={isSelecting}
style={[
styles.coverOptionCard,
cardWidthStyle,
isSelected ? styles.coverOptionCardSelected : null,
isSelecting ? styles.coverOptionCardDisabled : null,
]}
>
<ExpoImage
source={{ uri: optionUri }}
cachePolicy="memory-disk"
priority="high"
contentFit="cover"
transition={120}
style={styles.coverOptionImage}
/>
<View style={styles.coverOptionBadge}>
<Text style={styles.coverOptionBadgeLabel}>
{`Option ${index + 1}`}
</Text>
</View>
{isSelected && (
<View style={styles.coverOptionSelectedBadge}>
<Text style={styles.coverOptionSelectedLabel}>
Sélectionnée
</Text>
</View>
)}
</Pressable>
);
}
return ( return (
<View <View
key={option?.id || index} key={option?.id || index}
style={{ style={[
width: isWeb ? 280 : "100%", styles.coverPreviewCard,
alignItems: "center", isWeb
gap: 8, ? styles.coverPreviewCardWeb
}} : styles.coverPreviewCardMobile,
]}
> >
<ExpoImage <ExpoImage
source={{ uri: optionUri }} source={{ uri: optionUri }}
@@ -158,38 +362,36 @@ const PouchReady = () => {
priority="high" priority="high"
contentFit="cover" contentFit="cover"
transition={120} transition={120}
style={{ style={styles.coverPreviewImage}
width: "100%",
height: 260,
borderRadius: 20,
}}
/> />
{hasGeneratedOptions && (
<Text
style={{
color: Palette.white,
opacity: 0.7,
}}
>
Pochette {index + 1}
</Text>
)}
</View> </View>
); );
}) })
) : ( ) : (
<View // <View
style={{ // style={{
width: isWeb ? 300 : "100%", // width: isWeb ? 300 : "100%",
height: 300, // height: 300,
borderRadius: 20, // borderRadius: 20,
backgroundColor: "#00000040", // backgroundColor: "#00000040",
alignSelf: "center", // alignSelf: "center",
...Style.containerCenter, // ...Style.containerCenter,
}} // }}
> // >
<>
{isGenerating && ( {isGenerating && (
<View style={{ alignItems: "center", gap: 8 }}> <View
style={{
alignItems: "center",
gap: 8,
width: isWeb ? 300 : "100%",
height: 300,
borderRadius: 20,
backgroundColor: "#00000040",
alignSelf: "center",
...Style.containerCenter,
}}
>
<ActivityIndicator color={Palette.white} /> <ActivityIndicator color={Palette.white} />
{isWeb ? ( {isWeb ? (
coverBackgroundMessage ? ( coverBackgroundMessage ? (
@@ -218,18 +420,138 @@ const PouchReady = () => {
)} )}
</View> </View>
)} )}
</View> </>
// </View>
)} )}
</View> </View>
<CustomInput {shouldShowStylePanel && (
label="Style de la pochette" <BlurView
placeholder="Exemple : Collage rétro futuriste lumineux" tint="dark"
value={coverStyle} intensity={isWeb ? 40 : 30}
setValue={setCoverStyle} style={[styles.stylePanel, isWeb && styles.stylePanelWeb]}
multiline={false} >
height={55} <View style={styles.stylePanelHeader}>
maxLength={120} <Text style={styles.panelTitle}>Style de la pochette</Text>
/> <Text style={styles.panelSubtitle}>
Choisis une ambiance ou écris ton propre brief créatif.
</Text>
</View>
<View style={styles.modeContainer}>
<View
style={[
styles.modeCard,
styleMode === "preset" ? styles.modeCardActive : null,
]}
>
<AppCheckbox
label="Choisir un style prédéfini"
selected={styleMode === "preset"}
onPress={() => {
setStyleMode("preset");
setIsPresetDropdownOpen(false);
if (!selectedPresetStyle) {
setSelectedPresetStyle(COVER_STYLE_PRESETS[0]);
}
}}
/>
</View>
{styleMode === "preset" && (
<View style={styles.dropdownArea}>
<Pressable
style={styles.dropdownTrigger}
onPress={() => setIsPresetDropdownOpen((prev) => !prev)}
>
<Text style={styles.dropdownTriggerLabel} numberOfLines={2}>
{selectedPresetStyle}
</Text>
<Image
source={icons.chevronDown}
style={[
styles.dropdownArrow,
isPresetDropdownOpen ? styles.dropdownArrowOpen : null,
]}
/>
</Pressable>
{isPresetDropdownOpen && (
<View style={styles.dropdownList}>
{COVER_STYLE_PRESETS.map((styleOption, index) => {
const isActive = selectedPresetStyle === styleOption;
const isLast =
index === COVER_STYLE_PRESETS.length - 1;
return (
<Pressable
key={styleOption}
style={[
styles.dropdownOption,
!isLast ? styles.dropdownOptionDivider : null,
isActive ? styles.dropdownOptionActive : null,
]}
onPress={() => {
setSelectedPresetStyle(styleOption);
setIsPresetDropdownOpen(false);
}}
>
<Text
style={[
styles.dropdownOptionLabel,
isActive
? styles.dropdownOptionLabelActive
: null,
]}
>
{styleOption}
</Text>
{isActive && (
<Image
source={icons.check}
style={styles.dropdownOptionIcon}
resizeMode="contain"
/>
)}
</Pressable>
);
})}
</View>
)}
</View>
)}
<View style={styles.modeColumn}>
<View
style={[
styles.modeCard,
styleMode === "custom" ? styles.modeCardActive : null,
]}
>
<AppCheckbox
label="Style personnalisé"
selected={styleMode === "custom"}
onPress={() => {
setStyleMode("custom");
setIsPresetDropdownOpen(false);
}}
/>
</View>
{styleMode === "custom" && (
<View style={styles.customInputWrapper}>
<TextInput
placeholder="Exemple : Collage rétro futuriste lumineux"
placeholderTextColor={Palette.grayMid}
value={customStyle}
onChangeText={setCustomStyle}
style={styles.customInput}
multiline={false}
maxLength={120}
textAlignVertical="center"
autoCapitalize="sentences"
autoCorrect
selectionColor={Palette.primary}
/>
</View>
)}
</View>
</View>
</BlurView>
)}
</View> </View>
</View> </View>
</View> </View>
@@ -252,9 +574,9 @@ const PouchReady = () => {
/> />
)} )}
<GradientButton <GradientButton
title={isGenerating ? "Veuillez patienter..." : "Valider"} title={primaryActionTitle}
disabled={isGenerating || !coverPreviewUrl} disabled={isPrimaryActionDisabled}
onPress={() => validateCover()} onPress={onValidatePicture}
/> />
</View> </View>
</Page> </Page>
@@ -262,3 +584,210 @@ const PouchReady = () => {
}; };
export default PouchReady; export default PouchReady;
const styles = StyleSheet.create({
coverOptionCard: {
position: "relative",
borderRadius: 20,
borderWidth: 1,
borderColor: "rgba(255,255,255,0.12)",
overflow: "hidden",
backgroundColor: "rgba(15,12,20,0.4)",
},
coverOptionCardWeb: {
width: 280,
maxWidth: 320,
},
coverOptionCardMobile: {
width: "100%",
},
coverOptionCardSelected: {
borderColor: Palette.primary,
},
coverOptionCardDisabled: {
opacity: 0.85,
},
coverOptionImage: {
width: "100%",
aspectRatio: 1,
},
coverOptionBadge: {
position: "absolute",
top: 12,
right: 12,
backgroundColor: Palette.transparentBlack,
paddingHorizontal: 10,
paddingVertical: 6,
borderRadius: 12,
},
coverOptionBadgeLabel: {
color: Palette.white,
fontSize: 12,
fontFamily: FONT_FAMILY.InterSemiBold,
},
coverOptionSelectedBadge: {
position: "absolute",
bottom: 12,
left: 12,
backgroundColor: Palette.primary,
paddingHorizontal: 12,
paddingVertical: 6,
borderRadius: 12,
},
coverOptionSelectedLabel: {
color: Palette.white,
fontSize: 12,
fontFamily: FONT_FAMILY.InterSemiBold,
},
coverPreviewCard: {
alignItems: "center",
},
coverPreviewCardWeb: {
width: 300,
},
coverPreviewCardMobile: {
width: "100%",
},
coverPreviewImage: {
width: "100%",
height: 260,
borderRadius: 20,
},
stylePanel: {
gap: 20,
paddingHorizontal: 18,
paddingVertical: 20,
borderRadius: 24,
borderWidth: 1,
borderColor: "rgba(255,255,255,0.08)",
backgroundColor: "rgba(15, 12, 20, 0.78)",
overflow: "hidden",
},
stylePanelWeb: {
paddingHorizontal: 26,
},
stylePanelHeader: {
gap: 6,
},
panelTitle: {
fontSize: 18,
color: Palette.white,
fontFamily: FONT_FAMILY.InterSemiBold,
},
panelSubtitle: {
fontSize: 13,
color: Palette.gray,
fontFamily: FONT_FAMILY.InterRegular,
lineHeight: 18,
},
modeContainer: {
gap: 18,
},
modeCard: {
alignSelf: "stretch",
paddingHorizontal: 16,
paddingVertical: 12,
borderRadius: 14,
borderWidth: 1,
borderColor: "rgba(255,255,255,0.1)",
backgroundColor: "rgba(15,12,20,0.6)",
},
modeCardActive: {
borderColor: Palette.primary,
backgroundColor: "rgba(251,104,168,0.12)",
},
dropdownArea: {
alignSelf: "stretch",
gap: 12,
},
dropdownTrigger: {
flexDirection: "row",
alignItems: "center",
justifyContent: "space-between",
paddingHorizontal: 16,
paddingVertical: 14,
borderRadius: 16,
borderWidth: 1,
borderColor: "rgba(255,255,255,0.1)",
backgroundColor: "rgba(15,12,20,0.65)",
},
dropdownTriggerLabel: {
flex: 1,
fontSize: 15,
color: Palette.white,
fontFamily: FONT_FAMILY.InterMedium,
},
dropdownArrow: {
width: 16,
height: 16,
tintColor: Palette.gray,
marginLeft: 10,
},
dropdownArrowOpen: {
transform: [
{
rotate: "180deg",
},
],
},
dropdownList: {
borderRadius: 16,
borderWidth: 1,
borderColor: "rgba(255,255,255,0.08)",
backgroundColor: "rgba(12,10,18,0.95)",
overflow: "hidden",
},
dropdownOption: {
flexDirection: "row",
alignItems: "center",
justifyContent: "space-between",
paddingHorizontal: 16,
paddingVertical: 14,
},
dropdownOptionDivider: {
borderBottomWidth: 1,
borderBottomColor: "rgba(255,255,255,0.06)",
},
dropdownOptionActive: {
backgroundColor: "rgba(251,104,168,0.12)",
},
dropdownOptionLabel: {
flex: 1,
fontSize: 15,
color: Palette.white,
fontFamily: FONT_FAMILY.InterRegular,
},
dropdownOptionLabelActive: {
color: Palette.primary,
fontFamily: FONT_FAMILY.InterSemiBold,
},
dropdownOptionIcon: {
width: 18,
height: 18,
tintColor: Palette.primary,
marginLeft: 12,
},
modeColumn: {
flex: 1,
gap: 12,
minWidth: 240,
},
customInputWrapper: {
alignSelf: "stretch",
borderRadius: 18,
padding: 1,
borderWidth: 1,
borderColor: "rgba(255,255,255,0.1)",
backgroundColor: "rgba(15,12,20,0.4)",
},
customInput: {
minHeight: 55,
borderRadius: 16,
paddingHorizontal: 16,
paddingVertical: 12,
fontSize: 15,
color: Palette.white,
backgroundColor: "rgba(15,12,20,0.85)",
fontFamily: FONT_FAMILY.InterRegular,
},
});