diff --git a/mobile/app/_layout.tsx b/mobile/app/_layout.tsx index d3f4dfd..a073489 100644 --- a/mobile/app/_layout.tsx +++ b/mobile/app/_layout.tsx @@ -4,6 +4,11 @@ import { StyleSheet, View } from 'react-native'; import { Stack, type ErrorBoundaryProps, useRouter } from 'expo-router'; import * as Linking from 'expo-linking'; 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 AsyncStorage from '@react-native-async-storage/async-storage'; import { hideSplashScreen, initializeSplashScreen } from '@utils/splashScreenManager'; import { ThemeProvider, useTheme } from '@providers/ThemeProvider'; @@ -20,6 +25,19 @@ import { Sentry, initializeSentry } from '@config/sentry'; import { classifyWalletTxError } from '@/lib/walletErrors'; import { useWalletStore } from '@store/useStore'; +Notifications.setNotificationHandler({ + handleNotification: async () => ({ + shouldShowAlert: true, + shouldPlaySound: true, + shouldSetBadge: false, + shouldShowBanner: true, + shouldShowList: true, + }), +}); + +export const unstable_settings = { + initialRouteName: '(tabs)', +}; initializeSplashScreen(); initializeSentry(); @@ -88,6 +106,14 @@ function RootLayoutNav() { }, [fontsLoaded, fontError]); useEffect(() => { + Notifications.requestPermissionsAsync(); + }, []); + + useEffect(() => { + const backAction = () => { + if (router.canGoBack()) { + router.back(); + return true; if (!fontsLoaded && !fontError) return; let isMounted = true; diff --git a/mobile/package.json b/mobile/package.json index 42023a1..75b4a0c 100644 --- a/mobile/package.json +++ b/mobile/package.json @@ -36,6 +36,11 @@ "expo-notifications": "~55.0.23", "expo-router": "~55.0.16", "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", + "react-native": "0.81.5", "expo-splash-screen": "~55.0.21", "expo-status-bar": "~55.0.6", "lucide-react-native": "^1.16.0", diff --git a/mobile/store/huntStore.ts b/mobile/store/huntStore.ts index 809f9af..efb435b 100644 --- a/mobile/store/huntStore.ts +++ b/mobile/store/huntStore.ts @@ -6,6 +6,9 @@ import AsyncStorage from '@react-native-async-storage/async-storage'; import * as SecureStore from 'expo-secure-store'; import type { Clue, HuntStatus, StoredHunt } from '@lib/types'; +import type { HuntStatus, StoredHunt, Clue } from "@lib/types"; +import * as SecureStore from "expo-secure-store"; +import { scheduleHuntExpiryNotification } from "@utils/huntNotifications"; const HUNTS_KEY = 'hunty_hunts'; const CLUES_KEY = 'hunty_clues'; @@ -230,3 +233,14 @@ export async function getFeaturedHunts(limit = 3): Promise { }) .slice(0, limit); } + +/** + * 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}`); +}