frontend: new page to display a single sleep record entry (wip)
Parents:
2e527783 file(s) changed
- frontend/app/sleep/[id].tsx +287 -0
- frontend/components/history/MoodHistoryCard.tsx +2 -1
- frontend/components/history/SleepRecordCard.tsx +2 -1
frontend/app/sleep/[id].tsx
@@ -0,0 +1,287 @@
1 + import { useState, useEffect, use } from "react";
2 + import {
3 + StyleSheet,
4 + Text,
5 + View,
6 + ScrollView,
7 + Pressable,
8 + ActivityIndicator,
9 + } from "react-native";
10 + import { useLocalSearchParams, router, Stack } from "expo-router";
11 + import { format } from "date-fns";
12 + import { ptBR } from "date-fns/locale";
13 +
14 + import { Card, SubtleInfoCard } from "@/components/ui/Cards";
15 + import { Badge } from "@/components/ui/Badge";
16 + import { Button } from "@/components/ui/Button";
17 + import { Col, Between, Row } from "@/components/ui/LayoutHelpers";
18 + import { Section, SectionHeader } from "@/components/ui/Sections";
19 + import { Theme, Colors, Spacing } from "@/constants/theme";
20 + import { MaterialIcons } from "@expo/vector-icons";
21 + import {
22 + useSleepRecord,
23 + useDeleteSleepRecord,
24 + } from "@/hooks/useSleepRecord.queries";
25 + import { formatDate } from "@/lib/utils/time";
26 +
27 + export default function SleepDetailScreen() {
28 + const { id } = useLocalSearchParams<{ id: string }>();
29 + const { data: sleepRecord, isLoading, isError } = useSleepRecord(id);
30 + const deleteSleepRecord = useDeleteSleepRecord();
31 + const [minutesLeft, setMinutesLeft] = useState(0);
32 + const [canDelete, setCanDelete] = useState(false);
33 +
34 + useEffect(() => {
35 + const updateTimer = () => {
36 + const createdAt = sleepRecord?.createdAt;
37 + if (!createdAt) return;
38 +
39 + const msSinceCreation = Date.now() - createdAt.getTime();
40 + const deletionWindowMs = 5 * 60 * 1000;
41 +
42 + const newCanDelete = msSinceCreation < deletionWindowMs;
43 + const newMinutesLeft = Math.max(
44 + 0,
45 + Math.ceil((deletionWindowMs - msSinceCreation) / 60000),
46 + );
47 +
48 + setCanDelete(newCanDelete);
49 + setMinutesLeft(newMinutesLeft);
50 + };
51 +
52 + if (isLoading) return;
53 +
54 + updateTimer(); // Set initial values
55 +
56 + const interval = setInterval(updateTimer, 1000);
57 +
58 + return () => clearInterval(interval);
59 + }, [sleepRecord, isLoading]);
60 +
61 + console.log("sleepRecord", sleepRecord)
62 + console.log("isLoading", isLoading)
63 +
64 + if (isLoading) {
65 + return (
66 + <View style={styles.centered}>
67 + <ActivityIndicator size="large" color={Colors.light.tint} />
68 + </View>
69 + );
70 + }
71 +
72 + if (isError || !sleepRecord) {
73 + return (
74 + <View style={styles.centered}>
75 + <Text style={styles.errorText}>Registro não encontrado.</Text>
76 + <Pressable onPress={() => router.back()} style={styles.backButton}>
77 + <Text style={styles.backButtonText}>Voltar</Text>
78 + </Pressable>
79 + </View>
80 + );
81 + }
82 +
83 + const handleDelete = async () => {
84 + await deleteSleepRecord.mutateAsync(String(sleepRecord.id));
85 +
86 + router.back();
87 + };
88 +
89 + const deleteButtonMsg = () => {
90 + const msg = "Excluir Registro";
91 + const timeLeftStr = `(Disponível por ${minutesLeft < 1 ? "<1" : minutesLeft}:00)`;
92 + const suffix = canDelete ? timeLeftStr : "(Expirado)";
93 +
94 + return `${msg} ${suffix}`;
95 + };
96 +
97 + return (
98 + <>
99 + <Stack.Screen options={{ title: "Ver detalhes" }} />
100 +
101 + <ScrollView
102 + style={styles.container}
103 + contentContainerStyle={styles.content}
104 + showsVerticalScrollIndicator={false}
105 + >
106 + {/* Header card */}
107 + <Card style={styles.headerCard}>
108 + <Row style={{ alignItems: "center" }}>
109 + <View style={[styles.iconWrap, { backgroundColor: '#ede9fe' }]}>
110 + <MaterialIcons name="bedtime" size={22} color="#7c3aed" />
111 + </View>
112 + <Col>
113 + <Row style={{ alignItems: "flex-end" }}>
114 + <Text style={styles.averageLabel}>{sleepRecord.average}h</Text>
115 + <Text style={styles.suffixLabel}>de sono</Text>
116 + </Row>
117 +
118 + <Text style={styles.dateLabel}>
119 + {formatDate(new Date(sleepRecord.date))}
120 + </Text>
121 + </Col>
122 + </Row>
123 + </Card>
124 +
125 + {/* Annotation / notes */}
126 + {sleepRecord.annotations && (
127 + <Section>
128 + <SectionHeader title="Anotações" variant="subtle" />
129 + <Card style={{ padding: Spacing.cardGap }}>
130 + <Text style={styles.annotation}>
131 + "{sleepRecord.annotations?.trim()}"
132 + </Text>
133 + </Card>
134 + </Section>
135 + )}
136 +
137 + {/* Delete */}
138 + <View style={styles.section}>
139 + <Text style={styles.sectionLabel}>GESTÃO DO REGISTRO</Text>
140 + <Button
141 + variant="danger"
142 + isLoading={deleteSleepRecord.isPending}
143 + disabled={!canDelete || deleteSleepRecord.isPending}
144 + onPress={handleDelete}
145 + title={deleteButtonMsg()}
146 + />
147 +
148 + <SubtleInfoCard
149 + style={styles.deleteInfo}
150 + text={`
151 + A exclusão de registros é permitida apenas nos primeiros 5 minutos
152 + após o envio para garantir a integridade do histórico emocional.
153 + `}
154 + />
155 + </View>
156 + </ScrollView>
157 + </>
158 + );
159 + }
160 +
161 + const styles = StyleSheet.create({
162 + container: {
163 + flex: 1,
164 + backgroundColor: Theme.colors.background,
165 + },
166 + content: {
167 + padding: Spacing.containerPadding,
168 + gap: Spacing.sectionGap,
169 + paddingBottom: 48,
170 + },
171 + iconWrap: {
172 + width: 40,
173 + height: 40,
174 + borderRadius: 20,
175 + alignItems: 'center',
176 + justifyContent: 'center',
177 + marginTop: 1
178 + },
179 + headerCardContent: {
180 + flexDirection: "row",
181 + alignItems: "center",
182 + gap: 8,
183 + },
184 + averageLabel: {
185 + height: '100%',
186 + fontSize: 24,
187 + fontWeight: "700",
188 + color: Colors.light.text,
189 + },
190 + averageLabelSuffix: {
191 + height: '100%',
192 + fontSize: 17,
193 + fontWeight: "700",
194 + color: Colors.light.textSecondary,
195 + },
196 + centered: {
197 + flex: 1,
198 + justifyContent: "center",
199 + alignItems: "center",
200 + gap: 12,
201 + },
202 + errorText: {
203 + fontSize: 15,
204 + color: Colors.light.textSecondary,
205 + },
206 + backButton: {
207 + paddingHorizontal: 20,
208 + paddingVertical: 10,
209 + backgroundColor: Theme.colors.tint,
210 + borderRadius: Theme.borderRadius.md,
211 + },
212 + backButtonText: {
213 + fontWeight: "700",
214 + color: Theme.colors.text,
215 + },
216 +
217 + // Header
218 + headerCard: {
219 + padding: 16,
220 + },
221 + moodIcon: {
222 + fontSize: 40,
223 + },
224 + moodLabel: {
225 + fontSize: 17,
226 + fontWeight: "700",
227 + color: Colors.light.text,
228 + },
229 + moodTime: {
230 + fontSize: 13,
231 + color: Colors.light.textSecondary,
232 + },
233 +
234 + // Sections
235 + section: {
236 + gap: 8,
237 + },
238 + sectionLabel: {
239 + fontSize: 11,
240 + fontWeight: "600",
241 + letterSpacing: 0.8,
242 + color: Colors.light.textSecondary,
243 + textTransform: "uppercase",
244 + },
245 +
246 + // Components
247 + componentRow: {
248 + flexDirection: "row",
249 + justifyContent: "space-between",
250 + alignItems: "center",
251 + paddingVertical: 10,
252 + },
253 + componentLabel: {
254 + fontSize: 15,
255 + fontWeight: "700",
256 + color: Colors.light.text,
257 + },
258 + componentIntensity: {
259 + fontSize: 13,
260 + color: Colors.light.textSecondary,
261 + },
262 + componentIntensityGreen: {
263 + fontSize: 13,
264 + fontWeight: "700",
265 + color: Theme.colors.tint,
266 + },
267 + progressTrack: {
268 + height: 6,
269 + backgroundColor: Theme.colors.light.divider,
270 + borderRadius: Theme.borderRadius.full,
271 + marginBottom: 4,
272 + overflow: "hidden",
273 + },
274 + progressFill: {
275 + height: "100%",
276 + backgroundColor: Theme.colors.light.tint,
277 + borderRadius: Theme.borderRadius.full,
278 + },
279 + // Annotation
280 + annotation: {
281 + fontSize: 14,
282 + lineHeight: 22,
283 + color: Colors.light.textSecondary,
284 + fontStyle: "italic",
285 + padding: 4,
286 + },
287 + });
frontend/components/history/MoodHistoryCard.tsx
1 1
// components/history/cards/MoodHistoryCard.tsx
2 2
import { View, Text, StyleSheet } from 'react-native';
3 3
import { MaterialIcons } from '@expo/vector-icons';
4 4
import { Card } from '@/components/ui/Cards';
5 5
import { Badge } from '@/components/ui/Badge';
6 6
import type { HistoryCard } from '@/lib/history/types';
7 7
import type { MoodEntry } from '@/lib/api';
8 8
import { Spacing } from '@/constants/theme';
9 9
import { getMood } from '@/constants/moods';
10 10
import { format } from 'date-fns';
11 +
import { router } from 'expo-router';
11 12
12 13
function inferBadge(anxiety: number, stress: number, energy: number) {
13 14
if (anxiety >= 7) return { label: 'Ansiedade alta', variant: 'red' } as const
14 15
if (stress >= 7) return { label: 'Estresse alto', variant: 'orange' } as const
15 16
if (energy <= 3) return { label: 'Energia baixa', variant: 'yellow' } as const
▸ 17 unchanged lines
33 34
const badge = inferBadge(card.raw.anxietyLevel, card.raw.stressLevel, card.raw.energyLevel)
34 35
const time = format(card.timestamp, 'HH:mm')
35 36
const moodDef = getMood(card.raw.selectedMood.toLowerCase());
36 37
37 38
return (
38 -
<Card style={styles.card}>
39 +
<Card style={styles.card} onPress={() => router.push(`/entry/${card.raw.id}`)}>
39 40
<View style={styles.row}>
40 41
<View style={[styles.iconWrap, { backgroundColor: meta.bg }]}>
41 42
<Text style={styles.moodIconEmoji}>{moodDef?.icon ?? '😐'}</Text>
42 43
43 44
{false && (<MaterialIcons name={meta.icon} size={22} color={meta.color} />)}
▸ 61 unchanged lines
105 106
alignSelf: 'flex-start',
106 107
marginTop: 4,
107 108
maxWidth: '50%'
108 109
}
109 110
})
frontend/components/history/SleepRecordCard.tsx
1 1
import { View, Text, StyleSheet } from 'react-native'
2 2
import { MaterialIcons } from '@expo/vector-icons'
3 3
import { Card } from '@/components/ui/Cards'
4 4
import { Badge } from '@/components/ui/Badge'
5 5
import type { SleepRecord } from '@/lib/api'
6 6
import { Spacing } from '@/constants/theme';
7 +
import { router } from 'expo-router'
7 8
8 9
function sleepBadge(hours: number) {
9 10
if (hours >= 7) return { label: 'Excelente', variant: 'green' } as const
10 11
if (hours >= 5) return { label: 'Regular', variant: 'yellow' } as const
11 12
▸ 17 unchanged lines
29 30
30 31
const time = new Date(card.timestamp)
31 32
.toLocaleTimeString('pt-BR', { hour: '2-digit', minute: '2-digit' })
32 33
33 34
return (
34 -
<Card style={styles.card}>
35 +
<Card style={styles.card} onPress={() => router.push(`/sleep/${card.raw.id}`)}>
35 36
<View style={styles.row}>
36 37
<View style={[styles.iconWrap, { backgroundColor: '#ede9fe' }]}>
37 38
<MaterialIcons name="bedtime" size={22} color="#7c3aed" />
38 39
</View>
39 40
<View style={styles.body}>
▸ 72 unchanged lines
112 113
alignSelf: 'flex-start',
113 114
marginTop: 4,
114 115
maxWidth: '50%'
115 116
},
116 117
})