@xetwa/design-system 1.0.41 → 1.0.43
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/Headers/RouteHeader/RouteHeader.stories.tsx +104 -0
- package/src/components/Headers/RouteHeader/RouteHeader.tsx +218 -0
- package/src/components/Navigation/TabBar/TabBar.tsx +2 -2
- package/src/index.ts +1 -0
- package/src/styles/theme.ts +13 -7
- package/src/utils/iconMap.ts +2 -2
package/package.json
CHANGED
|
@@ -0,0 +1,104 @@
|
|
|
1
|
+
import type { Meta, StoryObj } from '@storybook/react';
|
|
2
|
+
import { View } from 'react-native';
|
|
3
|
+
import { RouteHeader } from './RouteHeader';
|
|
4
|
+
import { theme } from '../../../styles/theme';
|
|
5
|
+
|
|
6
|
+
const meta = {
|
|
7
|
+
title: 'Components/Headers/RouteHeader',
|
|
8
|
+
component: RouteHeader,
|
|
9
|
+
parameters: {
|
|
10
|
+
layout: 'fullscreen',
|
|
11
|
+
docs: {
|
|
12
|
+
description: {
|
|
13
|
+
component: `O **RouteHeader** é o cabeçalho principal (HUD) para ecrãs de percurso/navegação num mapa, substituindo a navegação clássica com setas de voltar.
|
|
14
|
+
|
|
15
|
+
### Caso de Uso Principal
|
|
16
|
+
Utilizado no topo do mapa para informar o utilizador:
|
|
17
|
+
1. Em que **país/módulo** se encontra (bandeira grande + nome da cidade).
|
|
18
|
+
2. Qual o seu **nível e evolução** atual (ex: A1 → C1, 63%).
|
|
19
|
+
3. Como está o seu **progresso**, que pode ser exibido de forma fluida (\`continuous\`) ou em paragens discretas (\`segmented\`).
|
|
20
|
+
|
|
21
|
+
### Lidar com a SafeArea (Notch / Status Bar)
|
|
22
|
+
Este componente estende-se até ao limite superior físico do telemóvel. Deves passar a propriedade \`safeAreaTop\` com o valor gerado pelo hook \`useSafeAreaInsets()\` do \`react-native-safe-area-context\`.
|
|
23
|
+
|
|
24
|
+
### Exemplo de Código
|
|
25
|
+
\`\`\`tsx
|
|
26
|
+
import { RouteHeader } from '@xetwa/design-system';
|
|
27
|
+
import { useSafeAreaInsets } from 'react-native-safe-area-context';
|
|
28
|
+
import { View } from 'react-native';
|
|
29
|
+
|
|
30
|
+
export const MapScreen = () => {
|
|
31
|
+
const insets = useSafeAreaInsets();
|
|
32
|
+
|
|
33
|
+
return (
|
|
34
|
+
<View style={{ flex: 1 }}>
|
|
35
|
+
<RouteHeader
|
|
36
|
+
routeLabel="ROTA DA FRANCOFONIA"
|
|
37
|
+
stepLabel="paragem 5 de 8"
|
|
38
|
+
countryCode="SN"
|
|
39
|
+
title="Dakar"
|
|
40
|
+
subtitle="Nível B2 · estás aqui"
|
|
41
|
+
progressText="63%"
|
|
42
|
+
progressSubText="A1 → C1"
|
|
43
|
+
progress={0.63}
|
|
44
|
+
progressType="segmented"
|
|
45
|
+
totalSegments={8}
|
|
46
|
+
safeAreaTop={insets.top}
|
|
47
|
+
/>
|
|
48
|
+
{/* ... Mapa Embaixo ... */}
|
|
49
|
+
</View>
|
|
50
|
+
);
|
|
51
|
+
};
|
|
52
|
+
\`\`\`
|
|
53
|
+
`,
|
|
54
|
+
},
|
|
55
|
+
},
|
|
56
|
+
},
|
|
57
|
+
decorators: [
|
|
58
|
+
(Story) => (
|
|
59
|
+
<View style={{ flex: 1, backgroundColor: theme.colors.brown[100] }}>
|
|
60
|
+
<Story />
|
|
61
|
+
</View>
|
|
62
|
+
),
|
|
63
|
+
],
|
|
64
|
+
tags: ['autodocs'],
|
|
65
|
+
argTypes: {
|
|
66
|
+
progressType: {
|
|
67
|
+
control: 'radio',
|
|
68
|
+
options: ['continuous', 'segmented'],
|
|
69
|
+
},
|
|
70
|
+
},
|
|
71
|
+
} satisfies Meta<typeof RouteHeader>;
|
|
72
|
+
|
|
73
|
+
export default meta;
|
|
74
|
+
type Story = StoryObj<typeof meta>;
|
|
75
|
+
|
|
76
|
+
export const Continuous: Story = {
|
|
77
|
+
args: {
|
|
78
|
+
routeLabel: 'ROTA DA FRANCOFONIA',
|
|
79
|
+
countryCode: 'SN',
|
|
80
|
+
title: 'Dakar',
|
|
81
|
+
subtitle: 'Nível B2 · estás aqui',
|
|
82
|
+
progressText: '63%',
|
|
83
|
+
progressSubText: 'A1 → C1',
|
|
84
|
+
progress: 0.63,
|
|
85
|
+
progressType: 'continuous',
|
|
86
|
+
safeAreaTop: 20,
|
|
87
|
+
},
|
|
88
|
+
};
|
|
89
|
+
|
|
90
|
+
export const Segmented: Story = {
|
|
91
|
+
args: {
|
|
92
|
+
routeLabel: 'ROTA DA FRANCOFONIA',
|
|
93
|
+
stepLabel: 'paragem 5 de 8',
|
|
94
|
+
countryCode: 'SN',
|
|
95
|
+
title: 'Dakar',
|
|
96
|
+
subtitle: 'Nível B2 · estás aqui',
|
|
97
|
+
progressText: '63%',
|
|
98
|
+
progressSubText: 'A1 → C1',
|
|
99
|
+
progress: 0.63,
|
|
100
|
+
totalSegments: 8,
|
|
101
|
+
progressType: 'segmented',
|
|
102
|
+
safeAreaTop: 20,
|
|
103
|
+
},
|
|
104
|
+
};
|
|
@@ -0,0 +1,218 @@
|
|
|
1
|
+
import React from 'react';
|
|
2
|
+
import { View, StyleSheet, Platform } from 'react-native';
|
|
3
|
+
import type { ViewStyle } from 'react-native';
|
|
4
|
+
import { Typography } from '../../Typography/Typography';
|
|
5
|
+
import { Flag } from '../../DataDisplay/Flag/Flag';
|
|
6
|
+
import { ProgressBar } from '../../DataDisplay/ProgressBar/ProgressBar';
|
|
7
|
+
import { theme } from '../../../styles/theme';
|
|
8
|
+
import { useResponsive } from '../../../hooks/useResponsive';
|
|
9
|
+
|
|
10
|
+
/**
|
|
11
|
+
* Propriedades para o componente RouteHeader.
|
|
12
|
+
*/
|
|
13
|
+
export interface RouteHeaderProps {
|
|
14
|
+
/**
|
|
15
|
+
* Label principal no topo esquerdo. Usado para indicar a rota global.
|
|
16
|
+
* @example "ROTA DA FRANCOFONIA"
|
|
17
|
+
*/
|
|
18
|
+
routeLabel: string;
|
|
19
|
+
/**
|
|
20
|
+
* Label secundária no topo direito. Usada para indicar a etapa ou paragem atual no caminho.
|
|
21
|
+
* @example "paragem 5 de 8"
|
|
22
|
+
*/
|
|
23
|
+
stepLabel?: string;
|
|
24
|
+
/**
|
|
25
|
+
* Código ISO Alpha-2 do país para a bandeira.
|
|
26
|
+
* A bandeira serve como o avatar principal ("estás aqui").
|
|
27
|
+
* @example "SN" (Senegal)
|
|
28
|
+
*/
|
|
29
|
+
countryCode: string;
|
|
30
|
+
/**
|
|
31
|
+
* Título principal, geralmente o nome do destino ou cidade atual.
|
|
32
|
+
* @example "Dakar"
|
|
33
|
+
*/
|
|
34
|
+
title: string;
|
|
35
|
+
/**
|
|
36
|
+
* Subtítulo por baixo do título. Mostra o nível ou estado do utilizador.
|
|
37
|
+
* @example "Nível B2 · estás aqui"
|
|
38
|
+
*/
|
|
39
|
+
subtitle: string;
|
|
40
|
+
/**
|
|
41
|
+
* Percentagem ou valor métrico em formato de texto grande.
|
|
42
|
+
* @example "63%"
|
|
43
|
+
*/
|
|
44
|
+
progressText: string;
|
|
45
|
+
/**
|
|
46
|
+
* Texto auxiliar debaixo da percentagem que descreve a evolução.
|
|
47
|
+
* @example "A1 → C1"
|
|
48
|
+
*/
|
|
49
|
+
progressSubText: string;
|
|
50
|
+
/**
|
|
51
|
+
* Valor numérico absoluto do progresso total (entre 0.0 e 1.0).
|
|
52
|
+
* Controla a percentagem preenchida da barra (independente de ser segmentada).
|
|
53
|
+
*/
|
|
54
|
+
progress: number;
|
|
55
|
+
/**
|
|
56
|
+
* Total de segmentos visíveis se a barra for do tipo "segmented".
|
|
57
|
+
* Representa o número total de paragens ou módulos de uma rota.
|
|
58
|
+
* @default 1
|
|
59
|
+
*/
|
|
60
|
+
totalSegments?: number;
|
|
61
|
+
/**
|
|
62
|
+
* Determina a aparência da barra de progresso.
|
|
63
|
+
* - `continuous`: Barra lisa, ideal para indicar a percentagem de uma única etapa longa.
|
|
64
|
+
* - `segmented`: Barra dividida em secções, excelente para rotas com várias "paragens".
|
|
65
|
+
* @default 'continuous'
|
|
66
|
+
*/
|
|
67
|
+
progressType?: 'continuous' | 'segmented';
|
|
68
|
+
/**
|
|
69
|
+
* Espaçamento extra no topo para evitar que o conteúdo fique por baixo da Notch/Status Bar.
|
|
70
|
+
* Deve receber o `insets.top` devolvido pelo hook `useSafeAreaInsets()`.
|
|
71
|
+
*/
|
|
72
|
+
safeAreaTop?: number;
|
|
73
|
+
/**
|
|
74
|
+
* Estilos adicionais para o contentor exterior (ex: zIndex, position absolute).
|
|
75
|
+
*/
|
|
76
|
+
style?: ViewStyle | ViewStyle[];
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
/**
|
|
80
|
+
* **RouteHeader**
|
|
81
|
+
*
|
|
82
|
+
* Cabeçalho principal para os ecrãs de percurso e navegação. Apresenta o progresso
|
|
83
|
+
* de aprendizagem do utilizador ao longo de uma "rota" cultural/geográfica.
|
|
84
|
+
*
|
|
85
|
+
* ### Casos de Uso
|
|
86
|
+
* - **Home do Percurso (Mapa Principal):** Usado sem setas de voltar, servindo de HUD de navegação global.
|
|
87
|
+
* Combina informações de localização (onde estou? -> Dakar) e informações de progresso (63%).
|
|
88
|
+
* - **Tipos de Progresso:** Pode apresentar o progresso fluido (`continuous`) para uma visão global, ou
|
|
89
|
+
* faseado por módulos/paragens (`segmented`) quando o foco é passar de um ponto para o outro num mapa.
|
|
90
|
+
*/
|
|
91
|
+
export const RouteHeader = ({
|
|
92
|
+
routeLabel,
|
|
93
|
+
stepLabel,
|
|
94
|
+
countryCode,
|
|
95
|
+
title,
|
|
96
|
+
subtitle,
|
|
97
|
+
progressText,
|
|
98
|
+
progressSubText,
|
|
99
|
+
progress,
|
|
100
|
+
totalSegments = 1,
|
|
101
|
+
progressType = 'continuous',
|
|
102
|
+
safeAreaTop = 0,
|
|
103
|
+
style,
|
|
104
|
+
}: RouteHeaderProps) => {
|
|
105
|
+
const { scale } = useResponsive();
|
|
106
|
+
|
|
107
|
+
const segments = progressType === 'segmented' ? Math.max(1, totalSegments) : 1;
|
|
108
|
+
|
|
109
|
+
return (
|
|
110
|
+
<View style={[styles.container, { paddingTop: safeAreaTop + scale(20), paddingHorizontal: scale(24), paddingBottom: scale(24) }, style]}>
|
|
111
|
+
{/* Top Row: Labels */}
|
|
112
|
+
<View style={[styles.topRow, { marginBottom: scale(16) }]}>
|
|
113
|
+
<Typography variant="bodySm" color={theme.colors.brown[500]} style={{ textTransform: 'uppercase', letterSpacing: 0.5 }}>
|
|
114
|
+
{routeLabel}
|
|
115
|
+
</Typography>
|
|
116
|
+
{stepLabel ? (
|
|
117
|
+
<Typography variant="bodySmHeavy" color={theme.colors.brown[500]}>
|
|
118
|
+
{stepLabel}
|
|
119
|
+
</Typography>
|
|
120
|
+
) : null}
|
|
121
|
+
</View>
|
|
122
|
+
|
|
123
|
+
{/* Middle Row: Flag, Title, Progress Text */}
|
|
124
|
+
<View style={[styles.middleRow, { marginBottom: scale(20) }]}>
|
|
125
|
+
{/* Bandeira com anel "estás aqui" e shadow 3D */}
|
|
126
|
+
<View style={[styles.flagWrapper, { width: scale(64), height: scale(64) }, styles.flagShadow]}>
|
|
127
|
+
<Flag countryCode={countryCode} size={scale(48)} />
|
|
128
|
+
</View>
|
|
129
|
+
|
|
130
|
+
<View style={[styles.titleContainer, { marginLeft: scale(16) }]}>
|
|
131
|
+
<Typography variant="h1" color={theme.colors.brown[800]}>
|
|
132
|
+
{title}
|
|
133
|
+
</Typography>
|
|
134
|
+
<Typography variant="bodyMdHeavy" color={theme.colors.primary}>
|
|
135
|
+
{subtitle}
|
|
136
|
+
</Typography>
|
|
137
|
+
</View>
|
|
138
|
+
|
|
139
|
+
<View style={styles.progressTextContainer}>
|
|
140
|
+
<Typography variant="h2" color={theme.colors.primary}>
|
|
141
|
+
{progressText}
|
|
142
|
+
</Typography>
|
|
143
|
+
<Typography variant="smallHeavy" color={theme.colors.brown[500]}>
|
|
144
|
+
{progressSubText}
|
|
145
|
+
</Typography>
|
|
146
|
+
</View>
|
|
147
|
+
</View>
|
|
148
|
+
|
|
149
|
+
{/* Bottom Row: Progress Bar */}
|
|
150
|
+
<ProgressBar
|
|
151
|
+
progress={progress}
|
|
152
|
+
segments={segments}
|
|
153
|
+
size="md"
|
|
154
|
+
variant="default" // Usa a cor primária (laranja)
|
|
155
|
+
/>
|
|
156
|
+
</View>
|
|
157
|
+
);
|
|
158
|
+
};
|
|
159
|
+
|
|
160
|
+
const styles = StyleSheet.create({
|
|
161
|
+
container: {
|
|
162
|
+
backgroundColor: theme.colors.brown[50], // cream color matching the image
|
|
163
|
+
width: '100%',
|
|
164
|
+
zIndex: 10,
|
|
165
|
+
// Soft shadow to separate from content below
|
|
166
|
+
...Platform.select({
|
|
167
|
+
web: {
|
|
168
|
+
boxShadow: '0px 4px 12px rgba(0, 0, 0, 0.05)',
|
|
169
|
+
},
|
|
170
|
+
default: {
|
|
171
|
+
shadowColor: '#000',
|
|
172
|
+
shadowOffset: { width: 0, height: 4 },
|
|
173
|
+
shadowOpacity: 0.05,
|
|
174
|
+
shadowRadius: 8,
|
|
175
|
+
elevation: 4,
|
|
176
|
+
}
|
|
177
|
+
}),
|
|
178
|
+
},
|
|
179
|
+
topRow: {
|
|
180
|
+
flexDirection: 'row',
|
|
181
|
+
justifyContent: 'space-between',
|
|
182
|
+
alignItems: 'center',
|
|
183
|
+
},
|
|
184
|
+
middleRow: {
|
|
185
|
+
flexDirection: 'row',
|
|
186
|
+
alignItems: 'center',
|
|
187
|
+
},
|
|
188
|
+
flagWrapper: {
|
|
189
|
+
borderRadius: 999,
|
|
190
|
+
borderWidth: 2.5,
|
|
191
|
+
borderColor: theme.colors.primary, // Anel laranja que significa "estás aqui"
|
|
192
|
+
justifyContent: 'center',
|
|
193
|
+
alignItems: 'center',
|
|
194
|
+
backgroundColor: theme.colors.white,
|
|
195
|
+
},
|
|
196
|
+
flagShadow: {
|
|
197
|
+
...Platform.select({
|
|
198
|
+
web: {
|
|
199
|
+
boxShadow: `0px 4px 0px ${theme.colors.brown[100]}`,
|
|
200
|
+
},
|
|
201
|
+
default: {
|
|
202
|
+
shadowColor: theme.colors.brown[100],
|
|
203
|
+
shadowOffset: { width: 0, height: 4 },
|
|
204
|
+
shadowOpacity: 1,
|
|
205
|
+
shadowRadius: 0,
|
|
206
|
+
elevation: 4,
|
|
207
|
+
}
|
|
208
|
+
})
|
|
209
|
+
},
|
|
210
|
+
titleContainer: {
|
|
211
|
+
flex: 1,
|
|
212
|
+
justifyContent: 'center',
|
|
213
|
+
},
|
|
214
|
+
progressTextContainer: {
|
|
215
|
+
alignItems: 'flex-end',
|
|
216
|
+
justifyContent: 'center',
|
|
217
|
+
},
|
|
218
|
+
});
|
|
@@ -135,7 +135,7 @@ const TabBarItemComponent = ({
|
|
|
135
135
|
variant="smallHeavy"
|
|
136
136
|
style={[
|
|
137
137
|
styles.label,
|
|
138
|
-
{ color, fontSize: scaleText(
|
|
138
|
+
{ color, fontSize: scaleText(11.5) },
|
|
139
139
|
variant === 'C' && { marginBottom: scale(4) }
|
|
140
140
|
]}
|
|
141
141
|
>
|
|
@@ -162,7 +162,7 @@ export const TabBar = ({
|
|
|
162
162
|
const { scale, scaleText, width } = useResponsive();
|
|
163
163
|
const isDesktop = width >= 768;
|
|
164
164
|
|
|
165
|
-
const iconSize = isDesktop ? scale(
|
|
165
|
+
const iconSize = isDesktop ? scale(30) : scale(26);
|
|
166
166
|
const bottomPadding = Math.max(scale(14), safeAreaBottom);
|
|
167
167
|
|
|
168
168
|
return (
|
package/src/index.ts
CHANGED
|
@@ -40,6 +40,7 @@ export * from './components/Forms/Switch/Switch';
|
|
|
40
40
|
export * from './components/Forms/TimeSlot/TimeSlot';
|
|
41
41
|
export * from './components/Headers/CountryHeader/CountryHeader';
|
|
42
42
|
export * from './components/Headers/ProfileHeader/ProfileHeader';
|
|
43
|
+
export * from './components/Headers/RouteHeader/RouteHeader';
|
|
43
44
|
export * from './components/Inputs/FillInTheBlank/FillInTheBlank';
|
|
44
45
|
export * from './components/Inputs/TextInput/TextInput';
|
|
45
46
|
export * from './components/Mascots/Vava/Vava';
|
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 }
|
|
9
9
|
}) as { fontFamily: string; fontWeight?: '400' | '500' | '600' | '700' | '800' | '900' };
|
|
10
10
|
};
|
|
11
11
|
|
|
@@ -236,31 +236,37 @@ export const theme = {
|
|
|
236
236
|
|
|
237
237
|
// FAMÍLIA NUNITO (Corpo, Labels, Listas e Infos)
|
|
238
238
|
bodyLgHeavy: {
|
|
239
|
-
...font('Nunito-
|
|
239
|
+
...font('Nunito-Black', '900'),
|
|
240
|
+
fontSize: 16,
|
|
241
|
+
lineHeight: 24,
|
|
242
|
+
desktopFontSize: 18,
|
|
243
|
+
},
|
|
244
|
+
bodyLg: {
|
|
245
|
+
...font('Nunito-Black', '900'),
|
|
240
246
|
fontSize: 16,
|
|
241
247
|
lineHeight: 24,
|
|
242
248
|
desktopFontSize: 18,
|
|
243
249
|
},
|
|
244
250
|
bodyMdHeavy: {
|
|
245
|
-
...font('Nunito-
|
|
251
|
+
...font('Nunito-Black', '900'),
|
|
246
252
|
fontSize: 15,
|
|
247
253
|
lineHeight: 22,
|
|
248
254
|
desktopFontSize: 16,
|
|
249
255
|
},
|
|
250
256
|
bodyMd: {
|
|
251
|
-
...font('Nunito-
|
|
257
|
+
...font('Nunito-Black', '900'),
|
|
252
258
|
fontSize: 15,
|
|
253
259
|
lineHeight: 22,
|
|
254
260
|
desktopFontSize: 16,
|
|
255
261
|
},
|
|
256
262
|
bodySmHeavy: {
|
|
257
|
-
...font('Nunito-
|
|
263
|
+
...font('Nunito-Black', '900'),
|
|
258
264
|
fontSize: 13.5,
|
|
259
265
|
lineHeight: 20,
|
|
260
266
|
desktopFontSize: 15,
|
|
261
267
|
},
|
|
262
268
|
bodySm: {
|
|
263
|
-
...font('Nunito-
|
|
269
|
+
...font('Nunito-Black', '900'),
|
|
264
270
|
fontSize: 13.5,
|
|
265
271
|
lineHeight: 20,
|
|
266
272
|
desktopFontSize: 15,
|
|
@@ -295,7 +301,7 @@ export const theme = {
|
|
|
295
301
|
desktopFontSize: 12,
|
|
296
302
|
},
|
|
297
303
|
smallHeavy: {
|
|
298
|
-
...font('Nunito-
|
|
304
|
+
...font('Nunito-Black', '900'),
|
|
299
305
|
fontSize: 10,
|
|
300
306
|
lineHeight: 14,
|
|
301
307
|
desktopFontSize: 11,
|
package/src/utils/iconMap.ts
CHANGED
|
@@ -1,5 +1,4 @@
|
|
|
1
|
-
import { FileText } from 'lucide-react';
|
|
2
|
-
import { Play, Volume2, Volume1, ArrowRight, X, Check, ChevronRight, ChevronLeft, Info, AlertTriangle, Book, Mic, Home, Compass, GraduationCap, FolderOpen, User, ShoppingCart } from 'lucide-react-native';
|
|
1
|
+
import { Play, Volume2, Volume1, ArrowRight, X, Check, ChevronRight, ChevronLeft, Info, AlertTriangle, Book, Mic, Home, Compass, GraduationCap, FolderOpen, User, ShoppingCart, FileText, Wallet } from 'lucide-react-native';
|
|
3
2
|
|
|
4
3
|
/**
|
|
5
4
|
* Para evitar que o Vite e o Metro (React Native) tentem empacotar os 1400+ ícones
|
|
@@ -29,4 +28,5 @@ export const IconMap: Record<string, any> = {
|
|
|
29
28
|
User,
|
|
30
29
|
ShoppingCart,
|
|
31
30
|
FileText,
|
|
31
|
+
Wallet,
|
|
32
32
|
};
|