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>
176 lines
4.8 KiB
TypeScript
176 lines
4.8 KiB
TypeScript
import * as Notifications from "expo-notifications";
|
|
import { Platform } from "react-native";
|
|
|
|
Notifications.setNotificationHandler({
|
|
handleNotification: async () => ({
|
|
shouldShowAlert: true,
|
|
shouldPlaySound: true,
|
|
shouldSetBadge: false,
|
|
shouldShowBanner: true,
|
|
shouldShowList: true,
|
|
}),
|
|
});
|
|
|
|
export async function ensureNotificationPermissions() {
|
|
const settings = await Notifications.getPermissionsAsync();
|
|
if (settings.granted) return true;
|
|
const req = await Notifications.requestPermissionsAsync();
|
|
return req.granted;
|
|
}
|
|
|
|
export function androidChannel() {
|
|
if (Platform.OS === "android") {
|
|
return Notifications.setNotificationChannelAsync("gishen-reminders", {
|
|
name: "Gishen reminders",
|
|
importance: Notifications.AndroidImportance.HIGH,
|
|
sound: "default",
|
|
});
|
|
}
|
|
return Promise.resolve();
|
|
}
|
|
|
|
export function parseTimeHHmm(value: string): { hour: number; minute: number } | null {
|
|
const m = value.trim().match(/^(\d{1,2}):(\d{2})$/);
|
|
if (!m) return null;
|
|
const hour = Number(m[1]);
|
|
const minute = Number(m[2]);
|
|
if (hour < 0 || hour > 23 || minute < 0 || minute > 59) return null;
|
|
return { hour, minute };
|
|
}
|
|
|
|
/** Seconds until the next occurrence of HH:mm (today or tomorrow). */
|
|
export function secondsUntilNextHHmm(value: string): number | null {
|
|
const parsed = parseTimeHHmm(value);
|
|
if (!parsed) return null;
|
|
const now = new Date();
|
|
const next = new Date(now);
|
|
next.setHours(parsed.hour, parsed.minute, 0, 0);
|
|
if (next.getTime() <= now.getTime()) {
|
|
next.setDate(next.getDate() + 1);
|
|
}
|
|
return Math.max(5, Math.round((next.getTime() - now.getTime()) / 1000));
|
|
}
|
|
|
|
export async function scheduleDoseReminder(opts: {
|
|
title: string;
|
|
body: string;
|
|
secondsFromNow: number;
|
|
data?: Record<string, string>;
|
|
}) {
|
|
await ensureNotificationPermissions();
|
|
await androidChannel();
|
|
return Notifications.scheduleNotificationAsync({
|
|
content: {
|
|
title: opts.title,
|
|
body: opts.body,
|
|
data: opts.data,
|
|
sound: "default",
|
|
categoryIdentifier: "dose",
|
|
},
|
|
trigger: {
|
|
type: Notifications.SchedulableTriggerInputTypes.TIME_INTERVAL,
|
|
seconds: Math.max(5, opts.secondsFromNow),
|
|
repeats: false,
|
|
},
|
|
});
|
|
}
|
|
|
|
export async function scheduleEscalationCheck(opts: {
|
|
title: string;
|
|
body: string;
|
|
graceSeconds: number;
|
|
data?: Record<string, string>;
|
|
}) {
|
|
await ensureNotificationPermissions();
|
|
await androidChannel();
|
|
return Notifications.scheduleNotificationAsync({
|
|
content: {
|
|
title: opts.title,
|
|
body: opts.body,
|
|
data: { ...opts.data, type: "escalation" },
|
|
sound: "default",
|
|
},
|
|
trigger: {
|
|
type: Notifications.SchedulableTriggerInputTypes.TIME_INTERVAL,
|
|
seconds: Math.max(10, opts.graceSeconds),
|
|
repeats: false,
|
|
},
|
|
});
|
|
}
|
|
|
|
/**
|
|
* Schedule repeating daily calendar notifications for each HH:mm time.
|
|
* Also queues a near-term demo notification for the soonest slot (~15s if within a few minutes).
|
|
*/
|
|
export async function scheduleDailyDoseReminders(opts: {
|
|
prescriptionId: string;
|
|
medicineName: string;
|
|
times: string[];
|
|
/** Optional prior notification ids to cancel */
|
|
previousIds?: string[];
|
|
}) {
|
|
const granted = await ensureNotificationPermissions();
|
|
await androidChannel();
|
|
|
|
if (opts.previousIds?.length) {
|
|
await Promise.all(
|
|
opts.previousIds.map((id) =>
|
|
Notifications.cancelScheduledNotificationAsync(id).catch(() => {})
|
|
)
|
|
);
|
|
}
|
|
|
|
const notificationIds: string[] = [];
|
|
const validTimes: string[] = [];
|
|
|
|
for (const time of opts.times) {
|
|
const parsed = parseTimeHHmm(time);
|
|
if (!parsed) continue;
|
|
validTimes.push(
|
|
`${String(parsed.hour).padStart(2, "0")}:${String(parsed.minute).padStart(2, "0")}`
|
|
);
|
|
|
|
if (granted) {
|
|
const id = await Notifications.scheduleNotificationAsync({
|
|
content: {
|
|
title: `Time for ${opts.medicineName}`,
|
|
body: `Take your ${time} dose. Open Gishen to confirm.`,
|
|
data: {
|
|
prescriptionId: opts.prescriptionId,
|
|
type: "dose",
|
|
time,
|
|
},
|
|
sound: "default",
|
|
categoryIdentifier: "dose",
|
|
},
|
|
trigger: {
|
|
type: Notifications.SchedulableTriggerInputTypes.DAILY,
|
|
hour: parsed.hour,
|
|
minute: parsed.minute,
|
|
},
|
|
});
|
|
notificationIds.push(id);
|
|
}
|
|
}
|
|
|
|
// Demo: fire a near-term push so testers can verify without waiting until clock time
|
|
if (granted && validTimes.length) {
|
|
const demoId = await scheduleDoseReminder({
|
|
title: `Reminder set · ${opts.medicineName}`,
|
|
body: `Daily alerts at ${validTimes.join(", ")}. Demo ping.`,
|
|
secondsFromNow: 12,
|
|
data: {
|
|
prescriptionId: opts.prescriptionId,
|
|
type: "dose_demo",
|
|
},
|
|
});
|
|
notificationIds.push(demoId);
|
|
}
|
|
|
|
return {
|
|
granted,
|
|
times: validTimes,
|
|
notificationIds,
|
|
};
|
|
}
|