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 · 4 months ago 120e1cdf02f34255474a21f7c49f6bc0025e3779 Browse files
Parents 9dde95c
Changed 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 +
};