frontend: notifications and insight first draft
Parents:
28956b712 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