eletrotupi / tcc/ commit / 94a9e94

frontend: add view details for a mood entry log

Pedro Lucas Porcellis porcellis@eletrotupi.com · 5 months ago 94a9e9492999dae406897bdab0c1e6e25eabe0cb Browse files
Parents d133c5d
Changed 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();