eletrotupi / tcc/ commit / f8f41f3

frontend/notifications: render the inbox from stored records

The screen only showed the last push that happened to arrive in the
foreground, so there was nothing to look back at. It now pages the
stored notifications newest-first, marks the ones whose window has
closed, and taps through to a medicine or mood surface
Pedro Lucas Porcellis porcellis@eletrotupi.com · 11 days ago f8f41f3d02a26b63d278ed849f86e913d1ea381b Browse files
Parents ce0e2eb
Changed files
8
Additions
+271
Deletions
-73
frontend/app/notifications.tsx
1 -
import {
2 -
  StyleSheet,
3 -
  ScrollView,
4 -
  KeyboardAvoidingView,
5 -
} from "react-native";
6 -
import { Text } from "@/components/ui/Text";
1 +
import { ActivityIndicator, FlatList, StyleSheet, View } from "react-native";
7 2

8 -
import { useState, useEffect } from "react";
9 -
import * as Notifications from "expo-notifications";
10 -
import { useLocalSearchParams } from "expo-router";
11 -
import { StatusBar } from "expo-status-bar";
12 -
import { SafeAreaView } from "react-native-safe-area-context";
3 +
import { EmptyState } from "@/components/ui/EmptyState";
4 +
import { SectionHeader } from "@/components/ui/Sections";
5 +
import { NotificationRow } from "@/components/notifications/NotificationRow";
6 +
import { useNotificationsInfinite } from "@/hooks";
13 7
import { Colors, Spacing } from "@/constants/theme";
14 -
import { Card } from "@/components/ui/Cards";
15 -
import { Section, SectionHeader } from "@/components/ui/Sections";
16 8

17 -
type NotificationParams = {
18 -
  title?: string;
19 -
  body?: string;
20 -
  notificationId?: string;
21 -
};
22 -

23 -
type ReceivedNotification = {
24 -
  title?: string;
25 -
  body?: string;
26 -
};
27 -

28 -
export default function NotificationsPage() {
29 -
  const params = useLocalSearchParams<NotificationParams>();
30 -
  const [liveNotification, setLiveNotification] = useState<ReceivedNotification | null>(null);
9 +
export default function NotificationsInbox() {
10 +
  const {
11 +
    data,
12 +
    isLoading,
13 +
    fetchNextPage,
14 +
    hasNextPage,
15 +
    isFetchingNextPage,
16 +
  } = useNotificationsInfinite();
31 17

32 -
  useEffect(() => {
33 -
    const sub = Notifications.addNotificationReceivedListener((n) => {
34 -
      setLiveNotification({
35 -
        title: n.request.content.title ?? undefined,
36 -
        body: n.request.content.body ?? undefined,
37 -
      });
38 -
    });
39 -
    return () => sub.remove();
40 -
  }, []);
41 -

42 -
  const displayed = liveNotification ?? (params.title || params.body ? { title: params.title, body: params.body } : null);
18 +
  const notifications = data?.pages.flatMap((page) => page.notifications) ?? [];
43 19

44 20
  return (
45 -
    <SafeAreaView style={styles.container}>
46 -
      <StatusBar style={"dark"} />
47 -
      <KeyboardAvoidingView behavior="height" style={styles.keyboardView}>
48 -
        <ScrollView
49 -
          contentContainerStyle={styles.scrollContainer}
50 -
          showsVerticalScrollIndicator={false}
51 -
        >
52 -
          <Section>
53 -
            <SectionHeader title="Recebidas" />
54 -

55 -
            {displayed ? (
56 -
              <Card style={{ padding: Spacing.cardGap }}>
57 -
                <Text>Title: {displayed.title}</Text>
58 -
                <Text>Body: {displayed.body}</Text>
59 -
              </Card>
60 -
            ) : (
61 -
              <Card style={{ padding: Spacing.cardGap }}>
62 -
                <Text>Nenhuma notificação recebida.</Text>
63 -
              </Card>
64 -
            )}
65 -
          </Section>
66 -
        </ScrollView>
67 -
      </KeyboardAvoidingView>
68 -
    </SafeAreaView>
21 +
    <View style={styles.container}>
22 +
      <FlatList
23 +
        testID="notifications-list"
24 +
        data={notifications}
25 +
        keyExtractor={(notification) => String(notification.id)}
26 +
        renderItem={({ item }) => <NotificationRow notification={item} />}
27 +
        ListHeaderComponent={
28 +
          notifications.length > 0 ? <SectionHeader title="Recentes" /> : null
29 +
        }
30 +
        contentContainerStyle={styles.content}
31 +
        onEndReachedThreshold={0.5}
32 +
        onEndReached={() => {
33 +
          if (hasNextPage && !isFetchingNextPage) fetchNextPage();
34 +
        }}
35 +
        ListEmptyComponent={
36 +
          isLoading ? (
37 +
            <ActivityIndicator style={styles.footer} color={Colors.light.tint} />
38 +
          ) : (
39 +
            <EmptyState
40 +
              title="Nenhuma notificação"
41 +
              subtitle="Avisaremos você quando algo chegar."
42 +
              icon="notifications-off-outline"
43 +
            />
44 +
          )
45 +
        }
46 +
        ListFooterComponent={
47 +
          isFetchingNextPage ? (
48 +
            <ActivityIndicator style={styles.footer} color={Colors.light.tint} />
49 +
          ) : null
50 +
        }
51 +
      />
52 +
    </View>
69 53
  );
70 54
}
71 55

72 56
const styles = StyleSheet.create({
73 57
  container: {
74 58
    flex: 1,
75 59
    backgroundColor: Colors.light.background,
76 60
  },
77 -
  keyboardView: {
78 -
    flex: 1,
61 +
  content: {
62 +
    paddingHorizontal: Spacing.containerPadding,
63 +
    paddingVertical: Spacing.sectionGap,
79 64
  },
80 -
  scrollContainer: {
81 -
    flexGrow: 1,
82 -
    paddingHorizontal: 24,
83 -
    justifyContent: "flex-start",
84 -
    minHeight: "100%",
85 -
  },
86 -
  header: {
87 -
    alignItems: "flex-start",
88 -
    marginBottom: 40,
65 +
  footer: {
66 +
    paddingVertical: Spacing.cardGap,
89 67
  },
90 68
});
/dev/null → frontend/components/notifications/NotificationRow.tsx
1 +
import { View, StyleSheet } from "react-native";
2 +
import { router, type Href } from "expo-router";
3 +
import { MaterialIcons } from "@expo/vector-icons";
4 +

5 +
import { Text } from "@/components/ui/Text";
6 +
import { Card } from "@/components/ui/Cards";
7 +
import { Badge } from "@/components/ui/Badge";
8 +
import { NOTIFICATION_KINDS } from "@/constants/notification-kinds";
9 +
import { formatMoment } from "@/lib/utils/time";
10 +
import { Spacing } from "@/constants/theme";
11 +
import type { Notification } from "@/lib/api";
12 +

13 +
type Props = {
14 +
  notification: Notification;
15 +
};
16 +

17 +
// A row is history once its relevance window has closed; a notification with no
18 +
// window (an insight alert) never expires
19 +
const isExpired = (notification: Notification): boolean =>
20 +
  notification.expiresAt !== null &&
21 +
  new Date(notification.expiresAt).getTime() <= Date.now();
22 +

23 +
// The same deep-link shape the push observer uses: a pathname plus params
24 +
function destination(notification: Notification): Href {
25 +
  if (notification.kind === "MEDICINE_REMINDER" && notification.regimenId !== null) {
26 +
    return {
27 +
      pathname: "/medicine-regimens/[id]",
28 +
      params: { id: String(notification.regimenId) },
29 +
    } as Href;
30 +
  }
31 +

32 +
  if (notification.kind === "MEDICINE_REMINDER") {
33 +
    return { pathname: "/medicine-regimens" } as Href;
34 +
  }
35 +

36 +
  if (notification.kind === "DAILY_REMINDER") {
37 +
    return { pathname: "/new/entry" } as Href;
38 +
  }
39 +

40 +
  return { pathname: "/insights" } as Href;
41 +
}
42 +

43 +
export function NotificationRow({ notification }: Props) {
44 +
  const meta = NOTIFICATION_KINDS[notification.kind];
45 +
  const expired = isExpired(notification);
46 +

47 +
  return (
48 +
    <Card
49 +
      style={styles.card}
50 +
      testID={`notification-${notification.id}`}
51 +
      onPress={() => router.push(destination(notification))}
52 +
    >
53 +
      <View style={[styles.row, expired && styles.expired]}>
54 +
        <View style={[styles.iconWrap, { backgroundColor: meta.iconBackground }]}>
55 +
          <MaterialIcons name={meta.icon} size={22} color={meta.color} />
56 +
        </View>
57 +

58 +
        <View style={styles.body}>
59 +
          <View style={styles.titleRow}>
60 +
            <Text style={styles.title} numberOfLines={1}>
61 +
              {notification.title}
62 +
            </Text>
63 +

64 +
            {expired && <Badge label="Expirada" variant="gray" />}
65 +
          </View>
66 +

67 +
          <Text style={styles.summary} numberOfLines={2}>
68 +
            {notification.body}
69 +
          </Text>
70 +

71 +
          <Text style={styles.moment}>
72 +
            {formatMoment(new Date(notification.scheduledFor))}
73 +
          </Text>
74 +
        </View>
75 +
      </View>
76 +
    </Card>
77 +
  );
78 +
}
79 +

80 +
const styles = StyleSheet.create({
81 +
  card: {
82 +
    padding: Spacing.cardGap,
83 +
  },
84 +
  row: {
85 +
    flexDirection: "row",
86 +
    alignItems: "flex-start",
87 +
    gap: 12,
88 +
  },
89 +
  expired: {
90 +
    opacity: 0.55,
91 +
  },
92 +
  iconWrap: {
93 +
    width: 40,
94 +
    height: 40,
95 +
    borderRadius: 20,
96 +
    alignItems: "center",
97 +
    justifyContent: "center",
98 +
    marginTop: 1,
99 +
  },
100 +
  body: {
101 +
    flex: 1,
102 +
  },
103 +
  titleRow: {
104 +
    flexDirection: "row",
105 +
    justifyContent: "space-between",
106 +
    alignItems: "center",
107 +
    gap: 8,
108 +
    marginBottom: 2,
109 +
  },
110 +
  title: {
111 +
    flexShrink: 1,
112 +
    fontSize: 15,
113 +
    fontWeight: "600",
114 +
  },
115 +
  summary: {
116 +
    fontSize: 13,
117 +
    opacity: 0.6,
118 +
    lineHeight: 18,
119 +
  },
120 +
  moment: {
121 +
    fontSize: 12,
122 +
    opacity: 0.45,
123 +
    marginTop: 4,
124 +
  },
125 +
});
/dev/null → frontend/constants/notification-kinds.ts
1 +
import { MaterialIcons } from "@expo/vector-icons";
2 +
import type { NotificationKind } from "@/lib/api/types";
3 +

4 +
export type NotificationKindMeta = {
5 +
  icon: keyof typeof MaterialIcons.glyphMap;
6 +
  color: string;
7 +
  iconBackground: string;
8 +
};
9 +

10 +
// The icon and pastel bubble that identify each kind at a glance; the title and
11 +
// body carry the rest
12 +
export const NOTIFICATION_KINDS: Record<NotificationKind, NotificationKindMeta> = {
13 +
  DAILY_REMINDER: {
14 +
    icon: "mood",
15 +
    color: "#0EA5E9",
16 +
    iconBackground: "#E0F2FE",
17 +
  },
18 +
  MEDICINE_REMINDER: {
19 +
    icon: "medication",
20 +
    color: "#7C3AED",
21 +
    iconBackground: "#EDE9FE",
22 +
  },
23 +
  INSIGHT_ALERT: {
24 +
    icon: "lightbulb-outline",
25 +
    color: "#D97706",
26 +
    iconBackground: "#FEF3C7",
27 +
  },
28 +
};
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

46 46
} from "@/hooks/useMedicineToday.queries";
47 47

48 48
export { usePatchUserMe } from "@/hooks/useUserPreferences.queries";
49 49

50 50
export {
51 +
  notificationKeys,
52 +
  useNotificationsInfinite,
53 +
} from "@/hooks/useNotifications.queries";
54 +

55 +
export {
51 56
  careActionKeys,
52 57
  useCareActions,
53 58
  useCareAction,
54 59
  useCreateCareAction,
55 60
  usePatchCareAction,
56 61
  useDeleteCareAction,
57 62
} from "@/hooks/useCareAction.queries";
/dev/null → frontend/hooks/useNotifications.queries.ts
1 +
import { useInfiniteQuery } from "@tanstack/react-query";
2 +

3 +
import { apiClient } from "@/lib/api";
4 +

5 +
export const notificationKeys = {
6 +
  all: () => ["notifications"] as const,
7 +
  lists: () => [...notificationKeys.all(), "list"] as const,
8 +
  infinite: () => [...notificationKeys.lists(), "infinite"] as const,
9 +
};
10 +

11 +
// The inbox reads newest-first and pages with a cursor: each response carries
12 +
// the next cursor, or null once the last page is reached
13 +
export const useNotificationsInfinite = (limit = 30) => {
14 +
  return useInfiniteQuery({
15 +
    queryKey: notificationKeys.infinite(),
16 +
    queryFn: ({ pageParam }) =>
17 +
      apiClient.getNotifications({ cursor: pageParam, limit }),
18 +
    getNextPageParam: (lastPage) => lastPage.nextCursor ?? undefined,
19 +
    initialPageParam: undefined as number | undefined,
20 +
  });
21 +
};
frontend/lib/api/client.ts
1 1
import * as SecureStore from "expo-secure-store";
2 2
import Constants from "expo-constants";
3 3

4 4
export class ApiError extends Error {
5 5
  status: number;
50 50
  TriggerMoodLink,
51 51
  LinkMoodPayload,
52 52
  AvatarResponse,
53 53
  RemoveAvatarResponse,
54 54
  ExportDataResponse,
55 +
  NotificationsPage,
55 56
} from "@/lib/api/types";
56 57

57 58
// Development builds already talk to Metro, so the API is on the same host.
58 59
// Release builds have no hostUri and always take the env var.
59 60
const API_BASE_URL =
426 427
    });
427 428
  }
428 429

429 430
  async exportData(): Promise<ExportDataResponse> {
430 431
    return this.request('/exports', { method: 'POST' });
432 +
  }
433 +

434 +
  // Notifications
435 +
  async getNotifications(params?: {
436 +
    limit?: number;
437 +
    cursor?: number;
438 +
  }): Promise<NotificationsPage> {
439 +
    const query = new URLSearchParams();
440 +
    if (params?.limit) query.set('limit', String(params.limit));
441 +
    if (params?.cursor) query.set('cursor', String(params.cursor));
442 +

443 +
    const qs = query.toString();
444 +
    return this.request(`/notifications${qs ? `?${qs}` : ''}`);
431 445
  }
432 446

433 447
  // Care Actions
434 448
  async getCareActions(
435 449
    params?: {
520 534
    return response.arrayBuffer()
521 535
  }
522 536
}
523 537

524 538
export const apiClient = new ApiClient();
frontend/lib/api/index.ts
1 1
export { apiClient, ApiError } from "@/lib/api/client";
2 2

3 3
export type {
4 4
  User,
5 5
  AuthResponse,
39 39
  CreateMedicineRegimenPayload,
40 40
  UpdateMedicineRegimenPayload,
41 41
  TodayMedicineEntry,
42 42
  UserPreferencesPayload,
43 43
  ExportDataResponse,
44 +
  // Notifications
45 +
  Notification,
46 +
  NotificationKind,
47 +
  NotificationsPage,
44 48
  UpdateTriggerPayload,
45 49
  // Trigger-Mood linking
46 50
  TriggerMoodLink,
47 51
  TriggerMoodLinkWithMood,
48 52
  TriggerMoodLinkWithTrigger,
49 53
  LinkMoodPayload,
50 54
} from "@/lib/api/types";
frontend/lib/api/types.ts
1 1
export interface User {
2 2
  id: number;
3 3
  email: string;
4 4
  firstName: string;
5 5
  lastName?: string;
71 71

72 72
export interface ExportDataResponse {
73 73
  message: string;
74 74
}
75 75

76 +
// Notifications
77 +
export type NotificationKind =
78 +
  | "DAILY_REMINDER"
79 +
  | "MEDICINE_REMINDER"
80 +
  | "INSIGHT_ALERT";
81 +

82 +
export interface Notification {
83 +
  id: number;
84 +
  kind: NotificationKind;
85 +
  title: string;
86 +
  body: string;
87 +
  scheduledFor: string;
88 +
  expiresAt: string | null;
89 +
  regimenId: number | null;
90 +
  createdAt: string;
91 +
}
92 +

93 +
// The inbox pages with a cursor id rather than a page number
94 +
export interface NotificationsPage {
95 +
  notifications: Notification[];
96 +
  nextCursor: number | null;
97 +
}
98 +

76 99
// Mood entries
77 100
export interface MoodComponentPayload {
78 101
  component: string;
79 102
  intensity: string;
80 103
}
390 413
  moodId?: number;
391 414
}
392 415
export interface MedicineRegimenResponse { regimen: MedicineRegimen; }
393 416

394 417
export interface LinkMoodPayload { moodId: number; perceivedImpact: number; }