frontend: add a report screen
Pedro Lucas Porcellis porcellis@eletrotupi.com · 4 months ago
8537a60124ae506d0bd448606eb32f95edd465d9 Browse filesParents
d7bf353Changed files
6
Additions
+285
Deletions
-11
frontend/app/(tabs)/settings.tsx
1 1
import {
2 2
TouchableOpacity,
3 3
StyleSheet,
4 4
Text,
5 5
SectionList,
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}
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>
333 361
height: StyleSheet.hairlineWidth,
334 362
backgroundColor: "#D1D5DB",
335 363
marginVertical: 16,
336 364
},
337 365
});
/dev/null → frontend/app/report.tsx
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>;
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',
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)`],
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;
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,
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",
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",
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",
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",
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": {
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",
62 64
"eslint-config-expo": "~10.0.0",
63 65
"typescript": "~5.9.2"
64 66
},
65 67
"private": true
66 68
}