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 · 4 months ago
120e1cdf02f34255474a21f7c49f6bc0025e3779 Browse filesParents
9dde95cChanged files
7
Additions
+350
Deletions
0
/dev/null → frontend/app/medicine-regimens/[id].tsx
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 +
});
/dev/null → frontend/app/medicine-regimens/index.tsx
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 +
});
/dev/null → frontend/components/medicine/MedicineRegimenList.tsx
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 +
});
/dev/null → frontend/components/medicine/TodayMedicines.tsx
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
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;
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 = () => {
92 94
onSuccess: () => {
93 95
queryClient.invalidateQueries({ queryKey: careActionKeys.lists() });
94 96
},
95 97
});
96 98
};
/dev/null → frontend/hooks/useMedicineToday.queries.ts
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 +
};