@xetwa/design-system 1.0.36 → 1.0.38

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.38",
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,88 @@ 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
+
230
+
171
231
  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
- >
232
+ <Animated.View style={[{ transform: [{ scale: scaleAnim }] }, style]}>
233
+ <Pressable
234
+ style={[
235
+ styles.container,
236
+ containerStyle,
237
+ status === 'active' && activeShadow,
238
+ {
239
+ paddingVertical: cardPaddingV,
240
+ paddingHorizontal: cardPaddingH,
241
+ borderRadius: scale(18),
242
+ },
243
+ ]}
244
+ onPress={onPress}
245
+ onPressIn={handlePressIn}
246
+ onPressOut={handlePressOut}
247
+ disabled={status === 'locked'}
248
+ >
187
249
  <View style={styles.topRow}>
188
250
  <View style={styles.leftCol}>
189
251
  {renderLeftIconBox()}
190
252
  </View>
191
253
 
192
254
  <View style={[styles.content, { marginLeft: colGap }]}>
193
- <Typography variant={titleVariant} style={[
194
- styles.title,
195
- {
196
- color: titleStyleColor,
197
- marginBottom: scale(2),
198
- }
199
- ]}>
255
+ <Typography
256
+ variant={titleVariant}
257
+ style={[
258
+ styles.title,
259
+ {
260
+ color: titleStyleColor,
261
+ marginBottom: scale(2),
262
+ }
263
+ ]}
264
+ >
200
265
  {title}
201
266
  </Typography>
202
267
 
203
268
  {(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>
269
+ <View style={{ flexDirection: 'row', justifyContent: 'space-between', alignItems: 'center', marginTop: scale(2) }}>
270
+ <Typography variant={subtitleVariant} style={[
271
+ styles.subtitle,
272
+ {
273
+ color: subtitleStyleColor,
274
+ flexShrink: 1, // Para o texto encolher se a tag ocupar espaço
275
+ }
276
+ ]}>
277
+ {subtitle}
278
+ {subtitle && subtext && ' '}
279
+ {subtext && <Typography variant={subtitleVariant} style={styles.subtext}>{subtext}</Typography>}
280
+ </Typography>
281
+ {tagLabel && (status === 'active-dark' || status === 'active') && (
282
+ <Tag
283
+ label={tagLabel}
284
+ variant="primary"
285
+ size={isMobileScreen ? 'xs' : 'sm'}
286
+ style={{ marginLeft: scale(8) }}
287
+ />
288
+ )}
289
+ </View>
214
290
  )}
215
291
  </View>
216
292
 
217
- {(tagLabel || status === 'locked' || status === 'dark') && (
293
+ {(status === 'locked' || status === 'dark' || status === 'completed') && (
218
294
  <View style={[styles.rightContent, { marginLeft: colGap }]}>
219
- {tagLabel && status === 'active' && (
220
- <Tag
221
- label={tagLabel}
222
- variant="primary"
223
- size={isMobileScreen ? 'xs' : 'sm'}
224
- />
295
+ {status === 'completed' && (
296
+ <Animated.View style={{ transform: [{ scale: completedReviewScale }] }}>
297
+ <Pressable
298
+ style={styles.reviewIconButton}
299
+ onPress={onReviewPress || (() => {})}
300
+ onPressIn={() => animateBtnIn(completedReviewScale)}
301
+ onPressOut={() => animateBtnOut(completedReviewScale)}
302
+ hitSlop={10}
303
+ >
304
+ <RotateCcw stroke={theme.colors.primary} strokeWidth={2.5} size={isMobileScreen ? scale(20) : scale(24)} />
305
+ <Typography variant="label" style={[styles.reviewIconText, { fontSize: scaleText(9) }]}>REVER</Typography>
306
+ </Pressable>
307
+ </Animated.View>
225
308
  )}
226
309
  {(status === 'locked' || status === 'dark') && (
227
310
  <Lock
@@ -243,7 +326,40 @@ export const ModuleCard = ({
243
326
  />
244
327
  </View>
245
328
  )}
329
+
330
+ {status === 'active-dark' && (
331
+ <View style={styles.compactDarkFooter}>
332
+ <Animated.View style={{ transform: [{ scale: scheduleScale }] }}>
333
+ <Pressable
334
+ onPress={onSchedulePress || (() => {})}
335
+ onPressIn={() => animateBtnIn(scheduleScale)}
336
+ onPressOut={() => animateBtnOut(scheduleScale)}
337
+ hitSlop={8}
338
+ style={{ flexDirection: 'row', alignItems: 'center', backgroundColor: theme.colors.primary, paddingHorizontal: scale(14), paddingVertical: scale(6), borderRadius: 100 }}
339
+ >
340
+ <CalendarPlus stroke={theme.colors.white} strokeWidth={2.5} size={scale(14)} />
341
+ <Typography variant="label" style={{ color: theme.colors.white, fontSize: scaleText(12), fontWeight: 'bold', marginLeft: scale(6) }}>Agendar Aula</Typography>
342
+ </Pressable>
343
+ </Animated.View>
344
+
345
+ <Animated.View style={{ transform: [{ scale: reviewScale }] }}>
346
+ <Pressable
347
+ style={styles.compactReviewBtn}
348
+ onPress={onReviewPress || (() => {})}
349
+ onPressIn={() => animateBtnIn(reviewScale)}
350
+ onPressOut={() => animateBtnOut(reviewScale)}
351
+ hitSlop={8}
352
+ >
353
+ <RotateCcw stroke={theme.colors.primary} strokeWidth={2.5} size={scale(16)} />
354
+ <Typography variant="label" style={[styles.reviewIconText, { color: theme.colors.primary, fontSize: scaleText(10), marginLeft: scale(4), marginTop: 0 }]}>REVER</Typography>
355
+ </Pressable>
356
+ </Animated.View>
357
+ </View>
358
+ )}
359
+
360
+
246
361
  </Pressable>
362
+ </Animated.View>
247
363
  );
248
364
  };
249
365
 
@@ -304,4 +420,35 @@ const styles = StyleSheet.create({
304
420
  marginLeft: 12,
305
421
  flexShrink: 0,
306
422
  },
423
+ reviewIconButton: {
424
+ alignItems: 'center',
425
+ justifyContent: 'center',
426
+ padding: 4,
427
+ },
428
+ reviewIconText: {
429
+ color: theme.colors.primary,
430
+ marginTop: 2,
431
+ textAlign: 'center',
432
+ },
433
+ compactDarkFooter: {
434
+ flexDirection: 'row',
435
+ alignItems: 'center',
436
+ justifyContent: 'space-between',
437
+ marginTop: 10,
438
+ paddingTop: 8,
439
+ borderTopWidth: 1,
440
+ borderTopColor: 'rgba(255,255,255,0.1)',
441
+ },
442
+ compactScheduleBtn: {
443
+ flexDirection: 'row',
444
+ alignItems: 'center',
445
+ },
446
+ compactReviewBtn: {
447
+ flexDirection: 'row',
448
+ alignItems: 'center',
449
+ backgroundColor: 'rgba(255,255,255,0.05)',
450
+ paddingHorizontal: 8,
451
+ paddingVertical: 4,
452
+ borderRadius: 8,
453
+ },
307
454
  });