Replace the profile tab with prescription management (inline reminders and family invite), add a shared tab top bar, separate notifications from orders, and support any-branch guest checkout with GSH codes. Co-authored-by: Cursor <cursoragent@cursor.com>
399 lines
14 KiB
TypeScript
399 lines
14 KiB
TypeScript
import { Ionicons } from "@expo/vector-icons";
|
|
import { router } from "expo-router";
|
|
import { useMemo, useState } from "react";
|
|
import { Alert, Image, Pressable, Share, ScrollView, View } from "react-native";
|
|
import { useTranslation } from "react-i18next";
|
|
import { Badge } from "@/components/ui/Badge";
|
|
import { Button } from "@/components/ui/Button";
|
|
import { Card, Section } from "@/components/ui/Card";
|
|
import { DoseReminderPanel } from "@/components/ui/DoseReminderPanel";
|
|
import { EmptyState } from "@/components/ui/EmptyState";
|
|
import { Screen, TAB_BAR_INSET } from "@/components/ui/Screen";
|
|
import { TabTopBar } from "@/components/ui/TabTopBar";
|
|
import { Text } from "@/components/ui/Text";
|
|
import { routes } from "@/lib/routes";
|
|
import { useFamilyStore } from "@/lib/stores/familyStore";
|
|
import {
|
|
usePrescriptionStore,
|
|
type Prescription,
|
|
type Reminder,
|
|
} from "@/lib/stores/prescriptionStore";
|
|
import { colors, gutter } from "@/lib/theme";
|
|
|
|
const statusTone = {
|
|
submitted: "neutral",
|
|
under_review: "warn",
|
|
approved: "brand",
|
|
queried: "info",
|
|
rejected: "danger",
|
|
filled: "brand",
|
|
} as const;
|
|
|
|
function refillDays(rx: Prescription) {
|
|
if (!rx.lastFilledAt) return null;
|
|
const due = new Date(rx.lastFilledAt);
|
|
due.setDate(due.getDate() + rx.daysSupply);
|
|
return Math.ceil((due.getTime() - Date.now()) / (1000 * 60 * 60 * 24));
|
|
}
|
|
|
|
function RxCareCard({
|
|
rx,
|
|
reminder,
|
|
expanded,
|
|
onToggleExpand,
|
|
}: {
|
|
rx: Prescription;
|
|
reminder?: Reminder;
|
|
expanded: boolean;
|
|
onToggleExpand: () => void;
|
|
}) {
|
|
const dueIn = refillDays(rx);
|
|
const thumb = rx.imageUris[0];
|
|
|
|
return (
|
|
<Card elevated style={{ padding: 0, overflow: "hidden" }}>
|
|
<Pressable
|
|
onPress={() => router.push(routes.rxDetail(rx.id))}
|
|
style={{ padding: 14 }}
|
|
>
|
|
<View style={{ flexDirection: "row", gap: 12 }}>
|
|
<View
|
|
style={{
|
|
width: 64,
|
|
height: 64,
|
|
borderRadius: 16,
|
|
backgroundColor: colors.brandSoft,
|
|
alignItems: "center",
|
|
justifyContent: "center",
|
|
overflow: "hidden",
|
|
}}
|
|
>
|
|
{thumb ? (
|
|
<Image source={{ uri: thumb }} style={{ width: 64, height: 64 }} />
|
|
) : (
|
|
<Ionicons name="medkit" size={26} color={colors.brandText} />
|
|
)}
|
|
</View>
|
|
<View style={{ flex: 1 }}>
|
|
<View style={{ flexDirection: "row", gap: 8, alignItems: "flex-start" }}>
|
|
<Text variant="h3" style={{ flex: 1 }} numberOfLines={2}>
|
|
{rx.medicineName}
|
|
</Text>
|
|
<Badge label={rx.status.replace("_", " ")} tone={statusTone[rx.status]} />
|
|
</View>
|
|
<Text variant="caption" color={colors.subtle} style={{ marginTop: 4 }}>
|
|
{rx.patientName} · {rx.medicines.length} medicine
|
|
{rx.medicines.length === 1 ? "" : "s"} · {rx.daysSupply}d supply
|
|
</Text>
|
|
<View style={{ flexDirection: "row", flexWrap: "wrap", gap: 6, marginTop: 10 }}>
|
|
{reminder?.enabled ? (
|
|
<Badge
|
|
label={`Reminders ${reminder.times.join(" · ")}`}
|
|
tone="brand"
|
|
icon="alarm-outline"
|
|
/>
|
|
) : (
|
|
<Badge label="No dose reminder" tone="warn" icon="alarm-outline" />
|
|
)}
|
|
{dueIn !== null ? (
|
|
<Badge
|
|
label={
|
|
dueIn <= 0 ? "Refill overdue" : `Refill in ${dueIn}d`
|
|
}
|
|
tone={dueIn <= 3 ? "warn" : "neutral"}
|
|
/>
|
|
) : (
|
|
<Badge label="Awaiting fill" tone="info" />
|
|
)}
|
|
</View>
|
|
</View>
|
|
</View>
|
|
</Pressable>
|
|
|
|
<View
|
|
style={{
|
|
flexDirection: "row",
|
|
borderTopWidth: 1,
|
|
borderTopColor: colors.line,
|
|
}}
|
|
>
|
|
<Pressable
|
|
onPress={onToggleExpand}
|
|
style={{
|
|
flex: 1,
|
|
paddingVertical: 12,
|
|
alignItems: "center",
|
|
flexDirection: "row",
|
|
justifyContent: "center",
|
|
gap: 6,
|
|
}}
|
|
>
|
|
<Ionicons
|
|
name={expanded ? "chevron-up" : "notifications-outline"}
|
|
size={16}
|
|
color={colors.brandText}
|
|
/>
|
|
<Text variant="label" color={colors.brandText}>
|
|
{expanded ? "Hide setup" : reminder?.enabled ? "Edit reminder" : "Set reminder"}
|
|
</Text>
|
|
</Pressable>
|
|
<View style={{ width: 1, backgroundColor: colors.line }} />
|
|
<Pressable
|
|
onPress={() => router.push(routes.rxCalendar(rx.id))}
|
|
style={{
|
|
flex: 1,
|
|
paddingVertical: 12,
|
|
alignItems: "center",
|
|
flexDirection: "row",
|
|
justifyContent: "center",
|
|
gap: 6,
|
|
}}
|
|
>
|
|
<Ionicons name="calendar-outline" size={16} color={colors.brandText} />
|
|
<Text variant="label" color={colors.brandText}>
|
|
Calendar
|
|
</Text>
|
|
</Pressable>
|
|
</View>
|
|
|
|
{expanded ? (
|
|
<View style={{ paddingHorizontal: 14, paddingBottom: 14 }}>
|
|
<DoseReminderPanel rx={rx} reminder={reminder} compact />
|
|
</View>
|
|
) : null}
|
|
</Card>
|
|
);
|
|
}
|
|
|
|
export default function PrescriptionsTabScreen() {
|
|
const { t } = useTranslation();
|
|
const prescriptions = usePrescriptionStore((s) => s.prescriptions);
|
|
const reminders = usePrescriptionStore((s) => s.reminders);
|
|
const members = useFamilyStore((s) => s.members);
|
|
const inviteCode = useFamilyStore((s) => s.inviteCode);
|
|
const acceptInvite = useFamilyStore((s) => s.acceptInvite);
|
|
const removeMember = useFamilyStore((s) => s.removeMember);
|
|
const setActiveMember = useFamilyStore((s) => s.setActiveMember);
|
|
const activeMemberId = useFamilyStore((s) => s.activeMemberId);
|
|
const [expandedId, setExpandedId] = useState<string | null>(null);
|
|
|
|
const reminderByRx = useMemo(() => {
|
|
const map = new Map<string, Reminder>();
|
|
reminders.forEach((r) => map.set(r.prescriptionId, r));
|
|
return map;
|
|
}, [reminders]);
|
|
|
|
const activeReminderCount = reminders.filter((r) => r.enabled).length;
|
|
const needingSetup = prescriptions.filter((p) => !reminderByRx.get(p.id)?.enabled).length;
|
|
|
|
return (
|
|
<Screen>
|
|
<TabTopBar subtitle={t("common.prescriptions")} />
|
|
<ScrollView
|
|
showsVerticalScrollIndicator={false}
|
|
contentContainerStyle={{
|
|
paddingBottom: TAB_BAR_INSET + 24,
|
|
paddingHorizontal: gutter,
|
|
gap: 14,
|
|
}}
|
|
>
|
|
<View>
|
|
<Text variant="h1">{t("common.prescriptions")}</Text>
|
|
<Text variant="bodySm" color={colors.subtle} style={{ marginTop: 4 }}>
|
|
Manage Rx, dose calendars, and family care shares
|
|
</Text>
|
|
</View>
|
|
|
|
<View style={{ flexDirection: "row", gap: 10 }}>
|
|
<Card elevated={false} tone="brand" style={{ flex: 1 }}>
|
|
<Text variant="caption" color={colors.muted}>
|
|
Active reminders
|
|
</Text>
|
|
<Text variant="h1" style={{ marginTop: 2 }}>
|
|
{activeReminderCount}
|
|
</Text>
|
|
</Card>
|
|
<Card elevated={false} style={{ flex: 1 }}>
|
|
<Text variant="caption" color={colors.muted}>
|
|
Need setup
|
|
</Text>
|
|
<Text variant="h1" style={{ marginTop: 2 }}>
|
|
{needingSetup}
|
|
</Text>
|
|
</Card>
|
|
</View>
|
|
|
|
<Button
|
|
title={t("common.scanRx")}
|
|
icon="scan-outline"
|
|
onPress={() => router.push(routes.rxScan)}
|
|
/>
|
|
|
|
<Section title="Your prescriptions">
|
|
{prescriptions.length === 0 ? (
|
|
<EmptyState
|
|
compact
|
|
icon="document-text-outline"
|
|
title="No prescriptions yet"
|
|
body="Scan or upload a prescription to start reminders."
|
|
actionTitle="Scan prescription"
|
|
onAction={() => router.push(routes.rxScan)}
|
|
/>
|
|
) : (
|
|
<View style={{ gap: 12 }}>
|
|
{prescriptions.map((rx) => (
|
|
<RxCareCard
|
|
key={rx.id}
|
|
rx={rx}
|
|
reminder={reminderByRx.get(rx.id)}
|
|
expanded={expandedId === rx.id}
|
|
onToggleExpand={() =>
|
|
setExpandedId((cur) => (cur === rx.id ? null : rx.id))
|
|
}
|
|
/>
|
|
))}
|
|
</View>
|
|
)}
|
|
</Section>
|
|
|
|
<Section title={t("common.family")}>
|
|
<Card tone="brand" elevated={false}>
|
|
<View style={{ flexDirection: "row", gap: 12, alignItems: "center" }}>
|
|
<View
|
|
style={{
|
|
width: 48,
|
|
height: 48,
|
|
borderRadius: 14,
|
|
backgroundColor: colors.surface,
|
|
alignItems: "center",
|
|
justifyContent: "center",
|
|
borderWidth: 1,
|
|
borderColor: colors.brandBorder,
|
|
}}
|
|
>
|
|
<Ionicons name="people" size={22} color={colors.brandText} />
|
|
</View>
|
|
<View style={{ flex: 1 }}>
|
|
<Text variant="caption" color={colors.muted}>
|
|
Family invite code
|
|
</Text>
|
|
<Text variant="h2" style={{ marginTop: 2, letterSpacing: 1 }}>
|
|
{inviteCode}
|
|
</Text>
|
|
</View>
|
|
</View>
|
|
<Text variant="bodySm" color={colors.muted} style={{ marginTop: 10 }}>
|
|
Share care so family can help with dose reminders and refills.
|
|
</Text>
|
|
<View style={{ flexDirection: "row", flexWrap: "wrap", gap: 8, marginTop: 12 }}>
|
|
<Button
|
|
title="Share code"
|
|
size="md"
|
|
fullWidth={false}
|
|
variant="outline"
|
|
icon="share-outline"
|
|
onPress={() =>
|
|
Share.share({
|
|
message: `Join my Gishen family care group with code ${inviteCode}`,
|
|
})
|
|
}
|
|
/>
|
|
<Button
|
|
title={t("common.inviteFamily")}
|
|
size="md"
|
|
fullWidth={false}
|
|
icon="person-add-outline"
|
|
onPress={() => router.push(routes.familyInvite)}
|
|
/>
|
|
</View>
|
|
</Card>
|
|
|
|
{members.length === 0 ? (
|
|
<Text variant="bodySm" color={colors.muted} style={{ marginTop: 8 }}>
|
|
No family members yet. Invite someone to share Rx care.
|
|
</Text>
|
|
) : (
|
|
<View style={{ gap: 10, marginTop: 4 }}>
|
|
{members.map((m) => {
|
|
const active = activeMemberId === m.id;
|
|
return (
|
|
<Card
|
|
key={m.id}
|
|
onPress={() => setActiveMember(active ? null : m.id)}
|
|
style={{
|
|
borderColor: active ? colors.brand : colors.line,
|
|
borderWidth: active ? 1.5 : 1,
|
|
}}
|
|
>
|
|
<View style={{ flexDirection: "row", gap: 12, alignItems: "center" }}>
|
|
<View
|
|
style={{
|
|
width: 42,
|
|
height: 42,
|
|
borderRadius: 21,
|
|
backgroundColor: colors.brandSoft,
|
|
alignItems: "center",
|
|
justifyContent: "center",
|
|
}}
|
|
>
|
|
<Text variant="label" color={colors.brandText}>
|
|
{m.name.slice(0, 1).toUpperCase()}
|
|
</Text>
|
|
</View>
|
|
<View style={{ flex: 1 }}>
|
|
<Text variant="h3">{m.name}</Text>
|
|
<Text variant="caption" color={colors.subtle} style={{ marginTop: 2 }}>
|
|
{m.relationship}
|
|
{m.phone ? ` · ${m.phone}` : ""}
|
|
{active ? " · Active for Rx" : ""}
|
|
</Text>
|
|
</View>
|
|
<Badge
|
|
label={m.status}
|
|
tone={m.status === "accepted" ? "brand" : "warn"}
|
|
/>
|
|
</View>
|
|
<View
|
|
style={{
|
|
flexDirection: "row",
|
|
flexWrap: "wrap",
|
|
gap: 8,
|
|
marginTop: 12,
|
|
}}
|
|
>
|
|
{m.status === "pending" ? (
|
|
<Button
|
|
title="Accept"
|
|
size="sm"
|
|
fullWidth={false}
|
|
onPress={() => acceptInvite(m.id)}
|
|
/>
|
|
) : null}
|
|
<Button
|
|
title="Remove"
|
|
size="sm"
|
|
variant="ghost"
|
|
fullWidth={false}
|
|
onPress={() =>
|
|
Alert.alert("Remove member?", m.name, [
|
|
{ text: "Cancel", style: "cancel" },
|
|
{
|
|
text: "Remove",
|
|
style: "destructive",
|
|
onPress: () => removeMember(m.id),
|
|
},
|
|
])
|
|
}
|
|
/>
|
|
</View>
|
|
</Card>
|
|
);
|
|
})}
|
|
</View>
|
|
)}
|
|
</Section>
|
|
</ScrollView>
|
|
</Screen>
|
|
);
|
|
}
|