eletrotupi / tcc/ commit / 120e1cd

frontend: medicine-regimens screen, TodayMedicines, MedicineRegimenList

Add useMedicineToday hook (with a staleTime 2m). Plus, invalidate
medicineToday cache when a care action is created. TodayMedicines
confirms doses via ±30min window with a optimistic checkmark on tap

MedicineRegimenList shows active regimens with toggle and schedule
summary, other misc UI fixes as well
Pedro Lucas Porcellis porcellis@eletrotupi.com 1 month ago 120e1cdf02f34255474a21f7c49f6bc0025e3779
Parents: 9dde95c
7 file(s) changed
  • frontend/app/medicine-regimens/[id].tsx +25 -0
  • frontend/app/medicine-regimens/index.tsx +36 -0
  • frontend/components/medicine/MedicineRegimenList.tsx +120 -0
  • frontend/components/medicine/TodayMedicines.tsx +147 -0
  • frontend/hooks/index.ts +5 -0
  • frontend/hooks/useCareAction.queries.ts +2 -0
  • frontend/hooks/useMedicineToday.queries.ts +15 -0
frontend/app/medicine-regimens/[id].tsx
@@ -0,0 +1,25 @@
1 + import { View, Text, StyleSheet } from "react-native";
2 + import { useLocalSearchParams } from "expo-router";
3 + import { Spacing, Typography, Colors } from "@/constants/theme";
4 +
5 + export default function MedicineRegimenDetail() {
6 + const { id } = useLocalSearchParams<{ id: string }>();
7 +
8 + return (
9 + <View style={styles.container}>
10 + <Text style={styles.title}>Medicamento #{id}</Text>
11 + <Text style={styles.subtitle}>Em breve: edição de medicamento</Text>
12 + </View>
13 + );
14 + }
15 +
16 + const styles = StyleSheet.create({
17 + container: {
18 + flex: 1,
19 + padding: Spacing.containerPadding,
20 + alignItems: "center",
21 + justifyContent: "center",
22 + },
23 + title: { ...Typography.headlineMd },
24 + subtitle: { ...Typography.bodyMd, color: Colors.light.textSecondary, marginTop: 8 },
25 + });
frontend/app/medicine-regimens/index.tsx
@@ -0,0 +1,36 @@
1 + import { ScrollView, StyleSheet } from "react-native";
2 + import { Section, SectionHeader } from "@/components/ui/Sections";
3 + import { Spacing } from "@/constants/theme";
4 + import { TodayMedicines } from "@/components/medicine/TodayMedicines";
5 + import { MedicineRegimenList } from "@/components/medicine/MedicineRegimenList";
6 +
7 + export default function MedicineRegimens() {
8 + return (
9 + <ScrollView
10 + contentContainerStyle={styles.container}
11 + scrollEventThrottle={16}
12 + >
13 + <Section>
14 + <SectionHeader title="Hoje" />
15 + <TodayMedicines />
16 + </Section>
17 +
18 + <Section>
19 + <SectionHeader
20 + title="Seus medicamentos"
21 + subtitle="Gerencie seus medicamentos e horários"
22 + />
23 + <MedicineRegimenList />
24 + </Section>
25 + </ScrollView>
26 + );
27 + }
28 +
29 + const styles = StyleSheet.create({
30 + container: {
31 + paddingHorizontal: Spacing.containerPadding,
32 + paddingVertical: Spacing.sectionGap,
33 + gap: Spacing.sectionGap,
34 + paddingBottom: 80,
35 + },
36 + });
frontend/components/medicine/MedicineRegimenList.tsx
@@ -0,0 +1,120 @@
1 + import { View, Text, Switch, TouchableOpacity, StyleSheet, ActivityIndicator } from "react-native";
2 + import { Ionicons } from "@expo/vector-icons";
3 + import { router } from "expo-router";
4 + import { Colors, Typography, Spacing, BorderRadius, Shadows } from "@/constants/theme";
5 + import { useMedicineRegimens, useToggleMedicineRegimen } from "@/hooks";
6 + import { MedicineRegimen } from "@/lib/api";
7 +
8 + const PERIODICITY_LABELS: Record<string, string> = {
9 + ONCE: "Uma vez",
10 + DAILY: "Diário",
11 + TWICE_DAILY: "2x ao dia",
12 + THREE_TIMES_DAILY: "3x ao dia",
13 + WEEKLY: "Semanal",
14 + BIWEEKLY: "Quinzenal",
15 + MONTHLY: "Mensal",
16 + };
17 +
18 + function scheduleSummary(regimen: MedicineRegimen): string {
19 + const times = regimen.scheduledAt.length > 0
20 + ? regimen.scheduledAt.join(" · ")
21 + : null;
22 + const label = PERIODICITY_LABELS[regimen.periodicity] ?? regimen.periodicity;
23 + return times ? `${times} · ${label}` : label;
24 + }
25 +
26 + export function MedicineRegimenList() {
27 + const { data, isLoading } = useMedicineRegimens();
28 + const toggleRegimen = useToggleMedicineRegimen();
29 +
30 + if (isLoading) {
31 + return (
32 + <View style={styles.center}>
33 + <ActivityIndicator color={Colors.light.tint} />
34 + </View>
35 + );
36 + }
37 +
38 + const regimens = data?.regimens ?? [];
39 +
40 + return (
41 + <View style={styles.container}>
42 + {regimens.length === 0 ? (
43 + <View style={styles.empty}>
44 + <Text style={styles.emptyText}>Nenhum medicamento cadastrado</Text>
45 + </View>
46 + ) : (
47 + regimens.map((regimen) => (
48 + <TouchableOpacity
49 + key={regimen.id}
50 + style={styles.row}
51 + activeOpacity={0.7}
52 + onPress={() => router.push(`/medicine-regimens/${regimen.id}`)}
53 + >
54 + <View style={styles.iconWrap}>
55 + <Ionicons name="bandage-outline" size={18} color={Colors.light.tint} />
56 + </View>
57 + <View style={styles.info}>
58 + <Text style={styles.name}>{regimen.name}</Text>
59 + <Text style={styles.meta}>
60 + {regimen.dosage} · {scheduleSummary(regimen)}
61 + </Text>
62 + </View>
63 + <Switch
64 + value={regimen.active}
65 + onValueChange={(val) =>
66 + toggleRegimen.mutate({ id: String(regimen.id), active: val })
67 + }
68 + trackColor={{ true: Colors.light.tint, false: Colors.light.divider }}
69 + thumbColor="#fff"
70 + />
71 + </TouchableOpacity>
72 + ))
73 + )}
74 +
75 + <TouchableOpacity
76 + style={styles.addButton}
77 + activeOpacity={0.7}
78 + onPress={() => router.push("/care-actions/medicine-new")}
79 + >
80 + <Ionicons name="add-circle-outline" size={18} color={Colors.light.tint} />
81 + <Text style={styles.addText}>Adicionar medicamento</Text>
82 + </TouchableOpacity>
83 + </View>
84 + );
85 + }
86 +
87 + const styles = StyleSheet.create({
88 + container: { gap: 1 },
89 + center: { padding: Spacing.cardGap, alignItems: "center" },
90 + empty: { padding: Spacing.cardGap },
91 + emptyText: { ...Typography.bodyMd, color: Colors.light.textSecondary },
92 + row: {
93 + flexDirection: "row",
94 + alignItems: "center",
95 + backgroundColor: "#fff",
96 + padding: Spacing.cardGap,
97 + gap: 12,
98 + ...Shadows.sm,
99 + borderRadius: BorderRadius.lg,
100 + },
101 + iconWrap: {
102 + width: 32,
103 + height: 32,
104 + borderRadius: BorderRadius.md,
105 + backgroundColor: Colors.light.tint + "1A",
106 + alignItems: "center",
107 + justifyContent: "center",
108 + },
109 + info: { flex: 1 },
110 + name: { ...Typography.bodyMd, fontWeight: "600" },
111 + meta: { ...Typography.labelSm, color: Colors.light.textSecondary },
112 + addButton: {
113 + flexDirection: "row",
114 + alignItems: "center",
115 + gap: 8,
116 + paddingVertical: Spacing.cardGap,
117 + paddingHorizontal: 4,
118 + },
119 + addText: { ...Typography.bodyMd, color: Colors.light.tint, fontWeight: "600" },
120 + });
frontend/components/medicine/TodayMedicines.tsx
@@ -0,0 +1,147 @@
1 + import { View, Text, TouchableOpacity, StyleSheet, ActivityIndicator } from "react-native";
2 + import { Ionicons } from "@expo/vector-icons";
3 + import { Colors, Typography, Spacing, BorderRadius, Shadows } from "@/constants/theme";
4 + import { useMedicineToday, useCreateCareAction, medicineTodayKeys } from "@/hooks";
5 + import { useQueryClient } from "@tanstack/react-query";
6 + import { TodayMedicineEntry } from "@/lib/api";
7 +
8 + function sortedTimes(scheduledAt: string[]): string[] {
9 + return [...scheduledAt].sort((a, b) => {
10 + const [ah, am] = a.split(":").map(Number);
11 + const [bh, bm] = b.split(":").map(Number);
12 + return ah * 60 + am - (bh * 60 + bm);
13 + });
14 + }
15 +
16 + function confirmedIndexes(entry: TodayMedicineEntry): Set<number> {
17 + const confirmed = new Set<number>();
18 + const n = Math.min(entry.logs.length, entry.regimen.scheduledAt.length);
19 + for (let i = 0; i < n; i++) confirmed.add(i);
20 + return confirmed;
21 + }
22 +
23 + export function TodayMedicines() {
24 + const { data, isLoading } = useMedicineToday();
25 + const createCareAction = useCreateCareAction();
26 + const queryClient = useQueryClient();
27 +
28 + if (isLoading) {
29 + return (
30 + <View style={styles.center}>
31 + <ActivityIndicator color={Colors.light.tint} />
32 + </View>
33 + );
34 + }
35 +
36 + const entries = data?.regimens ?? [];
37 +
38 + if (entries.length === 0) {
39 + return (
40 + <View style={styles.empty}>
41 + <Text style={styles.emptyText}>Nenhum medicamento agendado para hoje</Text>
42 + </View>
43 + );
44 + }
45 +
46 + const handleConfirm = async (regimenId: number) => {
47 + await createCareAction.mutateAsync({
48 + type: "MEDICINE",
49 + moment: new Date(),
50 + regimenId,
51 + });
52 + queryClient.invalidateQueries({ queryKey: medicineTodayKeys.list() });
53 + };
54 +
55 + return (
56 + <View style={styles.container}>
57 + {entries.map((entry) => {
58 + const times = entry.regimen.scheduledAt.length > 0
59 + ? sortedTimes(entry.regimen.scheduledAt)
60 + : ["hoje"];
61 + const confirmed = confirmedIndexes(entry);
62 +
63 + return (
64 + <View key={entry.regimen.id} style={styles.card}>
65 + <View style={styles.cardHeader}>
66 + <View style={styles.iconWrap}>
67 + <Ionicons name="bandage-outline" size={18} color={Colors.light.tint} />
68 + </View>
69 + <View style={styles.cardInfo}>
70 + <Text style={styles.name}>{entry.regimen.name}</Text>
71 + <Text style={styles.dosage}>{entry.regimen.dosage}</Text>
72 + </View>
73 + </View>
74 +
75 + {times.map((time, idx) => {
76 + const isDone = time === "hoje" ? entry.logs.length > 0 : confirmed.has(idx);
77 +
78 + return (
79 + <TouchableOpacity
80 + key={time}
81 + style={[styles.timeRow, isDone && styles.timeRowConfirmed]}
82 + onPress={isDone ? undefined : () => handleConfirm(entry.regimen.id)}
83 + activeOpacity={isDone ? 1 : 0.7}
84 + disabled={isDone || createCareAction.isPending}
85 + >
86 + <Text style={[styles.timeText, isDone && styles.timeTextConfirmed]}>
87 + {time === "hoje" ? "Hoje" : time}
88 + </Text>
89 + {isDone ? (
90 + <Ionicons name="checkmark-circle" size={20} color="#34C759" />
91 + ) : (
92 + <View style={styles.pendingCircle} />
93 + )}
94 + </TouchableOpacity>
95 + );
96 + })}
97 + </View>
98 + );
99 + })}
100 + </View>
101 + );
102 + }
103 +
104 + const styles = StyleSheet.create({
105 + container: { gap: Spacing.cardGap },
106 + center: { padding: Spacing.cardGap, alignItems: "center" },
107 + empty: { padding: Spacing.cardGap },
108 + emptyText: { ...Typography.bodyMd, color: Colors.light.textSecondary },
109 + card: {
110 + backgroundColor: "#fff",
111 + borderRadius: BorderRadius.lg,
112 + padding: Spacing.cardGap,
113 + gap: 10,
114 + ...Shadows.sm,
115 + },
116 + cardHeader: { flexDirection: "row", alignItems: "center", gap: 10 },
117 + iconWrap: {
118 + width: 32,
119 + height: 32,
120 + borderRadius: BorderRadius.md,
121 + backgroundColor: Colors.light.tint + "1A",
122 + alignItems: "center",
123 + justifyContent: "center",
124 + },
125 + cardInfo: { flex: 1 },
126 + name: { ...Typography.bodyMd, fontWeight: "600" },
127 + dosage: { ...Typography.labelSm, color: Colors.light.textSecondary },
128 + timeRow: {
129 + flexDirection: "row",
130 + alignItems: "center",
131 + justifyContent: "space-between",
132 + paddingVertical: 8,
133 + paddingHorizontal: 12,
134 + borderRadius: BorderRadius.md,
135 + backgroundColor: Colors.light.background,
136 + },
137 + timeRowConfirmed: { backgroundColor: "#F0FFF4", opacity: 0.8 },
138 + timeText: { ...Typography.bodyMd, fontWeight: "500" },
139 + timeTextConfirmed: { color: Colors.light.textSecondary },
140 + pendingCircle: {
141 + width: 20,
142 + height: 20,
143 + borderRadius: 10,
144 + borderWidth: 2,
145 + borderColor: Colors.light.divider,
146 + },
147 + });
frontend/hooks/index.ts
1 1
// Legacy shit from react native
2 2
export { useThemeColor } from "@/hooks/use-theme-color";
3 3

4 4
export { useColorScheme } from "@/hooks/use-color-scheme";
5 5

▸ 31 unchanged lines
37 37
  useUpdateMedicineRegimen,
38 38
  useDeleteMedicineRegimen,
39 39
} from "@/hooks/useMedicineRegimen.queries";
40 40

41 41
export {
42 +
  medicineTodayKeys,
43 +
  useMedicineToday,
44 +
} from "@/hooks/useMedicineToday.queries";
45 +

46 +
export {
42 47
  careActionKeys,
43 48
  useCareActions,
44 49
  useCareAction,
45 50
  useCreateCareAction,
46 51
  usePatchCareAction,
47 52
  useDeleteCareAction,
48 53
} from "@/hooks/useCareAction.queries";
frontend/hooks/useCareAction.queries.ts
1 1
import { useQuery, useMutation, useQueryClient } from "@tanstack/react-query";
2 2

3 3
import {
4 4
  apiClient,
5 5
  CareAction,
6 6
  CareActionType,
7 7
  CreateCareActionPayload,
8 8
  PatchCareActionPayload,
9 9
} from "@/lib/api";
10 +
import { medicineTodayKeys } from "@/hooks/useMedicineToday.queries";
10 11

11 12
interface CareActionFilters {
12 13
  type?: CareActionType;
13 14
  from?: string;
14 15
  to?: string;
▸ 29 unchanged lines
44 45
    mutationFn: (payload: CreateCareActionPayload) =>
45 46
      apiClient.createCareAction(payload),
46 47

47 48
    onSuccess: () => {
48 49
      queryClient.invalidateQueries({ queryKey: careActionKeys.lists() });
50 +
      queryClient.invalidateQueries({ queryKey: medicineTodayKeys.list() });
49 51
    },
50 52
  });
51 53
};
52 54

53 55
export const usePatchCareAction = () => {
▸ 38 unchanged lines
92 94
    onSuccess: () => {
93 95
      queryClient.invalidateQueries({ queryKey: careActionKeys.lists() });
94 96
    },
95 97
  });
96 98
};
frontend/hooks/useMedicineToday.queries.ts
@@ -0,0 +1,15 @@
1 + import { useQuery } from "@tanstack/react-query";
2 + import { apiClient } from "@/lib/api";
3 +
4 + export const medicineTodayKeys = {
5 + all: () => ["medicineToday"] as const,
6 + list: () => [...medicineTodayKeys.all(), "list"] as const,
7 + };
8 +
9 + export const useMedicineToday = () => {
10 + return useQuery({
11 + queryKey: medicineTodayKeys.list(),
12 + queryFn: () => apiClient.getMedicineRegimensToday(),
13 + staleTime: 2 * 60 * 1000,
14 + });
15 + };