@xetwa/design-system 1.0.39 → 1.0.40

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.39",
3
+ "version": "1.0.40",
4
4
  "type": "module",
5
5
  "publishConfig": {
6
6
  "access": "public"
@@ -0,0 +1,68 @@
1
+ import type { Meta, StoryObj } from '@storybook/react';
2
+ import { WordChipIcon } from './WordChipIcon';
3
+ import { Calendar, Bell } from 'lucide-react';
4
+ import React from 'react';
5
+
6
+ const meta = {
7
+ title: 'Components/Buttons/WordChipIcon',
8
+ component: WordChipIcon,
9
+ parameters: {
10
+ layout: 'centered',
11
+ docs: {
12
+ description: {
13
+ component: 'O **WordChipIcon** é um WordChip que permite renderizar um ícone no lado esquerdo ou direito.',
14
+ },
15
+ },
16
+ },
17
+ tags: ['autodocs'],
18
+ argTypes: {
19
+ word: { control: 'text' },
20
+ iconPosition: {
21
+ control: 'radio',
22
+ options: ['left', 'right'],
23
+ },
24
+ state: {
25
+ control: 'select',
26
+ options: ['default', 'selected', 'correct', 'wrong', 'used'],
27
+ },
28
+ disabled: { control: 'boolean' },
29
+ fullWidth: { control: 'boolean' },
30
+ size: {
31
+ control: 'select',
32
+ options: ['sm', 'md', 'lg'],
33
+ },
34
+ },
35
+ } satisfies Meta<typeof WordChipIcon>;
36
+
37
+ export default meta;
38
+ type Story = StoryObj<typeof meta>;
39
+
40
+ export const Default: Story = {
41
+ args: {
42
+ word: 'Calendário',
43
+ icon: <Calendar size={20} color="#cf7a30" />,
44
+ iconPosition: 'left',
45
+ state: 'default',
46
+ },
47
+ };
48
+
49
+ export const RightIcon: Story = {
50
+ args: {
51
+ word: 'Lembrete',
52
+ icon: <Bell size={20} color="#cf7a30" />,
53
+ iconPosition: 'right',
54
+ state: 'default',
55
+ },
56
+ };
57
+
58
+ export const AllStates: Story = {
59
+ render: () => (
60
+ <div style={{ display: 'flex', gap: '16px', flexWrap: 'wrap', maxWidth: '600px' }}>
61
+ <WordChipIcon word="Calendário" icon={<Calendar size={20} color="#cf7a30" />} state="default" />
62
+ <WordChipIcon word="Lembrete" icon={<Bell size={20} color="#cf7a30" />} state="selected" />
63
+ <WordChipIcon word="Alarme" icon={<Bell size={20} color="#cf7a30" />} state="correct" />
64
+ <WordChipIcon word="Erro" icon={<Calendar size={20} color="#cf7a30" />} state="wrong" />
65
+ <WordChipIcon word="Usado" icon={<Calendar size={20} color="#cf7a30" />} state="used" />
66
+ </div>
67
+ ),
68
+ };
@@ -0,0 +1,186 @@
1
+ import { useState, useEffect, useRef } from 'react';
2
+ import { View, StyleSheet, Pressable, Animated } from 'react-native';
3
+ import type { ViewStyle } from 'react-native';
4
+ import { theme } from '../../../styles/theme';
5
+ import { useResponsive } from '../../../hooks/useResponsive';
6
+ import { Typography } from '../../Typography/Typography';
7
+ import type { WordChipState } from '../WordChip/WordChip';
8
+
9
+ export interface WordChipIconProps {
10
+ /** A palavra ou texto a apresentar. */
11
+ word: string;
12
+ /** O ícone a ser renderizado. */
13
+ icon: React.ReactNode;
14
+ /** A posição do ícone. Padrão: 'left' */
15
+ iconPosition?: 'left' | 'right';
16
+ /** Estado atual do chip. */
17
+ state?: WordChipState;
18
+ /** Função disparada ao clicar. */
19
+ onPress?: () => void;
20
+ /** Estilos adicionais para o container. */
21
+ style?: ViewStyle | ViewStyle[];
22
+ /** Impede cliques. */
23
+ disabled?: boolean;
24
+ /** Se verdadeiro, ocupa a largura total disponível. */
25
+ fullWidth?: boolean;
26
+ /** Tamanho do chip. */
27
+ size?: 'sm' | 'md' | 'lg';
28
+ /** Largura personalizada. Ignora max-width e align-self flex-start. */
29
+ width?: number | string;
30
+ }
31
+
32
+ export const WordChipIcon = ({
33
+ word,
34
+ icon,
35
+ iconPosition = 'left',
36
+ state = 'default',
37
+ onPress,
38
+ style,
39
+ disabled = false,
40
+ fullWidth = false,
41
+ size = 'md',
42
+ width,
43
+ }: WordChipIconProps) => {
44
+ const { scale } = useResponsive();
45
+ const [isPressed, setIsPressed] = useState(false);
46
+ const scaleAnim = useRef(new Animated.Value(state === 'selected' ? 0.96 : 1)).current;
47
+
48
+ // Animação ao entrar na resposta (bounce) quando o estado passa a 'selected'
49
+ useEffect(() => {
50
+ if (state === 'selected') {
51
+ scaleAnim.setValue(0.96);
52
+ Animated.spring(scaleAnim, {
53
+ toValue: 1,
54
+ friction: 4,
55
+ tension: 100,
56
+ useNativeDriver: true,
57
+ }).start();
58
+ } else {
59
+ scaleAnim.setValue(1);
60
+ }
61
+ }, [state, scaleAnim]);
62
+
63
+ const getStateStyles = () => {
64
+ switch (state) {
65
+ case 'selected':
66
+ return {
67
+ bg: theme.colors.primaryLight,
68
+ border: theme.colors.primary,
69
+ shadow: 'rgba(242, 151, 75, 0.25)',
70
+ text: theme.colors.brown[800],
71
+ };
72
+ case 'correct':
73
+ return {
74
+ bg: '#eafaf2',
75
+ border: theme.colors.success,
76
+ shadow: 'rgba(22, 145, 90, 0.2)',
77
+ text: theme.colors.success,
78
+ };
79
+ case 'wrong':
80
+ return {
81
+ bg: '#fdf0ee',
82
+ border: theme.colors.error,
83
+ shadow: 'rgba(207, 91, 72, 0.2)',
84
+ text: theme.colors.error,
85
+ };
86
+ case 'used':
87
+ return {
88
+ bg: theme.colors.white,
89
+ border: theme.colors.brown[100],
90
+ shadow: theme.colors.brown[100],
91
+ text: theme.colors.brown[300],
92
+ };
93
+ case 'default':
94
+ default:
95
+ return {
96
+ bg: theme.colors.white,
97
+ border: theme.colors.brown[100],
98
+ shadow: theme.colors.brown[100],
99
+ text: theme.colors.brown[800],
100
+ };
101
+ }
102
+ };
103
+
104
+ const currentStyles = getStateStyles();
105
+ const isDisabled = disabled || state === 'used';
106
+
107
+ // Press state visual adjustments
108
+ const showPressed = isPressed && !isDisabled;
109
+
110
+ const getTypographyVariant = () => {
111
+ switch (size) {
112
+ case 'lg': return 'btnLg';
113
+ case 'sm': return 'bodyMd';
114
+ case 'md':
115
+ default: return 'bodyLg';
116
+ }
117
+ };
118
+
119
+ return (
120
+ <Animated.View style={[{ transform: [{ scale: scaleAnim }] }, width ? { width } : null, style]}>
121
+ <Pressable
122
+ onPressIn={() => setIsPressed(true)}
123
+ onPressOut={() => setIsPressed(false)}
124
+ onPress={onPress}
125
+ disabled={isDisabled}
126
+ style={{ alignSelf: width ? 'stretch' : (fullWidth ? 'stretch' : 'flex-start') }}
127
+ >
128
+ <View style={[
129
+ styles.shadowContainer,
130
+ {
131
+ backgroundColor: currentStyles.shadow,
132
+ borderRadius: scale(12),
133
+ paddingBottom: showPressed ? 0 : scale(3),
134
+ marginTop: showPressed ? scale(3) : 0,
135
+ },
136
+ (fullWidth || width) ? { alignSelf: 'stretch', width: '100%' } : null
137
+ ]}>
138
+ <View style={[
139
+ styles.innerContainer,
140
+ {
141
+ backgroundColor: currentStyles.bg,
142
+ borderColor: currentStyles.border,
143
+ borderRadius: scale(12),
144
+ paddingVertical: scale(size === 'lg' ? 14 : size === 'sm' ? 6 : 9),
145
+ paddingHorizontal: scale(size === 'lg' ? 20 : size === 'sm' ? 10 : 14),
146
+ minHeight: scale(size === 'lg' ? 56 : size === 'sm' ? 36 : 44),
147
+ },
148
+ (fullWidth || width) ? { width: '100%' } : null
149
+ ]}>
150
+ <View style={[
151
+ styles.contentRow,
152
+ { flexDirection: iconPosition === 'right' ? 'row-reverse' : 'row' }
153
+ ]}>
154
+ {icon}
155
+ <Typography variant={getTypographyVariant()} style={[
156
+ styles.text,
157
+ { color: currentStyles.text }
158
+ ]}>
159
+ {word}
160
+ </Typography>
161
+ </View>
162
+ </View>
163
+ </View>
164
+ </Pressable>
165
+ </Animated.View>
166
+ );
167
+ };
168
+
169
+ const styles = StyleSheet.create({
170
+ shadowContainer: {
171
+ alignSelf: 'flex-start',
172
+ },
173
+ innerContainer: {
174
+ borderWidth: 2,
175
+ alignItems: 'center',
176
+ justifyContent: 'center',
177
+ },
178
+ contentRow: {
179
+ alignItems: 'center',
180
+ justifyContent: 'center',
181
+ gap: 8,
182
+ },
183
+ text: {
184
+ textAlign: 'center',
185
+ },
186
+ });
@@ -0,0 +1 @@
1
+ export * from './WordChipIcon';
@@ -1,9 +1,9 @@
1
1
  import type { ReactNode } from 'react';
2
- import { View, Pressable } from 'react-native';
3
- import type { ViewStyle, DimensionValue } from 'react-native';
2
+ import type { DimensionValue, ViewStyle } from 'react-native';
3
+ import { Pressable, View } from 'react-native';
4
4
  import Animated, { useAnimatedStyle, withTiming } from 'react-native-reanimated';
5
- import { theme } from '../../../styles/theme';
6
5
  import { useResponsive } from '../../../hooks/useResponsive';
6
+ import { theme } from '../../../styles/theme';
7
7
 
8
8
  const AnimatedPressable = Animated.createAnimatedComponent(Pressable);
9
9
 
@@ -15,41 +15,41 @@ const AnimatedPressable = Animated.createAnimatedComponent(Pressable);
15
15
  export interface BaseCardProps {
16
16
  /** O conteúdo a ser renderizado dentro do cartão. */
17
17
  children?: ReactNode;
18
-
18
+
19
19
  /** Variante de estilo do cartão */
20
20
  variant?: 'default' | 'soft';
21
-
21
+
22
22
  // Container styling
23
23
  backgroundColor?: string;
24
24
  borderRadius?: number;
25
25
  borderWidth?: number;
26
26
  borderColor?: string;
27
27
  borderStyle?: 'solid' | 'dashed';
28
-
28
+
29
29
  // Padding & Layout
30
30
  padding?: number;
31
31
  paddingVertical?: number;
32
32
  paddingHorizontal?: number;
33
-
33
+
34
34
  // Sizing
35
35
  width?: DimensionValue;
36
36
  height?: DimensionValue;
37
37
  minHeight?: DimensionValue;
38
-
38
+
39
39
  // Shadow presets: 'none' | 'sm' | 'md' | 'lg'
40
40
  shadow?: 'none' | 'sm' | 'md' | 'lg';
41
41
  shadowColor?: string;
42
-
42
+
43
43
  // Interactive / Pressable behavior
44
44
  onPress?: () => void;
45
45
  disabled?: boolean;
46
-
46
+
47
47
  // Selection state
48
48
  isSelected?: boolean;
49
49
  selectedBorderColor?: string;
50
50
  selectedBackgroundColor?: string;
51
51
  selectedShadowColor?: string;
52
-
52
+
53
53
  // Opacity & other styles
54
54
  opacity?: number;
55
55
  overflow?: 'visible' | 'hidden';
@@ -107,12 +107,12 @@ export const BaseCard = ({
107
107
  const activeBorderWidth = isSelected ? 2 : borderWidth;
108
108
  const activeShadow = isSelected
109
109
  ? {
110
- shadowColor: selectedShadowColor || '#f2974b',
111
- shadowOffset: { width: 0, height: scale(4) },
112
- shadowOpacity: 0.14,
113
- shadowRadius: scale(14),
114
- elevation: 4,
115
- }
110
+ shadowColor: selectedShadowColor || '#f2974b',
111
+ shadowOffset: { width: 0, height: scale(4) },
112
+ shadowOpacity: 0.14,
113
+ shadowRadius: scale(14),
114
+ elevation: 4,
115
+ }
116
116
  : getShadowStyle();
117
117
 
118
118
  // Consolidate static card styles with responsive scaling
@@ -16,7 +16,7 @@ const meta = {
16
16
  },
17
17
  tags: ['autodocs'],
18
18
  argTypes: {
19
- status: { control: 'radio', options: ['active', 'locked', 'completed', 'dark', 'active-dark'] },
19
+ status: { control: 'radio', options: ['active', 'locked', 'completed', 'dark', 'active-dark', 'completed-dark'] },
20
20
  number: { control: 'number' },
21
21
  title: { control: 'text' },
22
22
  subtitle: { control: 'text' },
@@ -101,6 +101,18 @@ export const Completed: Story = {
101
101
  },
102
102
  };
103
103
 
104
+ export const CompletedDark: Story = {
105
+ args: {
106
+ status: 'completed-dark',
107
+ number: 1,
108
+ title: 'Saudações & teranga (Dark)',
109
+ subtitle: 'Concluído',
110
+ subtext: '• 5/5',
111
+ onReviewPress: () => console.log('Rever módulo'),
112
+ onNextCountryPress: () => console.log('Ir para próximo país'),
113
+ },
114
+ };
115
+
104
116
  export const Grid: Story = {
105
117
  args: {
106
118
  status: 'active',
@@ -153,6 +165,13 @@ export const Grid: Story = {
153
165
  tagLabel="POR FAZER"
154
166
  onExamPress={() => {}}
155
167
  />
168
+ <ModuleCard
169
+ status="completed-dark"
170
+ number={4}
171
+ title="Módulo Concluído Dark"
172
+ subtitle="Concluído"
173
+ subtext="• 5/5"
174
+ />
156
175
  </View>
157
176
  ),
158
177
  };
@@ -9,7 +9,7 @@ import { useResponsive } from '../../../hooks/useResponsive';
9
9
  import { Typography } from '../../Typography/Typography';
10
10
  import React, { useRef } from 'react';
11
11
 
12
- export type ModuleCardStatus = 'active' | 'locked' | 'completed' | 'dark' | 'active-dark';
12
+ export type ModuleCardStatus = 'active' | 'locked' | 'completed' | 'dark' | 'active-dark' | 'completed-dark';
13
13
 
14
14
  /**
15
15
  * Cartão que representa uma lição ou módulo no caminho de aprendizagem.
@@ -17,7 +17,7 @@ export type ModuleCardStatus = 'active' | 'locked' | 'completed' | 'dark' | 'act
17
17
  * Regras de Animação e Clique:
18
18
  * - `active`: O cartão como um todo tem animação de escala e é clicável via `onPress`.
19
19
  * - `active-dark`: O cartão é estático. Apenas os botões internos de "Agendar" (`onSchedulePress`), "Exame" (`onExamPress`) e "Rever" (`onReviewPress`) têm animação individual quando fornecidos.
20
- * - `completed`: O cartão é estático. Apenas o ícone/botão superior direito de "Rever" (`onReviewPress`) tem animação individual.
20
+ * - `completed` / `completed-dark`: O cartão é estático. Apenas o ícone/botão superior direito de "Rever" (`onReviewPress`) tem animação individual.
21
21
  * - `locked` / `dark`: O cartão não é interativo.
22
22
  */
23
23
  export interface ModuleCardProps {
@@ -51,6 +51,10 @@ export interface ModuleCardProps {
51
51
  onExamPress?: () => void;
52
52
  /** Função de clique no botão de Rever (modo completed/active/dark). */
53
53
  onReviewPress?: () => void;
54
+ /** Função de clique no botão de Ir para o Próximo País (modo completed-dark). */
55
+ onNextCountryPress?: () => void;
56
+ /** Label do botão de próximo país. Padrão: "Seguir Viagem". */
57
+ nextCountryLabel?: string;
54
58
  }
55
59
 
56
60
  export const ModuleCard = ({
@@ -70,6 +74,8 @@ export const ModuleCard = ({
70
74
  onSchedulePress,
71
75
  onExamPress,
72
76
  onReviewPress,
77
+ onNextCountryPress,
78
+ nextCountryLabel = 'Seguir Viagem',
73
79
  }: ModuleCardProps) => {
74
80
  const { scale, scaleText, width } = useResponsive();
75
81
  const isMobileScreen = width < 400;
@@ -78,6 +84,7 @@ export const ModuleCard = ({
78
84
  const examScale = useRef(new Animated.Value(1)).current;
79
85
  const reviewScale = useRef(new Animated.Value(1)).current;
80
86
  const completedReviewScale = useRef(new Animated.Value(1)).current;
87
+ const nextCountryScale = useRef(new Animated.Value(1)).current;
81
88
  const scaleAnim = useRef(new Animated.Value(1)).current;
82
89
 
83
90
  const handlePressIn = () => {
@@ -139,6 +146,7 @@ export const ModuleCard = ({
139
146
  };
140
147
  case 'dark':
141
148
  case 'active-dark':
149
+ case 'completed-dark':
142
150
  return {
143
151
  borderColor: theme.colors.brown[800],
144
152
  borderWidth: 0,
@@ -155,6 +163,7 @@ export const ModuleCard = ({
155
163
  return theme.colors.brown[400];
156
164
  case 'dark':
157
165
  case 'active-dark':
166
+ case 'completed-dark':
158
167
  return theme.colors.white;
159
168
  default:
160
169
  return theme.colors.brown[800];
@@ -163,7 +172,7 @@ export const ModuleCard = ({
163
172
 
164
173
  const containerStyle = getContainerStyle();
165
174
  const titleStyleColor = getTextColor();
166
- const subtitleStyleColor = subtitleColor || (status === 'completed' ? theme.colors.success : status === 'locked' || status === 'dark' || status === 'active-dark' ? theme.colors.brown[300] : theme.colors.brown[500]);
175
+ const subtitleStyleColor = subtitleColor || (status === 'completed' || status === 'completed-dark' ? theme.colors.success : status === 'locked' || status === 'dark' || status === 'active-dark' ? theme.colors.brown[300] : theme.colors.brown[500]);
167
176
 
168
177
  const activeShadow = status === 'active' ? {
169
178
  shadowColor: theme.colors.primary,
@@ -184,7 +193,7 @@ export const ModuleCard = ({
184
193
  />
185
194
  );
186
195
  }
187
- if (status === 'completed') {
196
+ if (status === 'completed' || status === 'completed-dark') {
188
197
  const checkSize = isMobileScreen ? scale(16) : scale(22);
189
198
  return (
190
199
  <Badge
@@ -294,9 +303,24 @@ export const ModuleCard = ({
294
303
  )}
295
304
  </View>
296
305
 
297
- {(status === 'locked' || status === 'dark' || status === 'completed') && (
306
+ {(status === 'locked' || status === 'dark' || status === 'completed' || status === 'completed-dark') && (
298
307
  <View style={[styles.rightContent, { marginLeft: colGap }]}>
299
- {status === 'completed' && (
308
+ {status === 'completed-dark' && onNextCountryPress ? (
309
+ <Animated.View style={{ transform: [{ scale: nextCountryScale }] }}>
310
+ <Pressable
311
+ onPress={onNextCountryPress}
312
+ onPressIn={() => animateBtnIn(nextCountryScale)}
313
+ onPressOut={() => animateBtnOut(nextCountryScale)}
314
+ hitSlop={10}
315
+ >
316
+ <Tag
317
+ label={nextCountryLabel}
318
+ variant="success"
319
+ size={isMobileScreen ? 'xs' : 'sm'}
320
+ />
321
+ </Pressable>
322
+ </Animated.View>
323
+ ) : (status === 'completed' || status === 'completed-dark') ? (
300
324
  <Animated.View style={{ transform: [{ scale: completedReviewScale }] }}>
301
325
  <Pressable
302
326
  style={styles.reviewIconButton}
@@ -309,7 +333,7 @@ export const ModuleCard = ({
309
333
  <Typography variant="label" style={[styles.reviewIconText, { fontSize: scaleText(9) }]}>REVER</Typography>
310
334
  </Pressable>
311
335
  </Animated.View>
312
- )}
336
+ ) : null}
313
337
  {(status === 'locked' || status === 'dark') && (
314
338
  <Lock
315
339
  stroke={status === 'dark' ? 'rgba(255,255,255,0.4)' : '#c3b1a4'}
@@ -0,0 +1,124 @@
1
+ import React, { useEffect, useRef, useState } from 'react';
2
+ import { View, StyleSheet, Animated, Modal, Pressable } from 'react-native';
3
+ import { Typography } from '../../Typography/Typography';
4
+ import { theme } from '../../../styles/theme';
5
+ import { useResponsive } from '../../../hooks/useResponsive';
6
+ import { X } from 'lucide-react-native';
7
+
8
+ export interface BottomSheetProps {
9
+ /** Se o painel está visível */
10
+ isVisible: boolean;
11
+ /** Callback para fechar o painel */
12
+ onClose: () => void;
13
+ /** Título do painel */
14
+ title?: string;
15
+ /** Conteúdo do painel */
16
+ children: React.ReactNode;
17
+ }
18
+
19
+ /**
20
+ * Componente BottomSheet
21
+ *
22
+ * Um painel que desliza desde a parte inferior do ecrã para mostrar
23
+ * conteúdos adicionais, como seletores, opções extras ou formulários.
24
+ */
25
+ export const BottomSheet: React.FC<BottomSheetProps> = ({
26
+ isVisible,
27
+ onClose,
28
+ title,
29
+ children,
30
+ }) => {
31
+ const { scale } = useResponsive();
32
+ const [renderModal, setRenderModal] = useState(isVisible);
33
+ const translateY = useRef(new Animated.Value(1000)).current;
34
+
35
+ // Sincronizar montagem do Modal com a animação
36
+ useEffect(() => {
37
+ if (isVisible) {
38
+ setRenderModal(true);
39
+ Animated.timing(translateY, {
40
+ toValue: 0,
41
+ duration: 300,
42
+ useNativeDriver: true,
43
+ easing: (value) => 1 - Math.pow(1 - value, 3), // ease-out
44
+ }).start();
45
+ } else if (renderModal) {
46
+ Animated.timing(translateY, {
47
+ toValue: 1000,
48
+ duration: 250,
49
+ useNativeDriver: true,
50
+ }).start(() => {
51
+ setRenderModal(false);
52
+ });
53
+ }
54
+ }, [isVisible, translateY, renderModal]);
55
+
56
+ const handleClose = () => {
57
+ onClose();
58
+ };
59
+
60
+ if (!renderModal) return null;
61
+
62
+ return (
63
+ <Modal visible={renderModal} transparent animationType="fade" onRequestClose={handleClose}>
64
+ <View style={styles.overlay}>
65
+ <Pressable style={styles.backdrop} onPress={handleClose} />
66
+ <Animated.View style={[styles.sheet, { transform: [{ translateY }] }]}>
67
+
68
+ <View style={[styles.header, { paddingVertical: scale(16), paddingHorizontal: scale(20) }]}>
69
+ {title ? (
70
+ <Typography variant="h2" style={styles.title}>{title}</Typography>
71
+ ) : <View />}
72
+
73
+ <Pressable onPress={handleClose} style={({ pressed }) => [styles.closeBtn, pressed && { opacity: 0.5 }]}>
74
+ <X stroke="#9a8478" strokeWidth={2.5} size={scale(24)} />
75
+ </Pressable>
76
+ </View>
77
+
78
+ <View style={[styles.content, { paddingHorizontal: scale(20), paddingBottom: scale(32) }]}>
79
+ {children}
80
+ </View>
81
+
82
+ </Animated.View>
83
+ </View>
84
+ </Modal>
85
+ );
86
+ };
87
+
88
+ const styles = StyleSheet.create({
89
+ overlay: {
90
+ flex: 1,
91
+ justifyContent: 'flex-end',
92
+ backgroundColor: 'rgba(0, 0, 0, 0.4)',
93
+ },
94
+ backdrop: {
95
+ ...StyleSheet.absoluteFillObject,
96
+ },
97
+ sheet: {
98
+ backgroundColor: theme.colors.white,
99
+ borderTopLeftRadius: theme.radius.xxl,
100
+ borderTopRightRadius: theme.radius.xxl,
101
+ width: '100%',
102
+ shadowColor: '#000',
103
+ shadowOffset: { width: 0, height: -2 },
104
+ shadowOpacity: 0.1,
105
+ shadowRadius: 10,
106
+ elevation: 10,
107
+ },
108
+ header: {
109
+ flexDirection: 'row',
110
+ justifyContent: 'space-between',
111
+ alignItems: 'center',
112
+ borderBottomWidth: 1,
113
+ borderBottomColor: theme.colors.brown[50], // #fbf6f0
114
+ },
115
+ title: {
116
+ color: theme.colors.brown[800],
117
+ },
118
+ closeBtn: {
119
+ padding: 4,
120
+ },
121
+ content: {
122
+ paddingTop: 16,
123
+ },
124
+ });
@@ -0,0 +1,31 @@
1
+ import React, { useState } from 'react';
2
+ import type { Meta, StoryObj } from '@storybook/react';
3
+ import { CalendarPicker } from './CalendarPicker';
4
+ import { View } from 'react-native';
5
+
6
+ const meta = {
7
+ title: 'Forms/CalendarPicker',
8
+ component: CalendarPicker,
9
+ decorators: [
10
+ (Story) => (
11
+ <View style={{ padding: 20, backgroundColor: '#fbf6f0', flex: 1 }}>
12
+ <Story />
13
+ </View>
14
+ ),
15
+ ],
16
+ } satisfies Meta<typeof CalendarPicker>;
17
+
18
+ export default meta;
19
+ type Story = StoryObj<typeof meta>;
20
+
21
+ export const Default: Story = {
22
+ render: () => {
23
+ const [date, setDate] = useState<string | Date>(new Date());
24
+ return (
25
+ <CalendarPicker
26
+ selectedDate={date}
27
+ onSelectDate={(str) => setDate(str)}
28
+ />
29
+ );
30
+ }
31
+ };
@@ -0,0 +1,220 @@
1
+ import React, { useState, useMemo } from 'react';
2
+ import { View, StyleSheet, Pressable } from 'react-native';
3
+ import { Typography } from '../../Typography/Typography';
4
+ import { theme } from '../../../styles/theme';
5
+ import { useResponsive } from '../../../hooks/useResponsive';
6
+ import { ChevronLeft, ChevronRight } from 'lucide-react-native';
7
+
8
+ export interface CalendarPickerProps {
9
+ /** Data atualmente selecionada (ex: '2024-03-24' ou Date object) */
10
+ selectedDate?: string | Date;
11
+ /** Callback quando uma data é escolhida */
12
+ onSelectDate: (dateString: string, date: Date) => void;
13
+ }
14
+
15
+ const WEEKDAYS = ['DOM', 'SEG', 'TER', 'QUA', 'QUI', 'SEX', 'SÁB'];
16
+ const MONTHS = [
17
+ 'JANEIRO', 'FEVEREIRO', 'MARÇO', 'ABRIL', 'MAIO', 'JUNHO',
18
+ 'JULHO', 'AGOSTO', 'SETEMBRO', 'OUTUBRO', 'NOVEMBRO', 'DEZEMBRO'
19
+ ];
20
+
21
+ /**
22
+ * Componente CalendarPicker
23
+ * Permite a seleção de uma data num calendário nativo customizado.
24
+ */
25
+ export const CalendarPicker: React.FC<CalendarPickerProps> = ({
26
+ selectedDate,
27
+ onSelectDate,
28
+ }) => {
29
+ const { scale } = useResponsive();
30
+
31
+ // Normalize selected date
32
+ const parsedSelectedDate = selectedDate
33
+ ? (typeof selectedDate === 'string' ? new Date(selectedDate) : selectedDate)
34
+ : null;
35
+
36
+ // Estado para o mês e ano visualizados
37
+ const [currentViewDate, setCurrentViewDate] = useState(
38
+ parsedSelectedDate || new Date()
39
+ );
40
+
41
+ const viewMonth = currentViewDate.getMonth();
42
+ const viewYear = currentViewDate.getFullYear();
43
+
44
+ const handlePrevMonth = () => {
45
+ setCurrentViewDate(new Date(viewYear, viewMonth - 1, 1));
46
+ };
47
+
48
+ const handleNextMonth = () => {
49
+ setCurrentViewDate(new Date(viewYear, viewMonth + 1, 1));
50
+ };
51
+
52
+ // Gerar dias do calendário
53
+ const daysInMonth = useMemo(() => {
54
+ const year = viewYear;
55
+ const month = viewMonth;
56
+ const date = new Date(year, month, 1);
57
+ const days: (Date | null)[] = [];
58
+
59
+ // Preencher dias vazios antes do dia 1 (para alinhar com o dia da semana correto)
60
+ const firstDayOfWeek = date.getDay(); // 0 = Sunday, 1 = Monday, etc.
61
+ for (let i = 0; i < firstDayOfWeek; i++) {
62
+ days.push(null);
63
+ }
64
+
65
+ // Preencher dias do mês
66
+ while (date.getMonth() === month) {
67
+ days.push(new Date(date));
68
+ date.setDate(date.getDate() + 1);
69
+ }
70
+
71
+ return days;
72
+ }, [viewYear, viewMonth]);
73
+
74
+ const isSameDay = (d1: Date, d2: Date) => {
75
+ return d1.getFullYear() === d2.getFullYear() &&
76
+ d1.getMonth() === d2.getMonth() &&
77
+ d1.getDate() === d2.getDate();
78
+ };
79
+
80
+ // Função para garantir formato YYYY-MM-DD local sem problema de fuso horário
81
+ const formatDateString = (date: Date) => {
82
+ const y = date.getFullYear();
83
+ const m = String(date.getMonth() + 1).padStart(2, '0');
84
+ const d = String(date.getDate()).padStart(2, '0');
85
+ return `${y}-${m}-${d}`;
86
+ };
87
+
88
+ return (
89
+ <View style={styles.container}>
90
+ {/* Header do Mês */}
91
+ <View style={styles.header}>
92
+ <Pressable onPress={handlePrevMonth} style={({ pressed }) => [styles.arrowBtn, pressed && { opacity: 0.5 }]}>
93
+ <ChevronLeft stroke={theme.colors.brown[800]} strokeWidth={2.5} size={scale(24)} />
94
+ </Pressable>
95
+ <Typography variant="h2" style={styles.monthText}>
96
+ {MONTHS[viewMonth]} {viewYear}
97
+ </Typography>
98
+ <Pressable onPress={handleNextMonth} style={({ pressed }) => [styles.arrowBtn, pressed && { opacity: 0.5 }]}>
99
+ <ChevronRight stroke={theme.colors.brown[800]} strokeWidth={2.5} size={scale(24)} />
100
+ </Pressable>
101
+ </View>
102
+
103
+ {/* Dias da Semana (D S T Q Q S S) */}
104
+ <View style={styles.weekdaysRow}>
105
+ {WEEKDAYS.map((day, index) => (
106
+ <View key={index} style={styles.weekdayCell}>
107
+ <Typography variant="label" style={styles.weekdayText}>
108
+ {day[0]}
109
+ </Typography>
110
+ </View>
111
+ ))}
112
+ </View>
113
+
114
+ {/* Grelha de Dias */}
115
+ <View style={styles.daysGrid}>
116
+ {daysInMonth.map((date, index) => {
117
+ if (!date) {
118
+ return <View key={`empty-${index}`} style={styles.dayCell} />;
119
+ }
120
+
121
+ const isSelected = parsedSelectedDate ? isSameDay(date, parsedSelectedDate) : false;
122
+ const isToday = isSameDay(date, new Date());
123
+ const isPast = date < new Date(new Date().setHours(0,0,0,0));
124
+
125
+ return (
126
+ <Pressable
127
+ key={index}
128
+ disabled={isPast}
129
+ onPress={() => onSelectDate(formatDateString(date), date)}
130
+ style={({ pressed }) => [
131
+ styles.dayCell,
132
+ pressed && !isPast && { opacity: 0.7 },
133
+ isPast && { opacity: 0.3 }
134
+ ]}
135
+ >
136
+ <View style={[
137
+ styles.dayCircle,
138
+ isSelected && styles.dayCircleSelected
139
+ ]}>
140
+ <Typography
141
+ variant="bodyLg"
142
+ weight={isSelected ? 'bold' : 'normal'}
143
+ style={[
144
+ styles.dayText,
145
+ isSelected && styles.dayTextSelected,
146
+ isToday && !isSelected && styles.dayTextToday
147
+ ]}
148
+ >
149
+ {date.getDate()}
150
+ </Typography>
151
+ </View>
152
+ </Pressable>
153
+ );
154
+ })}
155
+ </View>
156
+ </View>
157
+ );
158
+ };
159
+
160
+ const styles = StyleSheet.create({
161
+ container: {
162
+ width: '100%',
163
+ },
164
+ header: {
165
+ flexDirection: 'row',
166
+ alignItems: 'center',
167
+ justifyContent: 'space-between',
168
+ marginBottom: 20,
169
+ paddingHorizontal: 8,
170
+ },
171
+ monthText: {
172
+ color: theme.colors.brown[800],
173
+ textTransform: 'uppercase',
174
+ },
175
+ arrowBtn: {
176
+ padding: 4,
177
+ },
178
+ weekdaysRow: {
179
+ flexDirection: 'row',
180
+ marginBottom: 8,
181
+ },
182
+ weekdayCell: {
183
+ flex: 1,
184
+ alignItems: 'center',
185
+ justifyContent: 'center',
186
+ },
187
+ weekdayText: {
188
+ color: theme.colors.brown[400],
189
+ },
190
+ daysGrid: {
191
+ flexDirection: 'row',
192
+ flexWrap: 'wrap',
193
+ },
194
+ dayCell: {
195
+ width: `${100 / 7}%`,
196
+ height: 44, // Altura fixa para garantir que o círculo interior fique centrado
197
+ alignItems: 'center',
198
+ justifyContent: 'center',
199
+ },
200
+ dayCircle: {
201
+ width: 36,
202
+ height: 36,
203
+ borderRadius: 18,
204
+ alignItems: 'center',
205
+ justifyContent: 'center',
206
+ },
207
+ dayCircleSelected: {
208
+ backgroundColor: theme.colors.primary,
209
+ },
210
+ dayText: {
211
+ color: theme.colors.brown[700],
212
+ },
213
+ dayTextSelected: {
214
+ color: theme.colors.white,
215
+ },
216
+ dayTextToday: {
217
+ color: theme.colors.primary,
218
+ fontWeight: 'bold',
219
+ },
220
+ });
@@ -4,6 +4,7 @@ import type { ViewStyle } from 'react-native';
4
4
  import { theme } from '../../../styles/theme';
5
5
  import { useResponsive } from '../../../hooks/useResponsive';
6
6
  import { Typography } from '../../Typography/Typography';
7
+ import { Calendar } from 'lucide-react-native';
7
8
 
8
9
  /**
9
10
  * Interface para os dados de um dia disponível no DaySelector.
@@ -36,6 +37,8 @@ export interface DaySelectorProps {
36
37
  onSelectDay: (id: string, date: Date) => void;
37
38
  /** Estilo para o container exterior */
38
39
  style?: ViewStyle | ViewStyle[];
40
+ /** Callback chamado quando o utilizador clica no ícone de calendário (para ver mais datas) */
41
+ onCalendarPress?: () => void;
39
42
  }
40
43
 
41
44
  export const DaySelector = ({
@@ -43,6 +46,7 @@ export const DaySelector = ({
43
46
  selectedId,
44
47
  onSelectDay,
45
48
  style,
49
+ onCalendarPress,
46
50
  }: DaySelectorProps) => {
47
51
  const { scale, scaleText } = useResponsive();
48
52
 
@@ -110,6 +114,30 @@ export const DaySelector = ({
110
114
  </Pressable>
111
115
  );
112
116
  })}
117
+
118
+ {onCalendarPress && (
119
+ <Pressable
120
+ onPress={onCalendarPress}
121
+ style={({ pressed }) => [
122
+ styles.item,
123
+ transitionStyle,
124
+ {
125
+ minWidth: scale(48),
126
+ paddingVertical: scale(8),
127
+ borderRadius: scale(13),
128
+ backgroundColor: '#fff',
129
+ borderWidth: scale(1.5),
130
+ borderColor: '#efe2d6',
131
+ borderStyle: 'dashed',
132
+ },
133
+ pressed && {
134
+ opacity: 0.7
135
+ }
136
+ ]}
137
+ >
138
+ <Calendar stroke="#9a8478" strokeWidth={2} size={scale(20)} />
139
+ </Pressable>
140
+ )}
113
141
  </ScrollView>
114
142
  </View>
115
143
  );
@@ -3,6 +3,7 @@ import type { ViewStyle } from 'react-native';
3
3
  import { theme } from '../../../styles/theme';
4
4
  import { useResponsive } from '../../../hooks/useResponsive';
5
5
  import { Typography } from '../../Typography/Typography';
6
+ import { Plus } from 'lucide-react-native';
6
7
 
7
8
  /**
8
9
  * Interface para os dados de um horário disponível no TimeSlot.
@@ -31,6 +32,8 @@ export interface TimeSlotProps {
31
32
  onSelectTime: (id: string, time: string) => void;
32
33
  /** Estilo para o container exterior */
33
34
  style?: ViewStyle | ViewStyle[];
35
+ /** Callback chamado quando o utilizador clica em "Ver mais" */
36
+ onShowMorePress?: () => void;
34
37
  }
35
38
 
36
39
  export const TimeSlot = ({
@@ -38,6 +41,7 @@ export const TimeSlot = ({
38
41
  selectedId,
39
42
  onSelectTime,
40
43
  style,
44
+ onShowMorePress,
41
45
  }: TimeSlotProps) => {
42
46
  const { scale, scaleText } = useResponsive();
43
47
 
@@ -96,6 +100,31 @@ export const TimeSlot = ({
96
100
  </Pressable>
97
101
  );
98
102
  })}
103
+ {onShowMorePress && (
104
+ <Pressable
105
+ onPress={onShowMorePress}
106
+ style={({ pressed }) => [
107
+ styles.item,
108
+ transitionStyle,
109
+ {
110
+ paddingVertical: scale(9),
111
+ paddingHorizontal: scale(14),
112
+ borderRadius: scale(11),
113
+ backgroundColor: '#fff',
114
+ borderWidth: scale(1.5),
115
+ borderColor: '#efe2d6',
116
+ borderStyle: 'dashed',
117
+ minHeight: scale(44),
118
+ minWidth: scale(44),
119
+ },
120
+ pressed && {
121
+ opacity: 0.7
122
+ }
123
+ ]}
124
+ >
125
+ <Plus stroke="#9a8478" strokeWidth={2.5} size={scale(18)} />
126
+ </Pressable>
127
+ )}
99
128
  </View>
100
129
  </View>
101
130
  );
package/src/index.ts CHANGED
@@ -1,43 +1,36 @@
1
- export * from './components/Headers/CountryHeader/CountryHeader';
2
- export * from './components/Buttons/MainButton/MainButton';
3
- export * from './components/Buttons/BaseButton/BaseButton';
4
1
  export * from './components/Buttons/BackButton/BackButton';
2
+ export * from './components/Buttons/BaseButton/BaseButton';
5
3
  export * from './components/Buttons/FabButton/FabButton';
6
- export * from './components/Cards/ModuleCard/ModuleCard';
7
- export * from './components/Cards/ModuleCompletionCard/ModuleCompletionCard';
8
- export * from './components/Cards/AnswerOptionCard/AnswerOptionCard';
9
- export * from './components/DataDisplay/ProgressBar/ProgressBar';
10
- export * from './components/DataDisplay/Tag/Tag';
11
- export * from './components/DataDisplay/Badge/Badge';
12
- export * from './components/Inputs/TextInput/TextInput';
13
- export * from './components/Typography/Typography';
14
- export * from './components/Cards/BoardingPassCard/BoardingPassCard';
15
- export * from './components/Cards/SelectionCard/SelectionCard';
16
- export * from './components/Cards/PricingCard/PricingCard';
17
- export * from './components/DataDisplay/Flag/Flag';
18
- export * from './components/Cards/BaseCard/BaseCard';
19
- export * from './components/Cards/TeacherCard/TeacherCard';
20
- export * from './components/Cards/CountryCard/CountryCard';
21
- export * from './components/Cards/PlanCard/PlanCard';
22
- export * from './components/DataDisplay/LevelBadge/LevelBadge';
23
- export * from './hooks/useResponsive';
24
- export * from './components/Mascots/Vava/Vava';
25
- export * from './components/Mascots/Yaya/Yaya';
26
- export * from './components/Buttons/WordChip/WordChip';
27
- export * from './components/Feedback/FeedbackBottomSheet/FeedbackBottomSheet';
28
- export * from './components/Headers/ProfileHeader/ProfileHeader';
29
- export * from './components/Inputs/FillInTheBlank/FillInTheBlank';
30
- export * from './components/Charts/WeeklyProgressChart';
31
- export * from './components/Navigation/ProgressMap/ProgressMap';
32
4
  export * from './components/Buttons/IconButton/IconButton';
33
5
  export * from './components/Buttons/JourneyButton/JourneyButton';
6
+ export * from './components/Buttons/MainButton/MainButton';
34
7
  export * from './components/Buttons/WordChip/WordChip';
8
+ export * from './components/Buttons/WordChipIcon/WordChipIcon';
9
+ export * from './components/Cards/AnswerOptionCard/AnswerOptionCard';
10
+ export * from './components/Cards/BaseCard/BaseCard';
11
+ export * from './components/Cards/BoardingPassCard/BoardingPassCard';
12
+ export * from './components/Cards/CountryCard/CountryCard';
35
13
  export * from './components/Cards/LevelProgressCard/LevelProgressCard';
14
+ export * from './components/Cards/ModuleCard/ModuleCard';
15
+ export * from './components/Cards/ModuleCompletionCard/ModuleCompletionCard';
36
16
  export * from './components/Cards/Passport/PassportViewer';
17
+ export * from './components/Cards/PlanCard/PlanCard';
18
+ export * from './components/Cards/PricingCard/PricingCard';
19
+ export * from './components/Cards/SelectionCard/SelectionCard';
20
+ export * from './components/Cards/TeacherCard/TeacherCard';
21
+ export * from './components/Charts/WeeklyProgressChart';
37
22
  export * from './components/DataDisplay/Avatar/Avatar';
23
+ export * from './components/DataDisplay/Badge/Badge';
24
+ export * from './components/DataDisplay/Flag/Flag';
38
25
  export * from './components/DataDisplay/InstructionBubble/InstructionBubble';
39
- export * from './components/DataDisplay/StatBadge/StatBadge';
26
+ export * from './components/DataDisplay/LevelBadge/LevelBadge';
40
27
  export * from './components/DataDisplay/NumberBadge/NumberBadge';
28
+ export * from './components/DataDisplay/ProgressBar/ProgressBar';
29
+ export * from './components/DataDisplay/StatBadge/StatBadge';
30
+ export * from './components/DataDisplay/Tag/Tag';
31
+ export * from './components/Feedback/BottomSheet/BottomSheet';
32
+ export * from './components/Feedback/FeedbackBottomSheet/FeedbackBottomSheet';
33
+ export * from './components/Forms/CalendarPicker/CalendarPicker';
41
34
  export * from './components/Forms/Checkbox/Checkbox';
42
35
  export * from './components/Forms/DaySelector/DaySelector';
43
36
  export * from './components/Forms/Radio/Radio';
@@ -45,6 +38,16 @@ export * from './components/Forms/SegmentedToggle/SegmentedToggle';
45
38
  export * from './components/Forms/Select/Select';
46
39
  export * from './components/Forms/Switch/Switch';
47
40
  export * from './components/Forms/TimeSlot/TimeSlot';
41
+ export * from './components/Headers/CountryHeader/CountryHeader';
42
+ export * from './components/Headers/ProfileHeader/ProfileHeader';
43
+ export * from './components/Inputs/FillInTheBlank/FillInTheBlank';
44
+ export * from './components/Inputs/TextInput/TextInput';
45
+ export * from './components/Mascots/Vava/Vava';
46
+ export * from './components/Mascots/Yaya/Yaya';
48
47
  export * from './components/Navigation/BottomTabBar/BottomTabBar';
49
48
  export * from './components/Navigation/PaginationDots/PaginationDots';
49
+ export * from './components/Navigation/ProgressMap/ProgressMap';
50
50
  export * from './components/Navigation/WorldProgressMap/WorldProgressMap';
51
+ export * from './components/Typography/Typography';
52
+ export * from './hooks/useResponsive';
53
+
@@ -5,7 +5,7 @@ const font = (family: string, webWeight: '400' | '500' | '600' | '700' | '800' |
5
5
  const webFamily = family.split('-')[0];
6
6
  return Platform.select({
7
7
  web: { fontFamily: webFamily, fontWeight: webWeight },
8
- default: { fontFamily: family }
8
+ default: { fontFamily: family, fontWeight: webWeight }
9
9
  }) as { fontFamily: string; fontWeight?: '400' | '500' | '600' | '700' | '800' | '900' };
10
10
  };
11
11