Merge pull request #17 from pedrolucasp/notifications
Notifications + insights
17 file(s) changed
- api/src/controllers/moods.ts +36 -0
- api/src/routes/moods.ts +1 -0
- api/src/services/user.service.ts +10 -0
- frontend/app/(tabs)/_layout.tsx +23 -0
- frontend/app/(tabs)/new/index.tsx +126 -114
- frontend/app/auth/login.tsx +77 -44
- frontend/app/index.tsx +6 -6
- frontend/app/notifications.tsx +126 -0
- 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 +38 -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
api/src/controllers/moods.ts
1 1
import { Request, Response, NextFunction } from 'express';
2 2
import { AuthenticatedRequest } from '@app/middleware/auth';
3 +
import { User } from '@prisma/client';
3 4
import {
4 5
createMood,
5 6
getMoodsByUserId,
6 7
getMoodById,
7 8
destroyMoodById
8 9
} from '@app/services/mood.service';
10 +
11 +
import {
12 +
findUsersElligibleForPush
13 +
} from '@app/services/user.service';
9 14
10 15
import {
11 16
CreateMoodSchema
12 17
} from '@app/schemas'
13 18
▸ 52 unchanged lines
66 71
destroy: async (req: AuthenticatedRequest, res: Response, next: NextFunction) => {
67 72
try {
68 73
const result = await destroyMoodById(req.userId!, Number(req.params.id!));
69 74
70 75
return res.status(200).json({})
76 +
} catch (err) {
77 +
next(err);
78 +
}
79 +
},
80 +
81 +
sendNotification: async (req: Request, res: Response, next: NextFunction) => {
82 +
try {
83 +
const users = await findUsersElligibleForPush();
84 +
85 +
if (users) {
86 +
users.forEach(async (user: User) => {
87 +
const response = await fetch('https://exp.host/--/api/v2/push/send', {
88 +
method: 'POST', headers: {
89 +
'Content-Type': 'application/json'
90 +
},
91 +
body: JSON.stringify({
92 +
to: user.pushToken,
93 +
title: 'Como você está?',
94 +
body: 'Adicione mais registros de humor.',
95 +
data: { screen: 'notifications' }, // Deep link payload
96 +
})
97 +
});
98 +
99 +
const data = await response.json();
100 +
101 +
req.log.info("Resposta %s", data)
102 +
console.log("Resposta ", data)
103 +
})
104 +
}
105 +
106 +
return res.status(200).json(users)
71 107
} catch (err) {
72 108
next(err);
73 109
}
74 110
}
75 111
};
api/src/routes/moods.ts
1 1
import { Router } from "express";
2 2
import { MoodsController } from '@app/controllers/moods'
3 3
import { requireAuth } from '@app/middleware/auth';
4 4
5 5
const router = Router();
6 6
7 7
router.get('/', requireAuth, MoodsController.index);
8 8
router.get('/:id', requireAuth, MoodsController.show);
9 9
router.post('/', requireAuth, MoodsController.create);
10 10
router.delete('/:id', requireAuth, MoodsController.destroy);
11 +
router.post('/notify', MoodsController.sendNotification);
11 12
12 13
export default router;
api/src/services/user.service.ts
1 1
import { prisma } from '@app/lib/prisma';
2 2
import { User } from '@prisma/client';
3 3
import bcrypt from 'bcryptjs';
4 4
5 5
import {
▸ 49 unchanged lines
55 55
id: input.id
56 56
},
57 57
data: updateData
58 58
})
59 59
}
60 +
61 +
export const findUsersElligibleForPush = async (): Promise<User[]> => {
62 +
return await prisma.user.findMany({
63 +
where: {
64 +
pushToken: {
65 +
not: null
66 +
}
67 +
}
68 +
});
69 +
}
frontend/app/(tabs)/_layout.tsx
1 1
import { Tabs, Redirect, router } from 'expo-router';
2 2
import React, { useEffect } from 'react';
3 3
import { View, ActivityIndicator } from 'react-native';
4 4
5 5
import { HapticTab } from '@/components/misc/haptic-tab';
6 6
import { IconSymbol } from '@/components/ui/icon-symbol';
7 7
import { Colors } from '@/constants/theme';
8 8
import { useColorScheme } from '@/hooks/use-color-scheme';
9 9
import { useAuth } from '@/context/AuthContext';
10 +
import * as Notifications from "expo-notifications";
10 11
11 12
export default function TabLayout() {
12 13
const colorScheme = useColorScheme();
13 14
const { isAuthenticated, isLoading } = useAuth();
14 15
15 16
useEffect(() => {
16 17
if (!isLoading && !isAuthenticated) {
17 18
router.replace('/auth/login');
18 19
}
19 20
}, [isAuthenticated, isLoading]);
21 +
22 +
// TODO: Store the notification in a hook ctx, then capture if was
23 +
useEffect(() => {
24 +
const subscription = Notifications.addNotificationResponseReceivedListener(response => {
25 +
const screen = response.notification.request.content.data?.screen;
26 +
27 +
if (screen === 'notifications') {
28 +
router.push('/notifications');
29 +
}
30 +
});
31 +
32 +
// Handle notification tap when app was already open
33 +
Notifications.setNotificationHandler({
34 +
handleNotification: async () => ({
35 +
shouldShowAlert: true,
36 +
shouldPlaySound: false,
37 +
shouldSetBadge: false,
38 +
}),
39 +
});
40 +
41 +
return () => subscription.remove();
42 +
}, []);
20 43
21 44
if (isLoading) {
22 45
return (
23 46
<View style={{ flex: 1, justifyContent: 'center', alignItems: 'center' }}>
24 47
<ActivityIndicator size="large" />
▸ 54 unchanged lines
79 102
}}
80 103
/>
81 104
</Tabs>
82 105
);
83 106
}
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 -
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>
42 +
const onNotificationPress = () => {
43 +
router.push('/notifications');
44 +
}
56 45
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>
46 +
return (
47 +
<ScreenLayout
48 +
userName={user.firstName}
49 +
userAvatar={user.avatarURL}
50 +
onNotificationPress={onNotificationPress}
51 +
showNotificationBadge={true}
52 +
>
53 +
<Section>
54 +
<SectionHeader
55 +
title="Como você está hoje?"
56 +
subtitle="Sua jornada começa agora"
57 +
></SectionHeader>
63 58
64 -
<ThemedText style={styles.quickRegisterBadge}>
65 -
HOJE
66 -
</ThemedText>
67 -
</Between>
59 +
<Card style={{ minHeight: 200 }}>
60 +
<Col gap={16}>
61 +
<Between style={styles.quickRegisterHeader}>
62 +
<ThemedText style={styles.quickRegisterTitle}>
63 +
Registro rápido
64 +
</ThemedText>
68 65
69 -
<MoodSelector
70 -
style={{paddingLeft: 8, paddingRight: 8, paddingTop: 16 }}
71 -
items={MOODS}
72 -
value={mood}
73 -
onSelect={setMood}
74 -
/>
66 +
<ThemedText style={styles.quickRegisterBadge}>HOJE</ThemedText>
67 +
</Between>
75 68
76 -
<Button
77 -
title="Salvar Humor"
78 -
onPress={initQuickRegister}
79 -
style={styles.quickRegisterButton}
80 -
/>
81 -
</Col>
82 -
</Card>
83 -
</Section>
69 +
<MoodSelector
70 +
style={{ paddingLeft: 8, paddingRight: 8, paddingTop: 16 }}
71 +
items={MOODS}
72 +
value={mood}
73 +
onSelect={setMood}
74 +
/>
84 75
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" />
76 +
<Button
77 +
title="Salvar Humor"
78 +
onPress={initQuickRegister}
79 +
style={styles.quickRegisterButton}
80 +
/>
81 +
</Col>
82 +
</Card>
83 +
</Section>
91 84
92 -
<Text style={{ ...Typography.headlineMd, marginBottom: 10 }}>
93 -
Energia
94 -
</Text>
95 -
</View>
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
90 +
style={{
91 +
flex: 1,
92 +
flexDirection: "row",
93 +
flexGrow: 0,
94 +
paddingVertical: 10,
95 +
}}
96 +
>
97 +
<Ionicons name="flash" size={20} color="#64748B" />
96 98
97 99
<Text style={{ ...Typography.headlineMd, marginBottom: 10 }}>
98 -
Média Alta
100 +
Energia
99 101
</Text>
102 +
</View>
100 103
101 -
<View style={styles.lineTrack}>
102 -
<View style={[styles.lineFill, { width: `35%` }]} />
103 -
</View>
104 -
</Card>
104 +
<Text style={{ ...Typography.headlineMd, marginBottom: 10 }}>
105 +
Média Alta
106 +
</Text>
105 107
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" />
108 +
<View style={styles.lineTrack}>
109 +
<View style={[styles.lineFill, { width: `35%` }]} />
110 +
</View>
111 +
</Card>
109 112
110 -
<Text style={{ ...Typography.headlineMd, marginBottom: 10 }}>
111 -
Sono
112 -
</Text>
113 -
</View>
113 +
<Card style={{ padding: Spacing.cardGap }}>
114 +
<View
115 +
style={{
116 +
flex: 1,
117 +
flexDirection: "row",
118 +
flexGrow: 0,
119 +
paddingVertical: 10,
120 +
}}
121 +
>
122 +
<Ionicons name="moon-outline" size={20} color="#64748B" />
114 123
115 124
<Text style={{ ...Typography.headlineMd, marginBottom: 10 }}>
116 -
7h 30m
125 +
Sono
117 126
</Text>
127 +
</View>
118 128
119 -
<Text style={styles.indicatorGreen}>
120 -
+45min que ontem
121 -
</Text>
122 -
</Card>
123 -
</Grid>
124 -
</Section>
129 +
<Text style={{ ...Typography.headlineMd, marginBottom: 10 }}>
130 +
7h 30m
131 +
</Text>
125 132
126 -
<Section>
127 -
<SectionHeader title="Registros recentes" />
133 +
<Text style={styles.indicatorGreen}>+45min que ontem</Text>
134 +
</Card>
135 +
</Grid>
136 +
</Section>
128 137
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>
138 +
<Section>
139 +
<SectionHeader title="Registros recentes" />
140 +
141 +
{isLoading ? (
142 +
<ActivityIndicator />
143 +
) : (
144 +
<Col gap={8}>
145 +
{recentEntries.map((entry) => (
146 +
<MoodEntryLog key={entry.id} entry={entry} />
147 +
))}
148 +
</Col>
149 +
)}
150 +
</Section>
139 151
</ScreenLayout>
140 -
)
152 +
);
141 153
}
142 154
143 155
const styles = StyleSheet.create({
144 156
quickRegisterHeader: {
145 157
marginTop: 16,
146 158
paddingLeft: 16,
147 -
paddingRight: 16
159 +
paddingRight: 16,
148 160
},
149 161
quickRegisterTitle: {
150 -
...Typography.bodyLg
162 +
...Typography.bodyLg,
151 163
},
152 164
// TODO: Replace when we have badges
153 165
quickRegisterBadge: {
154 166
fontSize: 12,
155 -
lineHeight: 16
167 +
lineHeight: 16,
156 168
},
157 169
quickRegisterButton: {
158 170
marginLeft: 16,
159 171
marginRight: 16,
160 172
marginBottom: 16,
161 -
...Shadows.lg
173 +
...Shadows.lg,
162 174
},
163 175
lineTrack: {
164 176
height: 6,
165 177
backgroundColor: Colors.light.divider,
166 178
borderRadius: BorderRadius.full,
167 179
marginBottom: 4,
168 -
overflow: 'hidden',
180 +
overflow: "hidden",
169 181
},
170 182
lineFill: {
171 -
height: '100%',
183 +
height: "100%",
172 184
backgroundColor: Colors.light.tint,
173 185
borderRadius: BorderRadius.full,
174 186
},
175 187
indicatorGreen: {
176 188
color: Colors.light.tint,
177 189
fontSize: 11,
178 190
fontWeight: 600,
179 -
lineHeight: 16.5
180 -
}
191 +
lineHeight: 16.5,
192 +
},
181 193
});
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/app/notifications.tsx
@@ -0,0 +1,126 @@
1 + import {
2 + View,
3 + Text,
4 + StyleSheet,
5 + ScrollView,
6 + KeyboardAvoidingView,
7 + Platform,
8 + Alert,
9 + } from 'react-native';
10 +
11 + import {
12 + useState, useEffect
13 + } from 'react';
14 + import * as Notifications from 'expo-notifications';
15 + import { useAuth } from "@/context/AuthContext";
16 +
17 + import { StatusBar } from 'expo-status-bar';
18 + import { SafeAreaView } from 'react-native-safe-area-context';
19 + import { useThemeColor } from '@/hooks/use-theme-color';
20 + import { Button } from '@/components/ui/Button';
21 +
22 + export default function NotificationsPage() {
23 + const { user } = useAuth();
24 + const backgroundColor = useThemeColor({}, 'background');
25 + const [expoPushToken, setExpoPushToken] = useState(user.pushToken);
26 + const [notification, setNotification] = useState<Notifications.Notification | undefined>(
27 + undefined
28 + );
29 +
30 + useEffect(() => {
31 + const notificationListener = Notifications
32 + .addNotificationReceivedListener(notification => {
33 + setNotification(notification);
34 + });
35 +
36 + const responseListener = Notifications
37 + .addNotificationResponseReceivedListener(response => {
38 + console.log(response);
39 + });
40 +
41 + return () => {
42 + notificationListener.remove();
43 + responseListener.remove();
44 + };
45 + }, []);
46 +
47 + useEffect(() => {
48 + setExpoPushToken(user.pushToken)
49 + }, [user]);
50 +
51 + async function sendPushNotification(expoPushToken: string) {
52 + if (expoPushToken) {
53 + const message = {
54 + to: expoPushToken,
55 + sound: 'default',
56 + title: 'Oi meu querido',
57 + body: 'Vamo escutar um The Doors',
58 + data: { screen: 'notifications' },
59 + };
60 +
61 + const response = await fetch('https://exp.host/--/api/v2/push/send', {
62 + method: 'POST',
63 + headers: {
64 + Accept: 'application/json',
65 + 'Accept-encoding': 'gzip, deflate',
66 + 'Content-Type': 'application/json',
67 + },
68 + body: JSON.stringify(message),
69 + });
70 + }
71 + }
72 +
73 + return (
74 + <SafeAreaView style={[styles.container, { backgroundColor }]}>
75 + <StatusBar style={'dark'} />
76 + <KeyboardAvoidingView
77 + behavior='height'
78 + style={styles.keyboardView}
79 + >
80 + <ScrollView
81 + contentContainerStyle={styles.scrollContainer}
82 + showsVerticalScrollIndicator={false}
83 + >
84 + <View>
85 + <Text>
86 + Uma notificação!!!
87 + </Text>
88 + </View>
89 +
90 + <View style={{ alignItems: 'center', justifyContent: 'center' }}>
91 + <Text>Title: {notification && notification.request.content.title} </Text>
92 + <Text>Body: {notification && notification.request.content.body}</Text>
93 + <Text>Data: {notification && JSON.stringify(notification.request.content.data)}</Text>
94 + </View>
95 +
96 + <Button
97 + title="Quer ver uma coisa?"
98 + onPress={async () => {
99 + await sendPushNotification(expoPushToken);
100 + }}
101 + />
102 +
103 + </ScrollView>
104 + </KeyboardAvoidingView>
105 + </SafeAreaView>
106 + );
107 + }
108 +
109 + const styles = StyleSheet.create({
110 + container: {
111 + flex: 1,
112 + },
113 + keyboardView: {
114 + flex: 1,
115 + },
116 + scrollContainer: {
117 + flexGrow: 1,
118 + paddingHorizontal: 24,
119 + justifyContent: 'start',
120 + minHeight: '100%',
121 + },
122 + header: {
123 + alignItems: 'left',
124 + marginBottom: 40,
125 + },
126 + });
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,38 @@
1 + import * as Notifications from "expo-notifications";
2 + import * as Device from "expo-device";
3 + import Constants from "expo-constants";
4 +
5 + export async function registerForPushNotifications(): Promise<string | null> {
6 + if (!Device.isDevice) return null;
7 +
8 + // Request permission
9 + const { status: existingStatus } = await Notifications.getPermissionsAsync();
10 + let finalStatus = existingStatus;
11 +
12 + if (existingStatus !== "granted") {
13 + const { status } = await Notifications.requestPermissionsAsync();
14 + finalStatus = status;
15 + }
16 +
17 + if (finalStatus !== "granted") {
18 + console.warn("Push notification permission denied");
19 + return null;
20 + }
21 +
22 + // TODO: Experiment with different vibration patterns
23 + await Notifications.setNotificationChannelAsync("default", {
24 + name: "default",
25 + importance: Notifications.AndroidImportance.MAX,
26 + vibrationPattern: [0, 250, 250, 250],
27 + });
28 +
29 + // Get the Expo push token
30 + const projectId = Constants.expoConfig?.extra?.eas?.projectId;
31 + const token = (
32 + await Notifications.getExpoPushTokenAsync({
33 + projectId,
34 + })
35 + ).data;
36 +
37 + return token;
38 + }
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