frontend: add view details for a mood entry log

Pedro Lucas Porcellis porcellis@eletrotupi.com 1 month ago 94a9e9492999dae406897bdab0c1e6e25eabe0cb
Parents: d133c5d
2 file(s) changed
  • frontend/app/entry/[id].tsx +285 -0
  • frontend/lib/api/client.ts +4 -0
frontend/app/entry/[id].tsx
@@ -0,0 +1,285 @@
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
@@ -178,6 +178,10 @@
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();