eletrotupi / tcc/ commit / 8537a60

frontend: add a report screen

Pedro Lucas Porcellis porcellis@eletrotupi.com 29 days ago 8537a60124ae506d0bd448606eb32f95edd465d9
Parents: d7bf353
6 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
}