@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
|
@@ -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,
|
|
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
|
-
<
|
|
173
|
-
|
|
174
|
-
|
|
175
|
-
|
|
176
|
-
|
|
177
|
-
|
|
178
|
-
|
|
179
|
-
|
|
180
|
-
|
|
181
|
-
|
|
182
|
-
|
|
183
|
-
|
|
184
|
-
|
|
185
|
-
|
|
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
|
|
194
|
-
|
|
195
|
-
{
|
|
196
|
-
|
|
197
|
-
|
|
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
|
-
<
|
|
205
|
-
|
|
206
|
-
|
|
207
|
-
|
|
208
|
-
|
|
209
|
-
|
|
210
|
-
|
|
211
|
-
|
|
212
|
-
|
|
213
|
-
|
|
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
|
-
{(
|
|
293
|
+
{(status === 'locked' || status === 'dark' || status === 'completed') && (
|
|
218
294
|
<View style={[styles.rightContent, { marginLeft: colGap }]}>
|
|
219
|
-
{
|
|
220
|
-
<
|
|
221
|
-
|
|
222
|
-
|
|
223
|
-
|
|
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
|
});
|