frontend: add view details for a mood entry log
Pedro Lucas Porcellis porcellis@eletrotupi.com · 5 months ago
94a9e9492999dae406897bdab0c1e6e25eabe0cb Browse filesParents
d133c5dChanged files
2
Additions
+289
Deletions
0
/dev/null → frontend/app/entry/[id].tsx
1 +
import { useState, useEffect } from 'react';
2 +
import { StyleSheet, Text, View, ScrollView, Pressable, ActivityIndicator } from 'react-native';
3 +
import { useLocalSearchParams, router, Stack } from 'expo-router';
4 +
import { format } from 'date-fns';
5 +
import { ptBR } from 'date-fns/locale';
6 +
7 +
import { Card, SubtleInfoCard } from '@/components/ui/Cards';
8 +
import { Badge } from '@/components/ui/Badge';
9 +
import { Button } from '@/components/ui/Button';
10 +
import { Col, Between, Row } from '@/components/ui/LayoutHelpers';
11 +
import { Section, SectionHeader } from '@/components/ui/Sections';
12 +
import { Theme, Colors, Spacing } from '@/constants/theme';
13 +
import { getMood } from '@/constants/moods';
14 +
import { getMoodComponent, intensityLabel, intensityToValue } from '@/constants/mood-components';
15 +
import { useMoodEntry, useDeleteMoodEntry } from '@/hooks';
16 +
import { getTimeBadge, formatMoment } from '@/lib/utils/time';
17 +
import { MoodComponentCard } from '@/components/ui/MoodComponentCard';
18 +
19 +
export default function MoodDetailScreen() {
20 +
const { id } = useLocalSearchParams<{ id: string }>();
21 +
const { data: entry, isLoading, isError } = useMoodEntry(id);
22 +
const deleteMoodEntry = useDeleteMoodEntry();
23 +
const [minutesLeft, setMinutesLeft] = useState(0);
24 +
const [canDelete, setCanDelete] = useState(false);
25 +
26 +
if (isLoading) {
27 +
return (
28 +
<View style={styles.centered}>
29 +
<ActivityIndicator size="large" color={Theme.colors.tint} />
30 +
</View>
31 +
);
32 +
}
33 +
34 +
if (isError || !entry) {
35 +
return (
36 +
<View style={styles.centered}>
37 +
<Text style={styles.errorText}>Registro não encontrado.</Text>
38 +
<Pressable onPress={() => router.back()} style={styles.backButton}>
39 +
<Text style={styles.backButtonText}>Voltar</Text>
40 +
</Pressable>
41 +
</View>
42 +
);
43 +
}
44 +
45 +
const moment = new Date(entry.moment);
46 +
const moodDef = getMood(entry.selectedMood.toLowerCase());
47 +
const badge = getTimeBadge(moment);
48 +
49 +
useEffect(() => {
50 +
const updateTimer = () => {
51 +
const moment = new Date(entry.moment);
52 +
const createdAt = new Date(entry.moment);
53 +
const msSinceCreation = Date.now() - createdAt.getTime();
54 +
const deletionWindowMs = 5 * 60 * 1000;
55 +
56 +
const newCanDelete = msSinceCreation < deletionWindowMs;
57 +
const newMinutesLeft = Math.max(0, Math.ceil((deletionWindowMs - msSinceCreation) / 60000));
58 +
59 +
setCanDelete(newCanDelete);
60 +
setMinutesLeft(newMinutesLeft);
61 +
};
62 +
63 +
updateTimer(); // Set initial values
64 +
65 +
const interval = setInterval(updateTimer, 1000);
66 +
67 +
return () => clearInterval(interval);
68 +
}, [entry.moment]);
69 +
70 +
71 +
const handleDelete = async () => {
72 +
await deleteMoodEntry.mutateAsync(String(entry.id));
73 +
74 +
router.back();
75 +
};
76 +
77 +
const deleteButtonMsg = () => {
78 +
const msg = "Excluir Registro";
79 +
const timeLeftStr = `(Disponível por ${minutesLeft < 1 ? '<1' : minutesLeft}:00)`;
80 +
const suffix = canDelete ? timeLeftStr : "(Expirado)"
81 +
82 +
return `${msg} ${suffix}`
83 +
}
84 +
85 +
return (
86 +
<>
87 +
<Stack.Screen options={{ title: 'Ver detalhes' }} />
88 +
89 +
<ScrollView
90 +
style={styles.container}
91 +
contentContainerStyle={styles.content}
92 +
showsVerticalScrollIndicator={false}
93 +
>
94 +
{/* Header card */}
95 +
<Card style={styles.headerCard}>
96 +
<Between>
97 +
<Row gap={12} style={{ alignItems: 'center' }}>
98 +
<Text style={styles.moodIcon}>{moodDef?.icon ?? '😐'}</Text>
99 +
<Col gap={2}>
100 +
<Text style={styles.moodLabel}>{moodDef?.label ?? entry.selectedMood}</Text>
101 +
<Text style={styles.moodTime}>{formatMoment(moment)}</Text>
102 +
</Col>
103 +
</Row>
104 +
<Badge
105 +
label="Registro Ativo"
106 +
backgroundColor={Theme.colors.activeBackground}
107 +
color={Theme.colors.tint}
108 +
/>
109 +
</Between>
110 +
</Card>
111 +
112 +
{/* Core levels */}
113 +
<Section>
114 +
<SectionHeader title="Níveis" variant="subtle" />
115 +
{[
116 +
{ label: 'Energia', value: entry.energyLevel },
117 +
{ label: 'Estresse', value: entry.stressLevel },
118 +
{ label: 'Ansiedade', value: entry.anxietyLevel },
119 +
].map((item, index, arr) => (
120 +
<Card style={{ padding: Spacing.cardGap }}>
121 +
<View key={item.label}>
122 +
<View style={styles.componentRow}>
123 +
<Text style={styles.componentLabel}>{item.label}</Text>
124 +
<Text style={styles.componentIntensityGreen}>{item.value}/10</Text>
125 +
</View>
126 +
127 +
<View style={styles.progressTrack}>
128 +
<View style={[styles.progressFill, { width: `${(item.value / 10) * 100}%` }]} />
129 +
</View>
130 +
</View>
131 +
</Card>
132 +
))}
133 +
</Section>
134 +
135 +
<Section>
136 +
<SectionHeader title="Componentes" variant="subtle" />
137 +
<Col gap={0}>
138 +
{entry.moodComponents.map((comp, index) => {
139 +
const def = getMoodComponent(comp.component.toLowerCase());
140 +
const numericIntensity = comp.intensity === 'LIGHT' ? 2 : comp.intensity === 'MODERATE' ? 5 : 8;
141 +
142 +
return (
143 +
<MoodComponentCard
144 +
key={comp.id}
145 +
id={comp.component.toLowerCase()}
146 +
intensity={comp.intensity}
147 +
/>
148 +
);
149 +
})}
150 +
</Col>
151 +
</Section>
152 +
153 +
{/* Annotation / notes */}
154 +
{entry.annotation && (
155 +
<Section>
156 +
<SectionHeader title="Notas" variant="subtle" />
157 +
<Card style={{ padding: Spacing.cardGap }}>
158 +
<Text style={styles.annotation}>"{entry.annotation?.trim()}"</Text>
159 +
</Card>
160 +
</Section>
161 +
)}
162 +
163 +
{/* Delete */}
164 +
<View style={styles.section}>
165 +
<Text style={styles.sectionLabel}>GESTÃO DO REGISTRO</Text>
166 +
<Button variant="danger"
167 +
isLoading={deleteMoodEntry.isPending}
168 +
disabled={!canDelete || deleteMoodEntry.isPending}
169 +
onPress={handleDelete}
170 +
title={deleteButtonMsg()} />
171 +
172 +
<SubtleInfoCard style={styles.deleteInfo}
173 +
text={`
174 +
A exclusão de registros é permitida apenas nos primeiros 5 minutos
175 +
após o envio para garantir a integridade do histórico emocional.
176 +
`} />
177 +
</View>
178 +
179 +
</ScrollView>
180 +
</>
181 +
);
182 +
}
183 +
184 +
const styles = StyleSheet.create({
185 +
container: {
186 +
flex: 1,
187 +
backgroundColor: Theme.colors.background,
188 +
},
189 +
content: {
190 +
padding: Spacing.containerPadding,
191 +
gap: Spacing.sectionGap,
192 +
paddingBottom: 48,
193 +
},
194 +
centered: {
195 +
flex: 1,
196 +
justifyContent: 'center',
197 +
alignItems: 'center',
198 +
gap: 12,
199 +
},
200 +
errorText: {
201 +
fontSize: 15,
202 +
color: Colors.light.textSecondary,
203 +
},
204 +
backButton: {
205 +
paddingHorizontal: 20,
206 +
paddingVertical: 10,
207 +
backgroundColor: Theme.colors.tint,
208 +
borderRadius: Theme.borderRadius.md,
209 +
},
210 +
backButtonText: {
211 +
fontWeight: '700',
212 +
color: Theme.colors.text,
213 +
},
214 +
215 +
// Header
216 +
headerCard: {
217 +
padding: 16,
218 +
},
219 +
moodIcon: {
220 +
fontSize: 40,
221 +
},
222 +
moodLabel: {
223 +
fontSize: 17,
224 +
fontWeight: '700',
225 +
color: Colors.light.text,
226 +
},
227 +
moodTime: {
228 +
fontSize: 13,
229 +
color: Colors.light.textSecondary,
230 +
},
231 +
232 +
// Sections
233 +
section: {
234 +
gap: 8,
235 +
},
236 +
sectionLabel: {
237 +
fontSize: 11,
238 +
fontWeight: '600',
239 +
letterSpacing: 0.8,
240 +
color: Colors.light.textSecondary,
241 +
textTransform: 'uppercase',
242 +
},
243 +
244 +
// Components
245 +
componentRow: {
246 +
flexDirection: 'row',
247 +
justifyContent: 'space-between',
248 +
alignItems: 'center',
249 +
paddingVertical: 10,
250 +
},
251 +
componentLabel: {
252 +
fontSize: 15,
253 +
fontWeight: '700',
254 +
color: Colors.light.text,
255 +
},
256 +
componentIntensity: {
257 +
fontSize: 13,
258 +
color: Colors.light.textSecondary,
259 +
},
260 +
componentIntensityGreen: {
261 +
fontSize: 13,
262 +
fontWeight: '700',
263 +
color: Theme.colors.tint,
264 +
},
265 +
progressTrack: {
266 +
height: 6,
267 +
backgroundColor: Theme.colors.light.divider,
268 +
borderRadius: Theme.borderRadius.full,
269 +
marginBottom: 4,
270 +
overflow: 'hidden',
271 +
},
272 +
progressFill: {
273 +
height: '100%',
274 +
backgroundColor: Theme.colors.light.tint,
275 +
borderRadius: Theme.borderRadius.full,
276 +
},
277 +
// Annotation
278 +
annotation: {
279 +
fontSize: 14,
280 +
lineHeight: 22,
281 +
color: Colors.light.textSecondary,
282 +
fontStyle: 'italic',
283 +
padding: 4,
284 +
},
285 +
});
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,
176 176
}
177 177
178 178
async getMoodEntry(id: string): Promise<MoodEntry> {
179 179
return this.request(`/moods/${id}`);
180 180
}
181 +
182 +
async deleteMoodEntry(id: string): Promise<void> {
183 +
return this.request(`/moods/${id}`, { method: 'DELETE' });
184 +
}
181 185
}
182 186
183 187
export const apiClient = new ApiClient();