Skip to content

Commit 302f3ff

Browse files
committed
feat: list composed items
1 parent c356f47 commit 302f3ff

File tree

14 files changed

+1144
-362
lines changed

14 files changed

+1144
-362
lines changed

example/app/_layout.tsx

Lines changed: 12 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -30,6 +30,18 @@ export default function RootLayout() {
3030
presentation: 'formSheet',
3131
}}
3232
/>
33+
<Stack.Screen
34+
name="composer-import-modal"
35+
options={{
36+
presentation: 'formSheet',
37+
}}
38+
/>
39+
<Stack.Screen
40+
name="compositions-list"
41+
options={{
42+
presentation: 'modal',
43+
}}
44+
/>
3345
</Stack>
3446
</ComposerProvider>
3547
</RecorderProvider>
Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,6 @@
1+
import { ComposerImportModal } from '../src/screens/ComposerImportModal';
2+
3+
export default function ComposerImportModalRoute() {
4+
return <ComposerImportModal />;
5+
}
6+

example/app/compositions-list.tsx

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,6 @@
1+
import { CompositionsList } from '../src/screens/CompositionsList';
2+
3+
export default function CompositionsListRoute() {
4+
return <CompositionsList />;
5+
}
6+

example/bun.lock

Lines changed: 3 additions & 0 deletions
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

example/package.json

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -35,7 +35,8 @@
3535
"react-native-safe-area-context": "~5.6.0",
3636
"react-native-screens": "~4.16.0",
3737
"react-native-worklets": "^0.7.1",
38-
"zod": "^4.3.4"
38+
"zod": "^4.3.4",
39+
"zustand": "^5.0.9"
3940
},
4041
"devDependencies": {
4142
"@babel/core": "^7.25.2",

example/src/components/ComposerActionBar.tsx

Lines changed: 16 additions & 44 deletions
Original file line numberDiff line numberDiff line change
@@ -9,28 +9,22 @@ import { useTheme } from '../contexts/ThemeContext';
99

1010
interface ComposerActionBarProps {
1111
isPlaying: SharedValue<boolean>;
12-
canUndo: boolean;
13-
canRedo: boolean;
12+
canPlay: boolean;
1413
hasSelection: boolean;
1514
onPlay: () => void;
16-
onUndo: () => void;
17-
onRedo: () => void;
1815
onAdd: () => void;
1916
onDelete: () => void;
20-
onMore: () => void;
17+
onList: () => void;
2118
}
2219

2320
export default function ComposerActionBar({
2421
isPlaying,
25-
canUndo,
26-
canRedo,
2722
hasSelection,
23+
canPlay,
2824
onPlay,
29-
onUndo,
30-
onRedo,
3125
onAdd,
3226
onDelete,
33-
onMore,
27+
onList,
3428
}: ComposerActionBarProps) {
3529
const { colors } = useTheme();
3630
const insets = useSafeAreaInsets();
@@ -53,30 +47,23 @@ export default function ComposerActionBar({
5347
>
5448
<View style={[styles.bar, { backgroundColor: colors.background }]}>
5549
{/* Play/Pause */}
56-
<Animated.View style={[styles.buttonContainer, playButtonStyle]}>
50+
<Animated.View
51+
style={[
52+
styles.buttonContainer,
53+
!canPlay && { opacity: 0.4 },
54+
playButtonStyle,
55+
]}
56+
>
5757
<TouchableOpacity
58-
style={styles.buttonTouchable}
58+
style={[styles.buttonTouchable]}
5959
onPress={onPlay}
6060
activeOpacity={0.7}
61+
disabled={!canPlay}
6162
>
6263
<PlayPauseIcon isPlaying={isPlaying} />
6364
</TouchableOpacity>
6465
</Animated.View>
6566

66-
{/* Undo */}
67-
<TouchableOpacity
68-
style={[
69-
styles.button,
70-
{ backgroundColor: colors.blue },
71-
!canUndo && { opacity: 0.4 },
72-
]}
73-
onPress={onUndo}
74-
disabled={!canUndo}
75-
activeOpacity={0.7}
76-
>
77-
<SymbolView name="arrow.uturn.backward" size={22} tintColor="#FFFFFF" />
78-
</TouchableOpacity>
79-
8067
{/* Add */}
8168
<TouchableOpacity
8269
style={[styles.button, { backgroundColor: colors.blue }]}
@@ -100,27 +87,13 @@ export default function ComposerActionBar({
10087
<SymbolView name="trash" size={22} tintColor="#FFFFFF" />
10188
</TouchableOpacity>
10289

103-
{/* Redo */}
104-
<TouchableOpacity
105-
style={[
106-
styles.button,
107-
{ backgroundColor: colors.blue },
108-
!canRedo && { opacity: 0.4 },
109-
]}
110-
onPress={onRedo}
111-
disabled={!canRedo}
112-
activeOpacity={0.7}
113-
>
114-
<SymbolView name="arrow.uturn.forward" size={22} tintColor="#FFFFFF" />
115-
</TouchableOpacity>
116-
117-
{/* More options */}
90+
{/* List */}
11891
<TouchableOpacity
11992
style={[styles.button, { backgroundColor: '#FF8C32' }]}
120-
onPress={onMore}
93+
onPress={onList}
12194
activeOpacity={0.7}
12295
>
123-
<SymbolView name="ellipsis" size={22} tintColor="#FFFFFF" />
96+
<SymbolView name="list.bullet" size={22} tintColor="#FFFFFF" />
12497
</TouchableOpacity>
12598
</View>
12699
</View>
@@ -200,4 +173,3 @@ const styles = StyleSheet.create({
200173
justifyContent: 'center',
201174
},
202175
});
203-
Lines changed: 216 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,216 @@
1+
import {
2+
View,
3+
Text,
4+
TouchableOpacity,
5+
StyleSheet,
6+
Share,
7+
TextInput,
8+
} from 'react-native';
9+
import { SymbolView } from 'expo-symbols';
10+
import type { Composition } from '../types/composer';
11+
import { useTheme } from '../contexts/ThemeContext';
12+
import { composerEventToHapticEvent, composerEventsToCurves } from '../types/composer';
13+
14+
interface CompositionItemProps {
15+
composition: Composition;
16+
isSelected: boolean;
17+
isPlaying: boolean;
18+
onSelect: (id: string) => void;
19+
onPlay: (id: string) => void;
20+
onPause: (id: string) => void;
21+
onDelete: (id: string) => void;
22+
onNameChange: (id: string, name: string) => void;
23+
}
24+
25+
export default function CompositionItem({
26+
composition,
27+
isSelected,
28+
isPlaying,
29+
onSelect,
30+
onPlay,
31+
onPause,
32+
onDelete,
33+
onNameChange,
34+
}: CompositionItemProps) {
35+
const { colors } = useTheme();
36+
37+
const formatDuration = (events: Composition['events']) => {
38+
if (events.length === 0) return '0:00';
39+
const maxTime = events.reduce((max, event) => {
40+
const eventEnd =
41+
event.type === 'continuous'
42+
? event.startTime + event.duration
43+
: event.startTime + 0.1;
44+
return Math.max(max, eventEnd);
45+
}, 0);
46+
const mins = Math.floor(maxTime / 60);
47+
const secs = Math.floor(maxTime % 60);
48+
return `${mins}:${secs.toString().padStart(2, '0')}`;
49+
};
50+
51+
const formatDate = (timestamp: number) => {
52+
const date = new Date(timestamp);
53+
return date.toLocaleTimeString([], {
54+
hour: '2-digit',
55+
minute: '2-digit',
56+
});
57+
};
58+
59+
const handlePlayPausePress = () => {
60+
if (isPlaying) {
61+
onPause(composition.id);
62+
} else {
63+
onPlay(composition.id);
64+
}
65+
};
66+
67+
const handleExport = async () => {
68+
try {
69+
const hapticEvents = composition.events.map(composerEventToHapticEvent);
70+
const hapticCurves = composerEventsToCurves(composition.events);
71+
const exportData = {
72+
events: hapticEvents,
73+
curves: hapticCurves,
74+
};
75+
const jsonData = JSON.stringify(exportData, null, 2);
76+
77+
await Share.share({
78+
message: jsonData,
79+
title: `Export ${composition.name}`,
80+
});
81+
} catch (error) {
82+
console.error('Error sharing composition:', error);
83+
}
84+
};
85+
86+
return (
87+
<TouchableOpacity
88+
onPress={() => onSelect(composition.id)}
89+
activeOpacity={0.8}
90+
disabled={isSelected}
91+
>
92+
<View
93+
style={[
94+
styles.container,
95+
{ backgroundColor: colors.card, borderColor: colors.border },
96+
isSelected && {
97+
...styles.selectedContainer,
98+
borderColor: colors.borderActive,
99+
backgroundColor: colors.cardSelected,
100+
},
101+
]}
102+
>
103+
<View style={styles.info}>
104+
<TextInput
105+
style={[styles.name, { color: colors.text }]}
106+
editable={isSelected}
107+
pointerEvents={isSelected ? 'auto' : 'none'}
108+
defaultValue={composition.name}
109+
onChangeText={(text) => {
110+
onNameChange(composition.id, text);
111+
}}
112+
returnKeyType="done"
113+
placeholderTextColor={colors.tertiaryText}
114+
maxLength={50}
115+
/>
116+
<View style={styles.meta}>
117+
<Text style={[styles.metaText, { color: colors.secondaryText }]}>
118+
{formatDuration(composition.events)}
119+
</Text>
120+
<Text style={[styles.separator, { color: colors.secondaryText }]}>
121+
122+
</Text>
123+
<Text style={[styles.metaText, { color: colors.secondaryText }]}>
124+
{formatDate(composition.createdAt)}
125+
</Text>
126+
<Text style={[styles.separator, { color: colors.secondaryText }]}>
127+
128+
</Text>
129+
<Text style={[styles.metaText, { color: colors.secondaryText }]}>
130+
{composition.events.length} event
131+
{composition.events.length !== 1 ? 's' : ''}
132+
</Text>
133+
</View>
134+
</View>
135+
136+
{isSelected && (
137+
<View style={styles.actions}>
138+
<TouchableOpacity
139+
style={[styles.button, { backgroundColor: colors.blue }]}
140+
onPress={handlePlayPausePress}
141+
>
142+
<SymbolView
143+
name={isPlaying ? 'pause.fill' : 'play.fill'}
144+
size={16}
145+
tintColor="#FFFFFF"
146+
/>
147+
</TouchableOpacity>
148+
149+
<TouchableOpacity
150+
style={[styles.button, { backgroundColor: colors.green }]}
151+
onPress={handleExport}
152+
>
153+
<SymbolView
154+
name="square.and.arrow.up"
155+
size={16}
156+
tintColor="#FFFFFF"
157+
/>
158+
</TouchableOpacity>
159+
160+
<TouchableOpacity
161+
style={[styles.button, { backgroundColor: colors.accent }]}
162+
onPress={() => onDelete(composition.id)}
163+
>
164+
<SymbolView name="xmark" size={16} tintColor="#FFFFFF" />
165+
</TouchableOpacity>
166+
</View>
167+
)}
168+
</View>
169+
</TouchableOpacity>
170+
);
171+
}
172+
173+
const styles = StyleSheet.create({
174+
container: {
175+
flexDirection: 'row',
176+
alignItems: 'center',
177+
justifyContent: 'space-between',
178+
padding: 16,
179+
borderRadius: 12,
180+
marginBottom: 8,
181+
borderWidth: 2,
182+
},
183+
selectedContainer: {},
184+
info: {
185+
flex: 1,
186+
},
187+
name: {
188+
fontSize: 16,
189+
fontWeight: '600',
190+
marginBottom: 4,
191+
paddingEnd: 16,
192+
},
193+
meta: {
194+
flexDirection: 'row',
195+
alignItems: 'center',
196+
gap: 6,
197+
},
198+
metaText: {
199+
fontSize: 12,
200+
},
201+
separator: {
202+
fontSize: 12,
203+
},
204+
actions: {
205+
flexDirection: 'row',
206+
gap: 8,
207+
},
208+
button: {
209+
width: 30,
210+
height: 30,
211+
borderRadius: 30 / 2,
212+
alignItems: 'center',
213+
justifyContent: 'center',
214+
},
215+
});
216+

0 commit comments

Comments
 (0)