diff --git a/app.config.js b/app.config.js index 3805105..8873a7e 100644 --- a/app.config.js +++ b/app.config.js @@ -18,7 +18,7 @@ export default () => { bundleIdentifier: process.env.EXPO_PUBLIC_ENVIRONMENT === 'production' ? 'com.testausserveri.assemblyapp' - : 'com.testausserveri.assemblyapp-dev', + : 'com.testausserveri.assemblyapp_dev', }, android: { adaptiveIcon: { @@ -28,14 +28,26 @@ export default () => { package: process.env.EXPO_PUBLIC_ENVIRONMENT === 'production' ? 'com.testausserveri.assemblyapp' - : 'com.testausserveri.assemblyapp-dev', + : 'com.testausserveri.assemblyapp_dev', + useNextNotificationApi: true, }, web: { bundler: 'metro', output: 'static', favicon: './assets/images/favicon.png', }, - plugins: ['expo-router', 'expo-build-properties'], + plugins: [ + 'expo-router', + 'expo-build-properties', + [ + 'expo-notifications', + { + icon: './assets/images/icon.png', + color: '#191919', + sounds: [], + }, + ], + ], experiments: { typedRoutes: true, }, diff --git a/app/_layout.tsx b/app/_layout.tsx index 6ac37a5..b1d5447 100644 --- a/app/_layout.tsx +++ b/app/_layout.tsx @@ -3,6 +3,7 @@ import { GlobalStateProvider } from '@/hooks/providers/GlobalStateProvider'; import Locales from '@/locales'; import { Themes } from '@/styles'; import { useFonts } from 'expo-font'; +import * as Notifications from 'expo-notifications'; import { Stack, router } from 'expo-router'; import * as SplashScreen from 'expo-splash-screen'; import i18n from 'i18next'; @@ -28,6 +29,14 @@ i18n.use(initReactI18next).init({ }, }); +Notifications.setNotificationHandler({ + handleNotification: async () => ({ + shouldShowAlert: true, + shouldPlaySound: true, + shouldSetBadge: false, + }), +}); + export default function RootLayout() { const [loaded] = useFonts({ Gaba: require('../assets/fonts/Gaba-Super.otf'), diff --git a/components/timetable/Event.tsx b/components/timetable/Event.tsx index 0a03ca0..155fed7 100644 --- a/components/timetable/Event.tsx +++ b/components/timetable/Event.tsx @@ -110,19 +110,19 @@ const Event = ({ {`${t('time')}: ${timeString}`} - {dayjs().isBefore(start) || - ((process.env.EXPO_PUBLIC_ENVIRONMENT === 'development' || - process.env.EXPO_PUBLIC_ENVIRONMENT === 'preview') && ( - toggleFavorite()} - icon={isFavorite ? 'heart' : 'heart-outline'} - style={{ - position: 'absolute', - top: 0, - right: 0, - }} - /> - ))} + {(dayjs().isBefore(start) || + process.env.EXPO_PUBLIC_ENVIRONMENT === 'development' || + process.env.EXPO_PUBLIC_ENVIRONMENT === 'preview') && ( + toggleFavorite()} + icon={isFavorite ? 'heart' : 'heart-outline'} + style={{ + position: 'absolute', + top: 0, + right: 0, + }} + /> + )} ); }; diff --git a/elements/timetable/EventsBox.tsx b/elements/timetable/EventsBox.tsx index 5d632ae..d8970a3 100644 --- a/elements/timetable/EventsBox.tsx +++ b/elements/timetable/EventsBox.tsx @@ -5,7 +5,7 @@ import { ScrollView } from 'react-native'; interface EventsBoxProps { events: AssemblyEvent[]; favorites: number[]; - toggleFavorite: (id: number) => void; + toggleFavorite: (id: number, title: string, start: Date) => void; } const EventsBox = ({ events, favorites, toggleFavorite }: EventsBoxProps) => { @@ -24,7 +24,7 @@ const EventsBox = ({ events, favorites, toggleFavorite }: EventsBoxProps) => { end={event.end} color={event.color} thumbnail={event.thumbnail} - toggleFavorite={() => toggleFavorite(event.id)} + toggleFavorite={() => toggleFavorite(event.id, event.title, event.start)} isFavorite={favorites.includes(event.id)} /> ))} diff --git a/hooks/useFavorite.ts b/hooks/useFavorite.ts index e7be4db..60d3f3f 100644 --- a/hooks/useFavorite.ts +++ b/hooks/useFavorite.ts @@ -1,3 +1,4 @@ +import { cancelScheduledPushNotification, schedulePushNotification } from './useLocalNotification'; import AsyncStorage from '@react-native-async-storage/async-storage'; import { useEffect, useState } from 'react'; @@ -44,13 +45,15 @@ export const useFavorite = () => { }); }, []); - const toggle = async (id: number) => { + const toggle = async (id: number, eventTitle: string, start: Date) => { if (!favorites.includes(id)) { setFavorites([...favorites, id]); await saveFavorites([...favorites, id]); + schedulePushNotification(id, eventTitle, start); } else { setFavorites(favorites.filter((n) => n !== id)); await saveFavorites(favorites.filter((n) => n !== id)); + cancelScheduledPushNotification(id); } }; diff --git a/hooks/useLocalNotification.ts b/hooks/useLocalNotification.ts new file mode 100644 index 0000000..2e43d60 --- /dev/null +++ b/hooks/useLocalNotification.ts @@ -0,0 +1,119 @@ +import dayjs from 'dayjs'; +import * as Device from 'expo-device'; +import * as Notifications from 'expo-notifications'; +import { t } from 'i18next'; +import { useEffect, useRef, useState } from 'react'; +import { Platform } from 'react-native'; + +interface ILocalNotificationHook { + expoPushToken: string | undefined; + notification: Notifications.Notification; +} + +/** + * Custom hook for managing local notifications and ensuring permissions are correctly set. + * + * @returns An object containing the Expo push token and the current notification. + */ +export const useLocalNotification = (): ILocalNotificationHook => { + const [expoPushToken, setExpoPushToken] = useState(''); + const [notification, setNotification] = useState({} as Notifications.Notification); + const notificationListener = useRef(); + const responseListener = useRef(); + + useEffect(() => { + registerForPushNotificationsAsync().then((token) => { + setExpoPushToken(token || ''); + }); + + notificationListener.current = Notifications.addNotificationReceivedListener( + (notification) => { + setNotification(notification); + } + ); + + responseListener.current = Notifications.addNotificationResponseReceivedListener( + (response) => { + setNotification(response.notification); + } + ); + + return () => { + if (notificationListener.current?.remove) { + notificationListener.current.remove(); + } + if (responseListener.current?.remove) { + responseListener.current.remove(); + } + }; + }, []); + + return { expoPushToken, notification }; +}; + +/** + * Schedules a push notification for a given event. Notification is set 15 minutes before given start date. + * @param eventTitle - The title of the event. + * @param start - The start date of the event. + */ +export const schedulePushNotification = async (id: number, eventTitle: string, start: Date) => { + const quarterBeforeStart = dayjs(start).subtract(15, 'minutes'); + const timeDifference = Math.round(dayjs(quarterBeforeStart).diff(new Date(), 'seconds')); + + await Notifications.scheduleNotificationAsync({ + identifier: id.toString(), + content: { + title: `${eventTitle}`, + subtitle: '', + body: t('event-starting-15'), + }, + trigger: { + seconds: timeDifference, + }, + }); +}; + +/** + * Cancels a scheduled push notification with the specified event title. + * + * @param eventTitle - The title of the event associated with the push notification. + * @returns A promise that resolves when the notification is successfully canceled. + */ +export const cancelScheduledPushNotification = async (id: number) => { + await Notifications.cancelScheduledNotificationAsync(id.toString()); +}; + +/** + * Registers the device for push notifications and returns the push token. + * @returns A promise that resolves to the push token. + */ +export const registerForPushNotificationsAsync = async () => { + let token: string = ''; + + if (Platform.OS === 'android') { + await Notifications.setNotificationChannelAsync('default', { + name: 'default', + importance: Notifications.AndroidImportance.MAX, + vibrationPattern: [0, 250, 250, 250], + lightColor: '#FFAABBCC', + }); + } + + 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') { + alert('Failed to get push token for push notification!'); + return; + } + token = (await Notifications.getExpoPushTokenAsync()).data; + } else { + alert('Must use physical device for Push Notifications'); + } + + return token; +}; diff --git a/locales/en.ts b/locales/en.ts index 2efdeaf..61c223f 100644 --- a/locales/en.ts +++ b/locales/en.ts @@ -11,6 +11,7 @@ export default { english: 'English', 'success-lang-change': 'Language changed successfully', 'error-lang-change': 'Error changing language', + 'event-starting-15': 'Event is starting in 15 minutes', 'login-failed': 'Login failed', 'signup-failed': 'Signup failed', 'unknown-error': 'Unknown error', diff --git a/locales/fi.ts b/locales/fi.ts index 5cfe7fb..7bfe0fb 100644 --- a/locales/fi.ts +++ b/locales/fi.ts @@ -11,6 +11,7 @@ export default { english: 'Englanti', 'success-lang-change': 'Kieli vaihdettu onnistuneesti', 'error-lang-change': 'Virhe kielen vaihdossa', + 'event-starting-15': 'Tapahtuma alkaa 15 minuutin kuluttua', 'login-failed': 'Kirjautuminen epäonnistui', 'signup-failed': 'Rekisteröityminen epäonnistui', 'unknown-error': 'Tuntematon virhe', diff --git a/package-lock.json b/package-lock.json index 38ba39f..d46835e 100644 --- a/package-lock.json +++ b/package-lock.json @@ -16,8 +16,10 @@ "expo": "~51.0.14", "expo-build-properties": "~0.12.3", "expo-constants": "~16.0.2", + "expo-device": "~6.0.2", "expo-font": "~12.0.7", "expo-linking": "~6.3.1", + "expo-notifications": "~0.28.9", "expo-router": "~3.5.16", "expo-splash-screen": "~0.27.5", "expo-status-bar": "~1.12.1", @@ -3253,6 +3255,11 @@ "dev": true, "license": "BSD-3-Clause" }, + "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==" + }, "node_modules/@isaacs/cliui": { "version": "8.0.2", "license": "ISC", @@ -7130,6 +7137,18 @@ "version": "2.0.6", "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==", + "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.15.2", "license": "MIT", @@ -7406,6 +7425,11 @@ "@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==" + }, "node_modules/balanced-match": { "version": "1.0.2", "license": "MIT" @@ -9514,6 +9538,14 @@ "expo": "bin/cli" } }, + "node_modules/expo-application": { + "version": "5.9.1", + "resolved": "https://registry.npmjs.org/expo-application/-/expo-application-5.9.1.tgz", + "integrity": "sha512-uAfLBNZNahnDZLRU41ZFmNSKtetHUT9Ua557/q189ua0AWV7pQjoVAx49E4953feuvqc9swtU3ScZ/hN1XO/FQ==", + "peerDependencies": { + "expo": "*" + } + }, "node_modules/expo-asset": { "version": "10.0.9", "license": "MIT", @@ -9558,6 +9590,39 @@ "expo": "*" } }, + "node_modules/expo-device": { + "version": "6.0.2", + "resolved": "https://registry.npmjs.org/expo-device/-/expo-device-6.0.2.tgz", + "integrity": "sha512-sCt91CuTmAuMXX4SlFOn4lIos2UIr8vb0jDstDDZXys6kErcj0uynC7bQAMreU5uRUTKMAl4MAMpKt9ufCXPBw==", + "dependencies": { + "ua-parser-js": "^0.7.33" + }, + "peerDependencies": { + "expo": "*" + } + }, + "node_modules/expo-device/node_modules/ua-parser-js": { + "version": "0.7.38", + "resolved": "https://registry.npmjs.org/ua-parser-js/-/ua-parser-js-0.7.38.tgz", + "integrity": "sha512-fYmIy7fKTSFAhG3fuPlubeGaMoAd6r0rSnfEsO5nEY55i26KSLt9EH7PLQiiqPUhNqYIJvSkTy1oArIcXAbPbA==", + "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" + } + ], + "engines": { + "node": "*" + } + }, "node_modules/expo-file-system": { "version": "17.0.1", "license": "MIT", @@ -9699,6 +9764,57 @@ "invariant": "^2.2.4" } }, + "node_modules/expo-notifications": { + "version": "0.28.9", + "resolved": "https://registry.npmjs.org/expo-notifications/-/expo-notifications-0.28.9.tgz", + "integrity": "sha512-lilBS+0n+MC71jU6R9kYr91nev2God/OyxGRtab37XBmr7OWKL+jrahwdIRc2fJXq3hkQoiVWr4i9COGVFF8sA==", + "dependencies": { + "@expo/image-utils": "^0.5.0", + "@ide/backoff": "^1.0.0", + "abort-controller": "^3.0.0", + "assert": "^2.0.0", + "badgin": "^1.1.5", + "expo-application": "~5.9.0", + "expo-constants": "~16.0.0", + "fs-extra": "^9.1.0" + }, + "peerDependencies": { + "expo": "*" + } + }, + "node_modules/expo-notifications/node_modules/fs-extra": { + "version": "9.1.0", + "resolved": "https://registry.npmjs.org/fs-extra/-/fs-extra-9.1.0.tgz", + "integrity": "sha512-hcg3ZmepS30/7BSFqRvoo3DOMQu7IjqxO5nCDt+zM9XWjb33Wg7ziNT+Qvqbuc3+gWpzO02JubVyk2G4Zvo1OQ==", + "dependencies": { + "at-least-node": "^1.0.0", + "graceful-fs": "^4.2.0", + "jsonfile": "^6.0.1", + "universalify": "^2.0.0" + }, + "engines": { + "node": ">=10" + } + }, + "node_modules/expo-notifications/node_modules/jsonfile": { + "version": "6.1.0", + "resolved": "https://registry.npmjs.org/jsonfile/-/jsonfile-6.1.0.tgz", + "integrity": "sha512-5dgndWOriYSm5cnYaJNhalLNDKOqFwyDB/rr1E9ZsGciGvKPs8R2xYGCacuf3z6K1YKDz182fd+fY3cn3pMqXQ==", + "dependencies": { + "universalify": "^2.0.0" + }, + "optionalDependencies": { + "graceful-fs": "^4.1.6" + } + }, + "node_modules/expo-notifications/node_modules/universalify": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/universalify/-/universalify-2.0.1.tgz", + "integrity": "sha512-gptHNQghINnc/vTGIk0SOFGFNXw7JVrlRUtConJRlvaw6DuX0wO5Jeko9sWrMBhh+PsYAZ7oXAiOnf/UKogyiw==", + "engines": { + "node": ">= 10.0.0" + } + }, "node_modules/expo-router": { "version": "3.5.16", "license": "MIT", @@ -11010,6 +11126,21 @@ "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==", + "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", "license": "MIT", @@ -14689,6 +14820,21 @@ "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==", + "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", "license": "MIT", diff --git a/package.json b/package.json index 14ed514..99e32d5 100644 --- a/package.json +++ b/package.json @@ -11,6 +11,7 @@ "test": "jest --watchAll", "lint": "expo lint", "pretty": "prettier --write \"./**/*.{js,jsx,mjs,cjs,ts,tsx,json}\"", + "build:android:preview": "eas build --platform android --profile preview --non-interactive --no-wait", "check:types": "tsc --noEmit", "check:lint": "expo lint", "check:format": "prettier --check \"./**/*.{js,jsx,mjs,cjs,ts,tsx,json}\"", @@ -48,6 +49,9 @@ "react-native-safe-area-context": "^4.10.1", "react-native-screens": "3.31.1", "react-native-web": "~0.19.10", + "@react-native-async-storage/async-storage": "1.23.1", + "expo-notifications": "~0.28.9", + "expo-device": "~6.0.2", "react-native-webview": "13.8.6" }, "devDependencies": {