diff options
author | Santo Cariotti <santo@dcariotti.me> | 2024-09-08 16:29:57 +0200 |
---|---|---|
committer | Santo Cariotti <santo@dcariotti.me> | 2024-09-08 16:29:57 +0200 |
commit | 93fad903a993a1adc7622a4e3091dd4cca84764a (patch) | |
tree | 5aab244be5e0828c7af4a35651b7e7c12cd95c6c | |
parent | a0a67340a5ceb2bcf78c1b7494c042a5e544f218 (diff) |
Notifications page
-rw-r--r-- | app/(tabs)/_layout.tsx | 8 | ||||
-rw-r--r-- | app/(tabs)/index.tsx | 1 | ||||
-rw-r--r-- | app/(tabs)/notifications/[id].tsx | 301 | ||||
-rw-r--r-- | app/(tabs)/notifications/index.tsx | 204 | ||||
-rw-r--r-- | components/ParallaxScrollView.tsx | 45 |
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: { |