frontend: add a report screen
Parents:
d7bf3536 file(s) changed
- frontend/app/(tabs)/settings.tsx +29 -1
- frontend/app/report.tsx +199 -0
- frontend/lib/api/client.ts +31 -0
- frontend/lib/errors/translations.ts +3 -0
- frontend/package-lock.json +21 -10
- frontend/package.json +2 -0
frontend/app/(tabs)/settings.tsx
1 1
import {
2 2
TouchableOpacity,
3 3
StyleSheet,
4 4
Text,
5 5
SectionList,
▸ 116 unchanged lines
122 122
}}
123 123
>
124 124
<View
125 125
style={[
126 126
styles.cardIcon,
127 -
{ backgroundColor: Colors.light.accentBlue },
127 +
{ backgroundColor: Colors.light.accentBlue, marginBottom: 5 },
128 128
]}
129 129
>
130 130
<Ionicons
131 131
name="notifications-outline"
132 132
size={20}
▸ 76 unchanged lines
209 209
trackColor={{
210 210
false: Colors.light.disabled,
211 211
true: Colors.light.gray,
212 212
}}
213 213
/>
214 +
</View>
215 +
</Card>
216 +
</Section>
217 +
218 +
<Section>
219 +
<SectionHeader title="Relatório" />
220 +
<Card
221 +
style={{ padding: Spacing.cardGap }}
222 +
onPress={() => router.push("/report")}
223 +
>
224 +
<View
225 +
style={{
226 +
flexDirection: "row",
227 +
alignItems: "center",
228 +
justifyContent: "space-between",
229 +
}}
230 +
>
231 +
<View style={{ flexDirection: "row", alignItems: "center" }}>
232 +
<MaterialIcons
233 +
name="picture-as-pdf"
234 +
size={24}
235 +
color={Colors.light.textSecondary}
236 +
/>
237 +
<Text style={{ marginLeft: 8, ...styles.cardTitle }}>
238 +
Gerar Relatório
239 +
</Text>
240 +
</View>
241 +
<Ionicons name="chevron-forward" size={24} color={textColor} />
214 242
</View>
215 243
</Card>
216 244
</Section>
217 245
218 246
<Section>
▸ 114 unchanged lines
333 361
height: StyleSheet.hairlineWidth,
334 362
backgroundColor: "#D1D5DB",
335 363
marginVertical: 16,
336 364
},
337 365
});
frontend/app/report.tsx
@@ -0,0 +1,199 @@
1 + import React, { useState } from 'react'
2 + import {
3 + View,
4 + Text,
5 + TouchableOpacity,
6 + StyleSheet,
7 + ScrollView,
8 + } from 'react-native'
9 + import { Stack } from 'expo-router'
10 + import { format, subDays } from 'date-fns'
11 + import { Paths, File } from 'expo-file-system'
12 + import * as Sharing from 'expo-sharing'
13 + import { SafeAreaView } from 'react-native-safe-area-context'
14 + import { Button } from '@/components/ui/Button'
15 + import { Section, SectionHeader } from '@/components/ui/Sections'
16 + import { DatePickerField } from '@/components/ui/DatePickerField'
17 + import { apiClient, ApiError } from '@/lib/api'
18 + import { useToast } from '@/context/ToastContext'
19 + import { translateError } from '@/lib/errors/translations'
20 + import { Colors, Spacing, BorderRadius } from '@/constants/theme'
21 +
22 + const PRESETS = [
23 + { label: '7 dias', days: 7 },
24 + { label: '30 dias', days: 30 },
25 + { label: '90 dias', days: 90 },
26 + { label: 'Personalizado', days: 0 },
27 + ]
28 +
29 + export default function ReportScreen() {
30 + const { showToast } = useToast()
31 +
32 + const [preset, setPreset] = useState(30)
33 + const [startDate, setStartDate] = useState(() => subDays(new Date(), 30))
34 + const [endDate, setEndDate] = useState(() => new Date())
35 + const [loading, setLoading] = useState(false)
36 +
37 + const isCustom = preset === 0
38 +
39 + function selectPreset(days: number) {
40 + setPreset(days)
41 + if (days > 0) {
42 + setStartDate(subDays(new Date(), days))
43 + setEndDate(new Date())
44 + }
45 + }
46 +
47 + async function handleGenerate() {
48 + setLoading(true)
49 + try {
50 + const buffer = await apiClient.generateReport(
51 + format(startDate, 'yyyy-MM-dd'),
52 + format(endDate, 'yyyy-MM-dd'),
53 + )
54 +
55 + const fileName = `nexo-report-${format(startDate, 'yyyy-MM')}.pdf`
56 + const file = new File(Paths.cache, fileName)
57 + file.write(new Uint8Array(buffer))
58 + await Sharing.shareAsync(file.uri, {
59 + mimeType: 'application/pdf',
60 + dialogTitle: 'Salvar relatório',
61 + })
62 + } catch (err) {
63 + showToast(
64 + err instanceof ApiError ? translateError(err.message) : 'Erro ao gerar relatório',
65 + 'error',
66 + )
67 + } finally {
68 + setLoading(false)
69 + }
70 + }
71 +
72 + return (
73 + <SafeAreaView style={styles.safe} edges={['bottom']}>
74 + <Stack.Screen options={{ title: 'Relatório' }} />
75 + <ScrollView
76 + style={styles.scroll}
77 + contentContainerStyle={styles.content}
78 + >
79 + <View style={styles.intro}>
80 + <Text style={styles.introTitle}>Seu relatório de humor</Text>
81 + <Text style={styles.introBody}>
82 + Reúne os registros do período escolhido — humores, sentimentos e
83 + níveis de ansiedade, estresse e energia — num PDF pronto para
84 + compartilhar com seu terapeuta ou guardar só pra você.
85 + </Text>
86 + </View>
87 +
88 + <Section>
89 + <SectionHeader title="Período" />
90 + <View style={styles.chips}>
91 + {PRESETS.map((p) => (
92 + <TouchableOpacity
93 + key={p.days}
94 + style={[styles.chip, preset === p.days && styles.chipActive]}
95 + onPress={() => selectPreset(p.days)}
96 + activeOpacity={0.7}
97 + >
98 + <Text
99 + style={[
100 + styles.chipText,
101 + preset === p.days && styles.chipTextActive,
102 + ]}
103 + >
104 + {p.label}
105 + </Text>
106 + </TouchableOpacity>
107 + ))}
108 + </View>
109 + </Section>
110 +
111 + {isCustom && (
112 + <Section>
113 + <SectionHeader title="Intervalo" />
114 + <DatePickerField
115 + label="De"
116 + initialDate={startDate}
117 + onChange={setStartDate}
118 + maximumDate={endDate}
119 + />
120 + <View style={{ height: Spacing.cardGap }} />
121 + <DatePickerField
122 + label="Até"
123 + initialDate={endDate}
124 + onChange={setEndDate}
125 + minimumDate={startDate}
126 + maximumDate={new Date()}
127 + />
128 + </Section>
129 + )}
130 +
131 + <Button
132 + title="Gerar relatório"
133 + variant="primary"
134 + size="large"
135 + loading={loading}
136 + onPress={handleGenerate}
137 + style={{ marginTop: Spacing.sectionGap }}
138 + />
139 + </ScrollView>
140 + </SafeAreaView>
141 + )
142 + }
143 +
144 + const styles = StyleSheet.create({
145 + safe: {
146 + flex: 1,
147 + backgroundColor: Colors.light.background,
148 + },
149 + scroll: {
150 + flex: 1,
151 + },
152 + content: {
153 + paddingHorizontal: Spacing.containerPadding,
154 + paddingTop: Spacing.containerPadding,
155 + paddingBottom: Spacing.sectionGap,
156 + },
157 + intro: {
158 + backgroundColor: Colors.light.surface,
159 + borderRadius: BorderRadius.md,
160 + padding: Spacing.cardGap,
161 + marginBottom: Spacing.cardGap,
162 + },
163 + introTitle: {
164 + fontSize: 15,
165 + fontWeight: '600',
166 + color: Colors.light.text,
167 + marginBottom: 6,
168 + },
169 + introBody: {
170 + fontSize: 13,
171 + lineHeight: 20,
172 + color: Colors.light.textSecondary,
173 + },
174 + chips: {
175 + flexDirection: 'row',
176 + flexWrap: 'wrap',
177 + gap: 8,
178 + },
179 + chip: {
180 + paddingVertical: 8,
181 + paddingHorizontal: 16,
182 + borderRadius: BorderRadius.full,
183 + borderWidth: 1,
184 + borderColor: Colors.light.borderDashed,
185 + backgroundColor: Colors.light.surface,
186 + },
187 + chipActive: {
188 + backgroundColor: Colors.light.tint,
189 + borderColor: Colors.light.tint,
190 + },
191 + chipText: {
192 + fontSize: 14,
193 + fontWeight: '500',
194 + color: Colors.light.text,
195 + },
196 + chipTextActive: {
197 + color: Colors.light.background,
198 + },
199 + })
frontend/lib/api/client.ts
1 1
import * as SecureStore from "expo-secure-store";
2 2
3 3
export class ApiError extends Error {
4 4
status: number;
5 5
fields?: Record<string, string>;
▸ 470 unchanged lines
476 476
}
477 477
478 478
async unlinkMoodFromTrigger(triggerId: string, moodId: string): Promise<void> {
479 479
return this.request(`/triggers/${triggerId}/link-mood/${moodId}`, { method: 'DELETE' });
480 480
}
481 +
482 +
async generateReport(
483 +
periodStart: string,
484 +
periodEnd: string,
485 +
): Promise<ArrayBuffer> {
486 +
const token = await this.getStoredToken()
487 +
488 +
let response: Response
489 +
try {
490 +
response = await fetch(`${API_BASE_URL}/reports`, {
491 +
method: 'POST',
492 +
headers: {
493 +
'Content-Type': 'application/json',
494 +
...(token && { Authorization: `Bearer ${token}` }),
495 +
},
496 +
body: JSON.stringify({ periodStart, periodEnd }),
497 +
})
498 +
} catch {
499 +
throw new ApiError('Sem conexão com o servidor', 0)
500 +
}
501 +
502 +
if (!response.ok) {
503 +
if (response.status === 401) this.onUnauthorizedCallback?.()
504 +
const body = await response.json().catch(() => ({
505 +
error: `HTTP ${response.status}`,
506 +
}))
507 +
throw new ApiError(body.error ?? `HTTP ${response.status}`, response.status)
508 +
}
509 +
510 +
return response.arrayBuffer()
511 +
}
481 512
}
482 513
483 514
export const apiClient = new ApiClient();
frontend/lib/errors/translations.ts
1 1
const MESSAGE_MAP: Record<string, string> = {
2 2
'Required': 'Campo obrigatório',
3 3
'Invalid email': 'Email inválido',
4 4
'Invalid email address': 'Email inválido',
5 5
'Invalid credentials': 'Credenciais inválidas',
▸ 1 unchanged lines
7 7
'Unique constraint violation': 'Email já cadastrado',
8 8
'Network error': 'Erro de conexão',
9 9
'Internal server error': 'Erro interno. Tente novamente.',
10 10
'Invalid or expired token': 'Link inválido ou expirado',
11 11
'No token provided': 'Sessão inválida',
12 +
'periodEnd must be after periodStart and range ≤ 90 days': 'O período deve ter entre 1 e 90 dias',
12 13
};
13 14
14 15
const PATTERN_MAP: Array<[RegExp, string | ((m: RegExpMatchArray) => string)]> = [
15 16
[/String must contain at least (\d+) character\(s\)/, (m) => `Deve ter pelo menos ${m[1]} caractere(s)`],
16 17
[/String must contain at most (\d+) character\(s\)/, (m) => `Deve ter no máximo ${m[1]} caractere(s)`],
▸ 5 unchanged lines
22 23
[/^HTTP \d+$/, () => 'Algo deu errado. Tente novamente.'],
23 24
];
24 25
25 26
export function translateError(message: string): string {
26 27
if (MESSAGE_MAP[message]) return MESSAGE_MAP[message];
28 +
29 +
console.log("Erro foi: ", message)
27 30
28 31
for (const [pattern, replacement] of PATTERN_MAP) {
29 32
const match = message.match(pattern);
30 33
if (match) {
31 34
return typeof replacement === 'function' ? replacement(match) : replacement;
▸ 8 unchanged lines
40 43
): Record<string, string> {
41 44
return Object.fromEntries(
42 45
Object.entries(fields).map(([k, v]) => [k, translateError(v)]),
43 46
);
44 47
}
frontend/package-lock.json
1 1
{
2 2
"name": "orbit",
3 3
"version": "0.0.0",
4 4
"lockfileVersion": 3,
5 5
"requires": true,
▸ 16 unchanged lines
22 22
"date-fns": "^4.1.0",
23 23
"expo": "~54.0.35",
24 24
"expo-constants": "~18.0.13",
25 25
"expo-dev-client": "~6.0.21",
26 26
"expo-device": "~8.0.10",
27 +
"expo-file-system": "~19.0.23",
27 28
"expo-font": "~14.0.12",
28 29
"expo-haptics": "~15.0.8",
29 30
"expo-image": "~3.0.11",
30 31
"expo-image-picker": "~17.0.11",
31 32
"expo-linear-gradient": "~15.0.8",
32 33
"expo-linking": "~8.0.12",
33 34
"expo-notifications": "~0.32.17",
34 35
"expo-router": "~6.0.24",
35 36
"expo-secure-store": "~15.0.8",
37 +
"expo-sharing": "~14.0.8",
36 38
"expo-splash-screen": "~31.0.13",
37 39
"expo-status-bar": "~3.0.9",
38 40
"expo-symbols": "~1.0.8",
39 41
"expo-system-ui": "~6.0.9",
40 42
"expo-web-browser": "~15.0.11",
▸ 7172 unchanged lines
7213 7215
},
7214 7216
"engines": {
7215 7217
"node": "*"
7216 7218
}
7217 7219
},
7220 +
"node_modules/expo-file-system": {
7221 +
"version": "19.0.23",
7222 +
"resolved": "https://registry.npmjs.org/expo-file-system/-/expo-file-system-19.0.23.tgz",
7223 +
"integrity": "sha512-MeGkid9OeNILfT/qonaXHp4f2c15xaB28U/bcN7pqZej0Kx0+6+V7e9ZIXpPHm07zVatxA+QkMTPQEGfmvVOxA==",
7224 +
"license": "MIT",
7225 +
"peerDependencies": {
7226 +
"expo": "*",
7227 +
"react-native": "*"
7228 +
}
7229 +
},
7218 7230
"node_modules/expo-font": {
7219 7231
"version": "14.0.12",
7220 7232
"resolved": "https://registry.npmjs.org/expo-font/-/expo-font-14.0.12.tgz",
7221 7233
"integrity": "sha512-QQzunE2Mxk45AsCWm3tK7OpVljbtVnKD58q4/qliev+cbye1IOduUnRIdD+P7DyButw17G9MTX795kgaQiz5hQ==",
7222 7234
"license": "MIT",
▸ 425 unchanged lines
7648 7660
"license": "MIT",
7649 7661
"engines": {
7650 7662
"node": ">=20.16.0"
7651 7663
}
7652 7664
},
7665 +
"node_modules/expo-sharing": {
7666 +
"version": "14.0.8",
7667 +
"resolved": "https://registry.npmjs.org/expo-sharing/-/expo-sharing-14.0.8.tgz",
7668 +
"integrity": "sha512-A1pPr2iBrxypFDCWVAESk532HK+db7MFXbvO2sCV9ienaFXAk7lIBm6bkqgE6vzRd9O3RGdEGzYx80cYlc089Q==",
7669 +
"license": "MIT",
7670 +
"peerDependencies": {
7671 +
"expo": "*"
7672 +
}
7673 +
},
7653 7674
"node_modules/expo-splash-screen": {
7654 7675
"version": "31.0.13",
7655 7676
"resolved": "https://registry.npmjs.org/expo-splash-screen/-/expo-splash-screen-31.0.13.tgz",
7656 7677
"integrity": "sha512-1epJLC1cDlwwj089R2h8cxaU5uk4ONVAC+vzGiTZH4YARQhL4Stlz1MbR6yAS173GMosvkE6CAeihR7oIbCkDA==",
7657 7678
"license": "MIT",
▸ 242 unchanged lines
7900 7921
}
7901 7922
],
7902 7923
"license": "MIT",
7903 7924
"engines": {
7904 7925
"node": ">=8"
7905 -
}
7906 -
},
7907 -
"node_modules/expo/node_modules/expo-file-system": {
7908 -
"version": "19.0.23",
7909 -
"resolved": "https://registry.npmjs.org/expo-file-system/-/expo-file-system-19.0.23.tgz",
7910 -
"integrity": "sha512-MeGkid9OeNILfT/qonaXHp4f2c15xaB28U/bcN7pqZej0Kx0+6+V7e9ZIXpPHm07zVatxA+QkMTPQEGfmvVOxA==",
7911 -
"license": "MIT",
7912 -
"peerDependencies": {
7913 -
"expo": "*",
7914 -
"react-native": "*"
7915 7926
}
7916 7927
},
7917 7928
"node_modules/expo/node_modules/minimatch": {
7918 7929
"version": "9.0.9",
7919 7930
"resolved": "https://registry.npmjs.org/minimatch/-/minimatch-9.0.9.tgz",
▸ 7122 unchanged lines
15042 15053
}
15043 15054
}
15044 15055
}
15045 15056
}
15046 15057
}
frontend/package.json
1 1
{
2 2
"name": "orbit",
3 3
"main": "expo-router/entry",
4 4
"version": "0.0.0",
5 5
"scripts": {
▸ 18 unchanged lines
24 24
"date-fns": "^4.1.0",
25 25
"expo": "~54.0.35",
26 26
"expo-constants": "~18.0.13",
27 27
"expo-dev-client": "~6.0.21",
28 28
"expo-device": "~8.0.10",
29 +
"expo-file-system": "~19.0.23",
29 30
"expo-font": "~14.0.12",
30 31
"expo-haptics": "~15.0.8",
31 32
"expo-image": "~3.0.11",
32 33
"expo-image-picker": "~17.0.11",
33 34
"expo-linear-gradient": "~15.0.8",
34 35
"expo-linking": "~8.0.12",
35 36
"expo-notifications": "~0.32.17",
36 37
"expo-router": "~6.0.24",
37 38
"expo-secure-store": "~15.0.8",
39 +
"expo-sharing": "~14.0.8",
38 40
"expo-splash-screen": "~31.0.13",
39 41
"expo-status-bar": "~3.0.9",
40 42
"expo-symbols": "~1.0.8",
41 43
"expo-system-ui": "~6.0.9",
42 44
"expo-web-browser": "~15.0.11",
▸ 19 unchanged lines
62 64
"eslint-config-expo": "~10.0.0",
63 65
"typescript": "~5.9.2"
64 66
},
65 67
"private": true
66 68
}