From e8d259404ef4c1f3b2a3db8157832a18f263b8ac Mon Sep 17 00:00:00 2001 From: euniceamoni Date: Tue, 26 May 2026 15:46:49 +0000 Subject: [PATCH] feat(mobile): add local notification scheduler for hunt expiry (#219) --- mobile/app/_layout.tsx | 15 ++++++++++++++ mobile/package.json | 1 + mobile/store/huntStore.ts | 12 +++++++++++ mobile/utils/huntNotifications.ts | 34 +++++++++++++++++++++++++++++++ 4 files changed, 62 insertions(+) create mode 100644 mobile/utils/huntNotifications.ts diff --git a/mobile/app/_layout.tsx b/mobile/app/_layout.tsx index 74bdb47..00b4e30 100644 --- a/mobile/app/_layout.tsx +++ b/mobile/app/_layout.tsx @@ -3,12 +3,23 @@ import { BackHandler, Pressable, StyleSheet, Text, View } from 'react-native'; import { Stack, type ErrorBoundaryProps, useRouter } from 'expo-router'; import { SafeAreaProvider, SafeAreaView } from 'react-native-safe-area-context'; import { useFonts } from 'expo-font'; +import * as Notifications from 'expo-notifications'; import { hideSplashScreen } from '@utils/splashScreenManager'; import { useTheme } from '@providers/ThemeProvider'; import { ThemedCustomText, ThemedButton } from '@components/themed'; import { StackHeader } from '@components/navigation/StackHeader'; import { Sentry } from '@config/sentry'; +Notifications.setNotificationHandler({ + handleNotification: async () => ({ + shouldShowAlert: true, + shouldPlaySound: true, + shouldSetBadge: false, + shouldShowBanner: true, + shouldShowList: true, + }), +}); + export const unstable_settings = { initialRouteName: '(tabs)', }; @@ -52,6 +63,10 @@ export default function RootLayout() { } }, [loaded, error]); + useEffect(() => { + Notifications.requestPermissionsAsync(); + }, []); + useEffect(() => { const backAction = () => { if (router.canGoBack()) { diff --git a/mobile/package.json b/mobile/package.json index 1d90d57..3294169 100644 --- a/mobile/package.json +++ b/mobile/package.json @@ -18,6 +18,7 @@ "expo-linking": "~8.0.12", "expo-router": "~6.0.23", "expo-secure-store": "^55.0.13", + "expo-notifications": "~0.30.0", "expo-splash-screen": "~0.30.3", "expo-status-bar": "~3.0.9", "react": "19.1.0", diff --git a/mobile/store/huntStore.ts b/mobile/store/huntStore.ts index 2669624..ab56cef 100644 --- a/mobile/store/huntStore.ts +++ b/mobile/store/huntStore.ts @@ -5,6 +5,7 @@ import type { HuntStatus, StoredHunt, Clue } from "@lib/types"; import * as SecureStore from "expo-secure-store"; +import { scheduleHuntExpiryNotification } from "@utils/huntNotifications"; const STORAGE_KEY = "hunty_hunts"; const CLUES_KEY = "hunty_clues"; @@ -210,3 +211,14 @@ export function getFeaturedHunts(limit = 3): StoredHunt[] { .slice(0, limit) .map((s) => s.hunt); } + +/** + * Record that the current player has joined a hunt and schedule a local + * notification 1 hour before the hunt expires. + */ +export async function joinHunt(huntId: number): Promise { + const hunts = await readHunts(); + const hunt = hunts.find((h) => h.id === huntId); + if (!hunt || !hunt.endTime) return; + await scheduleHuntExpiryNotification(huntId, hunt.title, hunt.endTime); +} diff --git a/mobile/utils/huntNotifications.ts b/mobile/utils/huntNotifications.ts new file mode 100644 index 0000000..f92cdd8 --- /dev/null +++ b/mobile/utils/huntNotifications.ts @@ -0,0 +1,34 @@ +import * as Notifications from "expo-notifications"; + +const NOTIF_ID_PREFIX = "hunt_expiry_"; + +/** + * Schedule a local notification 1 hour before a hunt's endTime. + * If endTime is missing or already within 1 hour, no notification is scheduled. + * Cancels any existing notification for the same hunt before scheduling. + */ +export async function scheduleHuntExpiryNotification( + huntId: number, + huntTitle: string, + endTimeSeconds: number +): Promise { + const triggerAt = (endTimeSeconds - 3600) * 1000; // 1 hour before, in ms + if (triggerAt <= Date.now()) return; + + await cancelHuntExpiryNotification(huntId); + + await Notifications.scheduleNotificationAsync({ + identifier: `${NOTIF_ID_PREFIX}${huntId}`, + content: { + title: "Hunt Expiring Soon ⏰", + body: `Your joined Hunt challenge "${huntTitle}" expires in 1 Hour!`, + data: { huntId }, + }, + trigger: { type: Notifications.SchedulableTriggerInputTypes.DATE, date: new Date(triggerAt) }, + }); +} + +/** Cancel the expiry reminder for a specific hunt. */ +export async function cancelHuntExpiryNotification(huntId: number): Promise { + await Notifications.cancelScheduledNotificationAsync(`${NOTIF_ID_PREFIX}${huntId}`); +}