[V1.0] Working application, added notifications.
Ready to upload to store.
This commit is contained in:
@@ -9,8 +9,6 @@ const API_BASE_URL = process.env.EXPO_PUBLIC_API_URL || 'https://maia.depaoli.id
|
||||
const ACCESS_TOKEN_KEY = 'maia_access_token';
|
||||
const REFRESH_TOKEN_KEY = 'maia_refresh_token';
|
||||
|
||||
console.log("Using API Base URL:", API_BASE_URL);
|
||||
|
||||
// Helper functions for storage
|
||||
const storeToken = async (key: string, token: string): Promise<void> => {
|
||||
if (Platform.OS === 'web') {
|
||||
@@ -163,6 +161,7 @@ apiClient.interceptors.response.use(
|
||||
|
||||
} // End of 401 handling
|
||||
} else if (error.request) {
|
||||
console.log("Using API Base URL:", API_BASE_URL);
|
||||
console.error('[API Client] Network Error or No Response:', error.message);
|
||||
if (error.message.toLowerCase().includes('network error') && Platform.OS === 'web') {
|
||||
console.warn('[API Client] Hint: A "Network Error" on web often masks a CORS issue. Check browser console & backend CORS config.');
|
||||
|
||||
@@ -1,52 +1,99 @@
|
||||
import React, { useState } from 'react';
|
||||
import { View, StyleSheet } from 'react-native';
|
||||
import { Button, Checkbox, Text, ActivityIndicator, Snackbar } from 'react-native-paper';
|
||||
import { Button, Checkbox, Text, ActivityIndicator, Snackbar, TextInput, Divider, useTheme } from 'react-native-paper'; // Added TextInput, Divider, useTheme
|
||||
import { clearDatabase } from '../api/admin';
|
||||
// Remove useNavigation import if no longer needed elsewhere in this file
|
||||
// import { useNavigation } from '@react-navigation/native';
|
||||
import { useAuth } from '../contexts/AuthContext'; // Import useAuth
|
||||
import apiClient from '../api/client'; // Import apiClient
|
||||
import { useAuth } from '../contexts/AuthContext';
|
||||
|
||||
const AdminScreen = () => {
|
||||
const [isHardClear, setIsHardClear] = useState(false);
|
||||
const [isLoading, setIsLoading] = useState(false);
|
||||
const [snackbarVisible, setSnackbarVisible] = useState(false);
|
||||
const [snackbarMessage, setSnackbarMessage] = useState('');
|
||||
// const navigation = useNavigation(); // Remove if not used elsewhere
|
||||
const { logout } = useAuth(); // Get the logout function from context
|
||||
const theme = useTheme(); // Get theme for styling if needed
|
||||
|
||||
// --- State for Clear DB ---
|
||||
const [isHardClear, setIsHardClear] = useState(false);
|
||||
const [isClearingDb, setIsClearingDb] = useState(false); // Renamed from isLoading
|
||||
const [clearDbSnackbarVisible, setClearDbSnackbarVisible] = useState(false); // Renamed
|
||||
const [clearDbSnackbarMessage, setClearDbSnackbarMessage] = useState(''); // Renamed
|
||||
|
||||
// --- State for Send Notification ---
|
||||
const [username, setUsername] = useState('');
|
||||
const [title, setTitle] = useState('');
|
||||
const [body, setBody] = useState('');
|
||||
const [isSendingNotification, setIsSendingNotification] = useState(false); // New loading state
|
||||
const [notificationError, setNotificationError] = useState<string | null>(null); // New error state
|
||||
const [notificationSuccess, setNotificationSuccess] = useState<string | null>(null); // New success state
|
||||
|
||||
const { logout } = useAuth();
|
||||
|
||||
// --- Clear DB Handler ---
|
||||
const handleClearDb = async () => {
|
||||
setIsLoading(true);
|
||||
setSnackbarVisible(false);
|
||||
setIsClearingDb(true); // Use renamed state
|
||||
setClearDbSnackbarVisible(false);
|
||||
try {
|
||||
const response = await clearDatabase(isHardClear);
|
||||
setSnackbarMessage(response.message || 'Database cleared successfully.');
|
||||
setSnackbarVisible(true);
|
||||
setClearDbSnackbarMessage(response.message || 'Database cleared successfully.');
|
||||
setClearDbSnackbarVisible(true);
|
||||
|
||||
// If hard clear was successful, trigger the logout process from AuthContext
|
||||
if (isHardClear) {
|
||||
console.log('Hard clear successful, calling logout...');
|
||||
await logout(); // Call the logout function from AuthContext
|
||||
// The RootNavigator will automatically switch to the AuthFlow
|
||||
// No need to manually navigate or set loading to false here
|
||||
return; // Exit early
|
||||
await logout();
|
||||
return;
|
||||
}
|
||||
|
||||
} catch (error: any) {
|
||||
console.error("Error clearing database:", error);
|
||||
setSnackbarMessage(error.response?.data?.detail || 'Failed to clear database.');
|
||||
setSnackbarVisible(true);
|
||||
setClearDbSnackbarMessage(error.response?.data?.detail || 'Failed to clear database.');
|
||||
setClearDbSnackbarVisible(true);
|
||||
} finally {
|
||||
// Only set loading to false if it wasn't a hard clear (as logout handles navigation)
|
||||
if (!isHardClear) {
|
||||
setIsLoading(false);
|
||||
setIsClearingDb(false); // Use renamed state
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
// --- Send Notification Handler ---
|
||||
const handleSendNotification = async () => {
|
||||
if (!username || !title || !body) {
|
||||
setNotificationError('Username, Title, and Body are required.');
|
||||
setNotificationSuccess(null);
|
||||
return;
|
||||
}
|
||||
|
||||
setIsSendingNotification(true);
|
||||
setNotificationError(null);
|
||||
setNotificationSuccess(null);
|
||||
|
||||
try {
|
||||
const response = await apiClient.post('/admin/send-notification', {
|
||||
username,
|
||||
title,
|
||||
body,
|
||||
// data: {} // Add optional data payload if needed
|
||||
});
|
||||
|
||||
if (response.status === 200) {
|
||||
setNotificationSuccess(response.data.message || 'Notification sent successfully!');
|
||||
// Clear fields after success
|
||||
setUsername('');
|
||||
setTitle('');
|
||||
setBody('');
|
||||
} else {
|
||||
setNotificationError(response.data?.detail || 'Failed to send notification.');
|
||||
}
|
||||
} catch (err: any) {
|
||||
console.error("Error sending notification:", err.response?.data || err.message);
|
||||
setNotificationError(err.response?.data?.detail || 'An error occurred while sending the notification.');
|
||||
} finally {
|
||||
setIsSendingNotification(false);
|
||||
}
|
||||
};
|
||||
|
||||
|
||||
return (
|
||||
<View style={styles.container}>
|
||||
<Text variant="headlineMedium" style={styles.title}>Admin Controls</Text>
|
||||
|
||||
{/* --- Clear Database Section --- */}
|
||||
<Text variant="titleMedium" style={styles.sectionTitle}>Clear Database</Text>
|
||||
<View style={styles.checkboxContainer}>
|
||||
<Checkbox
|
||||
status={isHardClear ? 'checked' : 'unchecked'}
|
||||
@@ -54,24 +101,68 @@ const AdminScreen = () => {
|
||||
/>
|
||||
<Text onPress={() => setIsHardClear(!isHardClear)}>Hard Clear (Delete all data)</Text>
|
||||
</View>
|
||||
|
||||
<Button
|
||||
mode="contained"
|
||||
onPress={handleClearDb}
|
||||
disabled={isLoading}
|
||||
disabled={isClearingDb} // Use renamed state
|
||||
style={styles.button}
|
||||
buttonColor="red" // Make it look dangerous
|
||||
buttonColor="red"
|
||||
>
|
||||
{isLoading ? <ActivityIndicator animating={true} color="white" /> : 'Clear Database'}
|
||||
{isClearingDb ? <ActivityIndicator animating={true} color="white" /> : 'Clear Database'}
|
||||
</Button>
|
||||
|
||||
<Snackbar
|
||||
visible={snackbarVisible}
|
||||
onDismiss={() => setSnackbarVisible(false)}
|
||||
visible={clearDbSnackbarVisible} // Use renamed state
|
||||
onDismiss={() => setClearDbSnackbarVisible(false)}
|
||||
duration={Snackbar.DURATION_SHORT}
|
||||
>
|
||||
{snackbarMessage}
|
||||
{clearDbSnackbarMessage} {/* Use renamed state */}
|
||||
</Snackbar>
|
||||
|
||||
<Divider style={styles.divider} />
|
||||
|
||||
{/* --- Send Notification Section --- */}
|
||||
<Text variant="titleMedium" style={styles.sectionTitle}>Send Push Notification</Text>
|
||||
|
||||
{notificationError && <Text style={[styles.message, { color: theme.colors.error }]}>{notificationError}</Text>}
|
||||
{notificationSuccess && <Text style={[styles.message, { color: theme.colors.primary }]}>{notificationSuccess}</Text>}
|
||||
|
||||
<TextInput
|
||||
label="Username"
|
||||
value={username}
|
||||
onChangeText={setUsername}
|
||||
mode="outlined"
|
||||
style={styles.input}
|
||||
autoCapitalize="none"
|
||||
disabled={isSendingNotification}
|
||||
/>
|
||||
<TextInput
|
||||
label="Notification Title"
|
||||
value={title}
|
||||
onChangeText={setTitle}
|
||||
mode="outlined"
|
||||
style={styles.input}
|
||||
disabled={isSendingNotification}
|
||||
/>
|
||||
<TextInput
|
||||
label="Notification Body"
|
||||
value={body}
|
||||
onChangeText={setBody}
|
||||
mode="outlined"
|
||||
style={styles.input}
|
||||
multiline
|
||||
numberOfLines={3}
|
||||
disabled={isSendingNotification}
|
||||
/>
|
||||
<Button
|
||||
mode="contained"
|
||||
onPress={handleSendNotification}
|
||||
loading={isSendingNotification}
|
||||
disabled={isSendingNotification}
|
||||
style={styles.button}
|
||||
>
|
||||
{isSendingNotification ? 'Sending...' : 'Send Notification'}
|
||||
</Button>
|
||||
|
||||
</View>
|
||||
);
|
||||
};
|
||||
@@ -80,19 +171,37 @@ const styles = StyleSheet.create({
|
||||
container: {
|
||||
flex: 1,
|
||||
padding: 20,
|
||||
justifyContent: 'center',
|
||||
alignItems: 'center',
|
||||
// Removed justifyContent and alignItems to allow scrolling if content overflows
|
||||
},
|
||||
title: {
|
||||
marginBottom: 30,
|
||||
marginBottom: 20, // Reduced margin
|
||||
textAlign: 'center',
|
||||
},
|
||||
sectionTitle: {
|
||||
marginBottom: 15,
|
||||
marginTop: 10, // Add some space before the title
|
||||
textAlign: 'center',
|
||||
},
|
||||
checkboxContainer: {
|
||||
flexDirection: 'row',
|
||||
alignItems: 'center',
|
||||
marginBottom: 20,
|
||||
marginBottom: 10, // Reduced margin
|
||||
justifyContent: 'center', // Center checkbox
|
||||
},
|
||||
button: {
|
||||
marginTop: 10,
|
||||
marginBottom: 10, // Add margin below button
|
||||
},
|
||||
input: {
|
||||
marginBottom: 15,
|
||||
},
|
||||
message: {
|
||||
marginBottom: 15,
|
||||
textAlign: 'center',
|
||||
fontWeight: 'bold',
|
||||
},
|
||||
divider: {
|
||||
marginVertical: 30, // Add vertical space around the divider
|
||||
},
|
||||
});
|
||||
|
||||
|
||||
@@ -143,13 +143,6 @@ const EventFormScreen = () => {
|
||||
const handleStartDateConfirm = (date: Date) => {
|
||||
setStartDate(date);
|
||||
setWebStartDateInput(formatForWebInput(date)); // Update web input state
|
||||
// Optional: Auto-set end date if it's before start date or null
|
||||
if (!endDate || endDate < date) {
|
||||
const newEndDate = new Date(date);
|
||||
newEndDate.setHours(date.getHours() + 1); // Default to 1 hour later
|
||||
setEndDate(newEndDate);
|
||||
setWebEndDateInput(formatForWebInput(newEndDate)); // Update web input state
|
||||
}
|
||||
validateForm({ start: date }); // Validate after setting
|
||||
hideStartDatePicker();
|
||||
};
|
||||
@@ -189,13 +182,6 @@ const EventFormScreen = () => {
|
||||
if (isValid(parsedDate) && text.length >= 15) { // Basic length check for 'yyyy-MM-dd HH:mm'
|
||||
if (type === 'start') {
|
||||
setStartDate(parsedDate);
|
||||
// Optional: Auto-set end date
|
||||
if (!endDate || endDate < parsedDate) {
|
||||
const newEndDate = new Date(parsedDate);
|
||||
newEndDate.setHours(parsedDate.getHours() + 1);
|
||||
setEndDate(newEndDate);
|
||||
setWebEndDateInput(formatForWebInput(newEndDate)); // Update other web input too
|
||||
}
|
||||
validateForm({ start: parsedDate }); // Validate with the actual Date
|
||||
} else {
|
||||
setEndDate(parsedDate);
|
||||
|
||||
149
interfaces/nativeapp/src/services/notificationService.ts
Normal file
149
interfaces/nativeapp/src/services/notificationService.ts
Normal file
@@ -0,0 +1,149 @@
|
||||
import * as Device from 'expo-device';
|
||||
import * as Notifications from 'expo-notifications';
|
||||
import { Platform } from 'react-native';
|
||||
import apiClient from '../api/client';
|
||||
import Constants from 'expo-constants';
|
||||
|
||||
// Define the structure of the push token data expected by the backend
|
||||
interface PushTokenData {
|
||||
token: string;
|
||||
device_name?: string;
|
||||
token_type: 'expo'; // Indicate the type of token
|
||||
}
|
||||
|
||||
// --- Android Notification Channel Setup ---
|
||||
async function setupNotificationChannelsAndroid() {
|
||||
if (Platform.OS === 'android') {
|
||||
await Notifications.setNotificationChannelAsync('default', {
|
||||
name: 'Default',
|
||||
importance: Notifications.AndroidImportance.MAX,
|
||||
vibrationPattern: [0, 250, 250, 250],
|
||||
lightColor: '#FF231F7C',
|
||||
});
|
||||
console.log('[Notifications] Default Android channel set up.');
|
||||
}
|
||||
}
|
||||
|
||||
// --- Request Permissions and Get Token ---
|
||||
export async function registerForPushNotificationsAsync(): Promise<string | null> {
|
||||
if (Platform.OS !== 'android' && Platform.OS !== 'ios') {
|
||||
console.warn('[Notifications] Push notifications are only supported on Android and iOS.');
|
||||
return null;
|
||||
}
|
||||
let token: string | null = null;
|
||||
|
||||
if (!Device.isDevice) {
|
||||
console.warn('[Notifications] Push notifications require a physical device.');
|
||||
alert('Must use physical device for Push Notifications');
|
||||
return null;
|
||||
}
|
||||
|
||||
// 1. Setup Android Channels
|
||||
await setupNotificationChannelsAndroid();
|
||||
|
||||
// 2. Request Permissions
|
||||
const { status: existingStatus } = await Notifications.getPermissionsAsync();
|
||||
let finalStatus = existingStatus;
|
||||
if (existingStatus !== 'granted') {
|
||||
console.log('[Notifications] Requesting notification permissions...');
|
||||
const { status } = await Notifications.requestPermissionsAsync();
|
||||
finalStatus = status;
|
||||
}
|
||||
|
||||
if (finalStatus !== 'granted') {
|
||||
console.warn('[Notifications] Failed to get push token: Permission not granted.');
|
||||
alert('Failed to get push token for push notification!');
|
||||
return null;
|
||||
}
|
||||
|
||||
// 3. Get Expo Push Token
|
||||
try {
|
||||
// Use the default experience ID
|
||||
const projectId = process.env.EXPO_PROJECT_ID || Constants.expoConfig?.extra?.eas?.projectId;
|
||||
if (!projectId) {
|
||||
console.error('[Notifications] EAS project ID not found in app config. Cannot get push token.');
|
||||
alert('Configuration error: Project ID missing. Cannot get push token.');
|
||||
return null;
|
||||
}
|
||||
console.log(`[Notifications] Getting Expo push token with projectId: ${projectId}`);
|
||||
const expoPushToken = await Notifications.getExpoPushTokenAsync({ projectId });
|
||||
token = expoPushToken.data;
|
||||
console.log('[Notifications] Received Expo Push Token:', token);
|
||||
} catch (error) {
|
||||
console.error('[Notifications] Error getting Expo push token:', error);
|
||||
alert(`Error getting push token: ${error instanceof Error ? error.message : String(error)}`);
|
||||
return null;
|
||||
}
|
||||
|
||||
return token;
|
||||
}
|
||||
|
||||
// --- Send Token to Backend ---
|
||||
export async function sendPushTokenToBackend(expoPushToken: string): Promise<boolean> {
|
||||
if (!expoPushToken) {
|
||||
console.warn('[Notifications] No push token provided to send to backend.');
|
||||
return false;
|
||||
}
|
||||
|
||||
const tokenData: PushTokenData = {
|
||||
token: expoPushToken,
|
||||
device_name: Device.deviceName ?? undefined,
|
||||
token_type: 'expo',
|
||||
};
|
||||
|
||||
try {
|
||||
console.log('[Notifications] Sending push token to backend:', tokenData);
|
||||
const response = await apiClient.post('/user/push-token', tokenData);
|
||||
|
||||
if (response.status === 200 || response.status === 201) {
|
||||
console.log('[Notifications] Push token successfully sent to backend.');
|
||||
return true;
|
||||
} else {
|
||||
console.warn(`[Notifications] Backend returned status ${response.status} when sending push token.`);
|
||||
return false;
|
||||
}
|
||||
} catch (error: any) {
|
||||
console.error('[Notifications] Error sending push token to backend:', error.response?.data || error.message);
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
// --- Notification Handling Setup ---
|
||||
export function setupNotificationHandlers() {
|
||||
// Handle notifications that arrive while the app is foregrounded
|
||||
Notifications.setNotificationHandler({
|
||||
handleNotification: async () => ({
|
||||
shouldShowAlert: true,
|
||||
shouldPlaySound: true,
|
||||
shouldSetBadge: false,
|
||||
}),
|
||||
});
|
||||
|
||||
// Handle user interaction with notifications (tapping) when app is foregrounded/backgrounded
|
||||
const foregroundInteractionSubscription = Notifications.addNotificationResponseReceivedListener(response => {
|
||||
console.log('[Notifications] User interacted with notification (foreground/background):', response.notification.request.content);
|
||||
|
||||
// const data = response.notification.request.content.data;
|
||||
// if (data?.screen) {
|
||||
// navigation.navigate(data.screen);
|
||||
// }
|
||||
});
|
||||
|
||||
// Handle user interaction with notifications (tapping) when app was killed/not running
|
||||
// This requires careful setup, potentially using Linking or initial URL handling
|
||||
// Notifications.getLastNotificationResponseAsync().then(response => {
|
||||
// if (response) {
|
||||
// console.log('[Notifications] User opened app via notification (killed state):', response.notification.request.content);
|
||||
// // Handle navigation or action based on response.notification.request.content.data
|
||||
// }
|
||||
// });
|
||||
|
||||
|
||||
console.log('[Notifications] Notification handlers set up.');
|
||||
|
||||
// Return cleanup function for useEffect
|
||||
return () => {
|
||||
console.log('[Notifications] Removing notification listeners.');
|
||||
Notifications.removeNotificationSubscription(foregroundInteractionSubscription);
|
||||
};
|
||||
}
|
||||
Reference in New Issue
Block a user