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",