@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 +1 -1
- package/src/components/Buttons/WordChipIcon/WordChipIcon.stories.tsx +68 -0
- package/src/components/Buttons/WordChipIcon/WordChipIcon.tsx +186 -0
- package/src/components/Buttons/WordChipIcon/index.ts +1 -0
- package/src/components/Cards/BaseCard/BaseCard.tsx +17 -17
- package/src/components/Cards/ModuleCard/ModuleCard.stories.tsx +20 -1
- package/src/components/Cards/ModuleCard/ModuleCard.tsx +31 -7
- package/src/components/Feedback/BottomSheet/BottomSheet.tsx +124 -0
- package/src/components/Forms/CalendarPicker/CalendarPicker.stories.tsx +31 -0
- package/src/components/Forms/CalendarPicker/CalendarPicker.tsx +220 -0
- package/src/components/Forms/DaySelector/DaySelector.tsx +28 -0
- package/src/components/Forms/TimeSlot/TimeSlot.tsx +29 -0
- package/src/index.ts +33 -30
- package/src/styles/theme.ts +1 -1
package/package.json
CHANGED
|
@@ -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 {
|
|
3
|
-
import
|
|
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
|
-
|
|
111
|
-
|
|
112
|
-
|
|
113
|
-
|
|
114
|
-
|
|
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/
|
|
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
|
+
|
package/src/styles/theme.ts
CHANGED
|
@@ -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
|
|