Skip to content

Commit

Permalink
feat: event notifications (#20)
Browse files Browse the repository at this point in the history
* fix: correctly  remove event from favorites

* feat: add favorite event notification 15 minutes before event starts

* feat: add translations

* fix: remove hardcoded time

* feat: cancel event notification when event is removed from favorites

* fix: favorite toggle usage

* fix: finish notification functionality

---------

Co-authored-by: Samu Kupiainen <[email protected]>
  • Loading branch information
antoKeinanen and Adventune authored Jul 27, 2024
1 parent 36c7a1b commit 66b2e60
Show file tree
Hide file tree
Showing 10 changed files with 314 additions and 19 deletions.
18 changes: 15 additions & 3 deletions app.config.js
Original file line number Diff line number Diff line change
Expand Up @@ -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: {
Expand All @@ -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,
},
Expand Down
9 changes: 9 additions & 0 deletions app/_layout.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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';
Expand All @@ -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'),
Expand Down
26 changes: 13 additions & 13 deletions components/timetable/Event.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -110,19 +110,19 @@ const Event = ({
{`${t('time')}: ${timeString}`}
</Text>
</Surface>
{dayjs().isBefore(start) ||
((process.env.EXPO_PUBLIC_ENVIRONMENT === 'development' ||
process.env.EXPO_PUBLIC_ENVIRONMENT === 'preview') && (
<IconButton
onPress={() => 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') && (
<IconButton
onPress={() => toggleFavorite()}
icon={isFavorite ? 'heart' : 'heart-outline'}
style={{
position: 'absolute',
top: 0,
right: 0,
}}
/>
)}
</Surface>
);
};
Expand Down
4 changes: 2 additions & 2 deletions elements/timetable/EventsBox.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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) => {
Expand All @@ -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)}
/>
))}
Expand Down
5 changes: 4 additions & 1 deletion hooks/useFavorite.ts
Original file line number Diff line number Diff line change
@@ -1,3 +1,4 @@
import { cancelScheduledPushNotification, schedulePushNotification } from './useLocalNotification';
import AsyncStorage from '@react-native-async-storage/async-storage';
import { useEffect, useState } from 'react';

Expand Down Expand Up @@ -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);
}
};

Expand Down
119 changes: 119 additions & 0 deletions hooks/useLocalNotification.ts
Original file line number Diff line number Diff line change
@@ -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<Notifications.Subscription | undefined>();
const responseListener = useRef<Notifications.Subscription | undefined>();

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;
};
1 change: 1 addition & 0 deletions locales/en.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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',
Expand Down
1 change: 1 addition & 0 deletions locales/fi.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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',
Expand Down
Loading

0 comments on commit 66b2e60

Please sign in to comment.