summaryrefslogtreecommitdiff
diff options
context:
space:
mode:
authorSanto Cariotti <santo@dcariotti.me>2024-09-08 16:29:57 +0200
committerSanto Cariotti <santo@dcariotti.me>2024-09-08 16:29:57 +0200
commit93fad903a993a1adc7622a4e3091dd4cca84764a (patch)
tree5aab244be5e0828c7af4a35651b7e7c12cd95c6c
parenta0a67340a5ceb2bcf78c1b7494c042a5e544f218 (diff)
Notifications page
-rw-r--r--app/(tabs)/_layout.tsx8
-rw-r--r--app/(tabs)/index.tsx1
-rw-r--r--app/(tabs)/notifications/[id].tsx301
-rw-r--r--app/(tabs)/notifications/index.tsx204
-rw-r--r--components/ParallaxScrollView.tsx45
5 files changed, 536 insertions, 23 deletions
diff --git a/app/(tabs)/_layout.tsx b/app/(tabs)/_layout.tsx
index a0a2bba..e6852d9 100644
--- a/app/(tabs)/_layout.tsx
+++ b/app/(tabs)/_layout.tsx
@@ -36,6 +36,14 @@ export default function TabLayout() {
name="alerts/[id]"
options={{href: null}}
/>
+ <Tabs.Screen
+ name="notifications/index"
+ options={{href: null}}
+ />
+ <Tabs.Screen
+ name="notifications/[id]"
+ options={{href: null}}
+ />
</Tabs>
);
}
diff --git a/app/(tabs)/index.tsx b/app/(tabs)/index.tsx
index 9a17006..98fe65e 100644
--- a/app/(tabs)/index.tsx
+++ b/app/(tabs)/index.tsx
@@ -95,7 +95,6 @@ export default function HomeScreen() {
}
try {
- console.log(`${process.env.EXPO_PUBLIC_API_URL}/graphql`)
const response = await fetch(`${process.env.EXPO_PUBLIC_API_URL}/graphql`, {
method: 'POST',
headers: {
diff --git a/app/(tabs)/notifications/[id].tsx b/app/(tabs)/notifications/[id].tsx
new file mode 100644
index 0000000..e2d39ba
--- /dev/null
+++ b/app/(tabs)/notifications/[id].tsx
@@ -0,0 +1,301 @@
+import {
+ Alert,
+ Platform,
+ StyleSheet,
+} from 'react-native';
+import ParallaxScrollView from '@/components/ParallaxScrollView';
+import { ThemedText } from '@/components/ThemedText';
+import { ThemedView } from '@/components/ThemedView';
+import React, { useState, useCallback, useEffect, useRef } from 'react';
+import AsyncStorage from '@react-native-async-storage/async-storage';
+import { useFocusEffect } from '@react-navigation/native';
+import { Link, router, useLocalSearchParams } from 'expo-router';
+import MapView, { Marker, Polygon, Region } from 'react-native-maps';
+import { Ionicons } from '@expo/vector-icons';
+import { useColorScheme } from '@/hooks/useColorScheme.web';
+import { Colors } from '@/constants/Colors';
+
+interface AlertData {
+ id: string;
+ userId: string;
+ createdAt: string;
+ area: string;
+ extendedArea: string;
+ level: string;
+}
+
+interface PositionData {
+ id: string;
+ userId: string;
+ createdAt: string;
+ latitude: number;
+ longitude: number;
+ movingActivity: string;
+}
+
+interface NotificationData {
+ id: string;
+ alert: AlertData;
+ position: PositionData;
+ seen: boolean;
+ createdAt: string;
+}
+
+interface PolygonCoordinates {
+ latitude: number;
+ longitude: number;
+}
+
+export default function NotificationIdScreen() {
+ const [token, setToken] = useState<string>('');
+ const [userId, setUserId] = useState<string>('');
+ const [notification, setNotification] = useState<NotificationData | null>(null);
+ const [region, setRegion] = useState<Region>({
+ latitude: 44.49738301084014,
+ longitude: 11.356121722966094,
+ latitudeDelta: 0.03,
+ longitudeDelta: 0.03,
+ });
+ const [coordinates, setCoordinates] = useState({ latitude: 44.49738301084014, longitude: 11.356121722966094 });
+ const [polygon, setPolygon] = useState<PolygonCoordinates[]>([]);
+ const [extendedPolygon, setExtendedPolygon] = useState<PolygonCoordinates[]>([]);
+ const mapRef = useRef<MapView | null>(null);
+
+ const checkAuth = useCallback(async () => {
+ const storedToken =
+ Platform.OS === 'web'
+ ? localStorage.getItem('token')
+ : await AsyncStorage.getItem('token');
+ const storedUserId =
+ Platform.OS === 'web'
+ ? localStorage.getItem('userId')
+ : await AsyncStorage.getItem('userId');
+
+ setToken(storedToken || '');
+ setUserId(storedUserId || '');
+
+ if (!storedToken || !storedUserId) {
+ Alert.alert(
+ 'Login required',
+ 'You must log in to the system if you want to see notifications list',
+ [
+ {
+ text: 'Ok',
+ onPress: () => router.push('/'),
+ },
+ ]
+ );
+ }
+ }, []);
+
+ const updateSeenStatus = async (id: string) => {
+ const response = await fetch(`${process.env.EXPO_PUBLIC_API_URL}/graphql`, {
+ method: 'POST',
+ headers: {
+ 'Authorization': `Bearer ${token}`,
+ 'Content-Type': 'application/json',
+ },
+ body: JSON.stringify({
+ query: `
+ mutation NotificationUpdate($input: NotificationUpdateInput!) {
+ notificationUpdate(input: $input) { id seen }
+ }`,
+ variables: {
+ input: {
+ id,
+ seen: true,
+ },
+ },
+ }),
+ });
+
+ const data = await response.json();
+ if (data.errors) {
+ console.error(`On editing notification "${id}": ${JSON.stringify(data)}`);
+ }
+ };
+
+ const fetchNotification = useCallback(async (id: string) => {
+ if (!token || !userId) return;
+
+ try {
+ const response = await fetch(
+ `${process.env.EXPO_PUBLIC_API_URL}/graphql`,
+ {
+ method: 'POST',
+ headers: {
+ Authorization: `Bearer ${token}`,
+ 'Content-Type': 'application/json',
+ },
+ body: JSON.stringify({
+ query: `{ notifications(id: ${id}) {
+ id,
+ alert { id, userId, createdAt, area, extendedArea, level, reachedUsers },
+ position {id, userId, createdAt, latitude, longitude, movingActivity},
+ seen,
+ createdAt
+ } }`,
+ }),
+ }
+ );
+
+ const data = await response.json();
+
+ if (data.errors) {
+ Alert.alert('Error', 'Error fetching data');
+ } else if (data.data.notifications && data.data.notifications.length > 0) {
+ const notificationData = data.data.notifications[0];
+ const coordinatesString = notificationData.alert.area.substring(9, notificationData.alert.area.length - 2);
+ const coordinates = coordinatesString
+ .split(',')
+ .map((coord: string) => coord.trim().split(' '))
+ .map((pair: string[]) => ({
+ longitude: parseFloat(pair[0]),
+ latitude: parseFloat(pair[1]),
+ }));
+
+ const extendedCoordinatesString = notificationData.alert.extendedArea.substring(9, notificationData.alert.extendedArea.length - 2);
+ const extendedCoordinates = extendedCoordinatesString
+ .split(',')
+ .map((coord: string) => coord.trim().split(' '))
+ .map((pair: string[]) => ({
+ longitude: parseFloat(pair[0]),
+ latitude: parseFloat(pair[1]),
+ }));
+
+ setCoordinates({ latitude: notificationData.position.latitude, longitude: notificationData.position.longitude });
+ setNotification(notificationData);
+ setPolygon(coordinates);
+ setExtendedPolygon(extendedCoordinates);
+ setRegion({
+ latitude: coordinates[0]?.latitude || region.latitude,
+ longitude: coordinates[0]?.longitude || region.longitude,
+ latitudeDelta: 0.05,
+ longitudeDelta: 0.05,
+ });
+
+ return notificationData;
+ } else {
+ Alert.alert('Error', 'No data found');
+ router.push('/notifications/index');
+ }
+ } catch (err) {
+ console.error('Fetch Map Data Error:', err);
+ }
+
+ }, [token, userId, region.latitude, region.longitude]);
+
+ useFocusEffect(
+ useCallback(() => {
+ checkAuth();
+ }, [checkAuth])
+ );
+
+ const { id } = useLocalSearchParams();
+
+ useEffect(() => {
+ if (typeof id === 'string') {
+ fetchNotification(id).then((n) => {
+ if (n && !n.seen) {
+ updateSeenStatus(n.id);
+ }
+ })
+ }
+ }, [id, fetchNotification]);
+
+ useEffect(() => {
+ if (mapRef.current && region) {
+ mapRef.current.animateToRegion(region, 1000);
+ }
+ }, [region]);
+
+ const formatDate = (timestamp: string) => {
+ const date = new Date(parseInt(timestamp, 10) * 1000);
+ return `${date.toDateString()} ${date.getHours()}:${(date.getMinutes() < 10 ? '0' : '') + date.getMinutes()}`;
+ };
+
+ const theme = useColorScheme() ?? 'light';
+
+ return (
+ <ParallaxScrollView token={token} userId={userId}>
+ {notification === null ? (
+ <ThemedView>
+ <ThemedText>Loading...</ThemedText>
+ </ThemedView>
+ ) : (
+ <>
+ <ThemedView style={styles.dateRow}>
+ <Ionicons
+ name="chevron-back-outline"
+ size={18}
+ color="#0a7ea4"
+ style={styles.icon}
+ />
+ <Link href="/notifications">
+ <ThemedText type="link">Notifications list</ThemedText>
+ </Link>
+ </ThemedView>
+ <ThemedView>
+ <MapView
+ ref={mapRef}
+ initialRegion={region}
+ style={styles.map}
+ >
+ <Marker coordinate={coordinates} title="You were here" isPreselected />
+ <Polygon
+ coordinates={polygon}
+ strokeColor="#c0392b"
+ fillColor="rgba(192, 57, 43, 0.4)"
+ />
+ <Polygon
+ coordinates={extendedPolygon}
+ strokeColor="#c0392b"
+ fillColor="rgba(192, 57, 43, 0.4)"
+ />
+ </MapView>
+ </ThemedView>
+ <ThemedView style={styles.dateRow}>
+ <Ionicons
+ name="calendar-outline"
+ size={18}
+ color={theme === 'light' ? Colors.light.text : Colors.dark.text}
+ style={styles.icon}
+ />
+ <ThemedText>Notified: {formatDate(notification.createdAt)}</ThemedText>
+ </ThemedView>
+ <ThemedView style={styles.dateRow}>
+ <Ionicons
+ name="calendar-outline"
+ size={18}
+ color={theme === 'light' ? Colors.light.text : Colors.dark.text}
+ style={styles.icon}
+ />
+ <ThemedText>Alerted: {formatDate(notification.alert.createdAt)}</ThemedText>
+ </ThemedView>
+ <ThemedView style={styles.dateRow}>
+ <Ionicons
+ name="alert-circle-outline"
+ size={18}
+ color={theme === 'light' ? Colors.light.text : Colors.dark.text}
+ style={styles.icon}
+ />
+ <ThemedText>Level of alert: {notification.alert.level}</ThemedText>
+ </ThemedView>
+ </>
+ )}
+ </ParallaxScrollView>
+ );
+}
+
+const styles = StyleSheet.create({
+ map: {
+ height: 400,
+ },
+ dateRow: {
+ flexDirection: 'row',
+ alignItems: 'center',
+ },
+ icon: {
+ marginRight: 8,
+ },
+});
diff --git a/app/(tabs)/notifications/index.tsx b/app/(tabs)/notifications/index.tsx
new file mode 100644
index 0000000..673266f
--- /dev/null
+++ b/app/(tabs)/notifications/index.tsx
@@ -0,0 +1,204 @@
+import {
+ Alert,
+ FlatList,
+ Platform,
+ StyleSheet,
+ View,
+ RefreshControl,
+} from 'react-native';
+import ParallaxScrollView from '@/components/ParallaxScrollView';
+import { ThemedText } from '@/components/ThemedText';
+import { ThemedView } from '@/components/ThemedView';
+import React, { useState, useCallback } from 'react';
+import AsyncStorage from '@react-native-async-storage/async-storage';
+import { useFocusEffect } from '@react-navigation/native';
+import { Link, router } from 'expo-router';
+import { Ionicons } from '@expo/vector-icons';
+
+interface NotificationData {
+ id: string;
+ seen: boolean;
+ createdAt: string;
+}
+
+export default function NotificationsScreen() {
+ const [token, setToken] = useState('');
+ const [userId, setUserId] = useState('');
+ const [notifications, setNotifications] = useState<NotificationData[]>([]);
+ const [refreshing, setRefreshing] = useState(false);
+
+ const fetchNotifications = async (currentToken: string, currentUserId: string) => {
+ if (!currentToken || !currentUserId) return;
+
+ try {
+ const response = await fetch(
+ `${process.env.EXPO_PUBLIC_API_URL}/graphql`,
+ {
+ method: 'POST',
+ headers: {
+ Authorization: `Bearer ${currentToken}`,
+ 'Content-Type': 'application/json',
+ },
+ body: JSON.stringify({
+ query: `{ notifications { id, seen, createdAt } }`,
+ }),
+ }
+ );
+
+ const data = await response.json();
+
+ if (data.errors) {
+ Alert.alert('Error', 'Error fetching data');
+ } else if (data.data.notifications) {
+ setNotifications(data.data.notifications);
+ }
+ } catch (err) {
+ console.error('Fetch notifications Error:', err);
+ }
+ };
+
+ const checkAuth = async () => {
+ const storedToken =
+ Platform.OS === 'web'
+ ? localStorage.getItem('token')
+ : await AsyncStorage.getItem('token');
+ const storedUserId =
+ Platform.OS === 'web'
+ ? localStorage.getItem('userId')
+ : await AsyncStorage.getItem('userId');
+
+ setToken(storedToken || '');
+ setUserId(storedUserId || '');
+
+ if (!storedToken || !storedUserId) {
+ setNotifications([]);
+ Alert.alert(
+ 'Login required',
+ 'You must log in to the system if you want to see notifications list',
+ [
+ {
+ text: 'Ok',
+ onPress: () => router.push('/'),
+ },
+ ]
+ );
+ }
+
+ // Fetch notifications only after token and userId are set
+ return { storedToken, storedUserId };
+ };
+
+ useFocusEffect(
+ useCallback(() => {
+ const init = async () => {
+ const { storedToken, storedUserId } = await checkAuth();
+ if (storedToken && storedUserId) {
+ fetchNotifications(storedToken, storedUserId);
+ }
+ };
+
+ init();
+ }, [])
+ );
+
+ const onRefresh = useCallback(() => {
+ setRefreshing(true);
+ fetchNotifications(token, userId).finally(() => setRefreshing(false));
+ }, [token, userId]);
+
+ const formatDate = (timestamp: string) => {
+ const date = new Date(parseInt(timestamp) * 1000);
+ return `${date.toDateString()} ${date.getHours()}:${(date.getMinutes() < 10 ? '0' : '') + date.getMinutes()}`;
+ };
+
+ const renderNotification = ({ item }: { item: NotificationData }) => (
+ <ThemedView style={styles.notificationContainer}>
+ <View
+ style={[
+ styles.notificationBox,
+ {
+ backgroundColor: item.seen
+ ? '#fff'
+ : '#ff7979',
+ },
+ ]}
+ >
+ <Link
+ href={`/notifications/${item.id}`}
+ style={{ width: '100%' }}
+ >
+ <View style={styles.dateRow}>
+ <Ionicons
+ name="calendar-outline"
+ size={18}
+ color="black"
+ style={styles.icon}
+ />
+ <ThemedText style={styles.dateText}>
+ {formatDate(item.createdAt)}
+ </ThemedText>
+ </View>
+ </Link>
+ </View>
+ </ThemedView>
+ );
+
+ return (
+ <FlatList
+ ListHeaderComponent={
+ <ParallaxScrollView token={token} userId={userId}>
+ <ThemedView style={styles.header}>
+ <ThemedText type="subtitle">Notifications</ThemedText>
+ <ThemedText type="default">
+ Click on a notification to see about it.
+ </ThemedText>
+ </ThemedView>
+ </ParallaxScrollView>
+ }
+ data={notifications}
+ renderItem={renderNotification}
+ keyExtractor={(item) => item.id}
+ contentContainerStyle={styles.listContent}
+ refreshControl={
+ <RefreshControl refreshing={refreshing} onRefresh={onRefresh} />
+ }
+ />
+ );
+}
+
+const styles = StyleSheet.create({
+ container: {
+ flex: 1,
+ },
+ header: {
+ padding: 16,
+ backgroundColor: '#fff',
+ },
+ notificationContainer: {
+ paddingHorizontal: 16,
+ paddingBottom: 16,
+ },
+ notificationBox: {
+ padding: 16,
+ paddingBottom: 14,
+ borderRadius: 8,
+ shadowColor: '#000',
+ shadowOffset: { width: 0, height: 2 },
+ shadowOpacity: 0.1,
+ shadowRadius: 4,
+ backgroundColor: '#fff'
+ },
+ dateRow: {
+ flexDirection: 'row',
+ alignItems: 'center',
+ },
+ icon: {
+ marginRight: 8,
+ },
+ dateText: {
+ color: '#000',
+ },
+ listContent: {
+ paddingBottom: 32,
+ },
+});
diff --git a/components/ParallaxScrollView.tsx b/components/ParallaxScrollView.tsx
index 187730b..ae6be71 100644
--- a/components/ParallaxScrollView.tsx
+++ b/components/ParallaxScrollView.tsx
@@ -1,11 +1,10 @@
-import { useState, type PropsWithChildren, useCallback, useEffect, } from 'react';
-import { StyleSheet, SafeAreaView, useColorScheme, View, Text, Platform } from 'react-native';
+import { useState, type PropsWithChildren, useEffect, } from 'react';
+import { StyleSheet, SafeAreaView, useColorScheme, View, Text, Pressable } from 'react-native';
import { ThemedView } from '@/components/ThemedView';
import { ThemedText } from './ThemedText';
import { Ionicons } from '@expo/vector-icons';
-import AsyncStorage from '@react-native-async-storage/async-storage';
-import { useFocusEffect } from 'expo-router';
+import { router } from 'expo-router';
type Props = PropsWithChildren<{
@@ -53,7 +52,9 @@ export default function ParallaxScrollView({
};
if (token && userId) {
- fetchNotifications();
+ const intervalId = setInterval(fetchNotifications, 2000);
+
+ return () => clearInterval(intervalId);
} else {
setNotifications([]);
}
@@ -65,21 +66,21 @@ export default function ParallaxScrollView({
backgroundColor: (theme === 'light' ? 'rgba(0, 0, 0, .5)' : 'rgba(100, 100, 100, .5)'),
}}>
<ThemedText type="title" style={{ color: 'white', paddingVertical: 10 }}>CAS4</ThemedText>
- { notifications.length > 0 ? (
<SafeAreaView>
- <View style={styles.notificationCircle}>
- <Text style={styles.notificationCircleText}>{ notifications.length }</Text>
- </View>
- <Ionicons
- name="notifications-outline"
- size={32}
- style={styles.notification}
- />
+ {token && userId ? (
+ <Pressable onPress={() => router.push('/notifications')} style={styles.notificationWrapper}>
+ {notifications.length > 0 && (
+ <View style={styles.notificationCircle}>
+ <Text style={styles.notificationCircleText}>{notifications.length}</Text>
+ </View>
+ )}
+ <Ionicons name="notifications-outline" size={32} color="white" />
+ </Pressable>
+ ) : (
+ <>
+ </>
+ )}
</SafeAreaView>
- ) : (
- <>
- </>
- )}
</SafeAreaView>
<ThemedView style={styles.content}>{children}</ThemedView>
</ThemedView>
@@ -104,10 +105,10 @@ const styles = StyleSheet.create({
gap: 16,
overflow: 'hidden',
},
- notification: {
+ notificationWrapper: {
color: '#fff',
position: 'absolute',
- bottom: 10,
+ bottom: 15,
right: 30,
},
notificationCircle: {
@@ -116,8 +117,8 @@ const styles = StyleSheet.create({
borderRadius: 100,
backgroundColor: '#EA2027',
position: 'absolute',
- bottom: 30,
- right: 30,
+ top: -5,
+ right: 0,
zIndex: 1,
},
notificationCircleText: {