Added push notifications

This commit is contained in:
florian 2025-09-30 18:12:04 +02:00
parent 3025fbec9c
commit 96d38edda0
7 changed files with 13305 additions and 109 deletions

82
app/HomeScreen.styles.ts Normal file
View File

@ -0,0 +1,82 @@
import { StyleSheet } from "react-native";
export const styles = StyleSheet.create({
container: {
flex: 1,
flexDirection: "row",
backgroundColor: "#f2f2f2",
},
sideMenu: {
width: 0,
backgroundColor: "#333",
paddingTop: 40,
paddingHorizontal: 0,
overflow: "hidden",
},
sideMenuOpen: {
width: 180,
paddingHorizontal: 16,
},
menuItem: {
paddingVertical: 16,
paddingHorizontal: 8,
borderRadius: 4,
marginBottom: 8,
},
menuItemSelected: {
backgroundColor: "#555",
},
menuText: {
color: "#fff",
fontSize: 18,
},
mainContent: {
flex: 1,
justifyContent: "center",
alignItems: "center",
position: "relative",
},
menuButton: {
position: "absolute",
top: 40,
left: 16,
zIndex: 1,
backgroundColor: "#333",
borderRadius: 20,
padding: 8,
},
menuButtonText: {
color: "#fff",
fontSize: 24,
},
title: {
fontSize: 24,
fontWeight: "bold",
marginTop: 60,
color: "#333",
},
dataContainer: {
marginTop: 24,
width: "90%",
},
dataItem: {
backgroundColor: "#fff",
borderRadius: 8,
padding: 16,
marginBottom: 12,
shadowColor: "#000",
shadowOpacity: 0.05,
shadowRadius: 4,
elevation: 2,
},
dataTitle: {
fontSize: 18,
fontWeight: "bold",
color: "#222",
marginBottom: 4,
},
dataDescription: {
fontSize: 15,
color: "#555",
},
});

View File

@ -1,34 +1,53 @@
import React, { useEffect, useState } from 'react';
import { Linking, SafeAreaView, ScrollView, StyleSheet, Text, TouchableOpacity, View } from 'react-native';
import { Item } from 'types/Item';
import * as Notifications from "expo-notifications";
import React, { useEffect, useState } from "react";
import { Linking, SafeAreaView, ScrollView, Text, TouchableOpacity, View } from "react-native";
import { Item } from "types/Item";
import { useNotificationListener } from "../hooks/useNotificationListener";
import { usePushNotifications } from "../hooks/usePushNotifications";
import { styles } from "./HomeScreen.styles";
const API_URL = "http://192.168.178.22:8000";
const API_URL = "http://167.86.73.246:8100";
type Category = "home" | "royal-road" | "podcasts" | "mixtapes";
export default function HomeScreen() {
const [data, setData] = useState<Item[]>([]);
const [selected, setSelected] = useState<'home' | 'royal-road' | 'podcasts' | 'mixtapes'>('home');
const [selected, setSelected] = useState<Category>("home");
const [menuOpen, setMenuOpen] = useState(false);
const [lastNotification, setLastNotification] = useState<Notifications.Notification | null>(null);
const expoPushToken = usePushNotifications({
userId: 1,
apiKey: "super-secret-api-key",
backendUrl: API_URL,
appVersion: "1.0.0",
locale: "en-uk",
});
useNotificationListener(notification => {
setLastNotification(notification);
});
useEffect(() => {
fetch(API_URL)
.then(res => res.json())
.then(json => {
if (json.status === "ok" && Array.isArray(json.data)) {
setData(json.data);
}
})
.catch(err => console.error("API error:", err));
async function fetchData() {
try {
const res = await fetch(`${API_URL}/`);
const json = await res.json();
setData(json.data);
} catch (err) {
console.error("Failed to fetch data:", err);
}
}
fetchData();
}, []);
// Filter items for current category
const filteredData =
selected === "home" ? data : data.filter(item => item.category === selected);
const filteredData = selected === "home" ? data : data.filter(item => item.category === selected);
const menuItems = [
{ label: 'Home', key: 'home' },
{ label: 'Royal Road', key: 'royal-road' },
{ label: 'Podcasts', key: 'podcasts' },
{ label: 'Mixtapes', key: 'mixtapes' },
const menuItems: { label: string; key: Category }[] = [
{ label: "Home", key: "home" },
{ label: "Royal Road", key: "royal-road" },
{ label: "Podcasts", key: "podcasts" },
{ label: "Mixtapes", key: "mixtapes" },
];
return (
@ -40,7 +59,7 @@ export default function HomeScreen() {
key={item.key}
style={[styles.menuItem, selected === item.key && styles.menuItemSelected]}
onPress={() => {
setSelected(item.key as any);
setSelected(item.key);
setMenuOpen(false);
}}
>
@ -56,15 +75,15 @@ export default function HomeScreen() {
</TouchableOpacity>
<Text style={styles.title}>
{selected === 'home' && 'Welcome to Home!'}
{selected === 'royal-road' && 'Royal Road Updates'}
{selected === 'podcasts' && 'Latest Podcasts'}
{selected === 'mixtapes' && 'New Mixtapes'}
{selected === "home" && "Welcome to Home!"}
{selected === "royal-road" && "Royal Road Updates"}
{selected === "podcasts" && "Latest Podcasts"}
{selected === "mixtapes" && "New Mixtapes"}
</Text>
<ScrollView style={styles.dataContainer}>
{filteredData.map(item => (
<View key={item.timestamp + item.title} style={styles.dataItem}>
<View key={`${item.timestamp}-${item.title}`} style={styles.dataItem}>
<Text style={styles.dataTitle}>{item.title}</Text>
<Text style={styles.dataDescription}>{item.info}</Text>
<TouchableOpacity onPress={() => Linking.openURL(item.link)}>
@ -77,84 +96,3 @@ export default function HomeScreen() {
</SafeAreaView>
);
}
const styles = StyleSheet.create({
container: {
flex: 1,
flexDirection: 'row',
backgroundColor: '#f2f2f2',
},
sideMenu: {
width: 0,
backgroundColor: '#333',
paddingTop: 40,
paddingHorizontal: 0,
overflow: 'hidden',
},
sideMenuOpen: {
width: 180,
paddingHorizontal: 16,
},
menuItem: {
paddingVertical: 16,
paddingHorizontal: 8,
borderRadius: 4,
marginBottom: 8,
},
menuItemSelected: {
backgroundColor: '#555',
},
menuText: {
color: '#fff',
fontSize: 18,
},
mainContent: {
flex: 1,
justifyContent: 'center',
alignItems: 'center',
position: 'relative',
},
menuButton: {
position: 'absolute',
top: 40,
left: 16,
zIndex: 1,
backgroundColor: '#333',
borderRadius: 20,
padding: 8,
},
menuButtonText: {
color: '#fff',
fontSize: 24,
},
title: {
fontSize: 24,
fontWeight: 'bold',
marginTop: 60,
color: '#333',
},
dataContainer: {
marginTop: 24,
width: '90%',
},
dataItem: {
backgroundColor: '#fff',
borderRadius: 8,
padding: 16,
marginBottom: 12,
shadowColor: '#000',
shadowOpacity: 0.05,
shadowRadius: 4,
elevation: 2,
},
dataTitle: {
fontSize: 18,
fontWeight: 'bold',
color: '#222',
marginBottom: 4,
},
dataDescription: {
fontSize: 15,
color: '#555',
},
});

35
app/notifications.ts Normal file
View File

@ -0,0 +1,35 @@
import * as Device from 'expo-device';
import * as Notifications from 'expo-notifications';
import { Platform } from 'react-native';
export async function registerForPushNotificationsAsync() {
if (!Device.isDevice) {
alert('Must use physical device for Push Notifications');
return null;
}
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 null;
}
const token = (await Notifications.getExpoPushTokenAsync()).data;
console.log('Expo Push Token:', token);
if (Platform.OS === 'android') {
await Notifications.setNotificationChannelAsync('default', {
name: 'default',
importance: Notifications.AndroidImportance.MAX,
});
}
return token;
}

View File

@ -0,0 +1,25 @@
import * as Notifications from "expo-notifications";
import { useEffect } from "react";
export function useNotificationListener(
onNotificationReceived: (notification: Notifications.Notification) => void
) {
useEffect(() => {
// Listener for notifications received while app is in foreground
const subscription = Notifications.addNotificationReceivedListener(notification => {
console.log("Notification received:", notification);
onNotificationReceived(notification);
});
// Listener for user interacting with a notification
const responseSubscription = Notifications.addNotificationResponseReceivedListener(response => {
console.log("Notification response:", response);
onNotificationReceived(response.notification);
});
return () => {
subscription.remove();
responseSubscription.remove();
};
}, []);
}

View File

@ -0,0 +1,73 @@
import * as Notifications from "expo-notifications";
import * as Permissions from "expo-permissions";
import { useEffect, useState } from "react";
import { Platform } from "react-native";
type RegisterTokenParams = {
userId: number;
apiKey: string;
backendUrl: string;
appVersion?: string;
locale?: string;
topics?: string[];
};
export function usePushNotifications({
userId,
apiKey,
backendUrl,
appVersion = "1.0.0",
locale,
topics
}: RegisterTokenParams) {
const [expoPushToken, setExpoPushToken] = useState<string | null>(null);
useEffect(() => {
(async () => {
const token = await registerForPushNotificationsAsync();
if (token) {
setExpoPushToken(token);
await registerTokenWithBackend(token);
}
})();
}, []);
const registerTokenWithBackend = async (token: string) => {
try {
await fetch(`${backendUrl}/register_token`, {
method: "POST",
headers: {
"Content-Type": "application/json",
"X-API-Key": apiKey
},
body: JSON.stringify({
user_id: userId,
token,
platform: Platform.OS,
app_ver: appVersion,
locale,
topics
})
});
console.log("Push token registered with backend ✅");
} catch (error) {
console.error("Failed to register token with backend:", error);
}
};
return expoPushToken;
}
async function registerForPushNotificationsAsync() {
let { status } = await Permissions.askAsync(Permissions.NOTIFICATIONS);
if (status !== "granted") {
console.log("Permission not granted for push notifications.");
return null;
}
const tokenData = await Notifications.getExpoPushTokenAsync();
const token = tokenData.data;
console.log("Expo Push Token:", token);
return token;
}

13040
package-lock.json generated Normal file

File diff suppressed because it is too large Load Diff

View File

@ -15,10 +15,13 @@
"@expo/vector-icons": "^15.0.2",
"expo": "~54.0.10",
"expo-constants": "~18.0.9",
"expo-device": "~8.0.8",
"expo-font": "~14.0.8",
"expo-haptics": "~15.0.7",
"expo-image": "~3.0.8",
"expo-linking": "~8.0.8",
"expo-notifications": "~0.32.11",
"expo-permissions": "^14.4.0",
"expo-router": "~6.0.8",
"expo-splash-screen": "~31.0.10",
"expo-status-bar": "~3.0.8",
@ -41,4 +44,4 @@
"typescript": "~5.9.2"
},
"private": true
}
}