Add notifications support.

This commit is contained in:
Patrick Fic
2025-10-24 10:36:30 -07:00
parent 0809a01c90
commit 75ee01e896
12 changed files with 236 additions and 11 deletions

3
.gitignore vendored
View File

@@ -19,6 +19,7 @@ yarn-error.log
*.tar.gz
*.apk
.expo
*.app
android/**
ios/**
ios/**

View File

@@ -88,7 +88,8 @@
],
"expo-localization",
"expo-font",
"expo-router"
"expo-router",
"expo-notifications"
]
}
}

View File

@@ -7,6 +7,7 @@ import {
DefaultTheme,
ThemeProvider,
} from "@react-navigation/native";
import * as Notifications from "expo-notifications";
import { Stack } from "expo-router";
import {
Icon,
@@ -25,6 +26,7 @@ import { client } from "../graphql/client";
import { useTheme as usePaperTheme } from "../hooks/useTheme";
import { persistor, store } from "../redux/store";
import "../translations/i18n";
import { registerForPushNotificationsAsync } from "../util/notificationHandler";
function AuthenticatedLayout() {
const { t } = useTranslation();
@@ -109,6 +111,33 @@ function AppContent({ currentUser, checkUserSession, bodyshop }: any) {
checkUserSession();
}, [checkUserSession]);
useEffect(() => {
registerForPushNotificationsAsync()
.then((token) => console.log("Expo Push Token:", token))
.catch((error: any) =>
console.log("Error getting Expo Push Token:", error)
);
const notificationListener = Notifications.addNotificationReceivedListener(
async (notification) => {
console.log("Notification received:", notification);
Notifications.setBadgeCountAsync(
(await Notifications.getBadgeCountAsync()) + 1
);
}
);
const responseListener =
Notifications.addNotificationResponseReceivedListener((response) => {
console.log("Notification response received:", response);
});
return () => {
notificationListener.remove();
responseListener.remove();
};
}, []);
if (currentUser.authorized === null) {
return (
<ThemedLayout>

View File

@@ -3,7 +3,7 @@ import { Tabs } from "expo-router";
import { useTranslation } from "react-i18next";
import { useTheme } from "react-native-paper";
function JobTabLayout(props) {
function JobTabLayout() {
const { t } = useTranslation();
const theme = useTheme();

View File

@@ -1,8 +1,26 @@
import { Stack } from "expo-router";
import { openImagePicker } from "@/redux/photos/photos.actions";
import * as Haptics from "expo-haptics";
import { Stack, useLocalSearchParams } from "expo-router";
import { useCallback } from "react";
import { useTranslation } from "react-i18next";
import { IconButton } from "react-native-paper";
import { connect } from "react-redux";
function JobsStack() {
const mapDispatchToProps = (dispatch) => ({
openImagePicker: (id) => dispatch(openImagePicker(id)),
});
export default connect(null, mapDispatchToProps)(JobsStack);
function JobsStack({ openImagePicker }) {
const { t } = useTranslation();
const { jobId } = useLocalSearchParams();
console.log("*** ~ JobsStack ~ jobId:", jobId);
const handleUpload = useCallback(() => {
Haptics.impactAsync(Haptics.ImpactFeedbackStyle.Light);
openImagePicker(jobId);
}, [openImagePicker, jobId]);
return (
<Stack
screenOptions={{
@@ -23,10 +41,18 @@ function JobsStack() {
options={({ route }) => ({
//headerShown: false,
title: (route.params as any)?.title || "Job Details",
headerRight: () => (
<IconButton
onPress={handleUpload}
icon="cloud-upload-outline"
mode="contained-tonal"
size={8}
//style={{ marginBottom: 1 }}
accessibilityLabel={t("joblist.actions.upload")}
/>
),
})}
/>
</Stack>
);
}
export default JobsStack;

View File

@@ -7,12 +7,10 @@ import { Chip, IconButton, Text, useTheme } from "react-native-paper";
import { connect } from "react-redux";
import { createStructuredSelector } from "reselect";
import { logImEXEvent } from "../../firebase/firebase.analytics";
import { setCameraJobId } from "../../redux/app/app.actions";
import { openImagePicker } from "../../redux/photos/photos.actions";
const mapStateToProps = createStructuredSelector({});
const mapDispatchToProps = (dispatch) => ({
setCameraJobId: (id) => dispatch(setCameraJobId(id)),
openImagePicker: (id) => dispatch(openImagePicker(id)),
});

View File

@@ -48,7 +48,7 @@ export function JobListComponent({ bodyshop }) {
const jobs = data ? [...(data?.jobs || []), { id: "footer-spacer" }] : [];
return (
<SafeAreaView style={{ flex: 1, marginHorizontal: 12 }}>
<SafeAreaView style={{ flex: 1, marginHorizontal: 12 }} edges={["top"]}>
<Text
variant="headlineMedium"
style={{ marginBottom: 12, fontWeight: "600" }}

View File

@@ -1,10 +1,12 @@
import SignOutButton from "@/components-old/sign-out-button/sign-out-button.component";
import { selectDeleteAfterUpload } from "@/redux/app/app.selectors";
import { selectBodyshop, selectCurrentUser } from "@/redux/user/user.selectors";
import { registerForPushNotificationsAsync } from "@/util/notificationHandler";
import { formatBytes } from "@/util/uploadUtils";
import AsyncStorage from "@react-native-async-storage/async-storage";
import * as Application from "expo-application";
import Constants from "expo-constants";
import * as Notifications from "expo-notifications";
import { useTranslation } from "react-i18next";
import { Alert, ScrollView, StyleSheet, View } from "react-native";
import { Button, Card, Divider, List, Text } from "react-native-paper";
@@ -107,7 +109,55 @@ function Tab({ bodyshop, currentUser }) {
</View>
</List.Section>
</Card.Content>
<Card.Actions></Card.Actions>
<Card.Actions>
<Button
onPress={async () => {
try {
await registerForPushNotificationsAsync();
} catch (error) {
Alert.alert(
"Error",
`Unable to register for notifications: ${error.message}`
);
console.log("Notification registration error:", error);
}
}}
>
Enable Notifications
</Button>
<Button
onPress={async () => {
const projectId =
Constants?.expoConfig?.extra?.eas?.projectId ??
Constants?.easConfig?.projectId;
const pushTokenString = (
await Notifications.getExpoPushTokenAsync({
projectId,
})
).data;
const message = {
to: pushTokenString,
sound: "default",
title: "Original Title",
body: "And here is the body!",
data: { someData: "goes here" },
};
await fetch("https://exp.host/--/api/v2/push/send", {
method: "POST",
headers: {
Accept: "application/json",
"Accept-encoding": "gzip, deflate",
"Content-Type": "application/json",
},
body: JSON.stringify(message),
});
}}
>
Test Noti
</Button>
</Card.Actions>
</Card>
<Card style={styles.section}>

View File

@@ -23,3 +23,11 @@ export const QUERY_SHOP_ID = gql`
}
}
`;
export const UPDATE_FCM_TOKEN = gql`
mutation UPDATE_FCM_TOKEN($email: String!, $token: jsonb!) {
update_users(where: {email: {_eq: $email}}, _append: {fcmtokens: $token}) {
affected_rows
}
}`;

39
package-lock.json generated
View File

@@ -22,6 +22,7 @@
"expo-application": "~7.0.7",
"expo-constants": "~18.0.10",
"expo-dev-client": "~6.0.16",
"expo-device": "^8.0.9",
"expo-file-system": "~19.0.17",
"expo-font": "~14.0.9",
"expo-haptics": "~15.0.7",
@@ -8436,6 +8437,18 @@
"expo": "*"
}
},
"node_modules/expo-device": {
"version": "8.0.9",
"resolved": "https://registry.npmjs.org/expo-device/-/expo-device-8.0.9.tgz",
"integrity": "sha512-XqRpaljDNAYZGZzMpC+b9KZfzfydtkwx3pJAp6ODDH+O/5wjAw+mLc5wQMGJCx8/aqVmMsAokec7iebxDPFZDA==",
"license": "MIT",
"dependencies": {
"ua-parser-js": "^0.7.33"
},
"peerDependencies": {
"expo": "*"
}
},
"node_modules/expo-eas-client": {
"version": "1.0.7",
"resolved": "https://registry.npmjs.org/expo-eas-client/-/expo-eas-client-1.0.7.tgz",
@@ -15282,6 +15295,32 @@
"typescript-compare": "^0.0.2"
}
},
"node_modules/ua-parser-js": {
"version": "0.7.41",
"resolved": "https://registry.npmjs.org/ua-parser-js/-/ua-parser-js-0.7.41.tgz",
"integrity": "sha512-O3oYyCMPYgNNHuO7Jjk3uacJWZF8loBgwrfd/5LE/HyZ3lUIOdniQ7DNXJcIgZbwioZxk0fLfI4EVnetdiX5jg==",
"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/unbox-primitive": {
"version": "1.1.0",
"resolved": "https://registry.npmjs.org/unbox-primitive/-/unbox-primitive-1.1.0.tgz",

View File

@@ -34,6 +34,7 @@
"expo-application": "~7.0.7",
"expo-constants": "~18.0.10",
"expo-dev-client": "~6.0.16",
"expo-device": "^8.0.9",
"expo-file-system": "~19.0.17",
"expo-font": "~14.0.9",
"expo-haptics": "~15.0.7",

View File

@@ -0,0 +1,72 @@
import { getCurrentUser } from '@/firebase/firebase.utils';
import { UPDATE_FCM_TOKEN } from '@/graphql/bodyshop.queries';
import { client } from '@/graphql/client';
import Constants from 'expo-constants';
import * as Device from 'expo-device';
import * as Notifications from 'expo-notifications';
import { Platform } from 'react-native';
Notifications.setNotificationHandler({
handleNotification: async () => ({
shouldPlaySound: false,
shouldSetBadge: false,
shouldShowBanner: true,
shouldShowList: true,
}),
});
async function registerForPushNotificationsAsync() {
console.log("Registering for push notifications...");
if (Platform.OS === 'android') {
await Notifications.setNotificationChannelAsync('default', {
name: 'default',
importance: Notifications.AndroidImportance.MAX,
vibrationPattern: [0, 250, 250, 250],
lightColor: '#FF231F7C',
});
}
if (Device.isDevice) {
const { status: existingStatus } = await Notifications.getPermissionsAsync();
let finalStatus = existingStatus;
if (existingStatus !== 'granted') {
const { status } = await Notifications.requestPermissionsAsync();
finalStatus = status;
}
if (finalStatus !== 'granted') {
throw new Error('Permission not granted to get push token for push notification!');
}
const projectId =
Constants?.expoConfig?.extra?.eas?.projectId ?? Constants?.easConfig?.projectId;
if (!projectId) {
throw new Error('Project ID not found');
}
try {
const pushTokenString = (
await Notifications.getExpoPushTokenAsync({
projectId,
})
).data;
console.log(pushTokenString);
//Write the FCM token to database.
const result = await client.mutate({
mutation: UPDATE_FCM_TOKEN,
variables: {
email: (await getCurrentUser()).email,
token: { [pushTokenString]: { pushTokenString, platform: Platform.OS, timestamp: Date.now() } },
},
});
console.log("FCM token updated in database:", result);
return pushTokenString;
} catch (error) {
throw error
}
} else {
throw new Error('Must use physical device for push notifications');
}
}
export { registerForPushNotificationsAsync };