@xetwa/design-system 1.0.36 → 1.0.37

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@xetwa/design-system",
3
- "version": "1.0.36",
3
+ "version": "1.0.37",
4
4
  "type": "module",
5
5
  "publishConfig": {
6
6
  "access": "public"
@@ -10,13 +10,13 @@ const meta = {
10
10
  layout: 'padded',
11
11
  docs: {
12
12
  description: {
13
- component: 'O **ModuleCard** exibe as lições no percurso principal do utilizador (a "Tree" de aprendizagem).\n\nPossui diferentes estados (`active`, `locked`, `completed`, `dark`) e pode incluir barras de progresso fragmentadas.\n\n### Exemplo de Uso\n```tsx\n<ModuleCard \n status="active"\n number={2}\n title="No mercado de Dakar"\n subtitle="Regatear, números, comida"\n progress={0.6}\n segments={5}\n tagLabel="EM CURSO"\n/>\n```',
13
+ component: 'O **ModuleCard** exibe as lições no percurso principal do utilizador (a "Tree" de aprendizagem).\n\nPossui diferentes estados (`active`, `locked`, `completed`, `dark`, `active-dark`) e pode incluir barras de progresso fragmentadas.\n\n### Animações\n- **active**: O cartão inteiro é animado e reativo ao toque.\n- **active-dark**: O cartão é fixo, apenas os botões de ação ("Agendar Aula" e "Rever") são animados individualmente.\n- **completed**: O cartão é fixo, apenas o botão de revisão ("Rever Módulo" ou o ícone) tem animação individual.\n\n### Exemplo de Uso\n```tsx\n<ModuleCard \n status="active"\n number={2}\n title="No mercado de Dakar"\n subtitle="Regatear, números, comida"\n progress={0.6}\n segments={5}\n tagLabel="EM CURSO"\n/>\n```',
14
14
  }
15
15
  }
16
16
  },
17
17
  tags: ['autodocs'],
18
18
  argTypes: {
19
- status: { control: 'radio', options: ['active', 'locked', 'completed', 'dark'] },
19
+ status: { control: 'radio', options: ['active', 'locked', 'completed', 'dark', 'active-dark'] },
20
20
  number: { control: 'number' },
21
21
  title: { control: 'text' },
22
22
  subtitle: { control: 'text' },
@@ -68,6 +68,26 @@ export const DarkLocked: Story = {
68
68
  },
69
69
  };
70
70
 
71
+ export const ActiveDark: Story = {
72
+ args: {
73
+ status: 'active-dark',
74
+ title: 'No mercado de Dakar',
75
+ subtitle: 'Regatear, números, comida',
76
+ subtext: '• 3/5',
77
+ tagLabel: 'AULA MARCADA',
78
+ },
79
+ };
80
+
81
+ export const Completed: Story = {
82
+ args: {
83
+ status: 'completed',
84
+ number: 1,
85
+ title: 'Saudações & teranga',
86
+ subtitle: 'Concluído',
87
+ subtext: '• 5/5',
88
+ },
89
+ };
90
+
71
91
  export const Grid: Story = {
72
92
  args: {
73
93
  status: 'active',
@@ -103,6 +123,13 @@ export const Grid: Story = {
103
123
  title="Aula ao vivo + Teste final"
104
124
  subtitle="Acaba os 4 módulos para desbloquear"
105
125
  />
126
+ <ModuleCard
127
+ status="active-dark"
128
+ title="Aula de revisão agendada"
129
+ subtitle="Conversação e prática"
130
+ subtext="• 4/4"
131
+ tagLabel="AULA MARCADA"
132
+ />
106
133
  </View>
107
134
  ),
108
135
  };
@@ -1,20 +1,27 @@
1
- import { View, StyleSheet, Pressable } from 'react-native';
1
+ import { View, StyleSheet, Pressable, Animated } from 'react-native';
2
2
  import type { ViewStyle } from 'react-native';
3
3
  import { theme } from '../../../styles/theme';
4
4
  import { Tag } from '../../DataDisplay/Tag/Tag';
5
5
  import { ProgressBar } from '../../DataDisplay/ProgressBar/ProgressBar';
6
6
  import { Badge } from '../../DataDisplay/Badge/Badge';
7
- import { Lock, Check, GraduationCap } from 'lucide-react-native';
7
+ import { Lock, Check, GraduationCap, RotateCcw, CalendarPlus } from 'lucide-react-native';
8
8
  import { useResponsive } from '../../../hooks/useResponsive';
9
9
  import { Typography } from '../../Typography/Typography';
10
+ import React, { useRef } from 'react';
10
11
 
11
- export type ModuleCardStatus = 'active' | 'locked' | 'completed' | 'dark';
12
+ export type ModuleCardStatus = 'active' | 'locked' | 'completed' | 'dark' | 'active-dark';
12
13
 
13
14
  /**
14
15
  * Cartão que representa uma lição ou módulo no caminho de aprendizagem.
16
+ *
17
+ * Regras de Animação e Clique:
18
+ * - `active`: O cartão como um todo tem animação de escala e é clicável via `onPress`.
19
+ * - `active-dark`: O cartão é estático. Apenas os botões internos de "Agendar" (`onSchedulePress`) e "Rever" (`onReviewPress`) têm animação individual.
20
+ * - `completed`: O cartão é estático. Apenas o ícone/botão superior direito de "Rever" (`onReviewPress`) tem animação individual.
21
+ * - `locked` / `dark`: O cartão não é interativo.
15
22
  */
16
23
  export interface ModuleCardProps {
17
- /** Estado atual da lição (ativa, trancada, concluída, ou o módulo "dark" final). */
24
+ /** Estado atual da lição (ativa, trancada, concluída, etc). */
18
25
  status: ModuleCardStatus;
19
26
  /** Número da lição (ex: 1, 2, 3). */
20
27
  number?: number;
@@ -38,6 +45,10 @@ export interface ModuleCardProps {
38
45
  /** Cor customizada para o subtítulo. */
39
46
  subtitleColor?: string;
40
47
  style?: ViewStyle | ViewStyle[];
48
+ /** Função de clique no botão de Agendar (modo active/dark). */
49
+ onSchedulePress?: () => void;
50
+ /** Função de clique no botão de Rever (modo completed/active/dark). */
51
+ onReviewPress?: () => void;
41
52
  }
42
53
 
43
54
  export const ModuleCard = ({
@@ -54,11 +65,53 @@ export const ModuleCard = ({
54
65
  subtitleColor,
55
66
  onPress,
56
67
  style,
68
+ onSchedulePress,
69
+ onReviewPress,
57
70
  }: ModuleCardProps) => {
58
71
  const { scale, scaleText, width } = useResponsive();
59
-
60
72
  const isMobileScreen = width < 400;
61
73
 
74
+ const scheduleScale = useRef(new Animated.Value(1)).current;
75
+ const reviewScale = useRef(new Animated.Value(1)).current;
76
+ const completedReviewScale = useRef(new Animated.Value(1)).current;
77
+ const scaleAnim = useRef(new Animated.Value(1)).current;
78
+
79
+ const handlePressIn = () => {
80
+ if (status !== 'active') return;
81
+ Animated.timing(scaleAnim, {
82
+ toValue: 0.95,
83
+ duration: 100,
84
+ useNativeDriver: true,
85
+ }).start();
86
+ };
87
+
88
+ const handlePressOut = () => {
89
+ if (status !== 'active') return;
90
+ Animated.spring(scaleAnim, {
91
+ toValue: 1,
92
+ friction: 4,
93
+ tension: 40,
94
+ useNativeDriver: true,
95
+ }).start();
96
+ };
97
+
98
+ const animateBtnIn = (anim: Animated.Value) => {
99
+ Animated.timing(anim, {
100
+ toValue: 0.9,
101
+ duration: 100,
102
+ useNativeDriver: true,
103
+ }).start();
104
+ };
105
+
106
+ const animateBtnOut = (anim: Animated.Value) => {
107
+ Animated.spring(anim, {
108
+ toValue: 1,
109
+ friction: 4,
110
+ tension: 40,
111
+ useNativeDriver: true,
112
+ }).start();
113
+ };
114
+
62
115
  const getContainerStyle = () => {
63
116
  switch (status) {
64
117
  case 'active':
@@ -81,6 +134,7 @@ export const ModuleCard = ({
81
134
  ...theme.shadows.sm,
82
135
  };
83
136
  case 'dark':
137
+ case 'active-dark':
84
138
  return {
85
139
  borderColor: theme.colors.brown[800],
86
140
  borderWidth: 0,
@@ -96,6 +150,7 @@ export const ModuleCard = ({
96
150
  case 'locked':
97
151
  return theme.colors.brown[400];
98
152
  case 'dark':
153
+ case 'active-dark':
99
154
  return theme.colors.white;
100
155
  default:
101
156
  return theme.colors.brown[800];
@@ -104,7 +159,7 @@ export const ModuleCard = ({
104
159
 
105
160
  const containerStyle = getContainerStyle();
106
161
  const titleStyleColor = getTextColor();
107
- const subtitleStyleColor = subtitleColor || (status === 'completed' ? theme.colors.success : status === 'locked' || status === 'dark' ? theme.colors.brown[300] : theme.colors.brown[500]);
162
+ const subtitleStyleColor = subtitleColor || (status === 'completed' ? theme.colors.success : status === 'locked' || status === 'dark' || status === 'active-dark' ? theme.colors.brown[300] : theme.colors.brown[500]);
108
163
 
109
164
  const activeShadow = status === 'active' ? {
110
165
  shadowColor: theme.colors.primary,
@@ -151,6 +206,8 @@ export const ModuleCard = ({
151
206
  </View>
152
207
  );
153
208
  }
209
+
210
+
154
211
 
155
212
  return (
156
213
  <Badge
@@ -166,62 +223,99 @@ export const ModuleCard = ({
166
223
  const cardPaddingH = size === 'xl' ? scale(20) : size === 'lg' ? scale(18) : isMobileScreen ? scale(12) : scale(15);
167
224
 
168
225
  const titleVariant = size === 'xl' ? 'h1' : size === 'lg' ? 'h2' : 'h3';
226
+
169
227
  const subtitleVariant = size === 'xl' ? 'bodyLg' : size === 'lg' ? 'bodyMd' : 'bodySm';
170
228
 
229
+ let displaySubtext = subtext;
230
+ if (status === 'active-dark') {
231
+ if (subtext) {
232
+ if (subtext.trim().startsWith('•')) {
233
+ displaySubtext = `Aula de revisão ${subtext.trim()}`;
234
+ } else {
235
+ displaySubtext = `Aula de revisão • ${subtext}`;
236
+ }
237
+ } else {
238
+ displaySubtext = 'Aula de revisão';
239
+ }
240
+ }
241
+
171
242
  return (
172
- <Pressable
173
- style={[
174
- styles.container,
175
- containerStyle,
176
- status === 'active' && activeShadow,
177
- {
178
- paddingVertical: cardPaddingV,
179
- paddingHorizontal: cardPaddingH,
180
- borderRadius: scale(18),
181
- },
182
- style,
183
- ]}
184
- onPress={onPress}
185
- disabled={!onPress || (status === 'locked' && !onPress)}
186
- >
243
+ <Animated.View style={[{ transform: [{ scale: scaleAnim }] }, style]}>
244
+ <Pressable
245
+ style={[
246
+ styles.container,
247
+ containerStyle,
248
+ status === 'active' && activeShadow,
249
+ {
250
+ paddingVertical: cardPaddingV,
251
+ paddingHorizontal: cardPaddingH,
252
+ borderRadius: scale(18),
253
+ },
254
+ ]}
255
+ onPress={onPress}
256
+ onPressIn={handlePressIn}
257
+ onPressOut={handlePressOut}
258
+ disabled={status === 'locked'}
259
+ >
187
260
  <View style={styles.topRow}>
188
261
  <View style={styles.leftCol}>
189
262
  {renderLeftIconBox()}
190
263
  </View>
191
264
 
192
265
  <View style={[styles.content, { marginLeft: colGap }]}>
193
- <Typography variant={titleVariant} style={[
194
- styles.title,
195
- {
196
- color: titleStyleColor,
197
- marginBottom: scale(2),
198
- }
199
- ]}>
266
+ <Typography
267
+ variant={titleVariant}
268
+ style={[
269
+ styles.title,
270
+ {
271
+ color: titleStyleColor,
272
+ marginBottom: scale(2),
273
+ }
274
+ ]}
275
+ >
200
276
  {title}
201
277
  </Typography>
202
278
 
203
- {(subtitle || subtext) && (
204
- <Typography variant={subtitleVariant} style={[
205
- styles.subtitle,
206
- {
207
- color: subtitleStyleColor,
208
- }
209
- ]}>
210
- {subtitle}
211
- {subtitle && subtext && ' '}
212
- {subtext && <Typography variant={subtitleVariant} style={styles.subtext}>{subtext}</Typography>}
213
- </Typography>
279
+ {(subtitle || subtext || displaySubtext) && (
280
+ <View style={{ flexDirection: 'row', justifyContent: 'space-between', alignItems: 'center', marginTop: scale(2) }}>
281
+ <Typography variant={subtitleVariant} style={[
282
+ styles.subtitle,
283
+ {
284
+ color: subtitleStyleColor,
285
+ flexShrink: 1, // Para o texto encolher se a tag ocupar espaço
286
+ }
287
+ ]}>
288
+ {status !== 'active-dark' && subtitle}
289
+ {status !== 'active-dark' && subtitle && displaySubtext && ' '}
290
+ {displaySubtext && <Typography variant={subtitleVariant} style={styles.subtext}>{displaySubtext}</Typography>}
291
+ </Typography>
292
+ {tagLabel && (status === 'active-dark' || status === 'active') && (
293
+ <Tag
294
+ label={tagLabel}
295
+ variant="primary"
296
+ size={isMobileScreen ? 'xs' : 'sm'}
297
+ style={{ marginLeft: scale(8) }}
298
+ />
299
+ )}
300
+ </View>
214
301
  )}
215
302
  </View>
216
303
 
217
- {(tagLabel || status === 'locked' || status === 'dark') && (
304
+ {(status === 'locked' || status === 'dark' || status === 'completed') && (
218
305
  <View style={[styles.rightContent, { marginLeft: colGap }]}>
219
- {tagLabel && status === 'active' && (
220
- <Tag
221
- label={tagLabel}
222
- variant="primary"
223
- size={isMobileScreen ? 'xs' : 'sm'}
224
- />
306
+ {status === 'completed' && (
307
+ <Animated.View style={{ transform: [{ scale: completedReviewScale }] }}>
308
+ <Pressable
309
+ style={styles.reviewIconButton}
310
+ onPress={onReviewPress || (() => {})}
311
+ onPressIn={() => animateBtnIn(completedReviewScale)}
312
+ onPressOut={() => animateBtnOut(completedReviewScale)}
313
+ hitSlop={10}
314
+ >
315
+ <RotateCcw stroke={theme.colors.primary} strokeWidth={2.5} size={isMobileScreen ? scale(20) : scale(24)} />
316
+ <Typography variant="label" style={[styles.reviewIconText, { fontSize: scaleText(9) }]}>REVER</Typography>
317
+ </Pressable>
318
+ </Animated.View>
225
319
  )}
226
320
  {(status === 'locked' || status === 'dark') && (
227
321
  <Lock
@@ -243,7 +337,40 @@ export const ModuleCard = ({
243
337
  />
244
338
  </View>
245
339
  )}
340
+
341
+ {status === 'active-dark' && (
342
+ <View style={styles.compactDarkFooter}>
343
+ <Animated.View style={{ transform: [{ scale: scheduleScale }] }}>
344
+ <Pressable
345
+ onPress={onSchedulePress || (() => {})}
346
+ onPressIn={() => animateBtnIn(scheduleScale)}
347
+ onPressOut={() => animateBtnOut(scheduleScale)}
348
+ hitSlop={8}
349
+ style={{ flexDirection: 'row', alignItems: 'center', backgroundColor: theme.colors.primary, paddingHorizontal: scale(14), paddingVertical: scale(6), borderRadius: 100 }}
350
+ >
351
+ <CalendarPlus stroke={theme.colors.white} strokeWidth={2.5} size={scale(14)} />
352
+ <Typography variant="label" style={{ color: theme.colors.white, fontSize: scaleText(12), fontWeight: 'bold', marginLeft: scale(6) }}>Agendar Aula</Typography>
353
+ </Pressable>
354
+ </Animated.View>
355
+
356
+ <Animated.View style={{ transform: [{ scale: reviewScale }] }}>
357
+ <Pressable
358
+ style={styles.compactReviewBtn}
359
+ onPress={onReviewPress || (() => {})}
360
+ onPressIn={() => animateBtnIn(reviewScale)}
361
+ onPressOut={() => animateBtnOut(reviewScale)}
362
+ hitSlop={8}
363
+ >
364
+ <RotateCcw stroke={theme.colors.primary} strokeWidth={2.5} size={scale(16)} />
365
+ <Typography variant="label" style={[styles.reviewIconText, { color: theme.colors.primary, fontSize: scaleText(10), marginLeft: scale(4), marginTop: 0 }]}>REVER</Typography>
366
+ </Pressable>
367
+ </Animated.View>
368
+ </View>
369
+ )}
370
+
371
+
246
372
  </Pressable>
373
+ </Animated.View>
247
374
  );
248
375
  };
249
376
 
@@ -304,4 +431,35 @@ const styles = StyleSheet.create({
304
431
  marginLeft: 12,
305
432
  flexShrink: 0,
306
433
  },
434
+ reviewIconButton: {
435
+ alignItems: 'center',
436
+ justifyContent: 'center',
437
+ padding: 4,
438
+ },
439
+ reviewIconText: {
440
+ color: theme.colors.primary,
441
+ marginTop: 2,
442
+ textAlign: 'center',
443
+ },
444
+ compactDarkFooter: {
445
+ flexDirection: 'row',
446
+ alignItems: 'center',
447
+ justifyContent: 'space-between',
448
+ marginTop: 10,
449
+ paddingTop: 8,
450
+ borderTopWidth: 1,
451
+ borderTopColor: 'rgba(255,255,255,0.1)',
452
+ },
453
+ compactScheduleBtn: {
454
+ flexDirection: 'row',
455
+ alignItems: 'center',
456
+ },
457
+ compactReviewBtn: {
458
+ flexDirection: 'row',
459
+ alignItems: 'center',
460
+ backgroundColor: 'rgba(255,255,255,0.05)',
461
+ paddingHorizontal: 8,
462
+ paddingVertical: 4,
463
+ borderRadius: 8,
464
+ },
307
465
  });