eletrotupi / tcc/ commit / 8537a60

frontend: add a report screen

Pedro Lucas Porcellis porcellis@eletrotupi.com · 4 months ago 8537a60124ae506d0bd448606eb32f95edd465d9 Browse files
Parents d7bf353
Changed 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
}