Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Feat: favorite event starting notification #20

Merged
merged 8 commits into from
Jul 27, 2024
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
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
Loading