new song structure

This commit is contained in:
2025-10-22 16:04:12 +02:00
parent 7b68a8c101
commit da001a491b
6 changed files with 420 additions and 86 deletions
+3 -2
View File
@@ -27,6 +27,7 @@ const CreatingLyrics = ({ active, config, selections }) => {
const hasShownErrorAlert = useRef(false);
const { setIsLoading } = useMinuit();
const { updateProjectData } = useUser();
const progressValue = Math.max(0, Math.min(100, Math.round(progress)));
// Reset when becomes active
useEffect(() => {
@@ -301,7 +302,7 @@ const CreatingLyrics = ({ active, config, selections }) => {
: "Ton texte est\nen cours de création"}
</Text>
<View style={{ alignItems: "center", gap: 16 }}>
<ProgressBar gradient progress={progress} />
<ProgressBar gradient progress={progressValue} />
<Text
style={{
fontSize: 14,
@@ -309,7 +310,7 @@ const CreatingLyrics = ({ active, config, selections }) => {
fontFamily: FONT_FAMILY.InterRegular,
}}
>
{progress}%
{progressValue}%
</Text>
</View>
{!saved && result && !error && (
+178 -80
View File
@@ -1,5 +1,5 @@
import { BlurView } from "expo-blur";
import React, { useEffect, useMemo, useState } from "react";
import React, { useCallback, useEffect, useMemo, useState } from "react";
import {
FlatList,
Image,
@@ -15,6 +15,7 @@ import { icons } from "../../assets";
import { Palette } from "../../styles";
import { FONT_FAMILY } from "../../styles/Fonts";
import {
arraysAreSame,
formatStructureLabel,
getSegmentMeta,
OPTIONAL_STRUCTURE_SEGMENTS,
@@ -40,20 +41,28 @@ const CustomizeSongStructure = ({ baseStructure = [], onChange }) => {
() => sanitizeStructureList(baseStructure),
[baseStructure]
);
const extractValues = useCallback(
(items) =>
(items || []).map((item) => item?.type || item?.value).filter(Boolean),
[]
);
const [sortableItems, setSortableItems] = useState(() =>
buildSortableItems(sanitizedBase)
);
useEffect(() => {
setSortableItems(buildSortableItems(sanitizedBase));
}, [sanitizedBase]);
setSortableItems((prev) => {
const currentValues = extractValues(prev);
if (arraysAreSame(currentValues, sanitizedBase)) {
return prev;
}
return buildSortableItems(sanitizedBase);
});
}, [sanitizedBase, extractValues]);
useEffect(() => {
const values = sortableItems
.map((item) => item?.type || item?.value)
.filter(Boolean);
const values = extractValues(sortableItems);
onChange?.(values);
}, [sortableItems, onChange]);
}, [sortableItems, onChange, extractValues]);
const labeledItems = useMemo(() => {
const counts = {};
@@ -80,40 +89,63 @@ const CustomizeSongStructure = ({ baseStructure = [], onChange }) => {
[]
);
const selectedTypes = useMemo(() => {
const set = new Set();
sortableItems.forEach((item) => {
if (item?.type) set.add(item.type);
else if (item?.value) set.add(item.value);
const segmentCounts = useMemo(() => {
const counts = {};
extractValues(sortableItems).forEach((type) => {
counts[type] = (counts[type] || 0) + 1;
});
return set;
}, [sortableItems]);
return counts;
}, [sortableItems, extractValues]);
const toggleOptionalSegment = (type) => {
setSortableItems((prev) => {
const currentValues = prev.map((item) => item.type || item.value);
if (currentValues.includes(type)) {
const filtered = currentValues.filter((value) => value !== type);
return buildSortableItems(filtered);
}
const handleRemoveItem = useCallback(
(itemId) => {
setSortableItems((prev) => {
const remaining = prev.filter((item) => item.id !== itemId);
const sanitized = sanitizeStructureList(extractValues(remaining));
return buildSortableItems(sanitized);
});
},
[extractValues]
);
const handleOptionalSegmentPress = useCallback(
(type) => {
const meta = getSegmentMeta(type);
let nextValues = [...currentValues];
if (meta?.exclusiveGroup) {
nextValues = nextValues.filter((value) => {
const itemMeta = getSegmentMeta(value);
return itemMeta?.exclusiveGroup !== meta.exclusiveGroup;
});
}
nextValues.push(type);
return buildSortableItems(nextValues);
});
};
setSortableItems((prev) => {
const currentValues = extractValues(prev);
let nextValues = [...currentValues];
if (meta?.exclusiveGroup) {
nextValues = nextValues.filter((value) => {
const itemMeta = getSegmentMeta(value);
return itemMeta?.exclusiveGroup !== meta.exclusiveGroup;
});
}
const allowsMultiple = meta?.allowMultiple !== false;
if (!allowsMultiple && nextValues.includes(type)) {
const filtered = nextValues.filter((value) => value !== type);
const sanitized = sanitizeStructureList(filtered);
return buildSortableItems(sanitized);
}
nextValues.push(type);
const sanitized = sanitizeStructureList(nextValues);
return buildSortableItems(sanitized);
});
},
[extractValues]
);
const Row = ({ item: rowData, onPress }) => {
const isButton = typeof onPress === "function";
const Wrapper = isButton ? TouchableOpacity : View;
const selected = rowData?.selected;
const icon = rowData?.icon;
const baseType = rowData?.type || rowData?.value;
const meta = getSegmentMeta(baseType);
const canRemove = rowData?.sortable && meta?.optional === true;
const count = rowData?.count ?? 0;
return (
<Wrapper
onPress={onPress}
@@ -139,14 +171,29 @@ const CustomizeSongStructure = ({ baseStructure = [], onChange }) => {
<Text style={styles.rowText}>{rowData?.label || ""}</Text>
</View>
{rowData?.sortable ? (
<Sortable.Handle>
<Image source={icons.dragDots} style={styles.handleIcon} />
</Sortable.Handle>
<View style={styles.rowActions}>
<Sortable.Handle>
<Image source={icons.dragDots} style={styles.handleIcon} />
</Sortable.Handle>
{canRemove && (
<TouchableOpacity
onPress={() => handleRemoveItem(rowData.id)}
style={styles.removeButton}
hitSlop={{ top: 8, right: 8, bottom: 8, left: 8 }}
>
<Image source={icons.close} style={styles.removeIcon} />
</TouchableOpacity>
)}
</View>
) : (
<Image
source={icon || (selected ? icons.check : icons.add)}
style={[styles.optionIcon]}
/>
<View
style={[
styles.countBadge,
count > 0 ? styles.countBadgeActive : null,
]}
>
<Text style={styles.countBadgeText}>{count}</Text>
</View>
)}
</BlurView>
</View>
@@ -166,46 +213,50 @@ const CustomizeSongStructure = ({ baseStructure = [], onChange }) => {
contentContainerStyle={{ paddingVertical: 4, paddingBottom: 24 }}
showsVerticalScrollIndicator={false}
>
<>
<Sortable.Grid
columns={1}
rowGap={12}
data={labeledItems}
renderItem={({ item }) => <Row item={item} />}
customHandle
scrollableRef={scrollRef}
onDragEnd={({ data: newData }) => {
const values = (newData || [])
.map((it) => it?.type || it?.value)
.filter(Boolean);
const sanitized = sanitizeStructureList(values);
setSortableItems(buildSortableItems(sanitized));
}}
/>
<FlatList
data={optionalSegments}
gap={12}
renderItem={({ item }) => {
const selected = selectedTypes.has(item.type);
return (
<View style={{ marginTop: 12 }}>
<Row
item={{
...item,
sortable: false,
selected,
}}
onPress={() => toggleOptionalSegment(item.type)}
/>
</View>
);
}}
keyExtractor={(item) => item.type}
contentContainerStyle={{ paddingBottom: 24 }}
scrollEnabled={false}
nestedScrollEnabled={false}
/>
</>
<View style={styles.sectionTag}>
<Text style={styles.sectionTagText}>Structure actuelle</Text>
</View>
<Sortable.Grid
columns={1}
rowGap={12}
data={labeledItems}
renderItem={({ item }) => <Row item={item} />}
customHandle
scrollableRef={scrollRef}
onDragEnd={({ data: newData }) => {
const values = extractValues(newData);
const sanitized = sanitizeStructureList(values);
setSortableItems(buildSortableItems(sanitized));
}}
/>
<View style={[styles.sectionTag, { marginTop: 24 }]}>
<Text style={styles.sectionTagText}>Éléments à ajouter</Text>
</View>
<FlatList
data={optionalSegments}
gap={12}
renderItem={({ item }) => {
const count = segmentCounts[item.type] || 0;
const selected = count > 0;
return (
<View style={{ marginTop: 12 }}>
<Row
item={{
...item,
sortable: false,
selected,
count,
}}
onPress={() => handleOptionalSegmentPress(item.type)}
/>
</View>
);
}}
keyExtractor={(item) => item.type}
contentContainerStyle={{ paddingBottom: 24 }}
scrollEnabled={false}
nestedScrollEnabled={false}
/>
</Animated.ScrollView>
</View>
</View>
@@ -267,10 +318,57 @@ const styles = StyleSheet.create({
width: 22,
height: 22,
tintColor: Palette.white,
marginRight: 12,
},
optionIcon: {
width: 20,
height: 20,
tintColor: Palette.white,
},
countBadge: {
minWidth: 28,
paddingHorizontal: 8,
paddingVertical: 4,
borderRadius: 999,
backgroundColor: "rgba(255,255,255,0.08)",
alignItems: "center",
justifyContent: "center",
},
countBadgeActive: {
backgroundColor: Palette.transparentWhite,
},
countBadgeText: {
fontSize: 14,
color: Palette.white,
fontFamily: FONT_FAMILY.InterMedium,
},
rowActions: {
flexDirection: "row",
alignItems: "center",
},
removeButton: {
padding: 6,
borderRadius: 999,
backgroundColor: "rgba(255,255,255,0.08)",
},
removeIcon: {
width: 16,
height: 16,
tintColor: Palette.white,
},
sectionTag: {
alignSelf: "flex-start",
paddingHorizontal: 12,
paddingVertical: 6,
borderRadius: 999,
backgroundColor: "rgba(255,255,255,0.12)",
marginBottom: 16,
},
sectionTagText: {
fontSize: 12,
color: Palette.white,
fontFamily: FONT_FAMILY.InterSemiBold,
letterSpacing: 1,
textTransform: "uppercase",
},
});