eletrotupi / tcc/ commit / 424c22f

frontend: notifications and insight first draft

Pedro Lucas Porcellis porcellis@eletrotupi.com 1 month ago 424c22fd87eb81809be1593b7f9bed6b88a55d7b
Parents: 28956b7
12 file(s) changed
  • frontend/app/(tabs)/new/index.tsx +122 -114
  • frontend/app/auth/login.tsx +77 -44
  • frontend/app/index.tsx +6 -6
  • frontend/components/ui/MoodTrendWidget.tsx +117 -0
  • frontend/constants/theme.ts +1 -0
  • frontend/context/AuthContext.tsx +35 -25
  • frontend/hooks/useInsights.queries.ts +37 -0
  • frontend/hooks/useRegisterPushToken.ts +39 -0
  • frontend/lib/api/client.ts +21 -5
  • frontend/lib/api/index.ts +4 -0
  • frontend/lib/api/types.ts +46 -0
  • monografia/monografia.pdf +0 -0
frontend/app/(tabs)/new/index.tsx
1 -
import { useEffect, useState } from 'react';
2 -
import {
3 -
  StyleSheet,
4 -
  View,
5 -
  Text,
6 -
  ActivityIndicator
7 -
} from 'react-native';
1 +
import { useEffect, useState } from "react";
2 +
import { StyleSheet, View, Text, ActivityIndicator } from "react-native";
8 3

9 -
import { ThemedText } from '@/components/misc/themed-text';
10 -
import { ThemedView } from '@/components/misc/themed-view';
11 -
import { ScreenLayout } from '@/components/ui/ScreenLayout';
12 -
import { useAuth } from '@/context/AuthContext';
13 -
import { Grid, Col, Row, Between } from '@/components/ui/LayoutHelpers';
14 -
import { Card } from '@/components/ui/Cards';
15 -
import { Badge } from '@/components/ui/Badge';
16 -
import { Button } from '@/components/ui/Button';
17 -
import { Section, SectionHeader } from '@/components/ui/Sections';
18 -
import { MoodSelector } from '@/components/ui/EmojiSelectors';
19 -
import { Typography, Spacing, Shadows, Colors, BorderRadius } from '@/constants/theme';
20 -
import { Ionicons } from '@expo/vector-icons';
21 -
import { router } from 'expo-router';
4 +
import { ThemedText } from "@/components/misc/themed-text";
5 +
import { ThemedView } from "@/components/misc/themed-view";
6 +
import { ScreenLayout } from "@/components/ui/ScreenLayout";
7 +
import { useAuth } from "@/context/AuthContext";
8 +
import { Grid, Col, Row, Between } from "@/components/ui/LayoutHelpers";
9 +
import { Card } from "@/components/ui/Cards";
10 +
import { Badge } from "@/components/ui/Badge";
11 +
import { Button } from "@/components/ui/Button";
12 +
import { Section, SectionHeader } from "@/components/ui/Sections";
13 +
import { MoodSelector } from "@/components/ui/EmojiSelectors";
22 14
import {
23 -
  MoodDefinition, MOODS
24 -
} from '@/constants/moods'
15 +
  Typography,
16 +
  Spacing,
17 +
  Shadows,
18 +
  Colors,
19 +
  BorderRadius,
20 +
} from "@/constants/theme";
21 +
import { Ionicons } from "@expo/vector-icons";
22 +
import { router } from "expo-router";
23 +
import { MoodDefinition, MOODS } from "@/constants/moods";
25 24

26 -
import {
27 -
  useThemeColor,
28 -
  useMoodEntries
29 -
} from '@/hooks';
25 +
import { useThemeColor, useMoodEntries } from "@/hooks";
30 26

31 -
import MoodEntryLog from '@/components/ui/MoodEntryLog';
27 +
import MoodEntryLog from "@/components/ui/MoodEntryLog";
32 28

33 29
export default function New() {
34 30
  const { user } = useAuth();
35 -
  const [mood, setMood] = useState<string>('good');
31 +
  const [mood, setMood] = useState<string>("good");
36 32
  const { data, isLoading } = useMoodEntries({ limit: 5 });
37 33
  const recentEntries = data?.entries ?? [];
38 34

39 -
  console.log("entries: ", data, recentEntries)
35 +
  console.log("entries: ", data, recentEntries);
40 36

41 37
  const initQuickRegister = () => {
42 38
    // TODO: Set down on camelCase vs kebab case
43 -
    router.navigate(`/new/entry?initialMood=${mood}`)
39 +
    router.navigate(`/new/entry?initialMood=${mood}`);
44 40
  };
45 41

46 42
  return (
47 -
    <ScreenLayout userName={user.firstName} userAvatar={user.avatarURL}
48 -
      onNotificationPress={() => console.log('Notifications')}
49 -
      showNotificationBadge={true}>
50 -
        <Section>
51 -
          <SectionHeader
52 -
            title="Como você está hoje?"
53 -
            subtitle="Sua jornada começa agora"
54 -
            >
55 -
          </SectionHeader>
43 +
    <ScreenLayout
44 +
      userName={user.firstName}
45 +
      userAvatar={user.avatarURL}
46 +
      onNotificationPress={() => console.log("Notifications")}
47 +
      showNotificationBadge={true}
48 +
    >
49 +
      <Section>
50 +
        <SectionHeader
51 +
          title="Como você está hoje?"
52 +
          subtitle="Sua jornada começa agora"
53 +
        ></SectionHeader>
56 54

57 -
          <Card style={{ minHeight: 200 }}>
58 -
            <Col gap={16}>
59 -
              <Between style={styles.quickRegisterHeader}>
60 -
                <ThemedText style={styles.quickRegisterTitle}>
61 -
                  Registro rápido
62 -
                </ThemedText>
55 +
        <Card style={{ minHeight: 200 }}>
56 +
          <Col gap={16}>
57 +
            <Between style={styles.quickRegisterHeader}>
58 +
              <ThemedText style={styles.quickRegisterTitle}>
59 +
                Registro rápido
60 +
              </ThemedText>
63 61

64 -
                <ThemedText style={styles.quickRegisterBadge}>
65 -
                  HOJE
66 -
                </ThemedText>
67 -
              </Between>
62 +
              <ThemedText style={styles.quickRegisterBadge}>HOJE</ThemedText>
63 +
            </Between>
68 64

69 -
              <MoodSelector
70 -
                style={{paddingLeft: 8, paddingRight: 8, paddingTop: 16 }}
71 -
                items={MOODS}
72 -
                value={mood}
73 -
                onSelect={setMood}
74 -
              />
65 +
            <MoodSelector
66 +
              style={{ paddingLeft: 8, paddingRight: 8, paddingTop: 16 }}
67 +
              items={MOODS}
68 +
              value={mood}
69 +
              onSelect={setMood}
70 +
            />
75 71

76 -
              <Button
77 -
                title="Salvar Humor"
78 -
                onPress={initQuickRegister}
79 -
                style={styles.quickRegisterButton}
80 -
              />
81 -
            </Col>
82 -
          </Card>
83 -
        </Section>
84 -

85 -
        {/* TODO: Check whether we gonna have to generate these on demand */}
86 -
        <Section>
87 -
          <Grid gap={4}>
88 -
            <Card style={{ padding: Spacing.cardGap }}>
89 -
              <View style={{ flex: 1, flexDirection: 'row', flexGrow: 0, paddingVertical: 10 }}>
90 -
                <Ionicons name="flash" size={20} color="#64748B" />
72 +
            <Button
73 +
              title="Salvar Humor"
74 +
              onPress={initQuickRegister}
75 +
              style={styles.quickRegisterButton}
76 +
            />
77 +
          </Col>
78 +
        </Card>
79 +
      </Section>
91 80

92 -
                <Text style={{ ...Typography.headlineMd, marginBottom: 10 }}>
93 -
                  Energia
94 -
                </Text>
95 -
              </View>
81 +
      {/* TODO: Check whether we gonna have to generate these on demand */}
82 +
      <Section>
83 +
        <Grid gap={4}>
84 +
          <Card style={{ padding: Spacing.cardGap }}>
85 +
            <View
86 +
              style={{
87 +
                flex: 1,
88 +
                flexDirection: "row",
89 +
                flexGrow: 0,
90 +
                paddingVertical: 10,
91 +
              }}
92 +
            >
93 +
              <Ionicons name="flash" size={20} color="#64748B" />
96 94

97 95
              <Text style={{ ...Typography.headlineMd, marginBottom: 10 }}>
98 -
                Média Alta
96 +
                Energia
99 97
              </Text>
98 +
            </View>
100 99

101 -
              <View style={styles.lineTrack}>
102 -
                <View style={[styles.lineFill, { width: `35%` }]} />
103 -
              </View>
104 -
            </Card>
100 +
            <Text style={{ ...Typography.headlineMd, marginBottom: 10 }}>
101 +
              Média Alta
102 +
            </Text>
105 103

106 -
            <Card style={{ padding: Spacing.cardGap }}>
107 -
              <View style={{ flex: 1, flexDirection: 'row', flexGrow: 0, paddingVertical: 10 }}>
108 -
                <Ionicons name="moon-outline" size={20} color="#64748B" />
104 +
            <View style={styles.lineTrack}>
105 +
              <View style={[styles.lineFill, { width: `35%` }]} />
106 +
            </View>
107 +
          </Card>
109 108

110 -
                <Text style={{ ...Typography.headlineMd, marginBottom: 10 }}>
111 -
                  Sono
112 -
                </Text>
113 -
              </View>
109 +
          <Card style={{ padding: Spacing.cardGap }}>
110 +
            <View
111 +
              style={{
112 +
                flex: 1,
113 +
                flexDirection: "row",
114 +
                flexGrow: 0,
115 +
                paddingVertical: 10,
116 +
              }}
117 +
            >
118 +
              <Ionicons name="moon-outline" size={20} color="#64748B" />
114 119

115 120
              <Text style={{ ...Typography.headlineMd, marginBottom: 10 }}>
116 -
                7h 30m
121 +
                Sono
117 122
              </Text>
123 +
            </View>
118 124

119 -
              <Text style={styles.indicatorGreen}>
120 -
                +45min que ontem
121 -
              </Text>
122 -
            </Card>
123 -
          </Grid>
124 -
        </Section>
125 +
            <Text style={{ ...Typography.headlineMd, marginBottom: 10 }}>
126 +
              7h 30m
127 +
            </Text>
125 128

126 -
        <Section>
127 -
          <SectionHeader title="Registros recentes" />
129 +
            <Text style={styles.indicatorGreen}>+45min que ontem</Text>
130 +
          </Card>
131 +
        </Grid>
132 +
      </Section>
128 133

129 -
          {isLoading ? (
130 -
            <ActivityIndicator />
131 -
          ) : (
132 -
            <Col gap={8}>
133 -
              {recentEntries.map((entry) => (
134 -
                <MoodEntryLog key={entry.id} entry={entry} />
135 -
              ))}
136 -
            </Col>
137 -
          )}
138 -
        </Section>
134 +
      <Section>
135 +
        <SectionHeader title="Registros recentes" />
136 +

137 +
        {isLoading ? (
138 +
          <ActivityIndicator />
139 +
        ) : (
140 +
          <Col gap={8}>
141 +
            {recentEntries.map((entry) => (
142 +
              <MoodEntryLog key={entry.id} entry={entry} />
143 +
            ))}
144 +
          </Col>
145 +
        )}
146 +
      </Section>
139 147
    </ScreenLayout>
140 -
  )
148 +
  );
141 149
}
142 150

143 151
const styles = StyleSheet.create({
144 152
  quickRegisterHeader: {
145 153
    marginTop: 16,
146 154
    paddingLeft: 16,
147 -
    paddingRight: 16
155 +
    paddingRight: 16,
148 156
  },
149 157
  quickRegisterTitle: {
150 -
    ...Typography.bodyLg
158 +
    ...Typography.bodyLg,
151 159
  },
152 160
  // TODO: Replace when we have badges
153 161
  quickRegisterBadge: {
154 162
    fontSize: 12,
155 -
    lineHeight: 16
163 +
    lineHeight: 16,
156 164
  },
157 165
  quickRegisterButton: {
158 166
    marginLeft: 16,
159 167
    marginRight: 16,
160 168
    marginBottom: 16,
161 -
    ...Shadows.lg
169 +
    ...Shadows.lg,
162 170
  },
163 171
  lineTrack: {
164 172
    height: 6,
165 173
    backgroundColor: Colors.light.divider,
166 174
    borderRadius: BorderRadius.full,
167 175
    marginBottom: 4,
168 -
    overflow: 'hidden',
176 +
    overflow: "hidden",
169 177
  },
170 178
  lineFill: {
171 -
    height: '100%',
179 +
    height: "100%",
172 180
    backgroundColor: Colors.light.tint,
173 181
    borderRadius: BorderRadius.full,
174 182
  },
175 183
  indicatorGreen: {
176 184
    color: Colors.light.tint,
177 185
    fontSize: 11,
178 186
    fontWeight: 600,
179 -
    lineHeight: 16.5
180 -
  }
187 +
    lineHeight: 16.5,
188 +
  },
181 189
});
frontend/app/auth/login.tsx
1 -
import React, { useState } from 'react';
1 +
import React, { useState } from "react";
2 2
import {
3 3
  View,
4 4
  Text,
5 5
  StyleSheet,
6 6
  ScrollView,
7 7
  KeyboardAvoidingView,
8 8
  Platform,
9 9
  Alert,
10 -
} from 'react-native';
11 -
import { Link, router } from 'expo-router';
12 -
import { SafeAreaView } from 'react-native-safe-area-context';
13 -
import { useThemeColor } from '@/hooks/use-theme-color';
14 -
import { Button, Input } from '@/components/ui';
15 -
import { useAuth } from '@/context/AuthContext';
16 -
import { apiClient, User } from '@/lib/api';
10 +
} from "react-native";
11 +
import { Link, router } from "expo-router";
12 +
import { SafeAreaView } from "react-native-safe-area-context";
13 +
import { useThemeColor } from "@/hooks/use-theme-color";
14 +
import { Button, Input } from "@/components/ui";
15 +
import { useAuth } from "@/context/AuthContext";
16 +
import { apiClient, User } from "@/lib/api";
17 +
import { registerForPushNotifications } from "@/hooks/useRegisterPushToken";
17 18

18 19
export default function LoginScreen() {
19 -
  const [email, setEmail] = useState('');
20 -
  const [password, setPassword] = useState('');
21 -
  const [errors, setErrors] = useState<{ email?: string; password?: string }>({});
20 +
  const [email, setEmail] = useState("");
21 +
  const [password, setPassword] = useState("");
22 +
  const [errors, setErrors] = useState<{ email?: string; password?: string }>(
23 +
    {},
24 +
  );
22 25

23 -
  const { login, isLoading } = useAuth();
24 -
  const textColor = useThemeColor({}, 'text');
25 -
  const backgroundColor = useThemeColor({}, 'background');
26 -
  const tintColor = useThemeColor({}, 'tint');
26 +
  const { login, updateAuthUser, isLoading } = useAuth();
27 +
  const textColor = useThemeColor({}, "text");
28 +
  const backgroundColor = useThemeColor({}, "background");
29 +
  const tintColor = useThemeColor({}, "tint");
27 30

28 31
  const validateForm = () => {
29 32
    const newErrors: { email?: string; password?: string } = {};
30 33

31 34
    if (!email) {
32 -
      newErrors.email = 'Email é obrigatório';
35 +
      newErrors.email = "Email é obrigatório";
33 36
    } else if (!/\S+@\S+\.\S+/.test(email)) {
34 -
      newErrors.email = 'Por favor, use um email válido';
37 +
      newErrors.email = "Por favor, use um email válido";
35 38
    }
36 39

37 40
    if (!password) {
38 -
      newErrors.password = 'Senha é obrigatória';
41 +
      newErrors.password = "Senha é obrigatória";
39 42
    }
40 43

41 44
    setErrors(newErrors);
42 45
    return Object.keys(newErrors).length === 0;
43 46
  };
44 47

45 48
  const handleLogin = async () => {
46 49
    if (!validateForm()) return;
47 50

48 51
    try {
49 -
      await login(email, password);
50 -
      router.replace('/');
52 +
      const response = await login(email, password);
53 +

54 +
      if (response?.user) {
55 +
        const token = await registerForPushNotifications();
56 +
        if (token) {
57 +
          const updateResponse = await apiClient.updateUser({
58 +
            id: response.user.id,
59 +
            pushToken: token,
60 +
          });
61 +

62 +
          console.log("UPDATE USER response: ", updateResponse);
63 +

64 +
          if (updateResponse) {
65 +
            updateAuthUser(updateResponse.user);
66 +
          }
67 +
        }
68 +
      }
69 +

70 +
      router.replace("/");
51 71
    } catch (error: any) {
52 -
      console.log("LOGIN error: ", error)
53 -
      Alert.alert('Erro: ', 'Falha ao fazer login');
72 +
      console.log("LOGIN error: ", error);
73 +
      Alert.alert("Erro: ", "Falha ao fazer login");
54 74
    }
55 75
  };
56 76

57 77
  return (
58 78
    <SafeAreaView style={[styles.container, { backgroundColor }]}>
59 -
      <KeyboardAvoidingView
60 -
        behavior='height'
61 -
        style={styles.keyboardView}
62 -
      >
79 +
      <KeyboardAvoidingView behavior="height" style={styles.keyboardView}>
63 80
        <ScrollView
64 81
          contentContainerStyle={styles.scrollContainer}
65 82
          showsVerticalScrollIndicator={false}
66 83
        >
67 84
          <View style={styles.header}>
68 -
            <Text style={[styles.title, { color: textColor }]}>Bem vindo de volta</Text>
85 +
            <Text style={[styles.title, { color: textColor }]}>
86 +
              Bem vindo de volta
87 +
            </Text>
69 88
            <Text style={[styles.subtitle, { color: textColor, opacity: 0.7 }]}>
70 89
              Logue na sua conta nexo
71 90
            </Text>
72 91
          </View>
73 92

▸ 23 unchanged lines
97 116
              onPress={handleLogin}
98 117
              loading={isLoading}
99 118
              style={styles.loginButton}
100 119
            />
101 120

102 -
            <Link href="/auth/forgot-password" style={[styles.link, {color: tintColor }]} asChild>
103 -
              <Text style={[styles.footerText, { color: textColor, opacity: 0.7 }]}>
121 +
            <Link
122 +
              href="/auth/forgot-password"
123 +
              style={[styles.link, { color: tintColor }]}
124 +
              asChild
125 +
            >
126 +
              <Text
127 +
                style={[styles.footerText, { color: textColor, opacity: 0.7 }]}
128 +
              >
104 129
                Esqueceu a senha?
105 130
              </Text>
106 131
            </Link>
107 132
          </View>
108 133

109 134
          <View style={styles.footer}>
110 -
            <Text style={[styles.footerText, { color: textColor, opacity: 0.7 }]}>
111 -
              Não tem uma conta ainda?{' '}
135 +
            <Text
136 +
              style={[styles.footerText, { color: textColor, opacity: 0.7 }]}
137 +
            >
138 +
              Não tem uma conta ainda?{" "}
112 139
            </Text>
113 140

114 -
            <Link href="/auth/signup" asChild style={[styles.link, { color: tintColor }]}>
115 -
              <Text style={[styles.footerText, { color: textColor, opacity: 0.7 }]}>
141 +
            <Link
142 +
              href="/auth/signup"
143 +
              asChild
144 +
              style={[styles.link, { color: tintColor }]}
145 +
            >
146 +
              <Text
147 +
                style={[styles.footerText, { color: textColor, opacity: 0.7 }]}
148 +
              >
116 149
                Cadastre-se
117 150
              </Text>
118 151
            </Link>
119 152
          </View>
120 153
        </ScrollView>
▸ 10 unchanged lines
131 164
    flex: 1,
132 165
  },
133 166
  scrollContainer: {
134 167
    flexGrow: 1,
135 168
    paddingHorizontal: 24,
136 -
    justifyContent: 'center',
137 -
    minHeight: '100%',
169 +
    justifyContent: "center",
170 +
    minHeight: "100%",
138 171
  },
139 172
  header: {
140 -
    alignItems: 'center',
173 +
    alignItems: "center",
141 174
    marginBottom: 40,
142 175
  },
143 176
  title: {
144 177
    fontSize: 28,
145 -
    fontWeight: 'bold',
178 +
    fontWeight: "bold",
146 179
    marginBottom: 8,
147 180
  },
148 181
  subtitle: {
149 182
    fontSize: 16,
150 -
    textAlign: 'center',
183 +
    textAlign: "center",
151 184
  },
152 185
  form: {
153 186
    marginBottom: 32,
154 187
  },
155 188
  loginButton: {
156 189
    marginTop: 8,
157 190
    marginBottom: 16,
158 191
  },
159 192
  link: {
160 -
    textAlign: 'center',
193 +
    textAlign: "center",
161 194
    fontSize: 14,
162 -
    fontWeight: '500',
195 +
    fontWeight: "500",
163 196
  },
164 197
  footer: {
165 -
    flexDirection: 'row',
166 -
    justifyContent: 'center',
167 -
    alignItems: 'center',
168 -
    marginTop: 'auto',
198 +
    flexDirection: "row",
199 +
    justifyContent: "center",
200 +
    alignItems: "center",
201 +
    marginTop: "auto",
169 202
    paddingBottom: 40,
170 203
  },
171 204
  footerText: {
172 205
    fontSize: 14,
173 206
  },
174 207
});
frontend/app/index.tsx
1 -
import { Redirect } from 'expo-router';
2 -
import { useAuth } from '@/context/AuthContext';
3 -
import { ActivityIndicator, View } from 'react-native';
1 +
import { Redirect } from "expo-router";
2 +
import { useAuth } from "@/context/AuthContext";
3 +
import { ActivityIndicator, View } from "react-native";
4 4

5 5
export default function Index() {
6 6
  const { isAuthenticated, isLoading, user } = useAuth();
7 7
  let url = "/auth/login";
8 8

9 9
  if (isLoading) {
10 10
    return (
11 -
      <View style={{ flex: 1, justifyContent: 'center', alignItems: 'center' }}>
11 +
      <View style={{ flex: 1, justifyContent: "center", alignItems: "center" }}>
12 12
        <ActivityIndicator size="large" />
13 13
      </View>
14 14
    );
15 15
  }
16 16

17 17
  if (isAuthenticated) {
18 18
    if (user.active) {
19 -
      url = "/(tabs)/new"
19 +
      url = "/(tabs)/new";
20 20
    } else {
21 -
      url = "/auth/activate"
21 +
      url = "/auth/activate";
22 22
    }
23 23
  }
24 24

25 25
  return <Redirect href={url} />;
26 26
}
frontend/components/ui/MoodTrendWidget.tsx
@@ -0,0 +1,117 @@
1 + import React from "react";
2 + import { View, Text, StyleSheet, ActivityIndicator } from "react-native";
3 + import { Ionicons } from "@expo/vector-icons";
4 + import { Card } from "@/components/ui/Cards";
5 + import { useInsight } from "@/hooks/useInsights.queries";
6 + import { useThemeColor } from "@/hooks/use-theme-color";
7 + import { Spacing, Typography, BorderRadius } from "@/constants/theme";
8 +
9 + export const MoodTrendWidget: React.FC = () => {
10 + const { data: insight, isLoading } = useInsight("MOOD_TREND");
11 +
12 + const successColor = useThemeColor({}, "success");
13 + const errorColor = useThemeColor({}, "danger");
14 + const textSecondary = useThemeColor({}, "textSecondary");
15 +
16 + if (isLoading) {
17 + return (
18 + <Card style={styles.container}>
19 + <ActivityIndicator />
20 + </Card>
21 + );
22 + }
23 +
24 + if (!insight) {
25 + return (
26 + <Card style={styles.container}>
27 + <Text style={styles.label}>Humor</Text>
28 + <Text style={styles.empty}>
29 + Registre mais humores para ver tendências.
30 + </Text>
31 + </Card>
32 + );
33 + }
34 +
35 + const meta = insight.metadata as {
36 + delta: number;
37 + dominantMood: string;
38 + avgEnergy: number;
39 + };
40 +
41 + const isPositive = meta.delta > 0;
42 + const isNeutral = meta.delta === 0;
43 + const deltaColor = isNeutral
44 + ? textSecondary
45 + : isPositive
46 + ? successColor
47 + : errorColor;
48 + const iconName = isNeutral
49 + ? "remove-outline"
50 + : isPositive
51 + ? "trending-up-outline"
52 + : "trending-down-outline";
53 +
54 + return (
55 + <Card style={styles.container}>
56 + <View style={styles.header}>
57 + <Ionicons name="partly-sunny-outline" size={14} color={textSecondary} />
58 + <Text style={styles.label}>Humor</Text>
59 + </View>
60 +
61 + <View style={styles.valueRow}>
62 + <Text style={styles.value}>
63 + {MOOD_LABEL[meta.dominantMood] ?? meta.dominantMood}
64 + </Text>
65 + <Ionicons name={iconName} size={18} color={deltaColor} />
66 + </View>
67 +
68 + {!isNeutral && (
69 + <Text style={[styles.delta, { color: deltaColor }]}>
70 + {isPositive ? "+" : ""}
71 + {meta.delta}% vs período anterior
72 + </Text>
73 + )}
74 + </Card>
75 + );
76 + };
77 +
78 + const MOOD_LABEL: Record<string, string> = {
79 + GREAT: "Ótimo",
80 + GOOD: "Bom",
81 + NEUTRAL: "Neutro",
82 + SAD: "Triste",
83 + ANGRY: "Irritado",
84 + };
85 +
86 + const styles = StyleSheet.create({
87 + container: {
88 + flex: 1,
89 + },
90 + header: {
91 + flexDirection: "row",
92 + alignItems: "center",
93 + gap: 4,
94 + marginBottom: 10,
95 + },
96 + label: {
97 + ...Typography.labelXs,
98 + opacity: 0.6,
99 + },
100 + valueRow: {
101 + flexDirection: "row",
102 + alignItems: "center",
103 + justifyContent: "space-between",
104 + },
105 + value: {
106 + ...Typography.bodyLg,
107 + },
108 + delta: {
109 + ...Typography.labelSm,
110 + marginTop: 2,
111 + },
112 + empty: {
113 + ...Typography.labelXs,
114 + opacity: 0.5,
115 + marginTop: 4,
116 + },
117 + });
frontend/constants/theme.ts
1 1
/**
2 2
 * Below are the colors that are used in the app. The colors are defined in the light and dark mode.
3 3
 * There are many other ways to style your app. For example, [Nativewind](https://www.nativewind.dev/), [Tamagui](https://tamagui.dev/), [unistyles](https://reactnativeunistyles.vercel.app), etc.
4 4
 */
5 5

▸ 72 unchanged lines
78 78
    // Disabled / Secondary
79 79
    disabled: "#cbd5e1",
80 80
    divider: BORDER_SUBTLE,
81 81
    sliderTracking: "#E2E8F0",
82 82
    sliderLabels: "#94a3b8",
83 +
    success: "#22c55e",
83 84
    danger: "#EF4444",
84 85
    textBlack: PRIMARY_DARK,
85 86
  },
86 87
};
87 88

▸ 136 unchanged lines
224 225
  typography: Typography,
225 226
  spacing: Spacing,
226 227
  shadows: Shadows,
227 228
  borderRadius: BorderRadius,
228 229
};
frontend/context/AuthContext.tsx
1 -
import React, { createContext, useContext, useEffect, useState } from 'react';
2 -
import { apiClient, User } from '@/lib/api';
3 -
import { queryClient, storage } from '@/lib/queryClient';
1 +
import React, { createContext, useContext, useEffect, useState } from "react";
2 +
import { apiClient, User } from "@/lib/api";
3 +
import { queryClient, storage } from "@/lib/queryClient";
4 4

5 5
interface AuthProviderProps {
6 6
  children: React.ReactNode;
7 7
}
8 8

9 9
interface AuthContextType {
10 10
  user: User | null;
11 11
  isLoading: boolean;
12 12
  isAuthenticated: boolean;
13 13
  updateAuthUser: (user: User) => void;
14 -
  login: (email: string, password: string) => Promise<void>;
14 +
  login: (email: string, password: string) => Promise<{ user: User } | null>;
15 15
  signup: (userData: {
16 16
    firstName: string;
17 17
    lastName?: string;
18 18
    email: string;
19 19
    password: string;
20 20
  }) => Promise<void>;
21 21
  logout: () => Promise<void>;
22 -
  forgotPassword: (email: string) => Promise<{ message: string; token?: string }>;
22 +
  forgotPassword: (
23 +
    email: string,
24 +
  ) => Promise<{ message: string; token?: string }>;
23 25
  resetPassword: (token: string, password: string) => Promise<void>;
24 26
  activate: (code: string) => Promise<void>;
25 27
  requestActivateCode: () => Promise<void>;
26 28
}
27 29

▸ 4 unchanged lines
32 34
    firstName: data.firstName,
33 35
    lastName: data.lastName,
34 36
    updatedAt: data.updatedAt,
35 37
    avatarKey: data.avatarKey,
36 38
    avatarURL: data.avatarURL,
37 -
    active: data.active
39 +
    active: data.active,
38 40
  };
39 41
}
40 42

41 43
const AuthContext = createContext<AuthContextType | null>(null);
42 44

43 45
export function useAuth() {
44 46
  const context = useContext(AuthContext);
45 47
  if (!context) {
46 -
    throw new Error('useAuth must be used within an AuthProvider');
48 +
    throw new Error("useAuth must be used within an AuthProvider");
47 49
  }
48 50

49 51
  return context;
50 52
}
51 53

▸ 11 unchanged lines
63 65
    try {
64 66
      const { token } = await apiClient.getStoredAuthData();
65 67

66 68
      if (token) {
67 69
        const verifyResponse = await apiClient.verifyToken();
68 -
        if (verifyResponse.valid && verifyResponse.userId && verifyResponse.email) {
70 +
        if (
71 +
          verifyResponse.valid &&
72 +
          verifyResponse.userId &&
73 +
          verifyResponse.email
74 +
        ) {
69 75
          // TODO: Fetch the full user profile
70 76
          // For now, we'll create a basic user object
71 77
          setUser({
72 78
            id: verifyResponse.userId,
73 79
            email: verifyResponse.email,
74 -
            ...verifyResponse.user
80 +
            ...verifyResponse.user,
75 81
          });
76 82
        }
77 83
      }
78 84
    } catch (error) {
79 -
      console.error('Auth check failed:', error);
85 +
      console.error("Auth check failed:", error);
80 86
      // Token is invalid, wipe out any stored data
81 87
      await apiClient.logout();
82 88
    } finally {
83 89
      setIsLoading(false);
84 90
    }
85 91
  };
86 92

87 -
  const login = async (email: string, password: string) => {
93 +
  const login = async (
94 +
    email: string,
95 +
    password: string,
96 +
  ): Promise<{ user: User } | null> => {
88 97
    setIsLoading(true);
89 98

90 99
    try {
91 100
      const response = await apiClient.login(email, password);
92 -
      setUser(response.user);
101 +

102 +
      if (response.user) {
103 +
        setUser(response.user);
104 +
      }
105 +

106 +
      return response;
93 107
    } finally {
94 108
      setIsLoading(false);
95 109
    }
96 110
  };
97 111

▸ 36 unchanged lines
134 148

135 149
  const activate = async (code: number) => {
136 150
    const response = await apiClient.activate(code);
137 151

138 152
    if (response.user) {
139 -
      setUser(response.user)
153 +
      setUser(response.user);
140 154
    }
141 155

142 -
    return response
143 -
  }
156 +
    return response;
157 +
  };
144 158

145 -
  const requestActivateCode = async() => {
159 +
  const requestActivateCode = async () => {
146 160
    await apiClient.requestActivateCode();
147 -
  }
161 +
  };
148 162

149 163
  const updateAuthUser = (user) => {
150 164
    setUser(user);
151 -
  }
165 +
  };
152 166

153 167
  const value: AuthContextType = {
154 168
    user,
155 169
    isLoading,
156 170
    isAuthenticated,
▸ 2 unchanged lines
159 173
    logout,
160 174
    forgotPassword,
161 175
    resetPassword,
162 176
    updateAuthUser,
163 177
    activate,
164 -
    requestActivateCode
178 +
    requestActivateCode,
165 179
  };
166 180

167 -
  return (
168 -
    <AuthContext.Provider value={value}>
169 -
      {children}
170 -
    </AuthContext.Provider>
171 -
  );
172 -
}
181 +
  return <AuthContext.Provider value={value}>{children}</AuthContext.Provider>;
182 +
};
frontend/hooks/useInsights.queries.ts
@@ -0,0 +1,37 @@
1 + import { useQuery } from "@tanstack/react-query";
2 + import { apiClient, Insight, InsightType, InsightPeriod } from "@/lib/api";
3 +
4 + export const insightKeys = {
5 + all: () => ["insights"] as const,
6 + lists: () => [...insightKeys.all(), "list"] as const,
7 + list: (filters?: InsightFilters) =>
8 + [...insightKeys.lists(), filters] as const,
9 + byType: (type: InsightType) => [...insightKeys.all(), "type", type] as const,
10 + };
11 +
12 + interface InsightFilters {
13 + type?: InsightType;
14 + period?: InsightPeriod;
15 + limit?: number;
16 + }
17 +
18 + // All insights — for the insights page
19 + export const useInsights = (filters?: InsightFilters) => {
20 + return useQuery({
21 + queryKey: insightKeys.list(filters),
22 + queryFn: () => apiClient.getInsights(filters),
23 + staleTime: 1000 * 60 * 15,
24 + });
25 + };
26 +
27 + // Single type — for widgets that only care about one insight
28 + // Each call caches independently, so mood log page doesn't blow
29 + // away what the insights page already fetched
30 + export const useInsight = (type: InsightType) => {
31 + return useQuery({
32 + queryKey: insightKeys.byType(type),
33 + queryFn: () =>
34 + apiClient.getInsights({ type, limit: 1 }).then((res) => res[0] ?? null),
35 + staleTime: 1000 * 60 * 15,
36 + });
37 + };
frontend/hooks/useRegisterPushToken.ts
@@ -0,0 +1,39 @@
1 + // hooks/useRegisterPushToken.ts
2 + import * as Notifications from "expo-notifications";
3 + import * as Device from "expo-device";
4 + import Constants from "expo-constants";
5 +
6 + export async function registerForPushNotifications(): Promise<string | null> {
7 + if (!Device.isDevice) return null;
8 +
9 + // Request permission
10 + const { status: existingStatus } = await Notifications.getPermissionsAsync();
11 + let finalStatus = existingStatus;
12 +
13 + if (existingStatus !== "granted") {
14 + const { status } = await Notifications.requestPermissionsAsync();
15 + finalStatus = status;
16 + }
17 +
18 + if (finalStatus !== "granted") {
19 + console.warn("Push notification permission denied");
20 + return null;
21 + }
22 +
23 + // TODO: Experiment with different vibration patterns
24 + await Notifications.setNotificationChannelAsync("default", {
25 + name: "default",
26 + importance: Notifications.AndroidImportance.MAX,
27 + vibrationPattern: [0, 250, 250, 250],
28 + });
29 +
30 + // Get the Expo push token
31 + const projectId = Constants.expoConfig?.extra?.eas?.projectId;
32 + const token = (
33 + await Notifications.getExpoPushTokenAsync({
34 + projectId,
35 + })
36 + ).data;
37 +
38 + return token;
39 + }
frontend/lib/api/client.ts
1 1
import * as SecureStore from "expo-secure-store";
2 2
import {
3 3
  User,
4 4
  AuthResponse,
5 5
  VerifyTokenResponse,
▸ 10 unchanged lines
16 16
  Trigger,
17 17
  TriggerResponse,
18 18
  CreateTriggerPayload,
19 19
  ActivateResponse,
20 20
  PaginatedResponse,
21 +
  InsightType,
22 +
  InsightPeriod,
23 +
  InsightMetadata,
24 +
  Insight,
21 25
} from "@/lib/api/types";
22 26

23 27
const API_BASE_URL = process.env.EXPO_PUBLIC_API_BASE_URL;
24 28
const TOKEN_KEY = process.env.EXPO_PUBLIC_TOKEN_KEY;
25 29

▸ 59 unchanged lines
85 89

86 90
    return response.json();
87 91
  }
88 92

89 93
  // Auth
90 -
  async login(
91 -
    email: string,
92 -
    password: string,
93 -
  ): Promise<AuthResponse | { isActive: boolean }> {
94 +
  async login(email: string, password: string): Promise<AuthResponse> {
94 95
    const response = await this.request<AuthResponse>("/auth/login", {
95 96
      method: "POST",
96 97
      body: JSON.stringify({ email, password }),
97 98
    });
98 99

▸ 57 unchanged lines
156 157
  async logout(): Promise<void> {
157 158
    await this.removeToken();
158 159
  }
159 160

160 161
  // User-related
161 -
  async updateUser(user: UserUpdatePayload): Promise<User> {
162 +
  async updateUser(user: UserUpdatePayload): Promise<{ user: User }> {
162 163
    return await this.request(`/users/${user.id}`, {
163 164
      method: "PUT",
164 165
      body: JSON.stringify({ user }),
165 166
    });
166 167
  }
▸ 102 unchanged lines
269 270
    return this.request(`/triggers/${id}`);
270 271
  }
271 272

272 273
  async deleteTrigger(id: string): Promise<void> {
273 274
    return this.request(`/triggers/${id}`, { method: "DELETE" });
275 +
  }
276 +

277 +
  // Insights
278 +
  async getInsights(filters?: {
279 +
    type?: InsightType;
280 +
    period?: InsightPeriod;
281 +
    limit?: number;
282 +
  }): Promise<Insight[]> {
283 +
    const query = new URLSearchParams();
284 +
    if (filters?.type) query.set("type", filters.type);
285 +
    if (filters?.period) query.set("period", filters.period);
286 +
    if (filters?.limit) query.set("limit", String(filters.limit));
287 +

288 +
    const qs = query.toString();
289 +
    return this.request(`/insights${qs ? `?${qs}` : ""}`);
274 290
  }
275 291
}
276 292

277 293
export const apiClient = new ApiClient();
frontend/lib/api/index.ts
1 1
export { apiClient } from "@/lib/api/client";
2 2

3 3
export type {
4 4
  User,
5 5
  AuthResponse,
▸ 8 unchanged lines
14 14
  SleepRecordPayload,
15 15
  SleepRecord,
16 16
  // Trigger
17 17
  Trigger,
18 18
  CreateTriggerPayload,
19 +
  // Insights
20 +
  Insight,
21 +
  InsightType,
22 +
  InsightPeriod,
19 23
} from "@/lib/api/types";
frontend/lib/api/types.ts
1 1
export interface User {
2 2
  id: number;
3 3
  email: string;
4 4
  firstName: string;
5 5
  lastName?: string;
6 6
  updatedAt: Date;
7 7
  avatarKey?: string;
8 8
  avatarURL?: string;
9 9
  active: boolean;
10 +
  pushToken?: string;
10 11
}
11 12

12 13
// Auth
13 14
export interface AuthResponse {
14 15
  token: string;
▸ 104 unchanged lines
119 120
export interface CreateTriggerPayload {
120 121
  comment: string;
121 122
  category: string;
122 123
  moment: Date;
123 124
}
125 +

126 +
// Insights
127 +

128 +
// types/insight.ts
129 +
// Gotta keep those in sync with the Prisma schema
130 +
export type InsightType =
131 +
  | "MOOD_TREND"
132 +
  | "ENERGY_SLEEP_CORRELATION"
133 +
  | "TRIGGER_PATTERN";
134 +

135 +
export type InsightPeriod = "DAILY" | "WEEKLY" | "MONTHLY";
136 +

137 +
export type InsightMetadata =
138 +
  | {
139 +
      type: "MOOD_TREND";
140 +
      delta: number;
141 +
      avgFirst: number;
142 +
      avgSecond: number;
143 +
      dominantMood: string;
144 +
      avgEnergy: number;
145 +
    }
146 +
  | {
147 +
      type: "ENERGY_SLEEP_CORRELATION";
148 +
      correlationScore: number;
149 +
      sampleSize: number;
150 +
    }
151 +
  | {
152 +
      type: "TRIGGER_PATTERN";
153 +
      topCategory: string;
154 +
      topCount: number;
155 +
      total: number;
156 +
      distribution: Record<string, number>;
157 +
    };
158 +

159 +
export type Insight = {
160 +
  id: number;
161 +
  type: InsightType;
162 +
  period: InsightPeriod;
163 +
  title: string;
164 +
  body: string;
165 +
  metadata: InsightMetadata;
166 +
  generatedAt: string;
167 +
  periodStart: string;
168 +
  periodEnd: string;
169 +
};
124 170

125 171
// Generic stuff
126 172
export interface PaginatedResponse<T> {
127 173
  entries: T[];
128 174
  total: number;
▸ 10 unchanged lines
139 185
}
140 186

141 187
export interface TriggerResponse {
142 188
  trigger: Trigger;
143 189
}
monografia/monografia.pdf
Binary file