diff --git a/app.config.js b/app.config.js new file mode 100644 index 0000000..7475ca0 --- /dev/null +++ b/app.config.js @@ -0,0 +1,97 @@ +export default { + expo: { + name: "health-app", + slug: "health-app", + version: "1.0.0", + orientation: "portrait", + icon: "./assets/images/icon.png", + scheme: "myapp", + userInterfaceStyle: "automatic", + newArchEnabled: true, + jsEngine: "hermes", + ios: { + supportsTablet: true, + config: { + usesNonExemptEncryption: false, + }, + bundleIdentifier: "com.vinta.healthapp", + infoPlist: { + UIBackgroundModes: ["remote-notification"], + }, + }, + android: { + adaptiveIcon: { + foregroundImage: "./assets/images/adaptive-icon.png", + backgroundColor: "#ffffff", + }, + permissions: [ + "android.permission.RECORD_AUDIO", + "android.permission.RECEIVE_BOOT_COMPLETED", + "android.permission.SCHEDULE_EXACT_ALARM", + "android.permission.POST_NOTIFICATIONS", + "android.permission.USE_FULL_SCREEN_INTENT", + ], + package: "com.vinta.healthapp", + googleServicesFile: process.env.GOOGLE_SERVICES_JSON || "./_dev/google-services.json", + }, + web: { + bundler: "metro", + output: "static", + favicon: "./assets/images/favicon.png", + }, + plugins: [ + "expo-router", + [ + "expo-splash-screen", + { + image: "./assets/images/splash-icon.png", + imageWidth: 200, + resizeMode: "contain", + backgroundColor: "#ffffff", + }, + ], + [ + "expo-secure-store", + { + requireAuthentication: false, + }, + ], + [ + "expo-image-picker", + { + photosPermission: + "The app needs media access when you want to attach media to your messages.", + cameraPermission: + "The app needs camera access when you want to attach media to your messages.", + microphonePermission: + "The app needs microphone access when you want to attach media to your messages.", + }, + ], + [ + "expo-video", + { + supportsBackgroundPlayback: true, + supportsPictureInPicture: true, + }, + ], + [ + "expo-notifications", + { + icon: "./assets/images/adaptive-icon.png", + color: "#ffffff", + }, + ], + ], + experiments: { + typedRoutes: true, + }, + extra: { + router: { + origin: false, + }, + eas: { + projectId: "b90347a9-ca6d-4949-9545-82fcce6ed6aa", + }, + }, + }, +}; diff --git a/app.json b/app.json deleted file mode 100644 index e210040..0000000 --- a/app.json +++ /dev/null @@ -1,79 +0,0 @@ -{ - "expo": { - "name": "health-app", - "slug": "health-app", - "version": "1.0.0", - "orientation": "portrait", - "icon": "./assets/images/icon.png", - "scheme": "myapp", - "userInterfaceStyle": "automatic", - "newArchEnabled": true, - "jsEngine": "hermes", - "ios": { - "supportsTablet": true, - "config": { - "usesNonExemptEncryption": false - }, - "bundleIdentifier": "com.vinta.healthapp" - }, - "android": { - "adaptiveIcon": { - "foregroundImage": "./assets/images/adaptive-icon.png", - "backgroundColor": "#ffffff" - }, - "permissions": [ - "android.permission.RECORD_AUDIO" - ], - "package": "com.vinta.healthapp" - }, - "web": { - "bundler": "metro", - "output": "static", - "favicon": "./assets/images/favicon.png" - }, - "plugins": [ - "expo-router", - [ - "expo-splash-screen", - { - "image": "./assets/images/splash-icon.png", - "imageWidth": 200, - "resizeMode": "contain", - "backgroundColor": "#ffffff" - } - ], - [ - "expo-secure-store", - { - "requireAuthentication": false - } - ], - [ - "expo-image-picker", - { - "photosPermission": "The app needs media access when you want to attach media to your messages.", - "cameraPermission": "The app needs camera access when you want to attach media to your messages.", - "microphonePermission": "The app needs microphone access when you want to attach media to your messages." - } - ], - [ - "expo-video", - { - "supportsBackgroundPlayback": true, - "supportsPictureInPicture": true - } - ] - ], - "experiments": { - "typedRoutes": true - }, - "extra": { - "router": { - "origin": false - }, - "eas": { - "projectId": "b90347a9-ca6d-4949-9545-82fcce6ed6aa" - } - } - } -} diff --git a/app/(app)/_layout.tsx b/app/(app)/_layout.tsx index 57cf7d1..2d32566 100644 --- a/app/(app)/_layout.tsx +++ b/app/(app)/_layout.tsx @@ -1,14 +1,24 @@ import { useMedplumContext } from "@medplum/react-hooks"; import { Redirect, Slot } from "expo-router"; +import { useEffect } from "react"; import { LoadingScreen } from "@/components/LoadingScreen"; import { PractitionerBanner } from "@/components/PractitionerBanner"; import { ChatProvider } from "@/contexts/ChatContext"; +import { useNotifications } from "@/contexts/NotificationsContext"; export default function AppLayout() { const { medplum, profile } = useMedplumContext(); + const { setUpPushNotifications } = useNotifications(); const isPractitioner = profile?.resourceType === "Practitioner"; + useEffect(() => { + if (profile && medplum.getActiveLogin()) { + // Set up push notifications when user is logged in + setUpPushNotifications(); + } + }, [medplum, profile, setUpPushNotifications]); + if (medplum.isLoading()) { return ; } diff --git a/app/_layout.tsx b/app/_layout.tsx index 6425af6..f2d1a55 100644 --- a/app/_layout.tsx +++ b/app/_layout.tsx @@ -22,6 +22,7 @@ import { } from "react-native-safe-area-context"; import { GluestackUIProvider } from "@/components/gluestack-ui-provider"; +import { NotificationsProvider } from "@/contexts/NotificationsContext"; import { oauth2ClientId } from "@/utils/medplum-oauth2"; export const unstable_settings = { @@ -60,15 +61,17 @@ export default function RootLayout() { - - - + + + + + diff --git a/contexts/NotificationsContext.tsx b/contexts/NotificationsContext.tsx new file mode 100644 index 0000000..2992c42 --- /dev/null +++ b/contexts/NotificationsContext.tsx @@ -0,0 +1,161 @@ +import { MedplumClient } from "@medplum/core"; +import { useMedplum } from "@medplum/react-hooks"; +import Constants from "expo-constants"; +import * as Device from "expo-device"; +import * as Notifications from "expo-notifications"; +import { router } from "expo-router"; +import { createContext, useCallback, useContext, useEffect, useRef, useState } from "react"; +import { Platform } from "react-native"; + +Notifications.setNotificationHandler({ + handleNotification: async () => ({ + shouldShowAlert: true, + shouldPlaySound: true, + shouldSetBadge: true, + }), +}); + +interface NotificationsContextType { + isNotificationsEnabled: boolean; + setUpPushNotifications: () => Promise; +} + +const NotificationsContext = createContext({ + isNotificationsEnabled: false, + setUpPushNotifications: async () => false, +}); + +async function registerForPushNotificationsAsync() { + let token; + + if (!Device.isDevice) { + return; + } + + if (Platform.OS === "android") { + await Notifications.setNotificationChannelAsync("default", { + name: "default", + importance: Notifications.AndroidImportance.MAX, + vibrationPattern: [0, 250, 250, 250], + lightColor: "#FF231F7C", + }); + } + + try { + const projectId = Constants.expoConfig?.extra?.eas?.projectId; + if (!projectId) { + throw new Error("Missing EAS project ID"); + } + + token = await Notifications.getExpoPushTokenAsync({ + projectId, + }); + return token?.data; + } catch (error) { + console.error("Error getting push token:", error); + } +} + +function updateMedplumPushToken(medplum: MedplumClient, token: string): Promise { + return (async () => { + try { + const profile = medplum.getProfile(); + if (!profile) return; + + // Check if token already exists and matches + const existingToken = profile.extension?.find( + (e) => e.url === "https://medplum.com/push-token", + )?.valueString; + + if (existingToken === token) { + return; // Token hasn't changed, no need to update + } + + // Update the token + const extensions = + profile.extension?.filter((e) => e.url !== "https://medplum.com/push-token") || []; + extensions.push({ + url: "https://medplum.com/push-token", + valueString: token, + }); + await medplum.updateResource({ + ...profile, + extension: extensions, + }); + } catch (error) { + console.error("Error updating push token in Medplum:", error); + } + })(); +} + +function handleMessageNotificationInteraction(response: Notifications.NotificationResponse) { + const data = response.notification.request.content.data; + if (data.threadId) { + router.push(`/thread/${data.threadId}`); + } +} + +export function NotificationsProvider({ children }: { children: React.ReactNode }) { + const [isNotificationsEnabled, setIsNotificationsEnabled] = useState(false); + const responseListener = useRef(); + const medplum = useMedplum(); + + const setUpPushNotifications = useCallback(async () => { + // Check and request notification permissions + const { status: existingStatus } = await Notifications.getPermissionsAsync(); + let finalStatus = existingStatus; + if (existingStatus !== "granted") { + ({ status: finalStatus } = await Notifications.requestPermissionsAsync()); + } + + // Set up push notifications if we have permission + const isEnabled = finalStatus === "granted"; + setIsNotificationsEnabled(isEnabled); + if (isEnabled) { + const token = await registerForPushNotificationsAsync(); + if (token) { + await updateMedplumPushToken(medplum, token); + } + + // Set up notification listeners + responseListener.current = Notifications.addNotificationResponseReceivedListener( + handleMessageNotificationInteraction, + ); + } + return isEnabled; + }, [medplum]); + + // Effect to handle notifications when app is terminated + useEffect(() => { + const getInitialNotification = async () => { + const lastNotificationResponse = await Notifications.getLastNotificationResponseAsync(); + if (lastNotificationResponse) { + handleMessageNotificationInteraction(lastNotificationResponse); + } + }; + + getInitialNotification(); + }, []); + + // Cleanup + useEffect(() => { + return () => { + responseListener.current?.remove(); + }; + }, [responseListener]); + + return ( + + {children} + + ); +} + +export function useNotifications() { + return useContext(NotificationsContext); +} diff --git a/package-lock.json b/package-lock.json index e0053be..8651d03 100644 --- a/package-lock.json +++ b/package-lock.json @@ -50,15 +50,17 @@ "expo": "^52.0.23", "expo-auth-session": "~6.0.1", "expo-blur": "~14.0.3", - "expo-constants": "~17.0.3", + "expo-constants": "~17.0.5", "expo-crypto": "~14.0.1", "expo-dev-client": "~5.0.10", + "expo-device": "~7.0.2", "expo-file-system": "~18.0.7", "expo-font": "~13.0.2", "expo-haptics": "~14.0.0", "expo-image": "~2.0.4", "expo-image-picker": "~16.0.4", "expo-linking": "~7.0.3", + "expo-notifications": "~0.29.13", "expo-router": "~4.0.15", "expo-secure-store": "~14.0.1", "expo-sharing": "~13.0.1", @@ -3265,6 +3267,12 @@ "url": "https://github.com/sponsors/nzakas" } }, + "node_modules/@ide/backoff": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/@ide/backoff/-/backoff-1.0.0.tgz", + "integrity": "sha512-F0YfUDjvT+Mtt/R4xdl2X0EYCHMMiJqNLdxHD++jDT5ydEFIyqbCHh51Qx2E211dgZprPKhV7sHmnXKpLuvc5g==", + "license": "MIT" + }, "node_modules/@internationalized/date": { "version": "3.6.0", "resolved": "https://registry.npmjs.org/@internationalized/date/-/date-3.6.0.tgz", @@ -7182,6 +7190,19 @@ "integrity": "sha512-BSHWgDSAiKs50o2Re8ppvp3seVHXSRM44cdSsT9FfNEUUZLOGWVCsiWaRPWM1Znn+mqZ1OfVZ3z3DWEzSp7hRA==", "license": "MIT" }, + "node_modules/assert": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/assert/-/assert-2.1.0.tgz", + "integrity": "sha512-eLHpSK/Y4nhMJ07gDaAzoX/XAKS8PSaojml3M0DM4JpV1LAi5JOJ/p6H/XWrl8L+DzVEvVCW1z3vWAaB9oTsQw==", + "license": "MIT", + "dependencies": { + "call-bind": "^1.0.2", + "is-nan": "^1.3.2", + "object-is": "^1.1.5", + "object.assign": "^4.1.4", + "util": "^0.12.5" + } + }, "node_modules/ast-types": { "version": "0.16.1", "resolved": "https://registry.npmjs.org/ast-types/-/ast-types-0.16.1.tgz", @@ -7521,6 +7542,12 @@ "@babel/core": "^7.0.0" } }, + "node_modules/badgin": { + "version": "1.2.3", + "resolved": "https://registry.npmjs.org/badgin/-/badgin-1.2.3.tgz", + "integrity": "sha512-NQGA7LcfCpSzIbGRbkgjgdWkjy7HI+Th5VLxTJfW5EeaAf3fnS+xWQaQOCYiny+q6QSvxqoSO04vCx+4u++EJw==", + "license": "MIT" + }, "node_modules/balanced-match": { "version": "1.0.2", "resolved": "https://registry.npmjs.org/balanced-match/-/balanced-match-1.0.2.tgz", @@ -8875,7 +8902,6 @@ "version": "1.2.1", "resolved": "https://registry.npmjs.org/define-properties/-/define-properties-1.2.1.tgz", "integrity": "sha512-8QmQKqEASLd5nx0U1B1okLElbUuuttJ/AnYmRXbbbGDWh6uS208EjD4Xqq/I9wK7u0v6O08XhTWnt5XtEbR6Dg==", - "dev": true, "license": "MIT", "dependencies": { "define-data-property": "^1.0.1", @@ -10299,9 +10325,9 @@ } }, "node_modules/expo-constants": { - "version": "17.0.4", - "resolved": "https://registry.npmjs.org/expo-constants/-/expo-constants-17.0.4.tgz", - "integrity": "sha512-5c0VlZycmDyQUCMCr3Na3cpHAsVJJ+5o6KkkD4rmATQZ0++Xp/S2gpnjWyEo2riRmO91vxoyHwmAySXuktJddQ==", + "version": "17.0.5", + "resolved": "https://registry.npmjs.org/expo-constants/-/expo-constants-17.0.5.tgz", + "integrity": "sha512-6SHXh32jCB+vrp2TRDNkoGoM421eOBPZIXX9ixI0hKKz71tIjD+LMr/P+rGUd/ks312MP3WK3j5vcYYPkCD8tQ==", "license": "MIT", "dependencies": { "@expo/config": "~10.0.8", @@ -10392,6 +10418,44 @@ "expo": "*" } }, + "node_modules/expo-device": { + "version": "7.0.2", + "resolved": "https://registry.npmjs.org/expo-device/-/expo-device-7.0.2.tgz", + "integrity": "sha512-0PkTixE4Qi8VQBjixnj4aw2f6vE4tUZH7GK8zHROGKlBypZKcWmsA+W/Vp3RC5AyREjX71pO/hjKTSo/vF0E2w==", + "license": "MIT", + "dependencies": { + "ua-parser-js": "^0.7.33" + }, + "peerDependencies": { + "expo": "*" + } + }, + "node_modules/expo-device/node_modules/ua-parser-js": { + "version": "0.7.40", + "resolved": "https://registry.npmjs.org/ua-parser-js/-/ua-parser-js-0.7.40.tgz", + "integrity": "sha512-us1E3K+3jJppDBa3Tl0L3MOJiGhe1C6P0+nIvQAFYbxlMAx0h81eOwLmU57xgqToduDDPx3y5QsdjPfDu+FgOQ==", + "funding": [ + { + "type": "opencollective", + "url": "https://opencollective.com/ua-parser-js" + }, + { + "type": "paypal", + "url": "https://paypal.me/faisalman" + }, + { + "type": "github", + "url": "https://github.com/sponsors/faisalman" + } + ], + "license": "MIT", + "bin": { + "ua-parser-js": "script/cli.js" + }, + "engines": { + "node": "*" + } + }, "node_modules/expo-file-system": { "version": "18.0.7", "resolved": "https://registry.npmjs.org/expo-file-system/-/expo-file-system-18.0.7.tgz", @@ -10572,6 +10636,26 @@ "invariant": "^2.2.4" } }, + "node_modules/expo-notifications": { + "version": "0.29.13", + "resolved": "https://registry.npmjs.org/expo-notifications/-/expo-notifications-0.29.13.tgz", + "integrity": "sha512-GHye6XeI1uEeVttJO/hGwUyA5cgQsxR3mi5q37yOE7cZN3cMj36pIfEEmjXEr0nWIWSzoJ0w8c2QxNj5xfP1pA==", + "license": "MIT", + "dependencies": { + "@expo/image-utils": "^0.6.4", + "@ide/backoff": "^1.0.0", + "abort-controller": "^3.0.0", + "assert": "^2.0.0", + "badgin": "^1.1.5", + "expo-application": "~6.0.2", + "expo-constants": "~17.0.5" + }, + "peerDependencies": { + "expo": "*", + "react": "*", + "react-native": "*" + } + }, "node_modules/expo-router": { "version": "4.0.17", "resolved": "https://registry.npmjs.org/expo-router/-/expo-router-4.0.17.tgz", @@ -12263,6 +12347,22 @@ "url": "https://github.com/sponsors/ljharb" } }, + "node_modules/is-nan": { + "version": "1.3.2", + "resolved": "https://registry.npmjs.org/is-nan/-/is-nan-1.3.2.tgz", + "integrity": "sha512-E+zBKpQ2t6MEo1VsonYmluk9NxGrbzpeeLC2xIViuO2EjU2xsXsBPwTr3Ykv9l08UYEVEdWeRZNouaZqF6RN0w==", + "license": "MIT", + "dependencies": { + "call-bind": "^1.0.0", + "define-properties": "^1.1.3" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, "node_modules/is-negative-zero": { "version": "2.0.3", "resolved": "https://registry.npmjs.org/is-negative-zero/-/is-negative-zero-2.0.3.tgz", @@ -15745,6 +15845,22 @@ "url": "https://github.com/sponsors/ljharb" } }, + "node_modules/object-is": { + "version": "1.1.6", + "resolved": "https://registry.npmjs.org/object-is/-/object-is-1.1.6.tgz", + "integrity": "sha512-F8cZ+KfGlSGi09lJT7/Nd6KJZ9ygtvYC0/UYYLI9nmQKLMnydpB9yvbv9K1uSkEu7FU9vYPmVwLg328tX+ot3Q==", + "license": "MIT", + "dependencies": { + "call-bind": "^1.0.7", + "define-properties": "^1.2.1" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, "node_modules/object-keys": { "version": "1.1.1", "resolved": "https://registry.npmjs.org/object-keys/-/object-keys-1.1.1.tgz", @@ -15758,7 +15874,6 @@ "version": "4.1.5", "resolved": "https://registry.npmjs.org/object.assign/-/object.assign-4.1.5.tgz", "integrity": "sha512-byy+U7gp+FVwmyzKPYhW2h5l3crpmGsxl7X2s8y43IgxvG4g3QZ6CffDtsNQy1WsmZpQbO+ybo0AlW7TY6DcBQ==", - "dev": true, "license": "MIT", "dependencies": { "call-bind": "^1.0.5", diff --git a/package.json b/package.json index 63d6480..3c9be8f 100644 --- a/package.json +++ b/package.json @@ -80,7 +80,7 @@ "expo": "^52.0.23", "expo-auth-session": "~6.0.1", "expo-blur": "~14.0.3", - "expo-constants": "~17.0.3", + "expo-constants": "~17.0.5", "expo-crypto": "~14.0.1", "expo-dev-client": "~5.0.10", "expo-file-system": "~18.0.7", @@ -114,7 +114,9 @@ "react-native-webview": "13.12.5", "scheduler": "^0.25.0", "tailwindcss": "^3.4.17", - "use-context-selector": "^2.0.0" + "use-context-selector": "^2.0.0", + "expo-notifications": "~0.29.13", + "expo-device": "~7.0.2" }, "devDependencies": { "@babel/core": "^7.26.0",